/* The shell's half of the stylesheet — the reusable, app-agnostic one.
   Its app-side twin is styles.css at the repo root; docs/architecture.md →
   "The split" holds the reasoning, and the two contracts below are stated there
   as well as here.

   IT LOADS FIRST, AND THAT IS A CONTRACT. index.html links this file before
   styles.css so that at EQUAL specificity the app wins — the direction a host
   overriding a vendored shell needs. A unit test pins the order, because two
   <link> tags are trivially swappable and the failure is silent.
   Cascade layers (`@layer shell, app`) are the declarative version of that rule
   and were measured to be behaviour-identical here — zero cascade inversions
   across the boundary. They were left out because the prefix already makes
   collisions impossible, so all a layer buys is protection against a reordered
   <link>, which the test buys for no browser-support cost. The measurement is in
   TODO #16 if that trade is ever revisited.

   TWO TOKEN CONTRACTS, RUNNING IN OPPOSITE DIRECTIONS. Neither is optional and
   neither is prefixed — see docs/architecture.md for the list and for why the
   missing `--sh-` is a known gap parked on the extraction.
     app → shell   the palette. This file READS ~22 custom properties it never
                   defines (--bg, --text, --primary, --accent, --value, --danger,
                   --surface*, --border, --input-bg, --on-*, --icon-weight …).
                   A consuming app must supply the full set, in every theme it
                   ships. js/app/tests/unit/css-tokens.test.mjs checks it.
     shell → app   the panel metrics. The `:root` block above .sh-detail-value
                   PUBLISHES --slot-* and --panel-gap*; app controls that sit in
                   a value slot read them so the column keeps one left edge.

   Every class here is `sh-` prefixed, or a shared `is-`/`has-`/`u-` state class.
   js/shell/tests/unit/css-naming.test.mjs fails on anything else, so this file
   can be lifted into the sibling repo without reading it first. */

/* ---- Reboot ------------------------------------------------------------
   The global-scope rules: the box-sizing reset, the viewport scroll lock, the
   touch and selection behaviour, and the bare form elements. A component library
   shipping a reset is the thing library-authoring guidance warns about, so this
   is THE OPT-OUT SEAM: a host that wants its own reset replaces this block and
   nothing below it, which is why the block is fenced rather than scattered.

   The bare element rules cannot be handed to the host, though, and that is not a
   preference. `input[type="text"]` is (0,1,1) and TIES with `input.sh-detail-input`
   below — loading them after the shell would make the panel's own inputs lose
   their metrics, which is the 52px-input-beside-a-29px-select fault the value-box
   block records. They load before both halves or they are wrong. */

* { box-sizing: border-box; margin: 0; padding: 0; }

/* Lock the page to the viewport: only .sh-app-main's views scroll. Without this
   the body can grow past the *visible* height while the mobile address bar is
   expanded, letting the whole page scroll and the bottom tab bar drift below the
   fold until you scroll to snap it back. overscroll-behavior kills the
   rubber-band on top of that.
   height:100% rather than 100dvh — the shell is pinned with position:fixed and
   does not consult a viewport unit at all. See .sh-app-shell for why. */
html, body { height: 100%; overflow: hidden; }

/* Kill double-tap-to-zoom, keep pinch-to-zoom.
   `manipulation` means "panning and pinch zoom are fine, the double-tap gesture
   is not" — which is exactly the split we want. The app had no `touch-action`
   anywhere, so a stray double-tap zoomed it like a web page, and because the
   shell is scroll-locked above there is no scrolling left to settle it back:
   you're stranded in a scaled sub-region of an unchanged layout viewport, with
   the fixed header and tab bar anchored off where you can see, and taps landing
   where elements ARE rather than where they LOOK.
   Note this is NOT `user-scalable=no` / `maximum-scale=1` in the viewport meta.
   iOS has ignored both since iOS 10 for accessibility, and honouring them would
   take pinch-zoom away from anyone who needs it — which is a real cost, and this
   has none. Set on the root so it covers the modal and drawer layers too;
   touch-action is resolved up the ancestor chain, so one declaration does it.
   The other half of this bug is input font-size — see .sh-browse-search-input.

   Declared on the root AND on the things people actually tap. The root alone
   should be enough on paper — the effective touch behaviour is resolved up the
   ancestor chain — but touch-action is not an INHERITED property, so a
   descendant's computed value stays `auto` and the guarantee is invisible to
   both a reviewer and a test. Naming the interactive elements makes it
   observable (scripts/ui/specs/viewport.mjs asserts it), and hedges the engine
   we cannot test here: WebKit is where this bug lives and the only one neither
   headless browser covers. */
html { touch-action: manipulation; }

button, a, input, textarea, select, label, [role="button"] { touch-action: manipulation; }

/* iOS paints its own translucent grey box on every tap, sized to the element's
   bounds. Turned off because the app draws its own press feedback (the `:active`
   rules further down) and two highlights stacked read as a rendering fault on the
   bordered row tiles, where the system box corners do not follow border-radius.
   Declared ONCE on the root, unlike touch-action above: -webkit-tap-highlight-color
   IS an inherited property, so one declaration genuinely covers every descendant
   and a second would be noise. That asymmetry is the whole reason touch-action is
   repeated and this is not.
   Note it does not disable anything functional — it is paint only, and `:active`
   plus the touchstart listener in js/shell/touch-active.js replace what it did. */
html { -webkit-tap-highlight-color: transparent; }

/* Press and hold any tap target on iOS and the text-selection callout comes up
   instead of the press feedback — the gesture takes the touch over before the
   `:active` paint is worth looking at, which is also why v77's press rules could
   not be evaluated on a device at all. The app declared `user-select` NOWHERE, so
   this was every chip, button and row, not a chip fault.

   The third sibling of the two rules above, and it splits the difference between
   them. `user-select` IS inherited, like the tap highlight — but unlike the tap
   highlight it cannot be declared once on the root, because record text has to
   stay selectable: a person's address is a thing you copy. So it goes on the
   controls, the way `touch-action` does, and for a different reason.

   The selector list is `touch-action`'s MINUS the text-entry elements. Killing
   selection inside an input or a textarea would stop you selecting what you just
   typed, which is the one place the callout is the feature rather than the bug —
   and `.sh-settings-row` joins them because it is a div whose click forwards to
   its button, so its label is tappable text nothing else here covers.

   Both spellings: unprefixed `user-select` only reached Safari in 17.4, and the
   installed home-screen app is exactly where an older iOS survives longest. The
   prefix goes first so the standard property wins wherever it is understood. */
button, a, label, [role="button"], .sh-settings-row {
	-webkit-user-select: none;
	user-select: none;
}

/* PRESS FEEDBACK lives with each component, not here — a `:active` rule 600 lines
   from the thing it styles is how a stylesheet stops being readable. What is worth
   stating once, because every one of those rules repeats it:
   they pair `:active` with `transition-duration: 0s`, and the pairing is the point.
   Rows and buttons already ease `background` over 0.15–0.2s for hover, and
   inheriting that on press puts a perceptible fade between finger-down and the row
   acknowledging it — exactly the lag this is meant to remove. Zeroing the duration
   only while `:active` matches gives the platform's own shape: the highlight snaps
   on at touch-down, and when `:active` drops, the base rule's transition eases it
   back out. Instant on, eased off.
   Nothing here tracks state or toggles a class. The only scripted part is making
   iOS willing to match `:active` at all — js/shell/touch-active.js. */

/* HOVER, and the one idiom every :hover rule in this file repeats:

       :where(:root[data-input="pointer"]) .thing:hover { … }

   `data-input` is the input the user is touching the screen with RIGHT NOW,
   written on the root by js/shell/pointer-input.js and defaulting to `pointer`
   in index.html's markup. Read that file for why the input in use rather than
   the device's capability — briefly: iOS applies :hover on tap and keeps it
   until you tap elsewhere, and a media query can only ask what a device CAN do,
   which a touchscreen-plus-trackpad answers "both" to.

   `:where()` IS LOAD-BEARING AND IS NOT DECORATION. It contributes ZERO
   specificity, so a guarded rule weighs exactly what it weighed unguarded. This
   file leans on hover-vs-state ties at EQUAL specificity resolved by source
   order — .occ-row.is-selected, .sh-filter-chip.is-active and
   .design-row.is-selected all sit after their :hover at the same weight and
   win by coming later. A bare `:root[data-input="pointer"]` ancestor would add
   (0,1,0) to all 48 selectors and invert every one of those ties, so an opened
   row would wear hover over its selection on every desktop. Do not "simplify"
   the :where() away.

   Applied to EVERY :hover rule, uniformly, including ones on components a finger
   rarely meets: the guard is only worth anything if a reader can trust that an
   unguarded :hover does not exist, and a shell-side unit test asserts exactly
   that (js/shell/tests/unit/hover-guard.test.mjs), so a new rule cannot quietly
   opt out.

   NOT gated the way the head's control SIZING is, five hundred lines down, and
   the two disagreeing is deliberate rather than drift: `@media (pointer: fine)`
   asks about the device because a hit target is a commitment you make BEFORE the
   finger arrives — you cannot resize a control as a finger approaches without
   moving it under them. Hover is paint, it changes no geometry, and it is free
   to flip mid-session. Layout keys off capability; paint keys off use. */

/* There is deliberately no @media (display-mode: standalone) height rule here
   any more. It used to add env(safe-area-inset-top) back onto the shell, because
   black-translucent pulled the document under the status bar and dumped a 62px
   gap at the bottom (docs/reference/mobile-toolbar-floats-shortcut.PNG vs
   -good-browser.PNG). That worked, and it left the installed app with a document
   874px tall inside an 812px viewport with overflow:hidden — a shell larger than
   the screen with no scroll, which is the same shape as the pinch-zoom fault
   described above. The status bar is opaque now, so nothing is lost and there is
   nothing to buy back. Do not reintroduce this without reintroducing
   black-translucent first, and read #45 before doing either. */

body {
	font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
	background: var(--bg);
	color: var(--text);
	overscroll-behavior: none;
	transition: background 0.2s, color 0.2s;
}

label {
	display: block;
	font-size: 0.85rem;
	color: var(--text-muted);
	margin-bottom: 0.35rem;
}

/* `type="search"` is in this list because it was missing from it, and the gap was
   invisible: the only search input in the app for a long time was the browse
   field, which declares its own borderless look further down and so never wanted
   these rules. When the recipient picker's filter arrived it matched nothing at
   all and rendered as a raw browser search box — no border, no ground, no
   padding. Anything that wants out of these rules must be element-QUALIFIED to
   say so: this selector is (0,1,1) and a bare class is (0,1,0), so source order
   never gets a say. Adding it here without qualifying the two fields that
   opt out drew the base border and ground INSIDE their own frames — a box
   inside a box, on every browse view at once. */
input[type="email"],
input[type="text"],
input[type="number"],
input[type="search"],
input[type="url"] {
	width: 100%;
	padding: 0.75rem;
	border: 1px solid var(--border);
	border-radius: 0.5rem;
	background: var(--input-bg);
	color: var(--text);
	font-size: 1.1rem;
	outline: none;
	transition: border-color 0.2s;
}

input::placeholder { color: var(--text-faint); }

input:focus { border-color: var(--primary); }

textarea {
	width: 100%;
	padding: 0.75rem;
	border: 1px solid var(--border);
	border-radius: 0.5rem;
	background: var(--input-bg);
	color: var(--text);
	font-family: inherit;
	font-size: 1rem;
	line-height: 1.4;
	resize: vertical;
	outline: none;
	transition: border-color 0.2s;
}

textarea:focus { border-color: var(--primary); }

textarea::placeholder { color: var(--text-faint); }

/* ---- End of the reboot. Everything below is `sh-` components. ---------- */

/* ---- App shell: header / main / bottom tab bar -------------------------
   The shell fills the viewport and never scrolls as a page: .sh-app-main is the
   only flexible row, and each .sh-view scrolls inside it. That's what lets the
   master-detail panes scroll independently of each other and keeps their heads
   (and the tab bar) pinned. Everything from here down is the mobile layout;
   the rail + split take over in the media queries at the bottom of the file. */

/* Pinned with position:fixed + inset:0, NOT sized with a viewport unit.
   100dvh is not trustworthy during a pinch: measured on-device it resolved to
   931px in Chrome and 1111.75px in Safari on an 874px screen — both larger than
   100lvh, which is impossible, and the shell was being sized from that. fixed +
   inset:0 pins to the layout viewport and never asks for a dynamic unit, so
   there is no garbage number to inherit. position:fixed still establishes the
   containing block that the scrim and drawer overlay against, which is what
   position:relative was here for. */
.sh-app-shell {
	display: flex;
	flex-direction: column;
	position: fixed;
	inset: 0;
	overflow: hidden;
}

.sh-app-content {
	flex: 1;
	min-height: 0;   /* let .app-main shrink instead of pushing the tab bar off */
	display: flex;
	flex-direction: column;
}

/* The dev rail (js/shell/dev-origin.js) — four pixels along the top edge on any
   host that is not production, because a LAN copy talks to the same Supabase
   project and so shows the same live records.

   OUT OF FLOW, AND THAT IS THE WHOLE DESIGN. This app is opened on a real phone
   precisely to measure layout that Chromium cannot reproduce, so an indicator
   occupying a row would corrupt the reading it exists to support. The demo
   band's shape was the obvious thing to copy and is exactly wrong here: 44px
   subtracted from every on-device measurement, by hand, forever.

   IT INTRODUCES A HUE, which the demo band directly above is forbidden from
   doing — and the difference is that the demo band SHIPS. Every colour in the
   palette is allocated (gold is the primary action, mint a nudge, burnt orange
   destructive, rose and teal the card styles, --circle-* identity), so shipping
   chrome taking one of them would borrow a meaning it does not have. This rail
   never ships: it is inert on the production host. Its job is to be foreign to
   the vocabulary, and any palette colour it borrowed would read as a product
   state instead of as "you are not on the real app".

   Hardcoded rather than tokenised, deliberately: a dev marker that restyled
   itself per palette is a dev marker you would stop recognising. It is the one
   thing on screen that must look identical in all five themes.

   Above .sh-app-shell's modal overlay (z-index 50) so nothing can cover it. */
