mirror of
https://github.com/priyanshujain/margin-calendar.git
synced 2026-10-02 11:07:04 +00:00
Sign in on a phone with no console work, in the browser's own session
Mobile OAuth reused the desktop client all along; what stopped it was the browser. Sending the user out to Safari or Chrome backgrounds the app, iOS suspends it, and the redirect carrying the code arrives at a socket nobody is accepting on. The consent page now opens in front of the app instead, in SFSafariViewController or a Chrome Custom Tab, so the loopback listener stays live and the existing `installed` client is enough. Verified against Google's real consent screen on a simulator and an emulator. A per-platform client is still supported and is now an upgrade rather than a prerequisite. On iOS it buys ASWebAuthenticationSession, which shares Safari's session so nobody is asked to sign in to Google twice. Android needs nothing: Custom Tabs share Chrome's cookies, measured rather than assumed. iOS session sharing could not be confirmed on the simulator and wants a real device. Never an app-owned WebView: Google blocks it, and rightly, since a webview the app controls can read the password typed into it. Cancelling is no longer reported as a failure. AuthEvent carries a `cancelled` flag, set by comparing against the constant every back-out path returns, and Google's `access_denied` on desktop counts too. Five frontend bugs found by driving the real UI, not by reading it: the details card slid under the tab bar leaving its buttons unhittable; the ghost click after a touch pressed a button in the card that tap had just opened, opening the editor by itself; the swipe that pages the day was dead over every read-only block; 84px of macOS traffic-light lane was reserved on platforms with no traffic lights; and the desktop header ignored the top safe area on an iPad. A first launch now says what to do next rather than showing an empty grid, and accounts are named as Google accounts throughout.
This commit is contained in:
1 parent
661100dfdc
commit
d4c3a304b5
31 files changed
+1319
-114
No files matched your search
+5
-1
@@ -6,6 +6,7 @@ import { Header } from "./components/Header";
|
||||
import { PhoneMenu, PhoneTabBar, PhoneTopBar } from "./components/PhoneBar";
|
||||
import { GridView } from "./components/GridView";
|
||||
import { AgendaView } from "./components/AgendaView";
|
||||
import { FirstRun } from "./components/FirstRun";
|
||||
import { MiniMonthOverlay } from "./components/MiniMonth";
|
||||
import { CalendarList } from "./components/CalendarList";
|
||||
import { EventEditor } from "./components/EventEditor";
|
||||
@@ -51,7 +52,9 @@ function App() {
|
||||
if (!isTauri) return;
|
||||
const menu = listen<string>("menu-action", (event) => handleMenuAction(event.payload));
|
||||
const auth = listen<AuthEvent>("auth", (event) => {
|
||||
void useAccounts.getState().handleAuthEvent(event.payload.ok, event.payload.error);
|
||||
void useAccounts
|
||||
.getState()
|
||||
.handleAuthEvent(event.payload.ok, event.payload.error, event.payload.cancelled);
|
||||
});
|
||||
// A pass that fails in the background used to set the error and say nothing, so a calendar
|
||||
// that never arrived looked like a calendar you do not have. Surface it once per distinct
|
||||
@@ -113,6 +116,7 @@ function App() {
|
||||
<main className="stage">
|
||||
<GridView />
|
||||
<AgendaView />
|
||||
<FirstRun />
|
||||
</main>
|
||||
{phone ? <PhoneTabBar /> : null}
|
||||
<PhoneMenu />
|
||||
|
||||
@@ -55,7 +55,7 @@ export function Accounts() {
|
||||
return (
|
||||
<Sheet
|
||||
open={showing}
|
||||
title="Accounts"
|
||||
title="Google accounts"
|
||||
onClose={close}
|
||||
foot={
|
||||
confirming ? undefined : (
|
||||
@@ -67,7 +67,7 @@ export function Accounts() {
|
||||
disabled={connecting || phase === "working"}
|
||||
onClick={() => void connect()}
|
||||
>
|
||||
Connect an account
|
||||
Connect a Google account
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -78,8 +78,8 @@ export function Accounts() {
|
||||
body={
|
||||
<p>
|
||||
The token is revoked and every calendar, event and pending write stored on this
|
||||
computer for that account is deleted. Nothing changes in Google Calendar itself, and
|
||||
you can connect the account again afterwards.
|
||||
computer for that Google account is deleted. Nothing changes in Google Calendar
|
||||
itself, and you can connect it again afterwards.
|
||||
</p>
|
||||
}
|
||||
confirmLabel="Disconnect"
|
||||
@@ -127,7 +127,7 @@ export function Accounts() {
|
||||
|
||||
{accounts.length === 0 && !connecting ? (
|
||||
<p className="panel-note">
|
||||
No account is connected, so there is nothing to show on the grid yet.
|
||||
Connect a Google account to see its calendars here. Nothing syncs until you do.
|
||||
</p>
|
||||
) : (
|
||||
accounts.map((account) => (
|
||||
|
||||
@@ -74,7 +74,7 @@ export function CalendarList() {
|
||||
<div className="panel-empty">
|
||||
<p>No calendars here yet.</p>
|
||||
<button type="button" className="panel-button" onClick={() => show("accounts")}>
|
||||
Connect an account
|
||||
Connect a Google account
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -48,18 +48,26 @@ const TEXT = "M4 6h16M4 12h12M4 18h9";
|
||||
const USERS =
|
||||
"M17 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2M12.5 7.5a3.5 3.5 0 1 1-7 0 3.5 3.5 0 0 1 7 0M22 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75";
|
||||
|
||||
/** Clear of the window edges, and clear of the one row of chrome that is always resident. */
|
||||
/** Clear of the window edges, and clear of the chrome that is always resident. */
|
||||
const EDGE = 8;
|
||||
|
||||
/**
|
||||
* What the card is allowed to cover, measured off the chrome rather than named in tokens.
|
||||
*
|
||||
* A desktop has one row of it at the top. A phone has two, the second along the bottom, and both
|
||||
* of them pad themselves out of the way of a notch and a home indicator, so their rectangles are
|
||||
* the only thing that knows how tall they really are. Reading `--titlebar-h` instead meant the
|
||||
* card was placed against the window: in landscape it came down over the tab bar, and since the
|
||||
* bars paint above it, Edit, Delete and Close ended up behind the tab bar and unhittable.
|
||||
*/
|
||||
function viewBounds(): Bounds {
|
||||
const bar = Number.parseFloat(
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--titlebar-h"),
|
||||
);
|
||||
const top = document.querySelector(".titlebar, .phonebar")?.getBoundingClientRect().bottom ?? 0;
|
||||
const bottom = document.querySelector(".tabbar")?.getBoundingClientRect().top ?? window.innerHeight;
|
||||
return {
|
||||
top: (Number.isFinite(bar) ? bar : 0) + EDGE,
|
||||
top: top + EDGE,
|
||||
left: EDGE,
|
||||
right: window.innerWidth - EDGE,
|
||||
bottom: window.innerHeight - EDGE,
|
||||
bottom: bottom - EDGE,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// What a first launch says. With nothing connected the grid is a correct and completely empty
|
||||
// calendar, which looks exactly like a calendar you have nothing in, so the one thing to do next
|
||||
// went unsaid: the panel that does it is behind a key on the desktop and an overflow sheet on a
|
||||
// phone, and neither is somewhere you look when you do not yet know it exists.
|
||||
//
|
||||
// It covers the grid rather than sitting beside it. There is nothing underneath worth reading, and
|
||||
// a note floating over an empty axis reads as a thing that failed to load.
|
||||
|
||||
import { runCommand } from "../keys/commands";
|
||||
import { useAccounts } from "../store/useAccounts";
|
||||
|
||||
export function FirstRun() {
|
||||
const loaded = useAccounts((s) => s.loaded);
|
||||
const accounts = useAccounts((s) => s.accounts);
|
||||
|
||||
if (!loaded || accounts.length > 0) return null;
|
||||
|
||||
return (
|
||||
<div className="first-run">
|
||||
<div className="first-run-text">
|
||||
<h2 className="first-run-title">No Google account connected</h2>
|
||||
<p className="first-run-note">
|
||||
Connect one and the calendars on it show up here. Nothing syncs until you do.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="panel-button"
|
||||
data-variant="primary"
|
||||
onClick={() => runCommand("accounts")}
|
||||
>
|
||||
Connect a Google account
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FirstRun;
|
||||
@@ -31,6 +31,12 @@ import { openDetailsFor } from "./useDetails";
|
||||
const CLICK_SLOP = 3;
|
||||
const TOUCH_CLICK_SLOP = 12;
|
||||
|
||||
/**
|
||||
* How long the click a touch leaves behind is still worth waiting for. It lands a frame or so
|
||||
* after the release here, and historically as much as 300ms behind it on a mobile browser.
|
||||
*/
|
||||
const GHOST_CLICK_MS = 400;
|
||||
|
||||
interface GridEventProps {
|
||||
item: Placed;
|
||||
top: number;
|
||||
@@ -42,6 +48,34 @@ interface GridEventProps {
|
||||
onPointerDown: (e: ReactPointerEvent, item: Placed, mode: DragMode) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Eats the click the browser sends after a touch, and only that one.
|
||||
*
|
||||
* Cancelling the pointerdown stops the mouse events that travel with it but never the click, and
|
||||
* the click is hit tested wherever the finger is when it lands, which by then is the card this
|
||||
* press has just opened. Left alone it presses whatever the card put under the finger: on a phone
|
||||
* in landscape the card is the whole stage, so a tap on a meeting opened its editor or its
|
||||
* conference link on its own.
|
||||
*/
|
||||
function swallowGhostClick(): void {
|
||||
let timer = 0;
|
||||
const done = () => {
|
||||
window.clearTimeout(timer);
|
||||
window.removeEventListener("click", eat, true);
|
||||
window.removeEventListener("pointerdown", done, true);
|
||||
};
|
||||
const eat = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
done();
|
||||
};
|
||||
window.addEventListener("click", eat, true);
|
||||
// The first click after a release is the ghost, and a press that starts before it arrives means
|
||||
// it is never coming. The timer is only the backstop for a browser that sends neither.
|
||||
window.addEventListener("pointerdown", done, true);
|
||||
timer = window.setTimeout(done, GHOST_CLICK_MS);
|
||||
}
|
||||
|
||||
function edgeMode(target: EventTarget | null): DragMode {
|
||||
const el = target instanceof Element ? target.closest("[data-edge]") : null;
|
||||
const edge = el?.getAttribute("data-edge");
|
||||
@@ -103,6 +137,7 @@ export const GridEvent = memo(function GridEvent({
|
||||
if (Math.abs(event.clientX - downX) > slop) return;
|
||||
if (Math.abs(event.clientY - downY) > slop) return;
|
||||
open(element);
|
||||
if (event.pointerType !== "mouse") swallowGhostClick();
|
||||
};
|
||||
window.addEventListener("pointerup", up, true);
|
||||
window.addEventListener("pointercancel", stop, true);
|
||||
|
||||
@@ -430,12 +430,19 @@ export function GridView({ defaultCalendarId }: GridViewProps) {
|
||||
if (e.button !== 0 || gesture.current) return;
|
||||
e.stopPropagation();
|
||||
select(keyOf(item.instance));
|
||||
if (item.instance.readOnly || useGrid.getState().draft) return;
|
||||
const dayStart = startOfDay(item.startMs);
|
||||
const index = days.findIndex((d) => d === dayStart);
|
||||
if (index === -1) return;
|
||||
const { startMin, endMin } = dayMinutes(item, dayStart);
|
||||
begin(e, mode, item, index, startMin, endMin);
|
||||
// A block nothing can be done to is still something a swipe has to travel through. The press
|
||||
// is stopped here rather than on the canvas, so without this the page turn was dead over
|
||||
// every read-only event, which on a day with a couple of meetings marked busy is most of the
|
||||
// column. It gets a gesture with no long press behind it: the only thing it can become is
|
||||
// the swipe, and there is no item on it to commit a move to.
|
||||
const inert = item.instance.readOnly || useGrid.getState().draft !== null;
|
||||
if (inert && !isCoarse(e)) return;
|
||||
begin(e, mode, inert ? null : item, index, startMin, endMin);
|
||||
if (inert && gesture.current) clearPress(gesture.current);
|
||||
},
|
||||
// `begin` closes over the current layout and days, which is what a fresh gesture wants.
|
||||
[days, layout, select],
|
||||
|
||||
@@ -93,7 +93,7 @@ export function Settings() {
|
||||
|
||||
<div className="setting-row">
|
||||
<span className="setting-text">
|
||||
<span className="setting-name">Accounts</span>
|
||||
<span className="setting-name">Google accounts</span>
|
||||
<span className="setting-note">
|
||||
{accounts.length === 0
|
||||
? "No Google account connected yet."
|
||||
|
||||
@@ -30,7 +30,7 @@ const TITLES: Record<string, string> = {
|
||||
calendars: "calendars",
|
||||
"mini-month": "the calendar",
|
||||
editor: "the event",
|
||||
accounts: "accounts",
|
||||
accounts: "Google accounts",
|
||||
settings: "settings",
|
||||
shortcuts: "shortcuts",
|
||||
menu: "the menu",
|
||||
|
||||
+16
-2
@@ -24,13 +24,26 @@ function apply(list: Instance[]): Instance[] {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A first launch, which the fixture otherwise has no way to show: it is seeded with two connected
|
||||
* accounts, so the one screen somebody new actually opens on was the one screen nobody could look
|
||||
* at. With this set the three reads come back empty, the way they do before anything is connected.
|
||||
*/
|
||||
const firstRun = (): boolean => {
|
||||
try {
|
||||
return localStorage.getItem("margincal-dev-empty") === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export async function mockCall<T>(command: string, args?: Record<string, unknown>): Promise<T> {
|
||||
const a = (args ?? {}) as Record<string, never>;
|
||||
switch (command) {
|
||||
case "accounts_list":
|
||||
return devAccounts as unknown as T;
|
||||
return (firstRun() ? [] : devAccounts) as unknown as T;
|
||||
case "calendars_list":
|
||||
return calendars as unknown as T;
|
||||
return (firstRun() ? [] : calendars) as unknown as T;
|
||||
case "calendar_set_selected": {
|
||||
const id = a.calendarId as unknown as string;
|
||||
const selected = a.selected as unknown as boolean;
|
||||
@@ -39,6 +52,7 @@ export async function mockCall<T>(command: string, args?: Record<string, unknown
|
||||
return undefined as T;
|
||||
}
|
||||
case "instances_range": {
|
||||
if (firstRun()) return [] as unknown as T;
|
||||
const from = a.fromUtc as unknown as number;
|
||||
const to = a.toUtc as unknown as number;
|
||||
const selected = new Set(calendars.filter((c) => c.selected).map((c) => c.id));
|
||||
|
||||
+10
@@ -24,6 +24,14 @@ const isMobileOs =
|
||||
*/
|
||||
export const isDesktop = isTauri && !isMobileOs;
|
||||
|
||||
/**
|
||||
* The one window whose title bar has the traffic lights inside the page. `titleBarStyle: "Overlay"`
|
||||
* in tauri.conf.json is a macOS-only setting, so on Linux, Windows and every mobile build the
|
||||
* header has nothing to leave room for.
|
||||
*/
|
||||
export const isMacDesktop =
|
||||
isDesktop && typeof navigator !== "undefined" && /mac/i.test(navigator.userAgent);
|
||||
|
||||
/**
|
||||
* True when there is a backend to answer a command: Tauri, or the dev fixture in a browser.
|
||||
* Data-loading actions gate on this. Anything touching a window API must gate on `isDesktop`
|
||||
@@ -168,6 +176,8 @@ export interface AuthEvent {
|
||||
error: string | null;
|
||||
accountId: string | null;
|
||||
email: string | null;
|
||||
/** The consent browser was closed by hand. Not `ok`, but not a failure to report either. */
|
||||
cancelled: boolean;
|
||||
}
|
||||
|
||||
/** Payload of `sync-progress`. `store-changed` carries a plain reason string. */
|
||||
|
||||
@@ -176,7 +176,9 @@ const TABLE: Record<CommandId, Omit<Command, "id">> = {
|
||||
},
|
||||
},
|
||||
calendars: { label: "Calendars", palette: true, run: () => overlays().show("calendars") },
|
||||
accounts: { label: "Accounts", palette: true, run: () => overlays().show("accounts") },
|
||||
// Named for what it connects to rather than for the panel. "Accounts" on its own says nothing
|
||||
// about whose, and this row is how somebody opening the app cold finds the thing to do first.
|
||||
accounts: { label: "Google accounts", palette: true, run: () => overlays().show("accounts") },
|
||||
settings: { label: "Settings", palette: true, run: () => overlays().show("settings") },
|
||||
"toggle-theme": { label: "Toggle dark mode", palette: true, run: () => useTheme.getState().toggle() },
|
||||
"check-updates": {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import { isMacDesktop } from "./ipc";
|
||||
import { trackSafeArea } from "./safeArea";
|
||||
import "./styles/tokens.css";
|
||||
import "./styles/fonts.css";
|
||||
@@ -9,6 +10,10 @@ import "./styles/app.css";
|
||||
// Before the first render, so the bars are the right height on the first paint rather than after.
|
||||
trackSafeArea();
|
||||
|
||||
// The header's lane for the traffic lights, which only one platform draws over it. Written here
|
||||
// rather than assumed by the stylesheet, for the same reason: the first paint is the right shape.
|
||||
if (isMacDesktop) document.documentElement.setAttribute("data-traffic", "");
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
|
||||
@@ -10,6 +10,12 @@ type Phase = "idle" | "connecting" | "working" | "error";
|
||||
interface AccountsState {
|
||||
accounts: Account[];
|
||||
calendars: Calendar[];
|
||||
/**
|
||||
* True once a list has actually come back. An empty `accounts` before that is a page that has not
|
||||
* asked yet, and telling the two apart is the difference between "connect one" and a first frame
|
||||
* of it on every launch.
|
||||
*/
|
||||
loaded: boolean;
|
||||
phase: Phase;
|
||||
error: string | null;
|
||||
authUrl: string | null;
|
||||
@@ -17,7 +23,7 @@ interface AccountsState {
|
||||
refresh: () => Promise<void>;
|
||||
connect: () => Promise<boolean>;
|
||||
cancelConnect: () => void;
|
||||
handleAuthEvent: (ok: boolean, error: string | null) => Promise<void>;
|
||||
handleAuthEvent: (ok: boolean, error: string | null, cancelled?: boolean) => Promise<void>;
|
||||
openAuthUrl: () => void;
|
||||
copyAuthUrl: () => Promise<void>;
|
||||
disconnect: (accountId: string) => Promise<void>;
|
||||
@@ -27,6 +33,7 @@ interface AccountsState {
|
||||
export const useAccounts = create<AccountsState>((set, get) => ({
|
||||
accounts: [],
|
||||
calendars: [],
|
||||
loaded: false,
|
||||
phase: "idle",
|
||||
error: null,
|
||||
authUrl: null,
|
||||
@@ -35,7 +42,7 @@ export const useAccounts = create<AccountsState>((set, get) => ({
|
||||
if (!live()) return;
|
||||
try {
|
||||
const [accounts, calendars] = await Promise.all([accountsList(), calendarsList()]);
|
||||
set({ accounts, calendars });
|
||||
set({ accounts, calendars, loaded: true });
|
||||
} catch (e) {
|
||||
set({ error: String(e) });
|
||||
}
|
||||
@@ -50,7 +57,7 @@ export const useAccounts = create<AccountsState>((set, get) => ({
|
||||
.then((url) => set({ authUrl: url }))
|
||||
.catch((e) => {
|
||||
set({ phase: "error", error: String(e), resolveConnect: null });
|
||||
notify(`Could not connect: ${e}`);
|
||||
notify(`Could not connect your Google account: ${e}`);
|
||||
resolve(false);
|
||||
});
|
||||
}),
|
||||
@@ -59,17 +66,22 @@ export const useAccounts = create<AccountsState>((set, get) => ({
|
||||
set({ phase: "idle", authUrl: null, resolveConnect: null });
|
||||
resolve?.(false);
|
||||
},
|
||||
handleAuthEvent: async (ok, error) => {
|
||||
handleAuthEvent: async (ok, error, cancelled = false) => {
|
||||
if (get().phase !== "connecting") return;
|
||||
const resolve = get().resolveConnect;
|
||||
if (ok) {
|
||||
await get().refresh();
|
||||
set({ phase: "idle", authUrl: null, error: null, resolveConnect: null });
|
||||
notify("Connected to Google Calendar");
|
||||
} else if (cancelled) {
|
||||
// Closing the consent browser is an answer, not a fault. Back to idle with nothing said:
|
||||
// the user already knows what they did, and a red panel telling them about it reads as
|
||||
// though shutting the sheet broke something.
|
||||
set({ phase: "idle", authUrl: null, error: null, resolveConnect: null });
|
||||
} else {
|
||||
const message = error ?? "authorization failed";
|
||||
set({ phase: "error", authUrl: null, error: message, resolveConnect: null });
|
||||
notify(`Could not connect: ${message}`);
|
||||
notify(`Could not connect your Google account: ${message}`);
|
||||
}
|
||||
resolve?.(ok);
|
||||
},
|
||||
@@ -93,10 +105,10 @@ export const useAccounts = create<AccountsState>((set, get) => ({
|
||||
await accountDisconnect(accountId);
|
||||
await get().refresh();
|
||||
set({ phase: "idle" });
|
||||
notify("Disconnected");
|
||||
notify("Google account disconnected");
|
||||
} catch (e) {
|
||||
set({ phase: "error", error: String(e) });
|
||||
notify(`Could not disconnect: ${e}`);
|
||||
notify(`Could not disconnect that Google account: ${e}`);
|
||||
}
|
||||
},
|
||||
setSelected: async (calendarId, selected) => {
|
||||
|
||||
+48
-2
@@ -103,15 +103,26 @@ select {
|
||||
flex: none;
|
||||
position: relative;
|
||||
z-index: 45;
|
||||
height: var(--titlebar-h);
|
||||
/* Out of the way of a status bar or a notch, the same way the phone bars do it. A desktop reads
|
||||
both insets as zero; an iPad gets this header rather than those bars and does not. */
|
||||
height: calc(var(--titlebar-h) + var(--safe-top));
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
padding: 0 14px 0 var(--traffic-pad);
|
||||
padding: var(--safe-top) 14px 0;
|
||||
background: var(--shell);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
/* The macOS traffic lights float over this row, so it opens a lane for them and costs no extra
|
||||
height. macOS is the only platform with any: they come from `titleBarStyle: "Overlay"`, which is
|
||||
a macOS-only window setting, and on Linux, Windows, an iPad or a browser the lane was 84px of
|
||||
nothing, pushing the view switcher off centre and starving the date range of the room it needed
|
||||
to say what day it is. */
|
||||
:root[data-traffic] .titlebar {
|
||||
padding-left: var(--traffic-pad);
|
||||
}
|
||||
|
||||
.titlebar .lead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -273,9 +284,44 @@ select {
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* The first-run note lays itself over whatever is here, so this is the box it covers. */
|
||||
position: relative;
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
/* Above every part of the grid, which stacks up to 15, and under the overlays, which start at 20:
|
||||
the panel this opens has to come up in front of it. */
|
||||
.first-run {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 19;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
.first-run-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
max-width: 320px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.first-run-title {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 500;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.first-run-note {
|
||||
margin: 0;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
/* Overlays, margin's idiom: summoned by a key, dismissed with Escape, never resident. */
|
||||
.overlay {
|
||||
position: fixed;
|
||||
|
||||
@@ -45,6 +45,16 @@
|
||||
max-height: min(480px, calc(100vh - var(--titlebar-h) - 16px));
|
||||
}
|
||||
|
||||
/* There is no title bar here. What the card has to fit inside is the window less both bars and the
|
||||
insets they pad themselves with, which is the same box the placement measures. A cap any taller
|
||||
than that does not merely scroll, it gets placed over the top bar: the card is laid out first and
|
||||
pinned to its block second, so a card too tall for the gap has nowhere legal to go. */
|
||||
:root[data-phone] .details-card {
|
||||
max-height: calc(
|
||||
100dvh - var(--phonebar-h) - var(--safe-top) - var(--tabbar-h) - var(--safe-bottom) - 16px
|
||||
);
|
||||
}
|
||||
|
||||
/* The one scroll region. The footer is outside it, so however long the description runs, Edit,
|
||||
Delete and Close are where they were. */
|
||||
.details-body {
|
||||
|
||||
Reference in new issue
Block a user