.sh-dev-rail {
	display: none;
	position: fixed;
	top: 0;
	left: 0;
	right: 0;
	height: 4px;
	background: #2563eb;
	z-index: 60;
	pointer-events: none;
}

:root[data-origin="dev"] .sh-dev-rail { display: block; }

/* The demo band (js/shell/demo-bar.js). Sits inside .sh-app-content and above
   .sh-app-main, so it spans every view including Settings and, once the panes
   split, starts to the RIGHT of the rail rather than crossing it — the rail is
   permanent app furniture and the demo is not, so running the band across the
   brand mark would say the whole application is a demo rather than the data in
   it.

   IT INTRODUCES NO HUE, and that is a constraint rather than a preference. Every
   colour in this palette is spoken for: gold is the primary action, mint is "a
   nudge you can act on", burnt orange is destructive, rose and teal are the card
   styles, and the eleven --circle-* swatches are circle identity. A tinted band
   would have had to borrow one of those meanings to say something none of them
   mean. So the band reads as different through a surface step and a rule, and
   spends the accent only on Leave — which is the one thing in it you can do, and
   is this container's primary action rather than a third mark competing inside
   the pane head. */
.sh-demo-bar {
	flex-shrink: 0;
	display: flex;
	align-items: center;
	gap: 0.5rem;
	min-width: 0;
	/* No block padding: Leave's own 44pt sets the band's height. The pane head's
	   negative-margin trick is the alternative and is not wanted here — it would
	   hang the hit area over the pane head immediately below, where the add
	   button lives. The band lands at 44px, one timeline row, which is the most a
	   piece of temporary chrome should cost. */
	padding: 0 0.5rem 0 1rem;
	background: var(--surface-2);
	border-bottom: 1px solid var(--border);
	font-size: 0.8rem;
}

/* display:flex would otherwise beat the UA's [hidden] rule. */
.sh-demo-bar[hidden] { display: none; }

.sh-demo-bar-label { flex-shrink: 0; font-weight: 600; color: var(--text); }

/* The sentence is what gives way first at 390px, which is why the label is its
   own element rather than the head of one string. */
.sh-demo-bar-sub {
	min-width: 0;
	overflow: hidden;
	text-overflow: ellipsis;
	white-space: nowrap;
	color: var(--text-muted);
}

.sh-demo-bar-leave {
	flex-shrink: 0;
	margin-left: auto;
	min-height: 2.75rem;
	display: inline-flex;
	align-items: center;
	padding: 0 0.75rem;
	border: none;
	background: none;
	font: inherit;
	font-weight: 600;
	color: var(--primary);
	cursor: pointer;
	transition: color 0.15s;
}

:where(:root[data-input="pointer"]) .sh-demo-bar-leave:hover { color: var(--primary-hover); }

.sh-demo-bar-leave:active { filter: brightness(0.92); transition-duration: 0s; }

/* The demo panel (js/shell/demo-ui.js) — the drawer's non-form panel for
   starting and leaving. It shares .import-panel's rhythm rather than inventing a
   second one, but keeps its own block: `import-*` belongs to the app's
   import-ui.js, and a shell module borrowing an app block is precisely the
   collision the sh- prefix exists to make impossible. */
.sh-demo-panel { display: flex; flex-direction: column; gap: 0.7rem; }

.sh-demo-heading { font-size: 0.95rem; font-weight: 600; }

.sh-demo-note { font-size: 0.85rem; color: var(--text-muted); }

/* `.sh-settings-mark` is the same box without the control: a switch row's mark
   cannot be a <button>, because the row already has one and main.js forwards the
   row's click to the FIRST button it finds. A second one would swallow the tap
   and do nothing with it, and give keyboard users a tab stop that goes nowhere. */
.sh-theme-toggle,
.sh-account-btn,
.sh-settings-btn,
.sh-settings-mark {
	flex-shrink: 0;
	width: 2.3rem;
	height: 2.3rem;
	border-radius: 50%;
	border: 1px solid var(--border);
	background: var(--surface-2);
	color: var(--text-muted);
	font-size: 1.1rem;
	line-height: 1;
	cursor: pointer;
	display: flex;
	align-items: center;
	justify-content: center;
}

.sh-account-btn { color: var(--text-muted); }

.sh-account-btn.is-connected { color: var(--connected); border-color: var(--connected); }

.sh-account-btn.is-offline { color: var(--danger); border-color: var(--danger); }

/* The Demo row's mark, lit only while a demo is running — the pane head's rule
   that a utility idles muted and lights while it is doing something. It takes
   the accent rather than a colour of its own for the reason the band does: there
   is no unspoken-for hue, and this is not a fifth meaning. */
.sh-settings-btn.is-demo {
	color: var(--accent);
	border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
}

/* The scrolling region. Padding lives on .sh-view, not here, so a view can go
   full-bleed (the panes do) without fighting a padded parent. */
.sh-app-main {
	flex: 1;
	min-height: 0;
	display: flex;
	justify-content: center;
	overflow: hidden;
}

/* `.app-footer` — the centred build-version strip that used to sit here, below
   the scrolling region — was removed in v63. It cost a permanent row of height
   on every screen to show something you look at rarely; the version is now the
   last row of the Settings list (`.sh-settings-row--value`), where iOS puts it. */

/* Each view owns its own scrolling and padding. The top pad clears the notch,
   since there's no mobile header above the views anymore.

   THE SIDE PADDING IS `.sh-pane-body`'s 1rem, not a measure of its own. Settings
   is the only view this rule reaches — every other one is `.sh-view-panes`, which
   zeroes it — so any difference here is a difference between the Settings cards
   and the rows of every other view, on the same screen, one tab apart. */
.sh-view {
	width: 100%;
	overflow-y: auto;
	padding: calc(0.6rem + env(safe-area-inset-top)) 1rem 1.25rem;
}

/* THE COLUMN CAP IS A DESKTOP RULE, and belongs behind the breakpoint that makes
   it one. Unqualified, 22rem is 352px — narrower than a phone — so Settings
   painted a 352px column inset 25px on a 402px screen while People, Upcoming,
   Cards and Circles used all 402. It read as a view that had failed to lay out,
   and it got worse as the list gained rows with trailing values to fit.

   48rem is where the panes split, i.e. where "desktop" starts for every other
   rule in this file. Above it a full-bleed settings list would leave the eye
   travelling the width of a monitor for one switch, which is what the cap is for.

   `.sh-view.sh-view-panes` (two classes) still out-specifies this one (one class
   plus a media query, which adds no specificity), so the panes stay full-bleed at
   every width. */
@media (min-width: 48rem) {
	.sh-view { max-width: 22rem; }
}

/* nav.js switches views with the `hidden` attribute, which the UA implements as
   a plain `display: none` — so any view that sets its own `display` (.sh-view-panes
   sets grid) out-specifies it and stays painted on top of the view you switched
   to. !important is the point here, not a workaround: hidden must beat every
   layout rule below, whatever a future view sets. */
.sh-view[hidden] { display: none !important; }

.sh-view-head {
	margin: 0.25rem 0.25rem 0.9rem;
}

.sh-view-head h2 {
	font-size: 1.1rem;
	font-weight: 600;
	color: var(--accent);
}

/* A row of the shell rather than a fixed overlay: the shell is pinned, so the
   tab bar stays put without being taken out of flow.

   max(), not a bare env(). Dropping viewport-fit=cover makes every
   env(safe-area-inset-*) resolve to 0, and this was the one rule in the file
   whose bottom padding was ENTIRELY that env with no base value — so it silently
   became zero and the labels sat on the home indicator. The top needs no
   equivalent because an opaque status bar reserves its own space; the home
   indicator is an overlay and reserves nothing, so the space has to be asked
   for. max() keeps the env in play for any context that does report a real
   inset, and takes the larger of the two. */
.sh-tab-bar {
	flex-shrink: 0;
	display: flex;
	background: var(--surface);
	border-top: 1px solid var(--border);
	padding-bottom: max(1.75rem, env(safe-area-inset-bottom));
}

.sh-tab {
	flex: 1;
	display: flex;
	flex-direction: column;
	align-items: center;
	gap: 0.15rem;
	padding: 0.6rem 0.4rem 0.7rem;
	border: none;
	background: transparent;
	color: var(--text-muted);
	cursor: pointer;
	transition: color 0.2s;
}

.sh-tab-icon { font-size: 1.25rem; line-height: 1; }

.sh-tab-icon svg { width: 1.5rem; height: 1.5rem; display: block; }

.sh-tab-label { font-size: 0.7rem; font-weight: 500; }

.sh-tab.is-active { color: var(--value); }

/* The tab bar has no :hover rule (it is a touch control first), so this is its
   only acknowledgement of a finger. */
.sh-tab:active { background: var(--surface-2); transition-duration: 0s; }

/* ---- More view: settings list (theme / account / future settings) ------
   Each row pairs a control (the shared .sh-theme-toggle / .sh-account-btn, wired by
   theme.js / auth.js) with a label + subtitle. Mobile home for the low-frequency
   chrome that used to live in the top bar. */
/* GROUPED CARDS. A section is a filled, rounded, inset container on the page
   ground, with the hairlines inside it — which is what an iOS grouped list
   actually is (`UITableView.Style.insetGrouped`, and the shape macOS System
   Settings uses in its right-hand pane). THE CARD DOES THE GROUPING, which is
   why the gap between cards is ordinary rather than large.

   The clusters were always real and already load-bearing — ui-conventions.md →
   Settings rows keys its phrasing rule to them ("per group, not per list"), so
   destinations are nouns and actions are bare verbs — but the page drew them
   with row order alone, and an order is not something a reader can see. A third
   row shape (a switch, which is neither a destination nor an action) is what
   forced the issue.

   SEPARATING BY GAP ALONE WAS TRIED FIRST AND IS WRONG HERE, which is worth
   recording because `.sh-detail-group` further down this file does exactly that
   and cites Contacts for it. The difference is containment: a detail group sits
   inside a panel that already bounds it, so space is enough. This list has no
   container, so space alone leaves strips of hairlines floating with nothing
   holding them together — measurably chunkier, and it reads as arbitrary.

   No headings, on either shape. A heading over a group of one is a promise
   about a list that does not exist yet, and iOS leaves most groups unlabelled.
   Label one if it grows enough to need naming. */
.sh-settings-list { display: flex; flex-direction: column; gap: 0.8rem; }

.sh-settings-group {
	display: flex;
	flex-direction: column;
	background: var(--surface);
	border: 1px solid var(--border);
	border-radius: 0.7rem;
	overflow: hidden;
}

/* The same trap and the same answer as `.sh-view[hidden]` and
   `.sh-settings-row[hidden]`: the rule above sets `display`, which out-specifies
   the UA's `display: none` for the attribute, so a card built from a data set
   that turned out empty would paint as a bare rounded box. A card whose rows are
   generated has to be able to not exist. */
.sh-settings-group[hidden] { display: none !important; }

.sh-settings-row {
	display: flex;
	align-items: center;
	gap: 0.85rem;
	padding: 0.7rem 0.8rem;
	border-top: 1px solid var(--border);
}

.sh-settings-group > .sh-settings-row:first-child { border-top: none; }

/* The rule above sets `display`, which out-specifies the UA's `display: none` for
   the `hidden` attribute — so a row hidden in JS stayed painted, as an empty
   labelled row reading like a value that failed to arrive. Same trap and same
   answer as `.sh-view[hidden]` further up: !important is the point, not a
   workaround, because hidden must beat every layout rule a row ever gains. */
.sh-settings-row[hidden] { display: none !important; }

/* Rows that carry a control are clickable across their full width (main.js
   forwards the click to the button). The tint is what tells you so before you
   try — it sits on the row, so the whole strip lights, not the icon alone. */
/* No radius: the row's separator is a `border-top`, and a top border on a
   rounded box curls at both ends. */
.sh-settings-row.is-tappable { cursor: pointer; }

:where(:root[data-input="pointer"]) .sh-settings-row.is-tappable:hover { background: var(--surface-2); }

/* Scoped to .is-tappable for the same reason the hover above is: the read-only
   Version row must not answer a press it does nothing with. */
.sh-settings-row.is-tappable:active { background: var(--surface-3); transition-duration: 0s; }

/* `.sh-settings-row.is-disabled` (dimmed) and `.sh-settings-glyph` (an emoji in place of
   an icon button) went with the "Significance rules — Coming soon" row in v55;
   that row was their only user. Deleted rather than kept warm, and the bet paid:
   the significance rules came back as ordinary destination rows shaped like the
   ones above them, so a dimmed variant and a glyph slot would have been dead
   weight pretending to be scaffolding for a shape nothing ended up needing. */

.sh-settings-text { display: flex; flex-direction: column; gap: 0.1rem; flex: 1; min-width: 0; }

.sh-settings-label { font-size: 0.95rem; color: var(--text); }

.sh-settings-sub { font-size: 0.78rem; color: var(--text-muted); }

/* STATE GOES ON THE RIGHT, not underneath. A destination row reports where it
   will take you as a trailing value plus a chevron, which is the row anatomy iOS
   Settings uses throughout — and it is what keeps this list one line per row as
   more settings land. A subtitle is spent only where it says what the row DOES
   (the three data verbs), never where it merely reports state.

   `.sh-settings-row--value`, the inert label-left/value-right row, was deleted
   with the version row it existed for. Its justification here and in index.html
   was "iOS General → About → Software Version", and that had gone stale: current
   iOS makes that row NAVIGABLE — a version with a chevron onto the release notes
   — so an inert value row is no longer the shape it was cited as. See
   `.sh-settings-foot` below for where the build version went instead. */
.sh-settings-trail {
	margin-left: auto;
	flex: none;
	display: flex;
	align-items: center;
	gap: 0.25rem;
}

.sh-settings-value { font-size: 0.85rem; color: var(--text-faint); white-space: nowrap; }

.sh-settings-chev { display: flex; color: var(--text-faint); }

/* A SECTION FOOTER — small, quiet, below the last card and OUTSIDE it. A named
   part of the grouped list rather than something invented for this app:
   `Section(footer:)` in SwiftUI, `titleForFooterInSection` in UIKit, supported
   on the inset-grouped style.

   The build version lives here because it is the one thing on the page you
   cannot act on. Every row inside a card is a destination, an action or a
   switch; an inert row wearing the same shape as those is a control that does
   nothing when pressed. A footer is the component for text attached to a group
   that is not itself a control.

   NOT a return of `.app-footer` (removed in v63) — that was a centred strip
   under the panes on EVERY screen, which is a web-site convention. This is on
   Settings and nowhere else. If a release-notes page ever exists, the version
   becomes a navigable row again and this goes; today there is no such page and
   CLAUDE.md → Documentation conventions refuses the changelog one would need. */
.sh-settings-foot {
	font-size: 0.75rem;
	color: var(--text-faint);
	padding: 0.15rem 0.8rem 0;
	font-variant-numeric: tabular-nums;
}

/* A FOOTER IS NOT EVENLY SPACED — it hugs the card it describes, and the next
   section starts further down than an ordinary card gap. On the list's plain
   0.8rem rhythm a footer sat 12.8px from its own card and 12.8px from the next
   one, so it belonged to neither and read as a loose sentence between two
   sections. Proximity is the only thing that binds it, which is what iOS spends
   here: a few points above, a clear break below.

   Done with margins against the flex `gap` rather than by replacing the gap with
   margins throughout. `gap` skips a `display: none` child correctly and adjacent-
   sibling margins do not, and this list now has a card that hides itself when the
   data behind it is empty — so the rhythm has to survive a missing sibling. */
.sh-settings-foot { margin-top: -0.5rem; }

.sh-settings-foot + * { margin-top: 0.45rem; }

/* A SWITCH ROW — the third row shape, after the destination and the action.
   Label left, control right, and a leading mark like every other row in its card.

   IT USED TO CARRY NO MARK, and an indent — `margin-left: calc(2.3rem + 0.85rem)`
   on the text — standing in for the missing button so the labels still shared one
   column. That was defensible while the switch was alone in its card: an empty
   mark column reads as a deliberate absence when there is nothing beside it to
   compare against. It stopped being defensible the moment the milestone rows
   landed above it, because a column of marks with one blank cell in it reads as a
   mark that failed to draw, not as a rule. The mark is a `.sh-settings-mark` span
   and not a button — see that rule for why — so this needs no geometry of its own;
   the shared box supplies exactly the width the indent used to fake.

   `.sh-settings-row--switch` survives as a HOOK with no styling: it names the third
   row shape in the markup, and the settings spec selects on it to assert what that
   shape carries. It is declared in tests/app/unit/css-naming.test.mjs → HOOKS,
   which is what keeps the liveness check honest about the rest.

   The row still forwards its click to the button (main.js), so tapping anywhere
   flips it. That diverges from iOS, where a switch row's label is inert, and it
   loses on purpose: this list's own rule is that the whole row is the tap target
   (ui-conventions.md → Settings rows), the hover tint already promises as much,
   and a row that lights under the pointer and then ignores it is the worse of
   the two wrongs. A toggle is one tap to undo. */

/* The control. Three channels separate on from off — the track's ground, the
   knob's colour and the knob's position — because one of them being colour alone
   would put the whole state on a hue. The two colour pairs are ink-on-ground
   pairs the palettes already guarantee: `--text-faint` on `--input-bg` is the
   inert field, and `--bg` on `--primary` is the accent wearing the app's own
   ground, which clears every one of the five. */
.sh-switch {
	flex: none;
	position: relative;
	width: 2.25rem;
	height: 1.3rem;
	padding: 0;
	border: 1px solid var(--border);
	border-radius: 999px;
	background: var(--input-bg);
	cursor: pointer;
	transition: background 0.18s, border-color 0.18s;
}

.sh-switch::after {
	content: '';
	position: absolute;
	top: 50%;
	left: 0.16rem;
	width: 0.9rem;
	height: 0.9rem;
	margin-top: -0.45rem;
	border-radius: 50%;
	background: var(--text-faint);
	transition: transform 0.18s, background 0.18s;
}

.sh-switch[aria-checked="true"] { background: var(--primary); border-color: var(--primary); }

.sh-switch[aria-checked="true"]::after { background: var(--bg); transform: translateX(0.9rem); }

.sh-switch:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }

/* Dims rather than stepping its ground, the same call `.sh-filter-chip:active`
   made: brightness acts on whichever ground the switch resolved to, so one rule
   covers both states. */
.sh-switch:active { filter: brightness(0.92); transition-duration: 0s; }

@media (prefers-reduced-motion: reduce) {
	.sh-switch, .sh-switch::after { transition: none; }
}

/* ---- Theme picker (js/shell/theme.js, in the shell drawer) -------------
   Swatches, not a list of names. Choosing a palette is the one setting where
   the words are useless — "Kraft Dark" tells you nothing a two-second look
   doesn't tell you better — so each choice renders a miniature of the real UI
   in the palette it's offering.

   The miniature is real, not drawn: styles.css keys every palette to
   [data-theme-sample] as well as :root[data-theme], so a swatch simply WEARS
   the palette. Its parts read var(--bg), var(--accent) and so on exactly like
   the app does, which means a retuned token updates the picker for free and
   the sample can never drift from the thing it's sampling. */
.sh-theme-picker { display: flex; flex-direction: column; gap: 1.1rem; }

.sh-theme-group { display: flex; flex-direction: column; gap: 0.5rem; }

.sh-theme-group-label {
	font-size: 0.72rem;
	text-transform: uppercase;
	letter-spacing: 0.04em;
	color: var(--text-faint);
}

/* Two up: a pair sits side by side so its light and dark halves are compared
   against each other rather than scrolled between.
   Fixed at two columns rather than auto-fit, because auto-fit stretches a lone
   swatch across the full width — which made the unpaired Plum read as a banner
   heading the picker rather than as one more choice among five. */
.sh-theme-group-swatches {
	display: grid;
	grid-template-columns: repeat(2, minmax(0, 1fr));
	gap: 0.6rem;
}

.sh-theme-swatch {
	display: flex;
	flex-direction: column;
	gap: 0.4rem;
	padding: 0.4rem;
	border: 1px solid var(--border);
	border-radius: 0.6rem;
	/* The swatch wears its own palette, so --bg here is the SAMPLED theme's
	   ground, not the page's. That's the whole mechanism. */
	background: var(--bg);
	color: var(--text);
	cursor: pointer;
	text-align: left;
	transition: border-color 0.15s, box-shadow 0.15s;
}

:where(:root[data-input="pointer"]) .sh-theme-swatch:hover { border-color: var(--primary); }

.sh-theme-swatch:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }

/* The chosen one is ringed in its own primary — the only cue that survives
   being drawn on five different grounds. A check mark or a tint would have to
   be legible on cream and near-black alike; a ring in the palette's own accent
   is legible by construction. */
.sh-theme-swatch[aria-checked="true"] {
	border-color: var(--primary);
	box-shadow: 0 0 0 2px var(--primary);
}

.sh-theme-swatch-art {
	display: flex;
	flex-direction: column;
	gap: 0.3rem;
	padding: 0.45rem;
	border-radius: 0.4rem;
	background: var(--surface);
	border: 1px solid var(--border);
}

/* Stands in for a pane heading — the accent's most common job. */
.sh-theme-swatch-head {
	height: 0.4rem;
	width: 58%;
	border-radius: 999px;
	background: var(--accent);
}

.sh-theme-swatch-row { display: flex; align-items: center; gap: 0.3rem; }

/* The two dots are the status marks: --accent for made, --value for sent. Two
   rows is the fewest that shows both, and showing both is the point — it's the
   pair a palette can get wrong. */
.sh-theme-swatch-dot {
	width: 0.45rem;
	height: 0.45rem;
	border-radius: 50%;
	background: var(--accent);
	flex-shrink: 0;
}

.sh-theme-swatch-dot.sh-theme-swatch-dot--alt { background: var(--value); }

.sh-theme-swatch-line {
	height: 0.3rem;
	flex: 1;
	border-radius: 999px;
	background: var(--text-muted);
	opacity: 0.55;
}

.sh-theme-swatch-line.sh-theme-swatch-line--long { background: var(--text); opacity: 0.8; }

.sh-theme-swatch-btn {
	margin-top: 0.1rem;
	height: 0.65rem;
	width: 44%;
	border-radius: 0.2rem;
	background: var(--primary);
}

.sh-theme-swatch-name {
	font-size: 0.8rem;
	font-weight: 600;
	color: var(--text);
	padding-left: 0.1rem;
}

/* ---- Master-detail panes (js/shell/panes.js) ---------------------------
   A view that opts into `.sh-view-panes` gets a list pane and a detail pane.
   Mobile (here): one at a time — both live in the same grid area and
   `data-pane` picks the visible one. From 48rem they split side by side; see
   the responsive section at the bottom of the file. */

.sh-view.sh-view-panes {
	max-width: none;
	padding: 0;
	overflow: hidden;
	display: grid;
	grid-template-columns: 1fr;
	grid-template-rows: 1fr;
	grid-template-areas: "pane";
}

.sh-pane {
	grid-area: pane;
	min-height: 0;
	display: flex;
	flex-direction: column;
	overflow: hidden;
}

/* Pinned: it sits outside .sh-pane-body, which is the part that scrolls. Top pad
   clears the notch — the panes reach the top edge with no header above them. */
.sh-pane-head {
	flex-shrink: 0;
	display: flex;
	align-items: center;
	justify-content: space-between;
	gap: 0.5rem;
	padding: calc(0.7rem + env(safe-area-inset-top)) 1rem 0.7rem;
	background: var(--bg);
	border-bottom: 1px solid var(--border);
}

/* margin-right: auto, not justify-content: space-between on the parent. The head
   held two children for its whole life (title, add) and space-between read as
   "title left, control right"; it actually means "spread everything evenly", and
   the moment Filters joined it stranded that mark in the middle of the bar. Auto
   margin on the title is the rule that says what was always meant — title left,
   every control clustered right — and it stays correct as the cluster grows. */
.sh-pane-head h2 {
	margin-right: auto;
	font-size: 1.1rem;
	font-weight: 600;
	color: var(--accent);
}

/* --pane-pad-top is published, not just applied, because .sh-detail-head sticks
   to the top of this scroller and has to cancel exactly this much padding to
   land flush. Two rules set it (here and the desktop override) and one rule
   reads it, so the two numbers cannot drift apart by hand-copying. */
.sh-pane-body {
	--pane-pad-top: 0.75rem;
	flex: 1;
	min-height: 0;
	overflow-y: auto;
	padding: var(--pane-pad-top) 1rem 1.5rem;
}

.sh-pane-back {
	display: inline-flex;
	align-items: center;
	gap: 0.15rem;
	border: none;
	background: transparent;
	color: var(--text-muted);
	font-size: 0.85rem;
	padding: 0.2rem 0.15rem;
	cursor: pointer;
}

:where(:root[data-input="pointer"]) .sh-pane-back:hover { color: var(--text); }

/* Cross-view back for the SPLIT layout. On mobile the smart ‹ chevron in the
   pane-head does this job; but from 48rem up the pane-head is hidden (the panes are
   side by side), so a jumped-to detail would have no way back. This crumb sits at
   the top of the detail pane and appears only there — shown by JS (hidden attr) when
   there's cross-view history, and only rendered in the split layout via the media
   query below so the two backs never both appear. */
.sh-pane-crumb { display: none; }

.sh-pane-crumb {
	align-items: center;
	gap: 0.2rem;
	border: none;
	background: transparent;
	/* Quiet by default (TODO #18: "reads busy"). It used to be full --primary,
	   which put a second gold element at the top of a panel that already has a
	   gold Edit — and unlike Edit it's conditional chrome, only there when a
	   cross-link brought you in. Muted until you reach for it keeps it findable
	   without letting navigation shout louder than the thing you navigated to. */
	color: var(--text-muted);
	font-size: 0.85rem;
	/* Align with the detail body: same left inset as .pane-detail .pane-body's
	   padding, and top breathing room so it isn't jammed to the pane's top edge.
	   The body keeps its own top padding, so the design name still clears this. */
	padding: 1.1rem 1.25rem 0;
	cursor: pointer;
}

/* Split from :focus-visible rather than guarded together: the guard is about a
   finger stranding a hover, and a keyboard user's focus ring must not depend on
   what the last pointer was. */
:where(:root[data-input="pointer"]) .sh-pane-crumb:hover { color: var(--primary); }

.sh-pane-crumb:focus-visible { color: var(--primary); }

/* The chevron joins the outline icon language (see --icon-weight) instead of
   carrying its own stroke — it was 1.27px against the app's 1.375px. */
.sh-pane-crumb-ic {
	--icon-px: 16;
	width: calc(var(--icon-px) * 1px);
	height: calc(var(--icon-px) * 1px);
	display: block;
	fill: none;
	stroke: currentColor;
	stroke-width: var(--icon-weight);
	stroke-linecap: round;
	stroke-linejoin: round;
}

.sh-pane-crumb-ic * { vector-effect: non-scaling-stroke; }

@media (min-width: 48rem) {
	.sh-pane-crumb:not([hidden]) { display: inline-flex; }
}

/* Only one pane shows below the split — data-pane says which. */
@media (max-width: 47.99rem) {
	.sh-view-panes[data-pane="list"] .sh-pane-detail { display: none; }
	.sh-view-panes[data-pane="detail"] .sh-pane-list { display: none; }
}

/* ---- Form drawer (js/shell/drawer.js) ---------------------------------
   Overlays the whole shell, not just the content column, so the scrim dims the
   rail / tab bar too and the mobile sheet rises over the tab bar. Always in the
   DOM and translated off-screen (so it can animate), which is why drawer.js
   keeps it `inert` while closed. */

.sh-scrim {
	position: absolute;
	inset: 0;
	background: rgba(0, 0, 0, 0.5);
	opacity: 0;
	pointer-events: none;
	transition: opacity 0.28s;
	z-index: 20;   /* over the tab bar and the rail */
}

.sh-app-shell[data-drawer="open"] .sh-scrim { opacity: 1; pointer-events: auto; }

.sh-drawer {
	position: absolute;
	z-index: 21;
	display: flex;
	flex-direction: column;
	background: var(--surface);
	transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);

	/* Bottom sheet on mobile; a right-hand drawer once the panes split. */
	left: 0;
	right: 0;
	bottom: 0;
	height: 94%;
	transform: translateY(101%);
	border-top: 1px solid var(--border);
	border-radius: 1.1rem 1.1rem 0 0;
	box-shadow: 0 -16px 40px -20px var(--shadow);
}

.sh-app-shell[data-drawer="open"] .sh-drawer { transform: none; }

/* While the panel is sliding in it is a target moving ~2200px/s, so nothing on it
   — or on the scrim it is rising over — accepts a tap until it has arrived. Both
   halves are needed: the scrim alone let a tap close the sheet that had just been
   opened, and the panel alone let a press and its click land on two different
   controls. drawer.js sets and clears the attribute, and reads the wait off the
   transition below, so the two cannot disagree. */
.sh-app-shell[data-drawer-presenting] .sh-scrim,
.sh-app-shell[data-drawer-presenting] .sh-drawer { pointer-events: none; }

/* The panel takes focus on open (drawer.js), so it carries tabindex="-1" and can
   match :focus. Suppressed because the ring would be a rectangle around the whole
   sheet — Chrome's :focus-visible heuristic paints one when the drawer was opened
   from the keyboard, since focus moved programmatically from an element that was
   itself focus-visible. Nothing is lost: a dialog sliding in over a dimmed app is
   its own indicator, and the first Tab lands on a real control that shows its own
   ring. This is not the "never remove an outline" case — that rule is about
   controls, and the panel is a container you cannot act on. */
.sh-drawer:focus { outline: none; }

.sh-drawer-head {
	flex-shrink: 0;
	display: flex;
	align-items: center;
	justify-content: space-between;
	gap: 0.5rem;
	padding: 0.9rem 1rem;
	background: var(--surface);
	border-bottom: 1px solid var(--border);
}

.sh-drawer-head h3 { font-size: 1rem; font-weight: 700; color: var(--accent); }

/* A bottom sheet's header bar: Cancel · title · Save, the iOS modal presentation.
   Present only when an occupant has put controls here (js/shell/form.js moves
   them, below 48rem only), so every other sheet — Filters, Photos, Export,
   Appearance — keeps the plain title-and-X head it has always had.

   Three columns with the outer two equal, so the title is centred on the SHEET
   rather than on the gap left between two buttons of different widths — "Add an
   occasion" beside "Save changes" would otherwise sit visibly off-centre. */
.sh-drawer-head[data-head-actions] {
	display: grid;
	grid-template-columns: 1fr auto 1fr;
	align-items: center;
}

/* The title drops the accent while it shares the bar with Save. Gold is what the
   eye goes to, and a heading is not the thing to act on — with both gold, "Add a
   person" and "Add person" read as two titles rather than a label and a button.
   Alone with the X (every other sheet) the accent has nothing to compete with and
   stays. */
.sh-drawer-head[data-head-actions] h3 { text-align: center; color: var(--text); }

/* The X goes: Cancel is wired to the same drawer.close() in all six forms, so
   both present is two controls for one action in the narrowest row in the app. */
.sh-drawer-head[data-head-actions] .sh-drawer-close { display: none; }

/* Text buttons here, not the full-width filled .sh-btn the foot of a form wants.
   Save keeps the accent so the committing action is still the loud one; both get
   enough padding to clear the 44px touch target on their own. */
.sh-drawer-head[data-head-actions] [data-form-cancel],
.sh-drawer-head[data-head-actions] [data-form-submit] {
	width: auto;
	margin: 0;
	padding: 0.6rem 0.2rem;
	background: none;
	font-size: 0.95rem;
}

.sh-drawer-head[data-head-actions] [data-form-cancel] {
	justify-self: start;
	color: var(--text-muted);
	font-weight: 500;
}

.sh-drawer-head[data-head-actions] [data-form-submit] {
	justify-self: end;
	color: var(--accent);
	font-weight: 700;
}

:where(:root[data-input="pointer"]) .sh-drawer-head[data-head-actions] [data-form-cancel]:hover { color: var(--text); background: none; }

:where(:root[data-input="pointer"]) .sh-drawer-head[data-head-actions] [data-form-submit]:hover { background: none; text-decoration: underline; }

.sh-drawer-close {
	flex-shrink: 0;
	border: none;
	background: transparent;
	color: var(--text-faint);
	width: 2rem;
	height: 2rem;
	border-radius: 0.5rem;
	font-size: 1.4rem;
	line-height: 1;
	cursor: pointer;
}

:where(:root[data-input="pointer"]) .sh-drawer-close:hover { background: var(--surface-2); color: var(--text); }

.sh-drawer-body {
	flex: 1;
	min-height: 0;
	overflow-y: auto;
	padding: 0.25rem 1rem calc(1.5rem + env(safe-area-inset-bottom));
}

@media (prefers-reduced-motion: reduce) {
	.sh-scrim, .sh-drawer { transition: none; }
}

/* ---- Lightbox (js/shell/lightbox.js) ----------------------------------
   The picture viewer: full-bleed over the app, a track of three slides, a bar
   and a filmstrip. Reasoning, and the iOS / macOS audit behind the control
   placement, is in that module's header.

   BLACK, NOT A SURFACE TOKEN — the same call the viewfinder makes and for the
   same reason: this is the ground behind a photograph and reads as one in every
   theme, where a palette surface would tint it. It is a knowing divergence from
   both platforms, whose viewers follow the appearance and only go black once the
   chrome is hidden; this one has no chrome-hidden state to defer the switch to.
   Flip it to `var(--bg)` if a light theme ever makes the mode switch bite — it
   is this one declaration, and the controls below already read against either. */
.sh-lightbox {
	position: fixed;
	inset: 0;
	z-index: 45;   /* over the drawer (21) and its full-screen occupants (40) */
	display: flex;
	flex-direction: column;
	background: #000;
	color: #fff;
}

.sh-lightbox:focus { outline: none; }

.sh-lightbox.is-settling { transition: transform 0.2s ease-out, opacity 0.2s ease-out; }

/* Three columns with equal flanks, so the title stays centred on the VIEWER as
   the arrows appear and the count grows — the same arrangement, and the same
   reason, as .sh-drawer-head[data-head-actions].

   EVERY CHILD NAMES ITS COLUMN, and that is load-bearing rather than tidy. The
   arrows go `display: none` on two routes below — the narrow viewport and a
   single-picture set — and a `display: none` element is not a grid item at all,
   so auto-placement slid the heading into the first flank and the close into the
   middle, leaving the trailing `1fr` empty and both of them crowded into the left
   half of the bar. Reported from a phone, where the arrows are always absent.
   `visibility: hidden` would also hold the flanks and is the wrong fix: it
   reserves ~4.8rem of invisible chevrons on a 430px screen and spends it on the
   title. */
.sh-lightbox-bar {
	flex: 0 0 auto;
	display: grid;
	grid-template-columns: 1fr auto 1fr;
	align-items: center;
	gap: 0.5rem;
	padding: calc(0.55rem + env(safe-area-inset-top)) 0.6rem 0.55rem;
}

.sh-lightbox-arrows[hidden] { display: none; }

/* Paging is CHROME, never an overlay on the picture — see the module header.
   Below the split it is absent rather than disabled: there is no pointer to aim
   it, the drag and the filmstrip both work, and iOS Photos has no chevrons. */
.sh-lightbox-arrows { grid-column: 1; justify-self: start; display: none; gap: 0.2rem; }

.sh-lightbox-heading { grid-column: 2; text-align: center; min-width: 0; }

.sh-lightbox-title {
	font-size: 0.9rem;
	font-weight: 600;
	white-space: nowrap;
	overflow: hidden;
	text-overflow: ellipsis;
}

.sh-lightbox-count {
	font-size: 0.75rem;
	color: rgba(255, 255, 255, 0.62);
	font-variant-numeric: tabular-nums;   /* "9 of 10" must not shift the title */
}

/* The controls are painted ON the black in white rather than in palette tokens,
   for the reason .photo-cam-bar's are: this is not a surface of the app. */
.sh-lightbox-nav, .sh-lightbox-close {
	border: none;
	border-radius: 50%;
	background: rgba(255, 255, 255, 0.12);
	color: #fff;
	cursor: pointer;
	display: flex;
	align-items: center;
	justify-content: center;
	padding: 0;
}

.sh-lightbox-nav { width: 2.3rem; height: 2.3rem; }
.sh-lightbox-nav svg { width: 1.2rem; height: 1.2rem; fill: none; stroke: currentColor; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; }
.sh-lightbox-nav:disabled { opacity: 0.25; cursor: default; }

.sh-lightbox-close {
	grid-column: 3;
	justify-self: end;
	width: 2.4rem;
	height: 2.4rem;
	font-size: 1.35rem;
	line-height: 1;
}

:where(:root[data-input="pointer"]) .sh-lightbox-nav:not(:disabled):hover,
:where(:root[data-input="pointer"]) .sh-lightbox-close:hover { background: rgba(255, 255, 255, 0.24); }

/* `pinch-zoom` and not `none`: the drag needs both single-finger axes, but taking
   the browser's own pinch away would remove the only magnification this surface
   has — and never breaking pinch-zoom is a standing rule here. */
.sh-lightbox-stage {
	flex: 1;
	min-height: 0;
	position: relative;
	overflow: hidden;
	touch-action: pinch-zoom;
}

.sh-lightbox-track {
	display: flex;
	height: 100%;
	width: 300%;
	transform: translateX(-33.3333%);
}

.sh-lightbox-track.is-animating { transition: transform 0.24s cubic-bezier(0.4, 0, 0.2, 1); }

.sh-lightbox-slide {
	width: 33.3333%;
	height: 100%;
	display: flex;
	align-items: center;
	justify-content: center;
	padding: 0 0.5rem;
}

.sh-lightbox-img {
	max-width: 100%;
	max-height: 100%;
	object-fit: contain;
	display: block;
	user-select: none;
	-webkit-user-select: none;
	-webkit-user-drag: none;
}

.sh-lightbox-img.is-broken { opacity: 0.4; }

.sh-lightbox-foot { flex: 0 0 auto; padding: 0.5rem 0 max(1rem, env(safe-area-inset-bottom)); }
.sh-lightbox-foot[hidden] { display: none; }

/* Scrolls sideways and never wraps, for the reason the camera tray does not: a
   second row would grow mid-session and move the picture above it. */
.sh-lightbox-film {
	display: flex;
	gap: 0.35rem;
	overflow-x: auto;
	padding: 0 0.75rem;
	scrollbar-width: none;
	justify-content: safe center;
}

.sh-lightbox-film::-webkit-scrollbar { display: none; }

.sh-lightbox-thumb {
	flex: 0 0 auto;
	width: 3rem;
	height: 3rem;
	padding: 0;
	border: none;
	border-radius: 6px;
	overflow: hidden;
	cursor: pointer;
	background: rgba(255, 255, 255, 0.1);
	opacity: 0.5;
	transition: opacity 0.15s, box-shadow 0.15s;
}

.sh-lightbox-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }

/* Position is carried by BOTH opacity and a ring, not by the ring alone: a strip
   of near-identical card fronts is exactly where one thin outline is easy to
   lose, and dimming the rest says which one you are on at a glance. */
.sh-lightbox-thumb.is-current { opacity: 1; box-shadow: 0 0 0 2px #fff; }

:where(:root[data-input="pointer"]) .sh-lightbox-thumb:hover { opacity: 0.85; }

@media (prefers-reduced-motion: reduce) {
	.sh-lightbox-track, .sh-lightbox.is-settling, .sh-lightbox-thumb { transition: none; }
}

@media (min-width: 48rem) {
	.sh-lightbox-arrows { display: flex; }
	.sh-lightbox-thumb { width: 3.6rem; height: 3.6rem; }
}

/* ---- Buttons + form controls ------------------------------------------
   The bare `label` / `input` / `textarea` rules these build on are in the reboot
   block at the head of this file, not here — see its header for why they cannot
   sit app-side. */

/* Type-to-search combobox (js/shell/combobox.js): a text input + a themed popup we
   render ourselves, because native <datalist>/<select> popups can't be styled. */
.sh-combobox { position: relative; }

/* Tighter PADDING than a plain form input: the combobox shows up beside dense
   content (a picker sheet, a roster's add row) rather than in a spacious stacked
   form. It used to declare `font-size: 0.95rem` too, which never once applied —
   the widget renders an <input type="text">, and `input[type="text"]` above
   out-specifies a bare class (0,1,1 vs 0,1,0), so the computed size has always
   been the form's 1.1rem. Removed rather than made to win: at 15.2px it would
   have started triggering iOS's focus zoom the moment it took effect. */
.sh-combobox-input {
	width: 100%;
	padding: 0.55rem 0.65rem;
}

.sh-combobox-list {
	position: absolute;
	top: calc(100% + 0.25rem);
	left: 0;
	right: 0;
	z-index: 30;
	max-height: 14rem;
	overflow-y: auto;
	display: flex;
	flex-direction: column;
	gap: 0.1rem;
	padding: 0.3rem;
	background: var(--surface);
	border: 1px solid var(--border);
	border-radius: 0.6rem;
	box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
}

.sh-combobox-list[hidden] { display: none; }

.sh-combobox-opt {
	width: 100%;
	text-align: left;
	padding: 0.45rem 0.55rem;
	border: none;
	border-radius: 0.4rem;
	background: transparent;
	color: var(--text);
	font-size: 0.9rem;
	cursor: pointer;
}

/* Split, so .is-active keeps painting regardless of the input in use: it is the
   ARROW-KEY cursor, not a pointer state, and gating it would make the combobox
   unnavigable from the keyboard the moment anyone last touched the screen. */
:where(:root[data-input="pointer"]) .sh-combobox-opt:hover,
.sh-combobox-opt.is-active {
	background: color-mix(in srgb, var(--primary) 16%, var(--surface));
}

/* An option carrying a `hint` (see js/shell/combobox.js) puts the label left and
   the dimmed hint right — e.g. which circle-mate already has this design, so you
   can plan around a repeat instead of being warned after picking one. The hint
   never wraps under the label; a long design name gives way first. */
.sh-combobox-opt.has-hint {
	display: flex;
	align-items: baseline;
	justify-content: space-between;
	gap: 0.75rem;
}

.sh-combobox-opt-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

.sh-combobox-opt-hint {
	flex-shrink: 0;
	font-size: 0.78rem;
	color: var(--accent);
	opacity: 0.85;
}

/* ---- Popup menu (js/shell/menu.js) ------------------------------------
   Fixed rather than absolute, and positioned by the module: both surfaces it
   opens over are `overflow-y: auto`, so an in-flow menu is clipped by its own
   scroller and scrolls away from its anchor. The module's header carries the
   rest of the reasoning, including why this is a menu and not a bottom sheet.

   z-index clears the drawer (21) and its scrim (20): the same menu opens from
   the panel and from inside the sheet. */
.sh-menu {
	position: fixed;
	z-index: 40;
	min-width: 13.5rem;
	max-width: min(20rem, calc(100vw - 1rem));
	padding: 0.3rem;
	background: var(--surface-2);
	border: 1px solid var(--border);
	border-radius: 0.75rem;
	box-shadow: 0 14px 34px -14px var(--shadow);
}

.sh-menu-item {
	display: flex;
	align-items: center;
	gap: 0.65rem;
	width: 100%;
	padding: 0.6rem 0.55rem;
	border: none;
	border-radius: 0.5rem;
	background: none;
	color: var(--text);
	font: inherit;
	font-size: 0.95rem;
	text-align: left;
	cursor: pointer;
}

:where(:root[data-input="pointer"]) .sh-menu-item:hover { background: var(--surface-3); }

.sh-menu-item:focus-visible { outline: 2px solid var(--primary); outline-offset: -2px; }

/* Scoped to the control, not to the menu box, for the reason the browse bar's
   marks are (→ ui-conventions.md → Icons): a mark added later inherits the
   weight instead of choosing one. */
.sh-menu-ic {
	display: flex;
	flex: 0 0 auto;
	color: var(--text-muted);
	fill: none;
	stroke: currentColor;
	stroke-width: var(--icon-weight);
	stroke-linecap: round;
	stroke-linejoin: round;
}

.sh-menu-ic svg { width: 1.15rem; height: 1.15rem; display: block; }

.sh-menu-ic * { vector-effect: non-scaling-stroke; }

.sh-menu-sep { height: 1px; background: var(--border); margin: 0.3rem 0.45rem; }

/* The section action when it opens a menu rather than doing one thing.
   The mark is DRAWN, not typed. `⋯` at the heading's size is a couple of pixels
   of midline dots and vanishes on a phone; three circles in a 24-box scale with
   the rest of the type and hold their weight in every theme. The padding takes
   the target to a finger and the matching negative margin gives the space back,
   so the heading's baseline sits exactly where a text action left it. */
.sh-detail-action--menu {
	display: inline-flex;
	align-items: center;
	line-height: 1;
	padding: 0.6rem 0.7rem;
	margin: -0.6rem -0.7rem;
}

.sh-detail-action--menu svg {
	width: 1.4rem;
	height: 1.4rem;
	display: block;
	fill: currentColor;
	stroke: none;
}

.sh-btn {
	width: 100%;
	margin-top: 0.25rem;
	padding: 0.8rem;
	border: none;
	border-radius: 0.5rem;
	background: var(--primary);
	color: var(--on-primary);
	font-size: 0.95rem;
	font-weight: 600;
	cursor: pointer;
	transition: background 0.2s;
}

:where(:root[data-input="pointer"]) .sh-btn:hover { background: var(--primary-hover); }

/* The filled destructive variant (js/shell/form.js → dangerButton). Its ink is
   --on-danger, which OPPOSES the swatch rather than being white — see
   docs/ui-conventions.md → Themes for why, and the arithmetic.

   ORDER IS LOAD-BEARING: this sits BEFORE :disabled deliberately. Both selectors
   are specificity (0,2,0) — a pseudo-class counts as a class — so source order is
   the only tie-break, and :disabled has to win. Restore's button ships disabled
   until you type the confirm word, and a disabled control that still wears full
   danger red reads as armed. */
.sh-btn.sh-btn--danger { background: var(--danger); color: var(--on-danger); }

.sh-btn:disabled { background: var(--surface-2); color: var(--text-faint); cursor: default; }

.sh-btn.sh-btn--secondary { background: var(--surface-2); color: var(--text); }

:where(:root[data-input="pointer"]) .sh-btn.sh-btn--secondary:hover { background: var(--surface-3); }

/* Press: one rule covers primary, secondary AND danger, because `brightness` acts on
   whatever background the variant resolved to rather than naming a colour. The
   alternative was a `--primary-pressed` / `--surface-pressed` / `--danger-pressed`
   trio, which is fifteen new token declarations across five palettes to express a
   state that lasts as long as a finger is down. Opacity was refused for the reason
   the archived rows record: alpha MULTIPLIES what the token already was, so it
   compounds unpredictably per theme, where brightness composes with the hover colour
   already in play. Placed after :disabled so it cannot dim a button that is not
   pressable — :active never matches a disabled button, but the order says so too. */
.sh-btn:active { filter: brightness(0.92); transition-duration: 0s; }

.sh-empty {
	color: var(--text-faint);
	font-size: 0.95rem;
	text-align: center;
	line-height: 1.5;
	padding: 2rem 0;
}

/* ---- Loading skeleton -------------------------------------------------- */
/* Shown by resource-view.js in place of the empty state while a view's records
   are still coming — and ONLY when there is nothing cached to show instead, so
   this is what a first launch on a new device looks like, not what every launch
   looks like.

   Bars rather than a spinner: the pane is about to hold a list, and drawing the
   shape it is about to hold means the swap to real rows moves nothing. A spinner
   centred in the pane would announce a wait and then jump.

   Both delays below are the point of the block, and both are stated in CSS
   rather than in setTimeout so nothing has to be cancelled when the rows land
   and replace this wholesale. */

/* 1. The flash guard. A boot that resolves quickly — signed out, or a warm
   session on a good connection, which measured at ~125ms — must not strobe a
   skeleton on its way past. Nothing paints for the first 260ms; after that the
   wait is real and worth admitting to. */
.sh-skeleton {
	display: flex;
	flex-direction: column;
	gap: 0.5rem;
	padding: 0.75rem 0;
	opacity: 0;
	animation: sh-skeleton-in 0.25s ease 0.26s forwards;
}

@keyframes sh-skeleton-in { to { opacity: 1; } }

/* The bar itself: a row-height block carrying a slow sheen left to right. The
   sheen is what separates "loading" from "broken" — a column of static grey
   blocks is indistinguishable from a rendering fault, which is the failure this
   whole item started from. Staggered so it reads as one surface being swept
   rather than six things blinking together. */
.sh-skeleton-row {
	height: 3.25rem;
	border-radius: 0.6rem;
	background: linear-gradient(90deg, var(--surface) 25%, var(--surface-2) 37%, var(--surface) 63%);
	background-size: 400% 100%;
	animation: sh-skeleton-sheen 1.6s ease-in-out infinite;
}

.sh-skeleton-row:nth-child(2) { animation-delay: 0.08s; }

.sh-skeleton-row:nth-child(3) { animation-delay: 0.16s; }

.sh-skeleton-row:nth-child(4) { animation-delay: 0.24s; }

.sh-skeleton-row:nth-child(5) { animation-delay: 0.32s; }

.sh-skeleton-row:nth-child(6) { animation-delay: 0.40s; }

@keyframes sh-skeleton-sheen {
	from { background-position: 100% 0; }
	to   { background-position: 0 0; }
}

/* 2. The ten-second line. A skeleton held long enough stops reading as a wait
   and starts reading as the screen you landed on — which is exactly how a
   spinner over a genuine stall makes the stall look intended. This says the
   quiet part instead.

   `visibility`, not opacity alone: an invisible-but-present paragraph is still
   in the accessibility tree, so a screen reader would announce a slow connection
   the instant the pane painted. visibility:hidden keeps it out until it is
   true. */
.sh-skeleton-slow {
	margin: 0.75rem 0 0;
	color: var(--text-faint);
	font-size: 0.9rem;
	text-align: center;
	visibility: hidden;
	opacity: 0;
	animation: sh-skeleton-slow 0.4s ease 10s forwards;
}

@keyframes sh-skeleton-slow {
	from { visibility: visible; opacity: 0; }
	to   { visibility: visible; opacity: 1; }
}

/* Reduced motion keeps both REVEALS and drops the sheen: the delays carry
   meaning (too fast to bother / slow enough to explain), where the sweep is
   decoration. A still skeleton needs a visible edge to read as a placeholder at
   all, so it takes the flat surface plus a border. */
@media (prefers-reduced-motion: reduce) {
	.sh-skeleton-row {
		animation: none;
		background: var(--surface);
		border: 1px solid var(--border);
	}
}

/* ---- People: list pane + detail pane + form drawer --------------------- */

/* "Select a person…" / "No people yet." — centred in the pane rather than
   parked at the top of an otherwise empty column. */
.sh-pane-detail .sh-empty {
	display: grid;
	place-items: center;
	height: 100%;
	padding: 2rem;
}

/* Every control in the pane head is a bare tinted mark — no border, no label.
   That is the position both platforms treat this way (Finder, Mail and Notes ship
   icon-only toolbars; iOS nav bars likewise), and it is what reconciled Filters
   and Add, which used to share only their font size: a 999px accent-bordered pill
   beside a 0.45rem neutral-bordered box on --input-bg. Five axes of mismatch,
   settled by deleting both treatments rather than picking one — matching them to
   each other would have left the head arguing with the tab bar instead.

   2.75rem is 44pt, Apple's minimum tap target, and it is load-bearing rather than
   round: a bare mark has no label padding to grow the target for it, so the
   button has to carry the whole thing.

   THE NEGATIVE MARGIN IS WHY THAT COSTS NOTHING. .sh-pane-head pads 0.7rem above
   and below, and that padding ADDS to the tallest child rather than being
   absorbed by it — measured, because the opposite was assumed first: the head
   went 51pt → 67pt, +16pt on every browse view, which is precisely the chrome #51
   is trying to reclaim. Pulling 0.5rem off each end lets the 44pt target overhang
   into padding that was empty space anyway, so the button renders and HITS at
   44pt while contributing 28px to layout and the head stays at 51pt. The
   alternative — trimming .sh-pane-head's own padding — would have shrunk the
   DETAIL pane's head too, where the back button is short and that padding is the
   only thing holding the bar open. */
.sh-glyph-btn {
	flex-shrink: 0;
	width: 2.75rem;
	height: 2.75rem;
	margin-block: -0.5rem;
	display: grid;
	place-items: center;
	padding: 0;
	border: none;
	border-radius: 0.5rem;
	background: transparent;
	color: var(--primary);
	cursor: pointer;
	transition: color 0.15s, background 0.15s;
}

.sh-glyph-btn svg { width: 1.35rem; height: 1.35rem; display: block; }

:where(:root[data-input="pointer"]) .sh-glyph-btn:hover { background: var(--surface-2); }

.sh-glyph-btn[hidden] { display: none; }

/* 44pt is the TOUCH minimum, and it is the wrong number for a trackpad. macOS
   toolbar buttons run around 28pt because a pointer is precise and does not need
   the slop; shipping the phone's figure at every width put ~11px of dead space
   around a 21.6px glyph and read as three controls drifting apart.
   Gated on POINTER, not on width, and that is the whole subtlety: an iPad in
   landscape is well past 48rem and is still a finger, so a width query would
   shrink the target on exactly the device that needs it most. `pointer: fine`
   describes the PRIMARY input, so an iPad with a trackpad attached still reports
   coarse and keeps 44pt.
   DELIBERATELY a different gate from the one on :hover, which keys off the input in
   USE (data-input) rather than the device. A hit target is a commitment you make
   BEFORE the finger arrives — you cannot resize a control as a finger approaches
   without moving it under them — whereas hover is paint, changes no geometry, and is
   free to flip mid-session. Layout keys off capability; paint keys off use.
   The negative margin goes with the size. It exists to let a 44pt control overhang
   .sh-pane-head's 0.7rem padding so the head stays at 51pt; at 28pt the button
   fits inside that padding and needs no overhang — 28 + 22.4 + 1 = 51.4, the same
   head height by a different route. Keeping the margin here would have shrunk the
   head to ~35pt instead. */
@media (pointer: fine) {
	.sh-glyph-btn {
		width: 1.75rem;
		height: 1.75rem;
		margin-block: 0;
	}
	.sh-glyph-btn svg { width: 1.1rem; height: 1.1rem; }
}

/* Idle Filters sits in --text-muted so the head has one lit control (add) rather
   than two competing for the accent; ON lights it and FILLS it. The fill is the
   signal, not a badge: iOS tints and fills its filter mark and shows no numeral,
   and colour alone is a signal anyone who cannot separate these hues does not
   get. The count is not lost — it lives in the button's accessible name, and the
   sheet one tap away lists which filters are on rather than just how many. */
.sh-glyph-btn[data-open-filters],
.sh-glyph-btn[data-open-overflow] { color: var(--text-muted); }

/* Not scoped to either mark. Both report the same thing — CHANGED FROM THE
   DEFAULT — and a third that ever wants the state should get it by wearing
   is-active, not by being added to a selector list. */
.sh-glyph-btn.is-active { color: var(--primary); }

.sh-glyph-btn.is-active .sh-glyph-solid { fill: currentColor; }

/* Knocked out in the pane's own ground, the way an SF Symbols .fill pair works.
   --bg and not --surface: .sh-pane-head paints --bg. */
.sh-glyph-btn[data-open-filters].is-active .sh-glyph-knock { stroke: var(--bg); }

/* The overflow's dots are FILLED, where the filter's lines are stroked: a 1.35-unit
   ring at 1.35rem is a smudge rather than a dot. So this pair sets fill, and has to
   turn the inherited stroke off or each dot wears a halo of its own outline. */
.sh-glyph-btn[data-open-overflow] .sh-glyph-knock { fill: currentColor; stroke: none; }

.sh-glyph-btn[data-open-overflow].is-active .sh-glyph-knock { fill: var(--bg); }

/* ---- List view: occasions by date, grouped by month -------------------- */
/* Sticky control bar: grouping lens + hide-done, pinned as the list scrolls. */

/* The Filters button and its one-tap clear are .sh-glyph-btn now — see the head
   controls above. .sh-filter-count went with them: a bare mark says "on" by
   lighting and filling, so the badge had nothing left to add that the mark and
   the sheet behind it did not already say. */

/* Filter sheet contents (rendered into the shared drawer). */
.sh-filter-panel { display: flex; flex-direction: column; }

.sh-filter-group { margin-bottom: 0.4rem; }

.sh-filter-label {
	font-size: 0.72rem;
	text-transform: uppercase;
	letter-spacing: 0.04em;
	color: var(--text-faint);
	margin: 0.7rem 0 0.5rem;
}

/* A block with no label is the LOOSE one — chips that name their own axis, sat
   at the top of the sheet. Its label supplied the top margin for every other
   block, so it has to be replaced here or the first chips sit on the panel edge.
   The shell writes the modifier rather than the stylesheet asking `:not(:has())`:
   nothing else here uses :has(), it is the newest selector in the file's
   vocabulary (Firefox 121), and a modifier is what the naming grammar already
   spells for "variant of the thing before it". */
.sh-filter-group--loose { margin-top: 0.7rem; }

.sh-filter-chips { display: flex; flex-wrap: wrap; gap: 0.4rem; }

/* What a group renders instead of chips when the records cannot tell those chips
   apart. It is a sentence saying how to make the thing exist, never a bare
   "nothing here" — see docs/ui-conventions.md → The Filters sheet. */
.sh-filter-note { font-size: 0.85rem; color: var(--text-muted); line-height: 1.5; }

.sh-filter-chip {
	padding: 0.45rem 0.8rem;
	border: 1px solid var(--border);
	border-radius: 0.5rem;
	background: var(--input-bg);
	color: var(--text-muted);
	font-size: 0.9rem;
	cursor: pointer;
	transition: border-color 0.15s, background 0.15s, color 0.15s;
}

/* The chip is where this bug was reproducible by hand on an iPhone, and it is
   worth knowing why it was the only reliable target: tap an ON chip to turn it
   off and .is-active goes, leaving only :hover — which iOS then kept, so a chip
   you had just switched off wore the accent border that means on. Every row
   candidate gave a false negative, because .is-selected is declared after its
   :hover at equal specificity and painted over the evidence. */
:where(:root[data-input="pointer"]) .sh-filter-chip:hover { border-color: var(--primary); }

/* Press feedback, settled here rather than left inherited: the chip was the one
   tap target the v75 sweep left silent. The argument for silence was that the
   chip's own on/off flip IS the acknowledgement — but that only holds for a tap
   that lands and commits, and a finger that presses and slides off got nothing
   at all. Buttons dim rather than stepping their ground the way rows do (see the
   PRESS FEEDBACK note above); brightness acts on whatever ground the chip
   resolved to, so one rule covers both the on and off states. */
.sh-filter-chip:active { filter: brightness(0.92); transition-duration: 0s; }

.sh-filter-chip.is-active {
	border-color: var(--value);
	background: color-mix(in srgb, var(--value) 14%, var(--surface));
	color: var(--text);
	font-weight: 600;
}

.sh-filter-panel #filter-done { margin-top: 1rem; }

/* Floating "jump to today" pill (list.js): absolutely positioned on .sh-pane-list
   (outside the scrolling .sh-pane-body, so re-renders never disturb it); shown only
   while the current month's header is off-screen. The arrow flips to point at
   today — .is-down = today is below the viewport. */
.sh-pane-list { position: relative; }

.sh-detail-name { font-size: 1.15rem; font-weight: 600; color: var(--text); margin-bottom: 0.4rem; }

/* Rows do not rule against each other. A panel of eight fields was a panel of
   eight hairlines; grouping is carried by the gap between .sh-detail-group
   blocks instead, which is what the platform does — Contacts separates groups
   with space and heads none of them. */
.sh-detail-field {
	display: flex;
	flex-direction: column;
	gap: 0.3rem;
	padding: 0.3rem 0;
}

.sh-detail-group { display: flex; flex-direction: column; }

.sh-detail-group + .sh-detail-group { margin-top: var(--panel-gap); }

/* A field label is a field label in both of its forms. This declaration used to
   be byte-identical to .sh-detail-section's, so a field heading and a section
   heading were the same thing typographically and the reader had to know the
   data model to tell which they were looking at. Caps now means exactly one
   thing: a named list follows. */
.sh-detail-label {
	flex-shrink: 0;
	font-size: 0.85rem;
	color: var(--text-muted);
}

/* Label + a quiet action on one line — Pictures … Edit. The panel header's own
   "title … Edit" relationship, one level down. */
.sh-detail-field-head { display: flex; align-items: baseline; justify-content: space-between; gap: 0.75rem; }

/* ---- One value box ------------------------------------------------------
   Everything that can occupy the value slot — a read value, an input, a select,
   a textarea — carries identical metrics, so the column has ONE left edge and
   ONE right edge in both modes, and a row does not change height when the mode
   flips. The read value's transparent border is what buys that last part.
   Two failures this closes, both of which looked like rendering faults:
   a negative horizontal margin on the input alone put it 0.4rem left of every
   other control and made adjacent date boxes overlap; and cancelling only the
   padding, not the border, left every wrapped control exactly 1px right. */
/* ONE SOURCE FOR THE SIZE OF EVERY PANEL CONTROL.
   Retuning how big the controls are is editing --slot-pad-y and --slot-font
   here, and nothing else — which is the point. The first pass set these three
   times in three rules that disagreed by accident, and the result was a 29px
   select beside two 52px inputs in the same date row. */
:root {
	/* 1rem is 16px, and 16px is WebKit's floor: focus a text control below it on
	   iOS and the whole page zooms, with no scrolling left to settle it back
	   because the shell is scroll-locked. This was 0.95rem — 15.2px — and every
	   panel field has been on the wrong side of that line since editing moved
	   onto the panel. `.sh-combobox-input` records the same trap being dodged by
	   accident: it declared 0.95rem, `input[type="text"]` out-specified it, and
	   the declaration was deleted rather than made to win. `input.sh-detail-input`
	   is qualified precisely so it DOES win, so it took the value for real.
	   The spec never saw it: viewport.mjs swept views and add forms, and a panel
	   at rest renders read values rather than controls. It opens edit mode now. */
	--slot-font: 1rem;
	--slot-pad-y: 0.15rem;
	--slot-pad-x: 0.4rem;
	--slot-radius: 0.35rem;
	/* The pull returns the value box's padding AND its border; --slot-grow adds
	   back exactly what the pull removed, for a control whose width is explicit
	   — with one, a negative margin moves the box instead of stretching it. */
	--slot-pull: calc(-1 * var(--slot-pad-x) - 1px);
	--slot-grow: calc(2 * var(--slot-pad-x) + 2px);
	/* ONE SOURCE FOR THE VERTICAL RHYTHM OF A PANEL — the slot block's second
	   half, and it was missing. The slot block fixed how big a control is; the
	   space BETWEEN blocks stayed a per-block decision, so three rules picked
	   three numbers for one gap: 1.3rem on `.add-recip`, 1.35rem between groups,
	   1.4rem on the design's Making block. Measured at 430px, that plus two
	   blocks sitting BETWEEN groups (and so breaking the `+` adjacency the gap
	   rides on) left the design panel 7px under Making where every other group
	   boundary in the app is 22px, and the occasion panel 0px under the stash
	   nudge. Three steps, and they are three because they mean three things:
	   a gap between sibling groups, a heading introducing a list, a panel foot. */
	--panel-gap: 1.35rem;
	--panel-gap-section: 2rem;
	--panel-gap-foot: 2.25rem;
}

/* Every control that can occupy a value slot, sized from the block above.
   The element-qualified selectors are load-bearing, not decoration: a bare
   class is (0,1,0) and LOSES to `input[type="text"]` (0,1,1), so the panel's
   inputs silently rendered at the drawer's size — 52px tall against a 29px
   select beside them. `ui-conventions.md` → Mobile viewport records the same
   trap for the combobox, and it caught this file next. */
.sh-detail-value,
input.sh-detail-input,
textarea.sh-detail-textarea,
.sh-detail-form select,
.sh-detail-form input,
.sh-detail-form textarea {
	font-size: var(--slot-font);
	color: var(--text);
	line-height: 1.45;
	padding: var(--slot-pad-y) var(--slot-pad-x);
	border: 1px solid transparent;
	border-radius: var(--slot-radius);
	margin: 0;
}

/* The pull that cancels the value box's own padding, for the controls that can
   occupy the slot. THE APP HALF CARRIES THE SAME DECLARATION for its own four
   (.seg, .status-picker, .make-value, .chip-list) — one rule cannot name both
   halves' children once the sheets are apart, and the shell may not name an app
   class. Only the opt-in is stated twice; the value stays single-sourced in
   --slot-pull, so a retune still reaches both. */
.sh-detail-value > .sh-detail-date,
.sh-detail-value > .sh-combobox,
.sh-detail-value > select { margin-left: var(--slot-pull); margin-right: var(--slot-pull); }
.sh-detail-value > select { width: calc(100% + var(--slot-grow)); display: block; }

/* Editable fields. The border arrives on focus; the filled ground is what says
   "this is editable now" at rest — borderless-until-focus is a real idiom for
   one targeted field (Finder rename, the macOS inspectors) and far too quiet
   for a whole screen, where read and edit then look identical. */
input.sh-detail-input,
textarea.sh-detail-textarea {
	width: 100%;
	min-width: 0;
	font-family: inherit;
	background: var(--input-bg);
	outline: none;
	transition: border-color 0.15s;
}

textarea.sh-detail-textarea { resize: vertical; }

input.sh-detail-input:focus,
textarea.sh-detail-textarea:focus { border-color: var(--primary); }

input.sh-detail-input::placeholder,
textarea.sh-detail-textarea::placeholder { color: var(--text-faint); }

.sh-detail-check input { width: auto; accent-color: var(--primary); margin-right: 0.4rem; }

/* One date, read as one date. Four separately labelled rows was the clunkiest
   thing on the occasion form. Every child needs min-width:0 or the fixed bases
   plus the inputs' width:100% sum past the column and the year spills out. */
.sh-detail-date { display: flex; gap: 0.35rem; align-items: center; width: 100%; min-width: 0; }

.sh-detail-date > * { min-width: 0; margin: 0; }

.sh-detail-date select { flex: 1 1 6rem; width: auto; }

.sh-detail-date .sh-date-day { flex: 0 0 2.6rem; width: 2.6rem; text-align: center; padding-left: 0.2rem; padding-right: 0.2rem; }

.sh-detail-date .sh-date-year { flex: 0 0 3.6rem; width: 3.6rem; text-align: center; padding-left: 0.2rem; padding-right: 0.2rem; }

/* The foot of an edit panel — reversible sibling, then the destructive one. */
.sh-detail-danger { display: flex; flex-direction: column; gap: 0.1rem; margin-top: var(--panel-gap-foot); }

/* ---- Detail-pane template (js/shell/resource-view.js) --------------------
   One skeleton for every detail: header (mark + title + meta + Edit) → inline
   state controls → attribute rows → sectioned related lists → destructive
   action last. `.sh-detail-field.sh-detail-field--inline` (detailLine) is the DEFAULT row — a short
   scalar reads as label-left / value-right on one line; the stacked
   `.sh-detail-field` is kept only for values that genuinely run several lines. */
/* PINNED to the top of the scroller, so the head's action slot cannot be
   scrolled away. That slot is `Edit` at rest and `Cancel · Done` in edit mode,
   and Done is the one that made this worth doing: a long panel put the only way
   to commit an edit below the fold, with nothing on screen saying where it went.
   This is the panel's version of a rule the drawer already holds — below 48rem
   `wireForm` lifts Save and Cancel into `.sh-drawer-head` because `formScaffold`
   otherwise leaves Save at the foot of a scrolling body (measured 345px below
   the fold on Add a person). Same failure, same answer, and pinning it in BOTH
   modes is what keeps Edit and Done the same control in the same place.
   It cannot ride `.sh-pane-head` instead, which is the tempting alternative:
   that head is `display: none` from 48rem up, where the panes are split and
   there is no "back" to offer — so on the wider of the two primary devices
   there would be nothing to pin to.
   The three offsets are the price of being content inside the scroller and each
   one is load-bearing: `top` cancels the body's padding so the stuck head sits
   flush; the negative margin cancels it again for the RESTING position, or the
   head starts one padding lower than it used to; and the padding puts back what
   both ate, so nothing moves at rest. All three read --pane-pad-top rather than
   repeating a number — the same drift this file's slot block exists to stop.
   The opaque background is not decoration: without it the rows scroll THROUGH
   the stuck head, which is the bug the old sticky search strip papered over
   with a flat box-shadow. */
.sh-detail-head {
	position: sticky;
	top: calc(-1 * var(--pane-pad-top));
	z-index: 2;
	display: flex;
	align-items: flex-start;
	justify-content: space-between;
	gap: 0.75rem;
	margin-top: calc(-1 * var(--pane-pad-top));
	padding-top: var(--pane-pad-top);
	/* The gap below the head is PADDING, not margin, and that is the whole
	   difference between pinning working and pinning looking broken. A margin is
	   transparent, so the first field scrolled up through it and sat visibly on
	   top of the name — measured as a sliver of the Phone row across "Albert
	   Cancel Done". Padding is inside the box the background paints. */
	padding-bottom: 0.9rem;
	background: var(--bg);
}

.sh-detail-head-main { min-width: 0; }

.sh-detail-head .sh-detail-name { margin-bottom: 0; }

.sh-detail-meta { margin-top: 0.2rem; font-size: 0.85rem; color: var(--text-muted); }

/* Edit is a quiet text button in the header, macOS-inspector style — never a
   filled block interrupting the content. (It can't live in the pane chrome:
   .sh-pane-detail .sh-pane-head is hidden once the panes split.) */
/* Read shows one quiet action; edit shows two. The head never changes shape. */
.sh-detail-actions { display: flex; gap: 0.9rem; flex-shrink: 0; align-items: baseline; }

.sh-detail-edit--done { font-weight: 650; }

.sh-detail-edit,
.sh-detail-action {
	flex-shrink: 0;
	background: none;
	border: none;
	padding: 0.15rem 0.1rem;
	font: inherit;
	font-size: 0.9rem;
	color: var(--primary);
	cursor: pointer;
}

:where(:root[data-input="pointer"]) .sh-detail-edit:hover,
:where(:root[data-input="pointer"]) .sh-detail-action:hover { text-decoration: underline; }

/* Same shape as Edit, but quieter: Edit is the panel's one primary action, so a
   second gold word mid-panel would compete with it — the same reason the desktop
   back-crumb stopped being gold. Muted until you reach for it. */
.sh-detail-action { font-size: 0.82rem; color: var(--text-muted); }

:where(:root[data-input="pointer"]) .sh-detail-action:hover { color: var(--primary); }

/* Label column then value column, both left-aligned. This overturns the old
   "label-left / value-right" rule deliberately: value-right is the iOS SETTINGS
   idiom, and the record surfaces this app is modelled on — Contacts — are
   left-aligned in both modes. It is also a LAYOUT change, not a text-align one.
   The row is a flex line, so a shrink-to-fit value gets pushed right whatever
   its text-align says; the label takes a fixed column and the value takes the
   rest, which is what puts read and edit on the same grid. */
.sh-detail-field.sh-detail-field--inline {
	flex-direction: row;
	align-items: baseline;
	justify-content: flex-start;
	gap: 0.5rem;
}

.sh-detail-field.sh-detail-field--inline > .sh-detail-label { flex: 0 0 7.5rem; }

.sh-detail-field.sh-detail-field--inline > .sh-detail-value,
.sh-detail-field.sh-detail-field--inline > .sh-detail-input { flex: 1; min-width: 0; }

/* Section header for a related list — carries its own space, so a list never
   collides with the block above it (the Circles detail's old complaint). */
.sh-detail-section.has-action {
	display: flex;
	align-items: baseline;
	justify-content: space-between;
	gap: 0.75rem;
}

.sh-detail-section {
	margin: var(--panel-gap-section) 0 0.4rem;
	font-size: 0.72rem;
	text-transform: uppercase;
	letter-spacing: 0.04em;
	color: var(--text-faint);
}

/* Shared form layout, scoped to the DRAWER and to the panel form rather than
   enumerated by form class. The old `.person-form, .occ-form, .design-form,
   .card-form` list silently missed `.circle-form` entirely when it arrived — so
   that form had no column layout, no label rhythm, no select styling and no
   button spacing, which is why it read cramped. Scoping means the next form is
   styled by existing.

   The `:not(.sh-detail-form)` guards below are still load-bearing, and now they
   are the only thing standing between two genuinely different kinds of surface.
   A record's own form — add or edit — renders on the detail panel and takes the
   value box. What is left in the drawer is the surfaces that were never a
   record's fields: Import, Export, restore, the recipient sheet, the design
   picker, the photo sheets, and the card editor (which has no panel to move to —
   see js/app/resources/cards.js). Those keep the drawer's taller controls. */
.sh-drawer-body form, .sh-detail-form { display: flex; flex-direction: column; }

/* The scaffolded form's own message line (js/shell/form.js). Forms used to
   borrow .sh-auth-msg, which is the sign-in surface's element doing double duty. */
.sh-form-msg { font-size: 0.85rem; color: var(--text-muted); margin-top: 0.7rem; min-height: 1.2rem; }

/* Field rhythm. `<label class="check-row">` is a label too, so checkbox rows get
   the same gap instead of jamming against whatever control sits above them.
   NOT on a detail-shaped form: its rhythm is --panel-gap between groups, and its
   rows are labels too (detailCheck emits one), so this would add a stray step. */
.sh-drawer-body form:not(.sh-detail-form) label { margin-top: 0.7rem; }

/* Button block: submit, then the secondary Cancel, then the danger link. Keyed
   off type/class, not per-form ids — those were duplicated in two places and
   already missing the circle form's. */
.sh-drawer-body form button[type="submit"] { margin-top: 1rem; }

.sh-drawer-body form .sh-btn.sh-btn--secondary { margin-top: 0.5rem; }

/* A select in a panel field wears the value box, not the drawer's tall control:
   the metrics come from the shared block above, and only the arrow gutter and
   the filled ground are added here. */
.sh-detail-form select {
	appearance: none;
	-webkit-appearance: none;
	font-family: inherit;
	padding-right: 1.6rem;
	background: var(--input-bg) var(--select-arrow) no-repeat right 0.45rem center;
	outline: none;
	transition: border-color 0.15s;
}

.sh-detail-form select:focus { border-color: var(--primary); }

/* Native select, styled to match the text inputs (arrow from --select-arrow).
   The drawer's tall control, for the surfaces that are not a record's fields —
   a detail-shaped form takes the value box from the block above instead, and
   this rule outweighs it on both specificity and source order. */
.sh-drawer-body form:not(.sh-detail-form) select {
	width: 100%;
	padding: 0.75rem;
	border: 1px solid var(--border);
	border-radius: 0.5rem;
	background: var(--input-bg) var(--select-arrow) no-repeat right 0.85rem center;
	color: var(--text);
	font-size: 1.1rem;
	outline: none;
	appearance: none;
	-webkit-appearance: none;
	transition: border-color 0.2s;
}

.sh-drawer-body form:not(.sh-detail-form) select:focus { border-color: var(--primary); }

.sh-link-danger {
	margin-top: 0.9rem;
	align-self: center;
	background: none;
	border: none;
	color: var(--danger);
	font-size: 0.85rem;
	cursor: pointer;
}

:where(:root[data-input="pointer"]) .sh-link-danger:hover { text-decoration: underline; }

/* The reversible sibling to the destructive action (formScaffold's `altLabel`),
   sitting just above it. Same quiet text-button shape so the two read as one
   pair of ways out — but in the ordinary text colour, because it is safe. The
   colour IS the distinction: reaching for the red one has to stay a deliberate
   act, and two red buttons stacked would make it a coin toss. */
.sh-link-alt {
	margin-top: 0.9rem;
	align-self: center;
	background: none;
	border: none;
	color: var(--text-muted);
	font-size: 0.85rem;
	cursor: pointer;
}

:where(:root[data-input="pointer"]) .sh-link-alt:hover { color: var(--text); text-decoration: underline; }

/* Both present: the pair tightens up so they read as siblings rather than as
   two unrelated afterthoughts at the bottom of the form. */
.sh-link-alt + .sh-link-danger { margin-top: 0.35rem; }

/* ---- Browse header: shared by every searchable list ------------------- */
/* The live search field. Built by browseControls() in js/shell/search.js and used
   by List, Cards, People and Circles alike — hence .browse-, not .cards-.
   It is NOT in the scroller. It mounts between .sh-pane-head and .sh-pane-body,
   so it is a sibling of the thing that scrolls rather than the first row of it.
   That one move retires everything the old arrangement needed to survive being
   content: position: sticky, two cancellations of .sh-pane-body's padding-top, a
   flat box-shadow to paint the strip rows leaked through, and the scrollTop write
   that pushed it out of frame on every list paint. Chrome does not need any of
   it, because chrome does not scroll.
   Horizontal padding matches .sh-pane-body's 1rem so the field's edges line up
   with the rows beneath it; the head above uses the same figure. */
.sh-browse-controls {
	flex-shrink: 0;
	display: flex;
	align-items: center;
	gap: 0.5rem;
	padding: 0.5rem 1rem;
	background: var(--bg);
	border-bottom: 1px solid var(--border);
}

/* Below 48rem the field is revealed by the head's magnifier rather than being
   permanently on screen — a phone cannot spare a row of chrome for a control most
   sessions never touch, and #51 is trying to reclaim what is already there. The
   state class is the reveal, and it is inert at 48rem and up because this rule is
   the only thing that reads it: the field simply never hides on a pointer device,
   so resizing a hidden field up to desktop width shows it rather than stranding
   it closed. That is why the hiding is expressed as "hide unless open" in a media
   query and not as the [hidden] attribute, which no media query can undo. */
@media (max-width: 47.99rem) {
	.sh-browse-controls:not(.is-open) { display: none; }
}

/* The magnifier is mobile-only for the same reason — at 48rem and up the field it
   would reveal is already there, so the control would be a no-op you can press. */
@media (min-width: 48rem) {
	.sh-browse-toggle { display: none; }
}

/* Muted at rest, lit while the field is open — the same two-state treatment
   Filters wears, and for the same reason. .sh-glyph-btn's base colour is the
   accent, which is right for Add: the head should hold ONE lit control, the
   view's primary action, rather than three marks competing for it. Search and
   Filters are utilities that report a state, so they idle quiet and light only
   when they are doing something. Keyed to aria-expanded rather than a second
   state class because the attribute already has to be maintained for screen
   readers, and two sources of the same truth is how they drift apart. */
.sh-browse-toggle { color: var(--text-muted); }

.sh-browse-toggle[aria-expanded="true"] { color: var(--primary); }

/* The pane head's trailing cluster: Filters (and its one-tap clear) inboard, the
   view's add button outermost — utility inboard, primary action outermost, the
   shape Calendar and Contacts use. Everything in this head is view-scoped, which
   is the point: a global control here would be the mismatch that sent Settings
   back to the tab bar. */
.sh-head-actions {
	display: flex;
	align-items: center;
	gap: 0.35rem;
}

.sh-head-actions:empty { display: none; }

/* Hung on the CONTROL, not on the marks and not on .sh-head-actions. Filters and
   its clear × live in that box, but the add button is its SIBLING — resource-view
   inserts the box beside it — so a rule scoped to the container reached two of
   the head's three marks and left the third on the UA's 1px black fill. Scoping
   to .sh-glyph-btn is what makes "every head control speaks the icon language"
   true by construction rather than by remembering.

   Same reason as the browse bar: stroke-width is in viewBox units, so per-mark
   declarations paint different lines at different box sizes, and vector-effect
   goes on the drawn shapes because it is not inherited. The fill/stroke pair is
   here too, since these marks carry no presentation attributes of their own —
   the alternative is four copies of fill="none" stroke="currentColor" in the
   markup, which is exactly the drift this rule exists to prevent. */
.sh-glyph-btn svg {
	fill: none;
	stroke: currentColor;
	stroke-width: var(--icon-weight);
	stroke-linecap: round;
	stroke-linejoin: round;
}

.sh-glyph-btn svg * { vector-effect: non-scaling-stroke; }

/* Cross-view results, beneath this view's own rows. The rule is scope, not
   decoration: the divider says "past here, we left this list", so the sections
   read as an extension of the same answer rather than a second result set. */
.sh-browse-global:not(:empty) {
	margin-top: 1.25rem;
	padding-top: 0.9rem;
	border-top: 1px solid var(--border);
}

.sh-browse-search {
	flex: 1 1 auto;
	/* Shrinkable below the input's intrinsic width — Gecko's default input minimum
	   is wider than Chromium's and otherwise pushes the Filters button out of the
	   pane (clipped) at the ~20rem list-pane width. */
	min-width: 0;
	display: flex;
	align-items: center;
	gap: 0.4rem;
	padding: 0 0.6rem;
	border: 1px solid var(--border);
	border-radius: 0.45rem;
	background: var(--input-bg);
	transition: border-color 0.15s;
}

.sh-browse-search:focus-within { border-color: var(--primary); }

.sh-browse-search svg { width: 0.95rem; height: 0.95rem; flex-shrink: 0; color: var(--text-faint); }

/* Element-qualified so it can beat `input[type="search"]` above: the frame, the
   ground and the width belong to `.sh-browse-search` around it, and a bare class
   loses all three back to the base control rule (see its comment). */
input.sh-browse-search-input {
	flex: 1 1 auto;
	/* Not the base rule's 100%: that is a flex BASIS here, so the field claims the
	   whole row and shrinks the magnifier and the × out of their gaps. */
	width: auto;
	min-width: 0;
	border: none;
	background: transparent;
	color: var(--text);
	/* 1rem is a FLOOR, not a taste call: iOS Safari zooms the page whenever a
	   focused input computes under 16px, and this was 0.9rem (14.4px) — the one
	   text field in the app that was under. It sat below the threshold because it
	   was sized to look quiet beside the Filters button, which is a real concern
	   but not one worth a zoom you can't get back out of (the shell is
	   scroll-locked, so the usual scroll-and-it-settles recovery is gone). Quiet
	   is now the job of colour and the borderless ground, not the type size. */
	font-size: 1rem;
	padding: 0.4rem 0;
}

.sh-browse-search-input:focus { outline: none; }

.sh-browse-search-input::placeholder { color: var(--text-faint); }

/* WebKit draws its own clear button in a type="search" field. Ours replaces it —
   see browseControls() for why (Gecko is a target and does not have this, and the
   pseudo-element is neither styleable nor visible to a spec). Two stacked × marks
   read as a rendering fault, so the native one goes. */
.sh-browse-search-input::-webkit-search-cancel-button { display: none; }

/* Present only while there is text. Inside the field it clears, which is the most
   conventional control iOS has — not to be confused with the head's clear-FILTERS
   ×, which came out for meaning something unexplained beside an unrelated mark.
   The hit box is 2rem rather than the head's 44pt: it sits inside a 2.4rem field
   with the caret beside it, so a 44pt target would overlap the text you are aiming
   the caret at. That is the trade Apple's own in-field clear makes too. */
.sh-browse-clear {
	flex-shrink: 0;
	display: grid;
	place-items: center;
	width: 2rem;
	height: 2rem;
	margin-right: -0.45rem;
	padding: 0;
	border: none;
	border-radius: 999px;
	background: transparent;
	color: var(--text-faint);
	cursor: pointer;
	transition: color 0.15s;
}

.sh-browse-clear[hidden] { display: none; }

:where(:root[data-input="pointer"]) .sh-browse-clear:hover { color: var(--text); }

.sh-browse-clear:active { color: var(--text); transition-duration: 0s; }

.sh-browse-clear svg { width: 1.05rem; height: 1.05rem; display: block; }

/* (A `.sh-browse-controls .sh-filter-btn` rule sat here, keeping Filters from
   shrinking as the search field grew. It had been dead since v69 moved Filters
   into the pane head — browseControls() emits the field alone now — and the
   liveness check could not see it, because it matches class NAMES and both of
   these are live separately. A descendant combinator joining two live classes is
   the blind spot; worth remembering the next time a rule looks unreachable.) */

/* The bar's three marks join the outline icon language rather than each naming a
   stroke. They used to set stroke-width inline in js/shell/search.js (2, 1.9, 2)
   against three different box sizes, so they painted 1.27 / 1.20 / 1.07px against
   the app's 1.375 — the × worst, because its box is 0.8rem where the others are
   0.95rem. That is the same viewBox-units trap as the glyph set; see --icon-weight
   in the token block and docs/circle-identity.md → "Line weight". Scoped to the
   container so a fourth mark added to this bar inherits the weight instead of
   choosing one, and vector-effect goes on the drawn shapes because it does not
   inherit. */
.sh-browse-controls svg { stroke-width: var(--icon-weight); }

.sh-browse-controls svg * { vector-effect: non-scaling-stroke; }

/* ---- Global search (js/shell/search-view.js) --------------------------
   Spotlight's shape: a pinned input, a Top Hit, then fixed-order sections capped
   with "Show all". Single-mount — a result JUMPS to its view rather than opening
   a detail beside it.

   In Cardbase it is not a view at all: search is EMBEDDED in each browse list,
   below that list's own filtered rows (js/app/main.js → "No search view"), so
   these rules render inside a `.sh-pane-body` and take their width from it. The
   host app decides that — search-view.js takes a mount, not a view — which is why
   nothing here sets a width of its own. */


.sh-search-section { margin-bottom: 1.1rem; }

.sh-search-section-head {
	font-size: 0.72rem;
	font-weight: 600;
	text-transform: uppercase;
	letter-spacing: 0.04em;
	color: var(--text-faint);
	margin-bottom: 0.35rem;
}

/* The Top Hit is the only section that earns emphasis — it's the answer, the rest
   are alternatives. Accent header + a tinted row, nothing louder. */
.sh-search-top .sh-search-section-head { color: var(--accent); }

.sh-search-top .sh-search-row { border-color: var(--accent); background: var(--surface-2); }

.sh-search-row {
	width: 100%;
	display: flex;
	flex-direction: column;
	align-items: flex-start;
	gap: 0.12rem;
	text-align: left;
	padding: 0.6rem 0.7rem;
	margin-bottom: 0.35rem;
	border: 1px solid var(--border);
	border-radius: 0.5rem;
	background: var(--surface);
	color: inherit;
	font: inherit;
	cursor: pointer;
	transition: border-color 0.15s, background 0.15s;
}

:where(:root[data-input="pointer"]) .sh-search-row:hover { border-color: var(--primary); }

.sh-search-row:last-child { margin-bottom: 0; }

/* Disclosure toggle, not a link — the chevron flips instead of an arrow pointing
   away, because the section expands in place and there is nowhere to go back
   from. See the comment in search-view.js. */
.sh-search-more {
	display: flex;
	align-items: center;
	gap: 0.3rem;
	margin-top: 0.35rem;
	padding: 0.3rem 0.1rem;
	border: none;
	background: none;
	color: var(--accent);
	font: inherit;
	font-size: 0.82rem;
	cursor: pointer;
}

:where(:root[data-input="pointer"]) .sh-search-more:hover span { text-decoration: underline; }

.sh-search-more svg {
	width: 0.85rem;
	height: 0.85rem;
	transition: transform 0.15s;
}

.sh-search-more.is-open svg { transform: rotate(180deg); }

/* ---- Auth modal ------------------------------------------------------- */

.sh-modal {
	position: fixed;
	inset: 0;
	background: rgba(0,0,0,0.55);
	display: flex;
	align-items: center;
	justify-content: center;
	padding: 1.5rem;
	z-index: 50;
}

.sh-modal[hidden] { display: none; }

.sh-modal-card {
	position: relative;
	background: var(--surface);
	border-radius: 1rem;
	padding: 1.5rem;
	width: 100%;
	max-width: 22rem;
	box-shadow: 0 8px 40px rgba(0,0,0,0.4);
}

.sh-modal-card h2 { font-size: 1.15rem; color: var(--accent); margin-bottom: 0.6rem; }

.sh-modal-card p { font-size: 0.9rem; color: var(--text-muted); line-height: 1.5; margin-bottom: 0.9rem; }

.sh-modal-card input[type="email"] { margin-bottom: 0.75rem; }

.sh-modal-card input[type="text"] { margin-bottom: 0.75rem; margin-top: 0.75rem; }

.sh-modal-close {
	position: absolute;
	top: 0.6rem;
	right: 0.7rem;
	border: none;
	background: transparent;
	color: var(--text-faint);
	font-size: 1.5rem;
	line-height: 1;
	cursor: pointer;
}

.sh-auth-msg { font-size: 0.85rem; color: var(--text-muted); margin-top: 0.7rem; min-height: 1.2rem; }

/* ---- Responsive app shell --------------------------------------------------
   The rules above are the mobile layout: top header, single centered column, and
   a fixed bottom tab bar. From 48rem up, a left rail replaces the header + tab bar
   and the shell becomes a two-column grid (rail | content); rail labels appear at
   64rem. Account + theme live in the rail foot on wide screens, in the top header
   on mobile — both are wired by the shell (bind-by-class), so either works. */

.sh-rail { display: none; }

   /* hidden on mobile; header + tab bar drive nav there */

/* Rail internals — defined once; revealed by the media queries below. */
.sh-rail-brand {
	display: flex;
	align-items: center;
	gap: 0.55rem;
	height: 2.4rem;
	padding: 0 0.35rem;
	margin-bottom: 0.6rem;
}

.sh-rail-mark {
	width: 1.9rem;
	height: 1.9rem;
	flex-shrink: 0;
	border-radius: 0.5rem;
	background: var(--mark-ground);
	display: grid;
	place-items: center;
	/* Its own shadow token, not --shadow: the plinth is transparent in the dark
	   palettes, and a shadow without a card behind it draws a floating rectangle. */
	box-shadow: 0 1px 3px var(--mark-shadow);
}

.sh-rail-mark svg { width: 1.35rem; height: 1.35rem; display: block; }

.sh-rail-brandword {
	display: none;
	font-weight: 700;
	font-size: 1.02rem;
	letter-spacing: -0.01em;
	color: var(--text);
}

.sh-rail-nav { display: flex; flex-direction: column; gap: 0.15rem; }

.sh-rail-item {
	display: flex;
	align-items: center;
	justify-content: center;
	height: 2.75rem;
	width: 100%;
	border: none;
	background: transparent;
	color: var(--text-muted);
	border-radius: 0.6rem;
	cursor: pointer;
	transition: background 0.18s, color 0.18s;
}

.sh-rail-item svg { width: 1.35rem; height: 1.35rem; flex-shrink: 0; }

.sh-rail-label { display: none; font-size: 0.9rem; font-weight: 500; }

:where(:root[data-input="pointer"]) .sh-rail-item:hover { background: var(--surface-2); color: var(--text); }

.sh-rail-item.is-active { background: color-mix(in srgb, var(--value) 16%, transparent); color: var(--value); }

	.sh-rail-item:active { background: var(--surface-3); transition-duration: 0s; }

.sh-rail-spacer { flex: 1; }

.sh-rail-foot { display: flex; flex-direction: column; align-items: center; gap: 0.35rem; }

/* Tablet & desktop: rail replaces the mobile header + bottom tab bar, and the
   master-detail panes split side by side. */
@media (min-width: 48rem) {
	.sh-tab-bar { display: none; }

	.sh-app-shell {
		display: grid;
		grid-template-columns: 4.75rem 1fr;
	}
	.sh-app-content { min-width: 0; }   /* let the content column shrink, no blowout */

	.sh-rail {
		display: flex;
		flex-direction: column;
		gap: 0.2rem;
		height: 100%;
		padding: 0.9rem 0.6rem calc(0.9rem + env(safe-area-inset-bottom));
		background: var(--surface);
		border-right: 1px solid var(--border);
	}

	/* The top header cleared the notch; without it, each view needs its own.
	   (The panes push this down onto their heads instead — see below.) */
	.sh-view { max-width: 36rem; padding-top: calc(1.25rem + env(safe-area-inset-top)); }

	/* List | detail. The list is a fixed-ish sidebar, the detail takes the rest.

	   The list track is a CLAMP rather than `minmax(17rem, 24rem)`, and the
	   difference only shows at the narrow end of the split. `minmax` with a fixed
	   max takes that max whenever the space exists, so the list claimed its full
	   24rem the moment the panes split — at 768px (iPad 9th gen portrait, the
	   narrowest split there is) that left the detail pane 308px, of which the
	   fixed 7.5rem label column ate 120px and a field's value slot got 140px.
	   Measured, not estimated.

	   That was survivable while the panel only ever showed read values and an
	   edit form you had opened deliberately. It stopped being survivable when
	   ADDING a record moved here too (js/shell/panel-editor.js): the add forms are
	   the long ones, and the first thing you meet in a new app should not be the
	   most cramped surface in it.

	   40% is proportional, so nothing changes on a wide screen — at 1440 the
	   percentage resolves above 24rem and clamps straight back to it — while at
	   768 the list takes 277px and the detail 415px, which is wider than the
	   335px drawer this replaced. One expression, no second breakpoint to keep
	   in step with the 48rem the panes already split at. */
	.sh-view.sh-view-panes {
		padding-top: 0;
		grid-template-columns: clamp(17rem, 40%, 24rem) 1fr;
		grid-template-areas: "list detail";
	}
	.sh-pane-list { grid-area: list; border-right: 1px solid var(--border); }
	.sh-pane-detail { grid-area: detail; }

	/* Both panes are visible, so there's nowhere to go "back" to. */
	.sh-pane-detail .sh-pane-head { display: none; }
	/* The measure cap is on the CONTENT, not on the scroller.
	   `.sh-pane-body` is the element that scrolls, so capping IT at 44rem put the
	   scrollbar wherever the text column ended instead of at the edge of the pane:
	   measured 144px inside the pane at 1440, and 624px at 1920, floating in open
	   space with nothing under it. Capping the children instead leaves the scroller
	   filling the pane — bar at the edge, where every other scroller in the app puts
	   it — and the reading measure unchanged at 44rem, still left-aligned because
	   neither rule centres anything. `> *` rather than a list of the four view
	   classes: the shell must not learn what a person or a design is, and `.sh-empty`
	   is a direct child too. */
	.sh-pane-detail .sh-pane-body { --pane-pad-top: 1.15rem; padding: var(--pane-pad-top) 1.25rem 2rem; }
	.sh-pane-detail .sh-pane-body > * { max-width: 44rem; }

	/* There is deliberately no .sh-browse-controls rule here any more. It used to
	   carry position: sticky plus two cancellations of .sh-pane-body's 0.75rem
	   padding-top — `top: -0.75rem` for the stuck position, `margin-top: -0.75rem`
	   for the resting one — and a padding-top putting back what both offsets ate.
	   Every line of that existed to make a field that lived INSIDE the scroller
	   behave like chrome. The field is chrome now: it mounts between the pane head
	   and .sh-pane-body, so it holds its position on every viewport without being
	   told to, and there is nothing for a scrolled month header to peek through.
	   Do not reintroduce this without first moving the field back into the
	   scroller, which is the thing to argue about — the sticky rule was only ever
	   the consequence. */

	/* The sheet becomes a right-hand drawer, over the detail, list still in view. */
	.sh-drawer {
		top: 0;
		left: auto;
		right: 0;
		bottom: 0;
		width: min(23rem, 86%);
		height: auto;
		transform: translateX(101%);
		border-top: none;
		border-left: 1px solid var(--border);
		border-radius: 0;
		box-shadow: -16px 0 40px -20px var(--shadow);
	}
	.sh-drawer-head { padding-top: calc(0.9rem + env(safe-area-inset-top)); }
}

/* Desktop: widen the rail and show labels. */
@media (min-width: 64rem) {
	.sh-app-shell { grid-template-columns: 13rem 1fr; }
	.sh-rail { padding-left: 0.7rem; padding-right: 0.7rem; }
	.sh-rail-brand { padding: 0 0.5rem; }
	.sh-rail-brandword { display: inline; }
	.sh-rail-item { justify-content: flex-start; gap: 0.75rem; padding: 0 0.7rem; }
	.sh-rail-label { display: inline; }
	.sh-rail-foot { flex-direction: row; justify-content: flex-start; gap: 0.4rem; padding-left: 0.35rem; }
}
