/* base.css — reset, typography, rhythm.
 *
 * @docs ../../README.md
 *
 * Concatenated (with components.css, filename order) into public/site.css by
 * pipeline/emit.mjs. theme/tokens.css is NOT concatenated in — theme/head.html
 * links it, and it loads FIRST, so every token below is already in scope here.
 *
 * 🔴 NO LITERAL COLOURS BELOW THE FALLBACK BLOCK. design.md's rule: colour
 * comes from theme tokens, never hand-picked, so switching a theme repaints
 * everything with zero JS and zero edits here. A hex in a rule further down is
 * a bug, not a shortcut.
 */

/* ── The token contract, and the window before it exists ────────────────────
 *
 * These are the tokens src/css/ consumes. They are generated by the theme
 * build (theme/tokens.css) from the vendored ColorTheme documents; this block
 * is a floor for the window where a scaffolded site has run `npm run build`
 * but not yet `npm run tokens`, so the page reads as designed rather than as
 * unstyled black-on-white.
 *
 * 🔴 `:where()` is load-bearing, not decoration. It has ZERO specificity, so
 * theme/tokens.css's own `:root { … }` beats every line below without needing
 * !important, without depending on file order, and without this block ever
 * fighting a real theme. A plain `:root` here would silently override the
 * whole palette.
 *
 * The values are design.md's stated defaults: off-black on off-white, never
 * #000 or #fff, one muted accent held for emphasis.
 */
:where(:root) {
  --bg: #FBFAF8;
  --surface: #F4F2ED;
  --surface-lift: #EAE7E0;
  --border: #E0DCD3;
  --border-bright: #9A948A;
  --text-primary: #23211E;
  --text-bright: #121110;
  --text-secondary: #55514B;
  --text-muted: #6B665E;
  --accent: #7A5C3E;
  --accent-bright: #5E4630;
  --link: #5E4630;
  --ink-on-accent: #FBFAF8;

  --font-body: system-ui, -apple-system, "Helvetica Neue", sans-serif;
  --font-display: system-ui, -apple-system, "Helvetica Neue", sans-serif;
  --font-mono: ui-monospace, "SF Mono", SFMono-Regular, monospace;

  --space-1: 0.5rem;
  --space-2: 1rem;
  --space-3: 1.5rem;
  --space-4: 2.5rem;
  --space-5: 4rem;
  --space-6: 6rem;

  --motion-duration: 160ms;
  --motion-ease: cubic-bezier(0, 0, 0.2, 1);
}

/* Type-led rhythm the theme has no opinion about. `--measure` is the reading
 * line-length ceiling — roughly 70 characters, the editorial register design.md
 * asks for (a well-set spread, not a full-bleed dashboard). */
:root {
  --measure: 46rem;
  /* ⭐ 960px, AND IT IS CHRISTIAN'S OWN NUMBER — dialled in with the prototype's
   * `kit/dials.js` on 2026-10-01 and recorded as `--content: 960px` in
   * `docs/designs/refactor/one-device.dialled.json`. It was `72rem` (1152px);
   * this narrows the page by 192px.
   * ⚠️ IT IS NOT ONLY THE TEXT COLUMN. Four things read it, and two of them are
   * arithmetic rather than layout:
   *   · `main` (below), `.site-footer` and `.docs-deck` (components.css) — the
   *     measurable half: every page on the site, docs and legal included, is
   *     192px narrower.
   *   · `src/pages/home/20-device.css` single-sources `--content: var(--page,
   *     1100px)` from it, so it is ALSO the column the travelling device is
   *     placed within — which makes it the elbow's cap term and the term in
   *     every `figScale` height-vs-width threshold.
   * 🔴 AND THE DIRECTION OF THAT IS THE OPPOSITE OF THE OBVIOUS ONE, so it is
   * written out: `--fw` is `min(figScale · 50% of the column, --half · --ar)`, so
   * the `figScale` at which the HEIGHT cap takes over is `2 · cap / column` —
   * narrowing the column RAISES it. Measured at 1440×900: split threshold
   * 0.606 → **0.727**, stacked 0.332 → **0.398**. More scales are width-bound
   * (live), not fewer.
   * ⚠️ AND IT CHANGES NOTHING FOR THE NINE VALUES ACTUALLY AUTHORED, measured
   * in both engines: every one of them is still above its threshold at
   * 1440×900, so all five splits draw an identical 348.8×756 box and all three
   * stacked an identical 191×414 — exactly as before. The dials are live at
   * 390×844 (threshold 1.83 split, 1.00 stacked), where seven of the nine bind
   * on width. So this line is a real change to the page's measure and a no-op
   * for the device's size on a desktop; do not report it as either alone.
   * 🔴 So this one line is both a typographic measure and a placement constant.
   * Anything that wants to change one of those two without the other needs a
   * second token and a reason; it does not have one today. */
  --page: 960px;
  --tracking-tight: -0.011em;
}

/* ══ THE STORY'S DIALLED GLOBALS ═══════════════════════════════════════════
 * Christian's settled values from `docs/designs/refactor/one-device.dialled.json`
 * (2026-10-01, `kit/dials.js`). They live here rather than in the home bundle
 * because they are the dial's GLOBAL half — the per-section half is
 * `content/story.json > scenes[].place`.
 *
 * 🔴 NOTHING ON THIS SITE CONSUMES THE FOUR `--beat-*` TOKENS YET, and that is
 * stated rather than hidden. The prototype has a per-LINE beat primitive —
 * `[data-beat]` elements, one `animation-range` each, `@keyframes beat-in {
 * from { translate: 0 var(--beat-rise) } }` — and this site has never had one:
 * `src/pages/home/50-motion.css`'s `words-say` animates a scene's WHOLE
 * `.scene-words` block as one, and its travel is a literal `8px` written into
 * the keyframes. Grepped 2026-10-01: zero `.beat`, zero `[data-beat]`, zero
 * `var(--beat-…)` in `src/`.
 * ⚠️ WHICH MEANS `--beat-rise: 0px` DOES NOT REMOVE A RISE HERE — there is no
 * rise wired to it to remove. On the prototype that value makes every beat fade
 * without moving; on this page the words still travel their 8px. The value is
 * recorded so that whoever ports the beat primitive inherits the dialled number
 * instead of the kit's 26px default, and so that the two pages cannot disagree
 * about what Christian chose. It is NOT an invitation to point `words-say` at
 * it — that would be a behaviour change dressed as a token.
 * `--beat-start` / `--beat-step` / `--beat-len` are window arithmetic the kit's
 * JS reads (dials.js, video.js) and CSS never does; they are inert here for the
 * same reason and are carried for the same reason.
 * ⚰️ The lede's beats specifically are GONE rather than inert — Christian's
 * ruling of 2026-09-30: a first section is never approached, so no range of any
 * kind can fire on it. See `docs/designs/refactor/rulings.md` §1. */
:root {
  --beat-start: 0;
  --beat-step: 4;
  --beat-len: 3;
  --beat-rise: 0px;
}

*, *::before, *::after { box-sizing: border-box; }

html { -webkit-text-size-adjust: 100%; }

body {
  margin: 0;
  background: var(--bg);
  color: var(--text-primary);
  font-family: var(--font-body);
  font-size: 1rem;
  line-height: 1.6;
  letter-spacing: var(--tracking-tight);
}

/* ── THE LIT ROOM — two ink tiers, moved because the ground moved ───────────
 * 🔴 THE EXCEPTION TO "NO LITERAL COLOURS BELOW THE FALLBACK BLOCK" AT THE TOP
 * OF THIS FILE, AND IT IS AN ACCESSIBILITY FLOOR RATHER THAN A SHORTCUT. The
 * home page does not sit on `--bg`: since 2026-10-01 it paints its own ground at
 * `#282826` (`../pages/home/25-scenes.css` §the ground) so the phone, which
 * stays `#000`, is the darkest thing on the screen. That ground is LIGHTER than
 * `--bg` (#0C0907), and every ink tier therefore loses contrast on this one
 * page — which means theme/tokens.css's generated floors, which are computed
 * against `--bg`, stop holding exactly here.
 *
 * ⚠️ WHY IT CANNOT GO IN THE `:where(:root)` BLOCK ABOVE: that block is zero
 * specificity on purpose, so theme/tokens.css's own `:root` beats it. An AA
 * floor that a theme silently overrides is not a floor. `body[data-page="home"]`
 * is (0,1,1) against `:root`'s (0,1,0) and wins without `!important` and without
 * depending on file order — the same reasoning as the `:where()` above, run the
 * other way. And it is SCOPED, because the other eleven pages still sit on
 * `--bg`, where the generated values are correct and must not be touched.
 *
 * 🔴 MEASURED, NOT EYEBALLED (relative luminance, sRGB linearised, compared
 * before rounding — design.md: *the muted/fade text tiers must be verified with
 * math, never eyeballed*). On `#282826`:
 *   · `--text-muted`    #AC8356 → 4.32:1  FAILS AA body copy (4.5:1)
 *   · `--border-bright` #885C2C → 2.54:1  FAILS 1.4.11 (3:1)
 * Nine other tiers were computed and all pass with room to spare — primary
 * 11.35, bright 13.57, secondary 5.97, code 7.63, accent/link 6.88,
 * accent-bright 8.59, warning 8.08, secondary-bright 6.36, gold 5.07.
 *
 * ⭐ THE FIX MOVES THE TIER, NEVER THE GROUND — design.md states that outright
 * (*when a muted tier fails contrast, darken the tier — don't shrink its usage
 * or argue the aesthetics*). ⚠️ On a DARK theme "darken" reads inverted: the
 * ground came UP, so moving the tier away from it means lifting it. Hue and
 * saturation are held exactly; only HLS lightness moves, so the tiers keep
 * their character and nothing else in the palette shifts.
 *   · `--text-muted: #B28C63`    → 4.80:1 on the room (6.45:1 on `--bg`)
 *   · `--border-bright: #9D6B33` → 3.22:1 on the room (4.33:1 on `--bg`)
 * Both carry real headroom over the line on purpose: an AA floor in this repo
 * once survived two independent computations because both rounded first, so a
 * value that lands at 4.51 is a value nobody can re-check safely.
 *
 * ⚠️ WHO ACTUALLY RENDERS THESE, verified in the built page rather than assumed:
 * `--text-muted` is `.eyebrow` (twice in the home markup, components.css ▸
 * `.eyebrow`) and `.hero-note` / `.cta-final-note`; `--border-bright` is
 * `.btn--ghost`'s 1px border — a UI component boundary, which is what makes it
 * 1.4.11 and not a taste call — and `a`'s `text-decoration-color`, the
 * non-colour cue that keeps a link identifiable under 1.4.1.
 * ⚠️ AND IT IS NOT THE FOCUS RING, though the comment at §`:focus-visible`
 * below says tokens.css floors it *"for exactly this"*: that outline is
 * `var(--accent)` (#F0A030, 6.88:1 here) and always has been. The floor on
 * `--border-bright` is real and worth keeping; the sentence pointing it at the
 * ring is wrong, and is left for its owner to correct rather than edited here.
 *
 * 🔴 THE GLASS IS NOT THIS GROUND. Anything inside `.screen` is still on
 * `--story-ground: #000` and keeps the unlifted numbers; these two tokens are
 * inherited by the device's subtree too, but nothing the app draws reads them. */
body[data-page="home"] {
  --text-muted: #B28C63;
  --border-bright: #9D6B33;
}

/* Tighter than the browser default on purpose: loose-tracked type reads as
 * decorative, tight reads as confident (design.md). */
h1, h2, h3 {
  font-family: var(--font-display);
  color: var(--text-bright);
  letter-spacing: var(--tracking-tight);
  line-height: 1.15;
  text-wrap: balance;
  margin: 0 0 var(--space-2);
}

h1 { font-size: clamp(2rem, 5vw, 3.25rem); }
h2 { font-size: clamp(1.5rem, 3.5vw, 2.25rem); }
h3 { font-size: 1.125rem; }

p { margin: 0 0 var(--space-2); max-width: var(--measure); }

a {
  color: var(--link);
  text-decoration-color: var(--border-bright);
  text-underline-offset: 0.15em;
}
a:hover { text-decoration-color: currentColor; }

img { display: block; max-width: 100%; }

/* WCAG 2.1 AA (EN 301 549 / BFSG) is a launch blocker, not a preference. A
 * visible focus ring on EVERY interactive element, verified with tooling rather
 * than eyeballed — theme/tokens.css floors --border-bright at 3:1 (1.4.11) for
 * exactly this. */
:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
  border-radius: 2px;
  /* Not a corner — a ring drawn round whatever is focused, and §THE CORNER
   * below says so in as many words. It opts out of the house curve here rather
   * than being excluded from the rule that applies it. */
  corner-shape: round;
}

.skip-link {
  position: absolute;
  left: -999px;
  top: 0;
  z-index: 100;
  background: var(--surface);
  color: var(--text-bright);
  padding: var(--space-1) var(--space-2);
}
.skip-link:focus {
  left: var(--space-2);
  top: var(--space-2);
}

/* ── page frame ─────────────────────────────────────────────────────────── */

/* The footer floor (wireframe.yml `frame.footer: sticky`, the default — the
 * shell stamps it as data-frame-footer). This is the LAYOUT sense of "sticky
 * footer", not position:sticky: a min-100vh flex column floats a SHORT page's
 * footer to the bottom of the viewport instead of leaving it stranded
 * mid-screen, while a long page's footer still scrolls in after the content.
 * main takes the slack, centred by its own `margin: 0 auto` below. ⚠️ THAT
 * CLAIMED "max-width still holds" AGAINST THE DEFAULT STRETCH — IT DID NOT.
 * Auto cross-axis margins disable stretch, so main shrink-wrapped to its
 * content instead of clamping at --page; `main`'s own rule now restores the
 * fill explicitly (`width: 100%; min-width: 0`) rather than relying on a
 * mechanism that was measured not to do it.
 * `frame.footer: static` opts out — the footer just follows the content. */
body[data-frame-footer="sticky"] {
  min-height: 100vh;
  display: flex;
  flex-direction: column;
}
body[data-frame-footer="sticky"] > main { flex: 1 0 auto; }

main {
  max-width: var(--page);
  margin: 0 auto;
  padding: 0 var(--space-3) var(--space-6);
  /* ⚠️ AUTO CROSS-AXIS MARGINS DISABLE STRETCH — the comment above claims
   * they don't, and measured they did: `main` shrink-wrapped to its content's
   * max-content width instead of clamping at --page, so any wide child (the
   * story's absolutely-positioned .radar, 840px) forced horizontal scroll on
   * every page at every viewport under 1280px — 239px overflow on /support at
   * 1440px, 48px on /docs. `width: 100%` restores the fill the auto margins
   * were quietly cancelling; max-width still clamps it exactly as before.
   * `min-width: 0` is the flex-item default override — without it a flex
   * child's intrinsic min-width can exceed its own max-width and win anyway.
   * Found by the accessibility audit, 2026-09-21 (WCAG 1.4.10 Reflow, AA). */
  width: 100%;
  min-width: 0;
}

main > h1 { margin: var(--space-5) 0 var(--space-4); }

/* One main idea per screen (design.md: roomy, low density by default). Bands
 * are separated by air, not by rules. */
main > section,
main > header { margin: 0 0 var(--space-6); }

/* A structural SLOT — scaffolded, not yet written. It renders as nothing at
 * all rather than as an empty box, so an unfinished page looks unfinished in
 * the source and finished on the screen. */
.slot { display: none; }

/* Motion is under 200ms, ease-out, and only ever on a state change
 * (design.md). `prefers-reduced-motion` is honoured here AND in tokens.css,
 * which zeroes --motion-duration at the root — belt and braces, because this
 * rule is the one that survives a theme that forgot. */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

/* ── The PlotOS unit ───────────────────────────────────────────────────────
 *
 * ⭐ `--os-unit` is PlotOS's rescale unit and 3rem is its own default. It is a
 * METRIC, not a colour, so it is declared here rather than in the generated
 * theme — theme/tokens.css is derived from the ColorTheme documents and has no
 * vocabulary for a size.
 *
 * 🔴 ONE VALUE FOR THE WHOLE PAGE, and that is the contract rather than tidiness:
 * a card's header and footer rows align across cards BECAUSE their height
 * derives from this unit, and side-by-side.yml's `unit_rescale` block warns in
 * so many words that rescaling one card's rows leaves that shared baseline.
 * 2rem / 3rem / 4rem are the contract's values; nothing between them.
 *
 * ⚠️ It lived on `.docs-deck` until 2026-09-15, which worked only while the docs
 * pages were the sole caller. A feature card on another page inherited nothing
 * and every row collapsed to its content. */
:root { --os-unit: 3rem; }

/* ═══ THE HOUSE COLOURS — the app's two, and why they are not theme tokens ═══
 *
 * ../../PLAN.md §*The house constants*: "The disc's red #EB362C
 * (`scripts/icon.swift`) and the house amber #FA9A12 (`Color.camAmber`). Red is
 * the camera, amber is the film. One accent at a time; **the disc never changes
 * colour**."
 *
 * 🔴 THAT LAST CLAUSE IS WHY THESE TWO ARE HEXES IN THIS FILE AND NOT TOKENS IN
 * theme/tokens.css, which otherwise reads as a straight violation of this file's
 * own no-literal-colours rule at the top. A theme token is a value that MUST
 * repaint when the theme changes; these two are values that must NOT. The disc
 * is the app's one control and it is that red in every mode, on every ground —
 * a `--cam-red` that answered the palette would be a different product's disc
 * on ten of the twelve vendored themes. Same category as `--corner-fraction`
 * below: a constant read off the app's own source, hoisted here so the site
 * states it once. NOT a licence for other literals.
 *
 * ⚠️ HOISTED FROM `.story` IN src/pages/home/home.css ON 2026-09-22, where the
 * comment said "stated once on this page and nowhere else". That was true while
 * the story was the only thing wearing them; ../../PLAN.md's corrections of
 * 2026-09-22 item 2 puts the app's red disc in the RAIL, which is on every page,
 * and a page-scoped custom property is simply absent there — the mark would have
 * rendered with no fill and nothing would have said so. The story still reads
 * them by the same names; only the declaration moved. */
:root {
  --cam-red: #EB362C;
  --cam-amber: #FA9A12;
}

/* ═══ THE CORNER — two numbers, and the shape is the app's ════════════
 * ../../PLAN.md §*The house constants*: the app's corner is a FRACTION of the
 * side, 50/346 ≈ 0.1445 — never a pixel radius, because only a fraction is the
 * same shape in a 1080px export and a 46pt tile. Ruled 2026-09-21 to govern
 * this site's boxes too, so a card here and the square in the film are one
 * shape at two sizes. The second number is `--corner-entry`, and §*AND THE
 * CURVATURE* below is why a fraction alone could not draw the app's curve.
 *
 * 🔴 THE FRACTION IS EXACT ONLY ON A SQUARE. "0.1445 of the side" has one
 * meaning on a 1:1 box and no meaning at all on a 3:1 card, so the rule forks
 * once, here, rather than being re-decided per component:
 *   --corner-square  1:1 boxes (the picture, the film). The fraction, as a
 *                    percentage of the side.
 *   --radius         controls — the fraction of the 44px tap-target height,
 *                    which is what makes a button read as a small version of
 *                    the app's square instead of a stadium.
 *   --radius-lg      cards and panels — the fraction of two os-units.
 * A pill (999px) and the focus outline are not corners; they keep their radius
 * and say `corner-shape: round`, which is the same "left alone" spelled so the
 * one rule that applies the curve cannot reach them.
 *
 * ⚠️ These override theme/tokens.css's generated --radius / --radius-lg. That
 * file is built from the vendored dbo-contracts theme code and must not be
 * hand-edited; site.css loads after it, so this is where the override belongs.
 *
 * ── AND THE CURVATURE, 2026-09-21 ───────────────────────────────────────
 * The app draws a CONTINUOUS curve where `border-radius` draws a circular arc,
 * and the fix is `corner-shape` — but NOT the one-liner the note that stood
 * here proposed. Both numbers below were fitted by rendering the app's own
 * curve on this Mac (a CALayer with `cornerCurve = .continuous`, the same
 * construction as Camcorder/Core/GradeRenderer.swift ▸ maskImage) and sweeping
 * CSS candidates in headless Chrome, comparing the two boundaries row by row.
 * What that measured:
 *
 * ⭐ A CONTINUOUS CORNER IS NOT A ROUNDER CORNER. At the same radius it reaches
 * the SAME depth at 45° as the circular arc (0.294R, both) — it simply leaves
 * the straight edge earlier and takes longer to arrive, running ~1.2R along the
 * edge where the arc runs 1R. (The app's own docs say it from the other side:
 * "the corner runs about 12% longer along the edge than its nominal radius".)
 * So today's arc was never the wrong SIZE of corner; it was missing the entry.
 *
 * 🔴 WHICH IS WHY `corner-shape: squircle` ALONE WOULD HAVE MADE IT WORSE.
 * `squircle` is `superellipse(2)`, and at an unchanged radius it is far SQUARER
 * at the diagonal (0.161R deep against the app's 0.294R): dropped in as
 * written, it lands 6.7× FURTHER from the app's curve than plain border-radius
 * was — rms 1.79% of the side against today's 0.268%, on a 1200px box. The
 * shape needs both knobs: a gentler exponent AND a longer entry, which in CSS
 * means a bigger nominal radius. `superellipse(1.6)` at 1.4× the fraction fits
 * to 0.074% rms — 3.6× closer than today, and the spread between neighbouring
 * candidates is below the rasteriser's own noise. Anything in k 1.55–1.7 at
 * 1.35–1.48× fits that well; 1.6 and 1.4 are the round pair in the middle of
 * it, and 1.4 is what keeps the reel tile's inflated radius (41% of its side)
 * clear of the 50% mark where a browser starts scaling radii down.
 *
 * 🔴 THE INFLATION AND THE CURVE ARE ONE MOVE AND LIVE OR DIE TOGETHER. 1.4×
 * the radius WITHOUT the curve is a visibly over-rounded box — 3.78% rms, 14×
 * worse than changing nothing — so `--corner-entry` is 1 here and only the
 * @supports block below moves it. The tokens above keep today's exact numbers,
 * which is also the whole fallback: an engine with no `corner-shape` gets the
 * arc at 0.1445, the app's corner at the right DEPTH with a shorter entry,
 * 0.268% off. Nothing degrades to a square.
 *
 * ⚠️ AND ONLY BLINK HAS IT TODAY — all three engines measured on this Mac,
 * 2026-09-21: Chrome 153 yes, Safari 26.6 no (WebKit answers false to
 * `CSS.supports('corner-shape','squircle')`, and for an iOS app's site that is
 * most of the audience), Firefox 150 no. So the pass is progressive by design
 * rather than by accident, and most visitors still get the box they get today.
 * Re-measure rather than assume when the other two catch up. */
:root {
  --corner-fraction: 0.1445;
  /* How much longer the continuous curve runs along the edge than its nominal
   * radius — the one number that turns an arc's radius into a superellipse's.
   * 1 = the arc, i.e. exactly today. */
  --corner-entry: 1;
  --corner-square: calc(var(--corner-fraction) * var(--corner-entry) * 100%);
  --radius: calc(var(--corner-fraction) * var(--corner-entry) * 2.75rem);
  --radius-lg: calc(var(--corner-fraction) * var(--corner-entry) * var(--os-unit) * 2);
}

/* ── the curve, applied once, to everything ──────────────────────────────
 * 🔴 ONE RULE, NOT A LIST OF SELECTORS. PLAN.md rules that EVERY box on this
 * site takes the app's corner, and a list is that rule minus whatever anybody
 * adds next week. `corner-shape` does nothing where there is no radius to
 * shape, so a universal declaration only ever reaches boxes that already round
 * — and every one of those is meant to be in it.
 *
 * ⚠️ WHICH LEAVES THE SHAPES THAT ARE NOT CORNERS. A disc (50%) and a pill
 * (999px) come out as blobs under it — measured: a caught disc fills 0.58 of
 * its corner box where a circle fills 0.31. They opt out with
 * `corner-shape: round` where they are drawn — the focus ring above, the docs
 * pill in components.css, the story's discs in pages/home/home.css — which is
 * PLAN.md's own wording: a pill and the focus outline are not corners. `round`
 * is the property's initial value, so those lines need no @supports of their
 * own and cost nothing in an engine that never reads them.
 *
 * ⚠️ The condition names the exact value used. An engine that ships the
 * keywords but not `superellipse()` must fall back, not half-apply. */
@supports (corner-shape: superellipse(1.6)) {
  :root { --corner-entry: 1.4; }
  *, *::before, *::after { corner-shape: superellipse(1.6); }
}

/* components.css — the chrome partials and the eight section layouts.
 *
 * @docs ../../README.md
 *
 * One class per layout id, in the order layouts/ lists them, so a layout and
 * its styling are findable from either end. Everything reads theme tokens
 * (base.css documents the contract); a literal colour here is a bug.
 */

/* ── site header — ⚰️ GONE, 2026-09-22 ───────────────────────────────────
 * ../../PLAN.md §"The corrections of 2026-09-22" item 5: ".site-header COMES
 * OUT." `src/html/_header.html` is deleted and `_shell.html` no longer
 * includes it, so `.site-header`, `.wordmark`, the `body[data-frame-header=
 * "sticky"] .site-header` pin and `.site-theme-picker:empty` all went with the
 * markup that wore them. Same tombstone discipline as `.nav` below: what the
 * classes did, and where each idea lives now.
 *
 * ⭐ THE WORDMARK WAS THE SITE'S ONE ROUTE HOME, AND IT STILL HAS EXACTLY ONE.
 * It is the app's red record disc at the top of the rail — markup in
 * _nav.html (first row of `.rail-nav`), styling in nav.css §THE HOME MARK.
 * Items 2 and 5 of that PLAN section are ONE change and landed as one;
 * _nav.html's header carries the full argument, including why the rule that
 * used to forbid a Home row was rewritten rather than overridden.
 *
 * ⚰️ `.site-header` was a `--os-unit` row inset by `--space-3`, `position:
 * sticky` at z-index 10, with `background-color: inherit` (never `var(--bg)`
 * — home.css's ground is true black and the token is the theme's near-black,
 * so naming it painted a 92px band of a DIFFERENT black across the story).
 * That `inherit` lesson survives in nav.css's `.site-rail`, which needed the
 * same thing and is where the reasoning now lives.
 *
 * ⚰️ THE `--os-unit` COUPLING IS DISSOLVED, not relocated. This bar's
 * `min-height` existed so the wordmark's centre and the rail hamburger's
 * landed on one 48px line without either knowing about the other; with one of
 * the two gone there is nothing left to align, and nav.css's `.site-rail`
 * padding now states its own reason. Nothing in this file keys off
 * `--os-unit` any more — the rail's rows do, because a rail is a column of
 * rows, which is a reason of its own.
 *
 * ⚰️ `.site-theme-picker:empty { display: none }` collapsed a mount point that
 * never filled. It is deleted because the ELEMENT is deleted, not because the
 * theme seam closed: theme/picker-mount.js documents `[data-theme-picker]` as
 * "an OPTIONAL placement hook… a page that does nothing gets the vendored
 * default, a small floating control bottom-right", so picker mode still works
 * with no element at all. If a page ever wants the control docked, it marks an
 * element `data-theme-picker` and brings this one rule back with it.
 *
 * ⚠️ `data-frame-header` IS NOW INERT on this site — the attribute is still
 * stamped onto <body> (it is template contract, README `frame:`) and nothing
 * reads it. `body[data-frame-footer="sticky"]` in base.css is unaffected. */

/* `.nav` — GONE, 2026-09-21, with the markup that wore it. The row of links it
 * styled is the rail in nav.css now; its two ideas survived the move rather
 * than being reinvented there (the muted-to-bright hover ramp, and the 2px
 * accent rule marking the current page — stood on its end against the row's
 * leading edge, because that is what a horizontal underline becomes in a
 * column). Nothing else referenced the class; `rg '\bnav\b' src/` after the
 * change finds only the rail's own `.rail-nav`. */

/* ── site footer (src/html/_footer.html) ─────────────────────────────────── */

.site-footer {
  border-top: 1px solid var(--border);
  max-width: var(--page);
  margin: 0 auto;
  padding: var(--space-4) var(--space-3);
  display: flex;
  /* ⚠️ `flex-wrap` IS THIS ROW'S 320px FLOOR NOW that the legal nav's own wrap
   * has gone with it (the tombstone below): the copyright and the colophon
   * stack rather than overflow. And `align-items: baseline` earns its keep for
   * the first time — it never had two items on one line to align until the
   * colophon stopped claiming a line of its own. */
  flex-wrap: wrap;
  gap: var(--space-3);
  align-items: baseline;
  font-size: 0.875rem;
  color: var(--text-muted);
  /* The same auto-margin trap `main` pays for in base.css — and that the site
   * header paid for too, while it existed (its tombstone above). Measured
   * 496.3px wide at 1440 instead of the column, and at 320px it took its
   * 352.4px max-content and overflowed the screen. */
  width: 100%;
  min-width: 0;
}
/* ⚰️ `.legal-nav` AND `.footer-contact` — GONE, 2026-09-22, with the markup
 * that wore them. ../../PLAN.md §"The corrections of 2026-09-22" item 3: "the
 * footer itself reduces to what item 3 asks: copyright + the colophon's SHA,
 * nothing else." Same tombstone discipline as `.nav` and `.site-header` above.
 *
 * ⭐ THE FOUR LEGAL DOCUMENTS ARE NOT UNLINKED, and that is the whole of what
 * makes this deletion safe: `.legal-nav` was the only route to Privacy, Terms,
 * Impressum and Accessibility, and they are now the tree at /legal, reached
 * from the info-circle in the rail's end zone (nav.css §THE END ZONE,
 * src/html/_nav.html). The two changes are one change. _footer.html's header
 * carries the warning in full.
 *
 * ⚰️ `.legal-nav` was `display: flex; flex-wrap: wrap; gap: var(--space-3);
 * flex: 1`, its links `--text-secondary` going `--text-bright` and underlined
 * on hover — the same ramp the rail's rows use, which is where that idea lives
 * now. `.footer-contact`'s links shared both rules.
 *
 * ⚠️ AND ITS `flex-wrap: wrap` CARRIED A REAL WCAG 1.4.10 FIX, ADDED
 * 2026-09-21 — recorded here so its absence is not read later as a regression
 * that crept in. Four links in a nowrap row had a min-content width of ~304px,
 * so the footer needed 352px with its padding and overflowed a 320px screen by
 * 32 — 88 once the rail took another 56 there. THE FIX DIED WITH THE ROW IT
 * FIXED: there is no four-link row any more, and the footer's two remaining
 * items are short. `.site-footer`'s own `flex-wrap` is what holds the floor
 * now (see its note). Re-measure at 320px before putting any multi-item row
 * back down here — nav.css §THE PUSH clips sideways overflow, so a failure of
 * this kind hides rather than shows. */

/* ⚰️ `.colophon { width: 100% }` — GONE with them, and this one is a
 * SIMPLIFICATION rather than a casualty. That declaration existed to force a
 * line break under a three-item row (legal nav, contact, colophon); with two
 * items and nothing to break away from it was dead layout logic that also
 * defeated `.site-footer`'s `align-items: baseline` by putting the two items
 * on separate lines. Gone, the copyright and the colophon sit on one baseline
 * — which is the first time that rule has had two different type sizes to do
 * anything with — and wrap onto two lines by themselves when the width runs
 * out. Same disposal as the header's `--os-unit` coupling above: what the
 * removal made meaningless is deleted, not left standing. */
.colophon { font-family: var(--font-mono); font-size: 0.75rem; }

/* <small> is the right ELEMENT for a copyright (see _footer.html) and the
 * wrong SIZE: its 0.8em default would compound against the footer's own
 * 0.875rem to about 12px of muted text. `inherit` puts it back on the
 * footer's tier — the element for its semantics, not for its presentation. */
.copyright { font-size: inherit; }

/* ── hero ────────────────────────────────────────────────────────────────── */

.hero { padding: var(--space-5) 0 var(--space-4); }

.eyebrow {
  margin: 0 0 var(--space-2);
  font-size: 0.8125rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-muted);
}

/* The one accent word in the lockup — content/copy.json's `{{accent:…}}`
 * marker. Sparing, and the only place colour carries meaning on the page.
 * ⚠️ `.cta-final-title` joined it on 2026-09-22 (../../PLAN.md item 17): the
 * lockup moved there with the <h1> when `#hero` came out. `.hero-title` is kept
 * because layouts/hero.yml is kept — see that file's header. */
.hero-title .accent,
.cta-final-title .accent { color: var(--accent); }

.hero-sub,
.cta-final-sub {
  font-size: clamp(1.0625rem, 2vw, 1.25rem);
  color: var(--text-secondary);
  max-width: var(--measure);
  /* ⭐ `auto` MATTERS ONLY FOR THE SECOND SELECTOR. `.cta-final` centres its
   * text, and a `max-width` block inside a centred parent still sits hard left
   * without this — the line lengths would read centred while the block did not.
   * Harmless on `.hero-sub`, which has the full column anyway. */
  margin-inline: auto;
}

.hero-cta, .cta-final-actions {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2);
  margin-top: var(--space-3);
}

.hero-note,
.cta-final-note { color: var(--text-muted); font-size: 0.875rem; }

/* ── buttons and store badges (hero + cta-final) ─────────────────────────── */

.btn, .badge {
  display: inline-flex;
  align-items: center;
  min-height: 2.75rem;              /* 44px — the tap-target floor */
  padding: 0 var(--space-3);
  /* ⚠️ ADDED 2026-09-22, WITH THE SUPPORT FORM. Every caller of `.btn` was an
   * <a> until then, and an anchor inherits the document's family for free —
   * the form's submit is the site's first real <button>, and a button does NOT:
   * it falls back to the UA's own control font, which on this page rendered a
   * 13.33px system face inside a 16px Inter column. Fixed in the SHARED rule
   * rather than scoped to `.report-form`, because a component patched at its
   * second caller is the defect this file already names twice above. `font-size`
   * and `font-weight` were always stated here; `font-family` was the one that
   * had never been asked for. */
  font-family: inherit;
  border-radius: var(--radius);
  font-size: 0.9375rem;
  font-weight: 600;
  text-decoration: none;
  transition: background var(--motion-duration) var(--motion-ease),
              border-color var(--motion-duration) var(--motion-ease),
              color var(--motion-duration) var(--motion-ease);
}

.btn--primary {
  background: var(--accent);
  color: var(--ink-on-accent);
  border: 1px solid var(--accent);
}
.btn--primary:hover { background: var(--accent-bright); border-color: var(--accent-bright); }

.btn--ghost {
  border: 1px solid var(--border-bright);
  color: var(--text-primary);
}
.btn--ghost:hover { border-color: var(--accent); color: var(--text-bright); }

.badge {
  border: 1px solid var(--border-bright);
  color: var(--text-secondary);
  font-weight: 500;
}

/* A store badge with nowhere to point yet. It still carries its label, because
 * an unlabelled control fails WCAG 4.1.2 whether or not there is anything to
 * click — cta.json's own note makes that call. Muted, not hidden: the roadmap
 * is part of the message. */
.badge--coming-soon {
  color: var(--text-muted);
  border-style: dashed;
  cursor: default;
}

/* ── cta-final ───────────────────────────────────────────────────────────── */

/* ⚰️ THE BAND LOST ITS BOX, 2026-09-22 — ../../PLAN.md item 17. It was
 * `background: var(--surface)` inside `border: 1px solid var(--border)`, which
 * was right for a small closing call-out at the foot of a page and is wrong for
 * what this section is now: the page's PITCH, carrying the <h1>, sitting
 * between the scroll and "how it works". A bordered, filled card there reads as
 * an advert dropped into the page rather than as the page speaking.
 * 🔴 And the border was against a house constant the whole time — ../../PLAN.md
 * §The house constants: "Flat. No borders, no glows (b97) — Nima took out the
 * app's one coloured edge bleed on exactly this ground." Removing it is that
 * rule finally reaching this element, not a new taste.
 * The centring and the air stay: they are what make it read as a turn in the
 * page rather than as another band of prose. */
.cta-final {
  padding: var(--space-5) 0 var(--space-4);
  text-align: center;
}
.cta-final-actions { justify-content: center; }

/* ── showcase-band ───────────────────────────────────────────────────────── */

/* ⚠️ `min(…, 100%)` INSIDE THE minmax — THIS GRID AND THE TWO BELOW IT
 * (.steps-list, .feature-cards), 2026-09-21, with the rail. A bare
 * `minmax(16rem, 1fr)` is a HARD FLOOR: where the track is narrower than the
 * minimum the item does not shrink, it overflows. That was invisible while
 * `main`'s content box at 320px was 272px — wider than all three floors, by
 * luck — and the rail took 56px of it, so the feature cards hung 56px off the
 * screen. `min(…, 100%)` keeps the wrap threshold and lets the last column
 * shrink below it instead of spilling. WCAG 1.4.10 at 320px. */
.showcase-frames {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(16rem, 100%), 1fr));
  gap: var(--space-3);
}

.showcase-frame {
  margin: 0;
  padding: var(--space-3);
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
}
.showcase-frame h3 { margin-bottom: var(--space-2); }
.showcase-shot { border-radius: var(--radius); border: 1px solid var(--border); }

/* content/showcase.json's `reducedMotion: static-first-frame`, expressed
 * declaratively: when the visitor asks for less motion, a looping band shows
 * only its first frame instead of cycling. No JS reads the preference. */
@media (prefers-reduced-motion: reduce) {
  .showcase-band[data-loop="true"] .showcase-frame:not(:first-child) { display: none; }
}

/* ── steps ───────────────────────────────────────────────────────────────── */

/* ═══ ONE RASTER FOR "HOW IT WORKS" AND "WHAT IT DOES" ════════════════════
 * ../../PLAN.md item 21. Christian: *"'How it works' is not sitting in the same
 * raster as 'What it does' — this is because we have cards in cards in cards.
 * We can do that for 'How it works' too, and simply remove the lift colors and
 * borders — so they arrive at same alignments."*
 *
 * 🔴 THREE THINGS HAD TO MATCH, NOT ONE. A shared track floor is necessary and
 * nowhere near sufficient — two grids land on the same raster only when the
 * FLOOR, the GAP and the CONTAINER'S INSET all agree, and all three differed:
 *     floor     steps 15rem      features 17rem
 *     gap       steps --space-4  features --space-3
 *     inset     steps none       features --space-3 (from `.band-alt`)
 * Fixing only the floor would have left the columns out by the gap and the
 * whole features grid inset by 16px besides.
 *
 * ⭐ SO THE FLOOR IS A TOKEN, NOT A NUMBER TYPED TWICE. Neither 15 nor 17rem
 * had a recorded derivation, so the value is the midpoint and the POINT is
 * that there is now one of it: two grids reading `--band-col` cannot drift
 * apart again the way these two did. */
:root { --band-col: 16rem; }

.steps-list,
.feature-cards {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(var(--band-col), 100%), 1fr));
  gap: var(--space-4);
}

.steps-list {
  counter-reset: step;
  list-style: none;
  margin: 0;
  padding: 0;
}

/* 01 / 02 / 03 comes from a CSS counter, not from the markup and not from the
 * build — see layouts/steps.yml. `decimal-leading-zero` is the whole feature.
 * Generated content is not in the accessibility tree, which is right here: the
 * <ol> already announces the position, and a screen reader should not have to
 * hear "zero one" before every step title. */
.step { counter-increment: step; }
.step::before {
  content: counter(step, decimal-leading-zero);
  display: block;
  font-family: var(--font-mono);
  font-size: 0.8125rem;
  color: var(--accent);
  margin-bottom: var(--space-1);
}
.step-title { margin-bottom: var(--space-1); }
.step-detail { color: var(--text-secondary); margin: 0; }

/* ── feature-grid ────────────────────────────────────────────────────────── */

/* A per-placement band, set by wireframe.yml's `class: band-alt` and passed
 * through the layout's `#{plot@class!}`. The layout has no opinion about it —
 * the same grid on another page may want no band at all. */
/* ⚰️ `.band-alt`'s LIFT AND ITS INSET — GONE, 2026-09-22 (item 21). It was
 * `background: var(--surface)` inside `border-block: 1px solid var(--border)`,
 * with `padding: var(--space-4) var(--space-3)` and a `--radius-lg`.
 *
 * 🔴 THE PADDING HAD TO GO WITH THE BORDER, and that is the half a
 * strip-the-colours reading would have missed. Padding on a band exists to
 * inset content from an EDGE; with no background and no border there is no
 * edge, and all it does is make this section's grid 32px narrower than the
 * identical grid above it — which is the misalignment being reported, not a
 * side issue. The section's own `margin-bottom` in base.css already carries the
 * vertical rhythm the padding was doubling.
 *
 * ⚠️ The class STAYS in wireframe.yml (`class: band-alt` on home.features) and
 * still means "this band reads as a change of subject". It just says it with
 * air now instead of with a box — which is design.md's own order, type-led
 * before colour-led. Give it back a background and the raster breaks again. */
.band-alt { }

/* ⚰️ `.feature-card`'s BOX — GONE with it. It was `padding: var(--space-3)`,
 * `background: var(--bg)`, `border: 1px solid var(--border)`, `--radius-lg`:
 * the third of the three framing layers Christian counted ("cards in cards in
 * cards" — band, then grid, then card).
 *
 * ⭐ AND NOTHING REPLACED IT ON `.step`, WHICH IS THE POINT. Item 21 asks for
 * the two sections to share one shape; they do, and the shape is NONE. Giving
 * both a padded wrapper would have aligned them with each other and pushed
 * both out of line with their own section headings, which sit on the container
 * edge. Flat, with the text on the grid line, is the only arrangement where
 * the steps, the features and the two `<h2>`s above them all share one left
 * edge. 🔴 That is the alignment the item is actually about; the colours were
 * the symptom. */
.feature-card { }
.feature-card h3 { margin-bottom: var(--space-1); font-size: 1rem; }
.feature-card p { margin: 0; color: var(--text-secondary); }

/* ── feature-list ────────────────────────────────────────────────────────── */

.feature-entries { margin: 0; }
.feature-entry {
  padding: var(--space-3) 0;
  border-top: 1px solid var(--border);
}
.feature-entry:first-child { border-top: 0; padding-top: 0; }
.feature-entry dt {
  font-family: var(--font-display);
  font-size: 1.125rem;
  color: var(--text-bright);
  margin-bottom: var(--space-1);
}
.feature-entry dd { margin: 0; color: var(--text-secondary); max-width: var(--measure); }

/* ── release-notes ───────────────────────────────────────────────────────── */

.platform-section { margin-bottom: var(--space-5); }
/* /releases: the platform switch, and the space it leaves before the list. */
.release-switch { margin: 0 0 var(--space-4); padding: 0; border: 0; min-width: 0; }
.release-switch > legend { padding: 0; margin-bottom: var(--space-1); font-size: 0.9375rem; font-weight: 500; color: var(--text-primary); }
.platform-title { font-size: 1.25rem; }

.release-entries { list-style: none; margin: 0; padding: 0; }
.release-entry {
  padding: var(--space-3) 0;
  border-top: 1px solid var(--border);
}

.release-head {
  display: flex;
  gap: var(--space-2);
  align-items: baseline;
  font-size: 0.8125rem;
  color: var(--text-muted);
  margin-bottom: var(--space-2);
}
.release-head .build-number { font-family: var(--font-mono); }

.highlights { margin: 0 0 var(--space-2); }
.highlights dt { font-weight: 600; color: var(--text-primary); }
.highlights dd { margin: 0 0 var(--space-2); color: var(--text-secondary); max-width: var(--measure); }

/* The sanctioned record of a maintenance re-ship — an empty `highlights` in
 * release-notes.json. Stated in words, not left blank. */
.maintenance { color: var(--text-muted); font-style: italic; margin: 0; }

/* ── contact ─────────────────────────────────────────────────────────────── */

.contact-email {
  font-family: var(--font-mono);
  font-size: 1.125rem;
}

/* ═══ PORTED FROM BOUNCE, VERBATIM, 2026-09-21 ════════════════════════════
 * bounce/site/www/src/css/components.css lines 422–673, plus `--os-unit` in
 * base.css. Taken straight rather than adapted: PLAN.md's fork rule says copy
 * it with no Camcorder names in it, so the diff back to bounce stays readable.
 *
 * ✅ THE APP'S CORNER IS ON IT WITHOUT THE PORT BEING TOUCHED. Bounce's radii
 * are still written below, exactly as they came; the SIZE is re-pointed at the
 * site's tokens in §THE CORNER, APPLIED TO THE PORTED DECK further down, and
 * the CURVE arrives from base.css §THE CORNER, which shapes every rounded box
 * on the site in one rule. So the diff back to bounce stays readable and the
 * corner pass still happened in one place — see base.css for why the curve
 * needed a fitted radius as well as `corner-shape`. */

/* ═══ THE DOCS DECK — PlotOS's SIDE-BY-SIDE layout ═════════════════════════
 *
 * ⭐ The class contract is PlotOS's (`plot-os/specs/layouts/side-by-side.yml`,
 * ruled its default layout 2026-09-05 — and its own banner says it came FROM
 * Bounce: *"a new layout based on Bounce"*, `.os-deck` chosen to match Bounce's
 * lab `#deck`). The STYLESHEET is ours, because this is a marketing site with
 * its own token set and type scale; importing os-card.scss would put a second
 * token system and an icon font on an editorial page.
 *
 * 🔴 Matching the class names is what keeps that reversible: adopting the real
 * PlotOS stylesheet later is a <link>, not a rewrite. See layouts/docs-shell.yml.
 */

/* ── the track ───────────────────────────────────────────────────────────── */
/* ⭐⭐ THE FLOOR IS THE STACK THRESHOLD — `flex-wrap: wrap` plus each card's own
 * min-width IS the responsive behaviour, with no breakpoint and no JS. The spec
 * measured it live at a 700px viewport. It is the only spelling that cannot
 * drift from the floor.
 * 🔴 AND THE FLOOR STILL APPLIES WHEN STACKED. Bounce shipped it not applying
 * and got a device report — "when stacked, it allows the window card size to be
 * less than the 350." A floor that holds in one of two layouts is not a floor,
 * so it is on the card and never on a media query. */
.docs-deck {
  --os-card-min-width: 350px;   /* ⚠️ PER-PRODUCT by the spec's own account:
                                 * three codebases derived this independently
                                 * and landed within 70px — PlotOS 376, Bounce
                                 * 350, Terminal C 420. Ours is Bounce's. */
  --docs-rail-width: 17rem;
  max-width: var(--page);
  margin: 0 auto;
  padding: var(--space-3);
  display: flex;
  flex-wrap: wrap;
  align-items: start;
  gap: var(--space-3);
}

/* ── a card ──────────────────────────────────────────────────────────────── */
/* ⭐⭐ UNSCOPED SINCE 2026-09-15, and the un-scoping is the point. These rules
 * lived under `.docs-deck >` while the docs pages were the only thing using
 * them, which made `.os-card` look like a component and behave like a docs
 * detail — the second a feature card appeared outside the deck it would have
 * rendered as a bare <div> with the right class on it. 🔴 A component scoped to
 * its first caller is not a component; it is a page style wearing a
 * component's name, and the failure arrives at the SECOND caller, which is the
 * one nobody tests against.
 *
 * What stays deck-scoped below is only what is genuinely about the TRACK:
 * flex sizing, the ground modifier, the rail width. */
.os-card {
  display: flex;
  flex-direction: column;
  border: 1px solid var(--border);
  border-radius: 10px;
  background: var(--surface);
  overflow: hidden;
}

/* The three-row contract: header / body / footer, the body taking the slack.
 * `auto 1fr auto` in the spec's terms. A card with no header or no footer is
 * still correct — the rows are what they are, not what position they sit in. */
.os-card > header.os-row { border-bottom: 1px solid var(--border); }
.os-card > footer.os-row { border-top: 1px solid var(--border); font-size: 0.8125rem; color: var(--text-muted); }
.os-card > .os-card-body { padding: var(--space-3) var(--space-2); }
.os-card > .os-card-body > :first-child { margin-top: 0; }
.os-card > .os-card-body > :last-child { margin-bottom: 0; }

/* ── a track of cards ────────────────────────────────────────────────────── */
/* ⭐ THE FLOOR IS THE STACK THRESHOLD, hoisted out of the docs deck for the
 * same reason as the card: a features row and a steps row want exactly this
 * behaviour and nothing about it is documentation-specific. `flex-wrap: wrap`
 * plus each card's own min-width IS the responsive rule — no breakpoint, no JS,
 * and the only spelling that cannot drift from the floor it is derived from. */
.os-deck {
  --os-card-min-width: 350px;
  display: flex;
  flex-wrap: wrap;
  align-items: stretch;
  gap: var(--space-3);
}
.os-deck > .os-card {
  flex: 1 1 var(--os-card-min-width);
  /* 🔴 THE FLOOR STILL APPLIES WHEN STACKED. Bounce shipped it not applying and
   * got a device report — "when stacked, it allows the window card size to be
   * less than the 350." A floor that holds in one of two layouts is not a
   * floor, so it is on the CARD and never inside a media query. `min()` is what
   * keeps a narrow phone from overflowing rather than clamping. */
  min-width: min(var(--os-card-min-width), 100%);
}

.docs-deck > .os-card {
  flex: 1 1 var(--os-card-min-width);
  min-width: min(var(--os-card-min-width), 100%);
}

/* 🔴 THE SIDEBAR IS A CARD, AND ITS BORDER STAYS. `os-card-ground` makes the
 * 1px border the ground colour rather than removing it — that border is what
 * holds the rail's rows on the same baseline as the rows in the card beside it,
 * which is the alignment this whole layout exists for. Removing it instead of
 * recolouring it looks identical and breaks the one invariant.
 *
 * ⚠️ THE MODIFIER IS UNSCOPED AND THE WIDTH IS NOT, because they answer
 * different questions. "Read as page ground rather than as a card" is true of
 * this modifier wherever it appears; "be the rail width" is true only inside the
 * docs deck. They were one rule until check-components caught it, which is the
 * component-scoped-to-its-first-caller defect in miniature: a second surface
 * wanting a ground card would have got a rail-width one or nothing at all. */
.os-card-ground {
  border-color: var(--bg);
  background: var(--bg);
}
/* ═══ THE RAIL STAYS WHILE THE DOCUMENT SCROLLS ═══════════════════════════
 * ../../PLAN.md item 20. Christian: "the os-deck should have taken care of the
 * overflowing and aside.os-card to stay while scrolling down."
 *
 * ⚠️ AND IT WAS IN THE FIRST MOCKUP, NOT NEW SCOPE. The reference markup
 * Christian pasted at the start of this pass already carried
 * `os-tree-sticky-summaries` and `--os-tree-sticky-top` on the tree nav —
 * PlotOS's own vocabulary for exactly this — and it never made it into the
 * build. `.os-card-scroll { overflow: auto }` has been sitting below this whole
 * time as the right INNER mechanism; what was missing is the outer bound that
 * makes it ever engage.
 *
 * 🔴 STICKY ALONE WOULD NOT HAVE BEEN A FIX. A sticky card with no height cap
 * is still a card as tall as its content — it just scrolls off in one piece
 * instead of staying. The three declarations are one mechanism: `sticky` pins
 * it, `max-height` stops it growing past the viewport, and the `os-card-scroll`
 * inside it then has somewhere to scroll. Take any one away and the other two
 * do nothing useful.
 *
 * ⭐ `top` IS SMALL ON PURPOSE. There is no fixed header to clear since item 5
 * deleted `.site-header` — the rail down the left edge is `position: fixed` and
 * horizontal, so nothing overlaps this from above. `--space-3` is the deck's
 * own padding, so the card rests on the same gutter it starts in rather than
 * touching the top of the glass.
 *
 * ⚠️ The `max-height` subtracts twice that gutter so the card breathes at the
 * bottom as well as the top, which is what keeps its lower edge from reading as
 * cut off at exactly the moment its inner scrollbar appears. */
.docs-deck > aside.os-card-ground {
  flex: 0 1 var(--docs-rail-width);
  position: sticky;
  top: var(--space-3);
  max-height: calc(100svh - var(--space-3) * 2);
}
@media (max-width: 720px) {
  /* 🔴 NOT STICKY IN THE COLUMN. Stacked, the rail sits ABOVE the document, so
   * pinning it would park a tree over the prose for the whole scroll and eat a
   * viewport of a phone — the opposite of the favour. It also cannot usefully
   * cap its height there: it is the first thing on screen, not a companion to
   * something beside it. Mobile-second (design.md) means this behaviour is the
   * desktop's affordance, added on top, not the baseline. */
  .docs-deck > aside.os-card-ground {
    position: static;
    max-height: none;
  }
}

/* ── the three rows ──────────────────────────────────────────────────────── */
/* A card is header / body / footer and the body takes the slack — `auto 1fr
 * auto` in the spec's terms, spelled here as flex.
 *
 * ⚠️⚠️ THIS BLOCK WAS WRONG IN ITS FIRST FORM AND THE CORRECTION IS THE
 * INTERESTING PART. It read `grid-template-columns: var(--space-4) 1fr auto` —
 * a FIXED first track — which matched PlotOS's class NAMES while contradicting
 * its layout, and produced two defects Christian found on the rendered page:
 * `hidden` on a col1 did not remove its gap, and an empty col1 did not collapse.
 *
 * 🔴 NEITHER WAS AN UPSTREAM BUG. `list-item.scss` already ships both — the
 * tracks are `fit-content()` on the outside so an empty column takes no width,
 * `&.col1:empty:not(.hidden)` floors at `--os-unit * .3` (exactly the value
 * Christian named), and `display.scss` hides `[hidden]` with `!important` so it
 * beats the `display: flex` these cells carry. A fixed track reproduces neither,
 * because with a fixed track it is the TRACK and not the cell that holds the
 * width — hiding the cell changes nothing.
 *
 * ⭐⭐ THE LESSON IS ABOUT PORTING, NOT ABOUT GRID: *matching a class contract
 * means matching what the classes DO, not what they are called.* The names
 * lined up perfectly and the behaviour the names promise was absent, which is
 * the one kind of mismatch nobody reviewing either file can see.
 *
 * ⭐ `--os-unit` is PlotOS's rescale unit and 3rem is its own default. It is
 * declared on the deck so both cards share it — the header/footer baseline
 * holding across cards is a consequence of one unit, not of two that agree.
 * Side effect worth having: a 3rem row is a 48px target, over the 44px floor.
 */
.os-row {
  display: grid;
  /* Verbatim from list-item.scss. `fit-content` outside / `minmax` inside is
   * load-bearing and its comment says why: when everything cannot fit at once
   * the grid shrinks the fit-content tracks before col2's minmax floor gives —
   * a minmax minimum is a hard floor in a way fit-content's is not — so the
   * text column is protected first. */
  grid-template-columns:
    fit-content(calc(var(--os-unit) * 6))
    minmax(calc(var(--os-unit) * 2), 1fr)
    fit-content(calc(var(--os-unit) * 6));
  grid-template-areas: "col1 col2 col3";
  align-items: center;
  gap: var(--space-2);
  min-height: var(--os-unit);
  padding: 0 var(--space-2);
}

/* 🔴 PLACED BY NAME, NOT BY POSITION. With positional placement, hiding col1
 * makes col2 the first child and it slides into col1's track — the row keeps
 * three columns and the content shifts, which reads as "hidden did nothing". */
.os-row > .col1 { grid-area: col1; display: flex; justify-content: center; }
.os-row > .col2 { grid-area: col2; min-width: 0; }
.os-row > .col3 { grid-area: col3; display: flex; justify-content: flex-end; }

/* An empty leading cell is a GUTTER, not a column — `--os-unit * .3`, which is
 * list-item.scss's own number. ⚠️ `:not(.hidden)` excludes a cell that is
 * hidden by class: a hidden cell must not reserve a gutter either. */
.os-row > .col1:empty:not(.hidden) { min-width: calc(var(--os-unit) * .3); }
.os-row > .col3:empty:not(.hidden) { min-width: 0; }

/* ⚠️ THE SITE HAD NO [hidden] RESET AND THE CELLS ABOVE SET `display`, so the
 * UA's own `[hidden] { display: none }` — which any author rule outranks — was
 * being beaten by them. PlotOS ships this in display.scss for exactly this
 * reason; without it `hidden` is inert on any cell, which is what was seen.
 * `[hidden="false" i]` is excluded, case-insensitively, as upstream does. */
[hidden]:not([hidden="false" i]) { display: none !important; }
.docs-deck > aside.os-card-ground > header.os-row,
.docs-deck > aside.os-card-ground > footer.os-row { border-color: transparent; }
.os-card > header.os-row h1, .os-card > header.os-row h2, .os-card > header.os-row h3 { margin: 0; font-size: 1.0625rem; }
/* ⚰️ `.docs-deck > .os-card > header.os-row h1 { font-size: 1.25rem }` — GONE,
 * 2026-09-22 (item 18). It sized an <h1> that sat inside an `os-row` card
 * header, and no <h1> does any more: the page's heading is plain markup in the
 * one content card and is sized by §THE DOCUMENT'S OWN TYPE SCALE below. The
 * rail card's own row carries a `<b>`, never a heading — item 16 ruled on that
 * directly, because `docsLabel` names the TREE and not the page. */

/* ⚠️ `tabindex="0"` lives on this element in the markup and is NOT decoration:
 * a scroll region a keyboard cannot reach is a WCAG 2.1 failure. The visible
 * focus ring is the other half of that — do not remove it. */
.os-card-scroll { overflow: auto; padding: var(--space-3) var(--space-2); }
.os-card-scroll:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }

/* ── the tree ────────────────────────────────────────────────────────────── */
.os-tree { display: grid; gap: var(--space-2); }
/* ⚠️ HAD NO RULE AT ALL until check-components asked. A <details>'s children are
 * a plain block by default, so the leaves were stacked with no gap — which looks
 * like a deliberate dense list rather than a missing rule, and is exactly the
 * kind of omission a rendered page does not report. */
.os-tree-children { display: grid; gap: 1px; padding-top: var(--space-1); }
.os-tree-summary {
  cursor: pointer;
  font-size: 0.75rem;
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-muted);
}
.os-tree-summary::-webkit-details-marker { display: none; }
.os-tree-chevron { width: 0.5rem; height: 0.5rem; border-right: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor; rotate: 45deg; }
.os-tree-node[open] > .os-tree-summary .os-tree-chevron { rotate: -135deg; }

.docs-leaf {
  color: var(--text-secondary);
  text-decoration: none;
  border-radius: 6px;
}
.docs-leaf:hover { background: var(--surface); color: var(--text-bright); }
.docs-leaf.is-current { background: var(--surface-lift); color: var(--text-bright); font-weight: 600; }

/* ── the panel body ──────────────────────────────────────────────────────── */
.docs-blurb { margin: 0 0 var(--space-4); font-size: 1.0625rem; color: var(--text-secondary); }
.docs-group { margin: 0 0 var(--space-4); }
.docs-group h2 { margin: 0 0 var(--space-1); font-size: 0.75rem; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-muted); }
.docs-group-blurb { margin: 0 0 var(--space-2); color: var(--text-secondary); }
.docs-cards { margin: 0; padding: 0; list-style: none; display: grid; gap: var(--space-2); }
.docs-cards a {
  display: grid;
  gap: var(--space-1);
  padding: var(--space-2);
  border: 1px solid var(--border);
  border-radius: 8px;
  color: var(--text-primary);
  text-decoration: none;
}
.docs-cards a:hover { border-color: var(--border-bright); background: var(--bg); }
.docs-cards a span { color: var(--text-secondary); font-size: 0.9375rem; }

/* ⚠️ The flag describes the SUBJECT, not the page — "the thing this documents is
 * partly built". A site that renders it cannot quietly describe something that
 * is not there, which is ../../web/site's own docs discipline. */
.docs-state {
  font-size: 0.6875rem;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--text-muted);
  border: 1px solid var(--border);
  border-radius: 999px;
  /* A pill is not a corner (base.css §THE CORNER). Without this it is caught by
   * the one rule that gives every rounded box the app's curve, and a stadium
   * end under a superellipse reads as a blob. */
  corner-shape: round;
  padding: 0.05rem 0.4rem;
}
.docs-source code { font-family: var(--font-mono); font-size: 0.75rem; }
/* The prose slot is empty until a markdown renderer exists (docs-shell.yml
 * says why). It reserves nothing, so an empty one costs no layout. */
.docs-prose:empty { display: none; }

/* ═══ THE CORNER, APPLIED TO THE PORTED DECK ════════════════════════
 * The block above is bounce's, verbatim, and stays that way so the diff back to
 * it stays readable — PLAN.md is explicit that the corner pass belongs in ONE
 * place and never inside a ported file. So the three deck rules that carry a
 * hardcoded radius are re-pointed HERE, at the site's own token, instead.
 * If bounce adopts the same corner upstream, these three lines simply go.
 *
 * ⚠️ SIZE ONLY. The CURVE is not here and must not be added here: base.css
 * shapes every rounded box on the site in one rule, and a `corner-shape` in
 * this block would be the second place the corner is decided. */
.os-card { border-radius: var(--radius-lg); }
.docs-leaf { border-radius: var(--radius); }
.docs-cards a { border-radius: var(--radius-lg); }

/* ═══ THE DOCS SHELL, IN PLOTOS'S OWN SHAPE ══════════════════════════════
 * Read off terminal-c/site/public/screens (2026-09-21, all 20): a row ABOVE
 * the deck, and the right side a NESTED deck of cards rather than one panel.
 * The block far above is bounce's ported CSS and still does the heavy lifting
 * (.docs-deck, .os-card, .os-row, .os-tree); this only adds what the new shape
 * needs, so the diff back to bounce stays readable. */
.docs-shell {
  /* ⚰️ `display: flex; flex-direction: column; gap: var(--space-2)` — GONE with
   * the topbar (tombstone below). Those three existed to stack TWO children and
   * put air between them; with `.docs-deck` the only child left they were doing
   * nothing, and the same disposal discipline the site header's `--os-unit`
   * coupling got applies here: what a removal makes meaningless is deleted, not
   * left standing. What remains is the full-bleed escape and the gutter, which
   * are about this element and not about what is inside it. */
  width: 100vw;
  margin-inline: calc(50% - 50vw);
  padding-inline: max(16px, var(--space-3));
  box-sizing: border-box;
}

/* ⚰️ `.docs-topbar` / `.docs-topbar h1` — GONE, 2026-09-22, with the markup that
 * wore them. ../../PLAN.md §"The corrections of 2026-09-22" item 12: "the
 * `docs-topbar` COMES OUT, SITEWIDE." Same tombstone discipline as `.nav` and
 * `.site-header` above: what the classes did, and where each idea lives now.
 *
 * ⚰️ It was a `--surface` row in a `--border` box at `--radius-lg`, its <h1> cut
 * to 1.05rem so a page-scale heading would not tower over a row.
 *
 * ⭐ THE <h1> IS NOT LOST, AND IT IS NO LONGER SHRUNK. It is the shell's own
 * (`{{^page.hasHeading}}<h1>` in src/html/_shell.html), reached by dropping
 * `opening` from layouts/docs-shell.yml's `keywords.role` — the same declaration
 * layouts/legal-doc.yml already refuses for the same reason. So a tree page's
 * heading is now `main > h1` at the page scale base.css sets, exactly like
 * /support and the four legal documents, instead of a 1.05rem row label.
 *
 * ⭐ AND THE col3 STATE BADGE MOVED RATHER THAN DYING — into the leaf card's own
 * body, as `.docs-state-line` below. docs-shell.yml's comment there says why the
 * rail's short form was not a substitute. */

/* The long state form, leading the leaf card. `.docs-state` already draws the
 * pill (see it above, with its `corner-shape: round` opt-out); this is only the
 * LINE it sits on — a paragraph, so the flag is read out before the blurb it
 * qualifies rather than floating beside it. */
.docs-state-line { margin: 0 0 var(--space-2); }

/* ⚰️ `.docs-panes` AND `.docs-title` — GONE, 2026-09-22, with the deck they
 * wrapped. ../../PLAN.md item 18: the right side is ONE card now, so there is
 * no nested column of cards to lay out and no title card to size. The single
 * card is a direct child of `.docs-deck` again, which means it takes that
 * deck's own row rule (`flex: 1 1 var(--os-card-min-width)`) — the rule that
 * was always right for a ROW of two cards and only misfired when a column was
 * nested inside it.
 *
 * ⚠️ AND THE TWO FIXES THAT LIVED HERE DIED WITH THE PROBLEM, not with their
 * reasoning — recorded so neither is re-discovered from scratch:
 *   · `.docs-panes { flex: 1 1 0 }` existed because `auto` made the nested
 *     deck's basis its max-content, which overflowed the line beside a 350px
 *     rail and dropped the whole column BELOW the tree. There is no nested
 *     deck to do that now.
 *   · `.docs-panes > .os-card { flex: 0 0 auto }` existed because
 *     `.os-deck > .os-card`'s 350px is a WIDTH floor, and applied down a
 *     COLUMN it became a card HEIGHT — every pane exactly 350px tall. The one
 *     card is in a ROW, where 350px means what it was written to mean.
 * 🔴 So do not "restore" either one. If a column of cards ever comes back on
 * the right, both come back with it and this note is the measurement. */

.docs-pane .os-card-body { padding: var(--space-4); }

/* ═══ THE DOCUMENT'S OWN TYPE SCALE ═══════════════════════════════════════
 * ../../PLAN.md item 18 — and this is the part the layout kept postponing. Its
 * own comment said so in as many words: "A real <h2> here would inherit
 * base.css's PAGE-scale heading and come out larger than the page's own <h1>;
 * making them real headings is a small addition to src/css/components.css that
 * the prose task did not own." The objection was never that the <h2> was
 * wrong — it was that nobody had sized it. This is that sizing.
 *
 * 🔴 base.css's scale is for a PAGE, and these headings are in a DOCUMENT.
 * `h1` there runs to 3.25rem and `h2` to 2.25rem, which is right for a landing
 * page where the heading IS the composition and wrong inside a card of prose:
 * at those sizes a `##` section break reads as a new page rather than as the
 * next paragraph's name. Scaled down and, more importantly, scaled APART — the
 * ratio is what carries hierarchy, not the absolute size (design.md: type-led
 * hierarchy before colour-led).
 *
 *   h1  1.75 → 2.25rem   the page's title, once
 *   h2  1.125 → 1.375rem  a group on the index, a `##` on a leaf
 *   ratio at both ends ≈ 1.6, so the outline is unmistakable at every width
 *
 * ⚠️ THE VERTICAL RHYTHM IS DOING WHAT THE CARD GAPS USED TO. Sections were
 * separated by the gap between cards; in one continuous body that job falls to
 * the heading's own top margin, which is why `h2` carries a large one and the
 * first child never does (a leading gap inside a padded card is a hole).
 * Adjacent-sibling margins collapse, so the h1's bottom and a following h2's
 * top resolve to the larger of the two rather than summing — which is the
 * behaviour wanted here and the reason these are not equal numbers. */
/* 🔴 DESCENDANT, NOT DIRECT-CHILD — and this cost a bug worth recording.
 * These were `.docs-body > h1` / `> h2` when the scale was written for the docs
 * shell, where both ARE direct children. Item 19 then put the four legal
 * documents in the same card, and THEIR `<h2>`s sit one level deeper inside
 * `<section class="legal-section">` — so they matched nothing and fell back to
 * base.css's page scale. Measured on /privacy and /terms: h1 36px, h2 36px,
 * ratio 1.00 — no hierarchy at all, which is the exact defect this block was
 * written to prevent, reintroduced by a combinator.
 * ⚠️ Safe as a descendant selector because `.docs-prose` cannot contain an
 * <h2>: pipeline/markdown.mjs accepts only `##` and the layout lifts each one
 * out into a heading of its own (it rejects `#` and `###`+ by name). If the
 * prose grammar ever grows a heading level, this is the rule to revisit. */
.docs-body h1 {
  font-size: clamp(1.75rem, 3vw, 2.25rem);
  margin: 0 0 var(--space-3);
}
.docs-body h2 {
  font-size: clamp(1.125rem, 1.6vw, 1.375rem);
  margin: var(--space-5) 0 var(--space-2);
}
/* A leading gap inside a padded card is a hole — true whether the first thing
 * is the heading itself or the section wrapping it. */
.docs-body > :first-child,
.docs-body > :first-child > :first-child { margin-block-start: 0; }

/* The prose's own blocks keep base.css's body rhythm; only the last one loses
 * its trailing margin, so the card's padding is the only air at the foot. */
.docs-body > .docs-prose > :last-child,
.docs-body > :last-child > :last-child,
.docs-body > :last-child { margin-block-end: 0; }

/* ⭐ THE PAGE TITLE'S CARD — ../../PLAN.md item 16, "no <h1> above the shell".
 * The heading is a PAGE heading that happens to sit in a card row, so it takes
 * the page tier rather than the row tier: without this it inherited `.os-row`'s
 * 17px and the page's own title came out smaller than the group headings under
 * it. `.docs-deck > .os-card > header.os-row h1` above cannot reach it — that
 * selector wants a DIRECT child of `.docs-deck`, and this card is a child of
 * the nested `.docs-panes`. Sized off the same scale base.css gives an <h1>,
 * one step down because a card row is not a page banner. */
.docs-title > header.os-row h1 {
  font-size: clamp(1.5rem, 3.2vw, 2rem);
  line-height: 1.15;
  letter-spacing: var(--tracking-tight);
  color: var(--text-bright);
  margin: 0;
}

/* ⚰️ `.docs-deck > .docs-panes > .os-card { height: auto }` — GONE with
 * `.docs-panes` itself (item 18). It was an attempt at the height problem
 * below and could not work: `height` does not override `flex-basis` on the
 * main axis, which is the whole trap. The real answer is the `flex` in the
 * media query underneath. */

@media (max-width: 720px) {
  /* The rail goes above the card rather than beside it — one column, which is
   * the mobile-second half of design.md's order. */
  .docs-deck { flex-direction: column; }

  /* 🔴 AND THE FLEX BASIS HAS TO STOP BEING A HEIGHT WHEN THE AXIS TURNS —
   * the third appearance of this exact trap, so here is the rule in one line:
   * `flex-basis` measures along the MAIN AXIS, so `flex: 1 1 350px` is a width
   * floor in a row and a HEIGHT in a column. `.docs-deck > .os-card` carries
   * that 350px for the desktop row, and this query turns the deck into a
   * column — so without this every card became exactly 350px tall regardless
   * of its content. Measured at 400×850 before: /docs, /legal and a leaf page
   * all reported a card height of 350 with content needing 2002.
   * ⚠️ It bit twice before this: once on the nested `.docs-panes` column (item
   * 16, where it made every pane 350px and /docs's first group an empty box),
   * and once here. Any NEW `flex-direction: column` on a deck needs this line
   * with it. */
  .docs-deck > .os-card { flex: 0 0 auto; }
}

/* ═══ THE DOCS SHELL IS FULL-BLEED ═══════════════════════════════════════
 * Christian, 2026-09-21: "I still see the main content wrapper centered, that
 * is not wanted, and the header and footer span full 100vw."
 *
 * `main` is a centred 72rem column (base.css §main) — right for a document
 * page, wrong for a shell that is a window. The deck now spans the viewport
 * like the header and footer above and below it.
 *
 * 🔴 The escape is the standard full-bleed pair rather than a width override:
 * `main`'s own max-width still holds for everything else on the page, and only
 * the element that asked for the whole width takes it. `50%` resolves against
 * main's CONTENT box, which is itself centred, so the two halves cancel and the
 * shell's edges land on the viewport's.
 *
 * ⚠️ The side gutter is re-added here, because a window that touches the glass
 * has no frame. 16px minimum at every width — the same floor every other page
 * keeps. */

/* ═══ THE SUPPORT FORM (layouts/support-form.yml) ═════════════════════════
 *
 * ⭐ WRITTEN FROM SCRATCH, 2026-09-22. ../../PLAN.md §"The corrections of
 * 2026-09-22" item 14: every `report-*` class below had zero CSS anywhere in
 * src/css, and the layout's own header said so — *"this section ships
 * unstyled… the classes below are the hooks for whoever styles it."*
 *
 * 🔴 SAME DISCIPLINE AS THE DECK ABOVE: the VISUAL MODEL is read off PlotOS's
 * `packages/styles/src/sass/components/form.scss`, and the STYLESHEET is ours.
 * Importing form.scss would put a second token system and an icon font on an
 * editorial page — the argument layouts/docs-shell.yml's header already makes
 * at length — and 2,562 lines of it are Bootstrap-named operator compatibility,
 * Quill and intl-tel-input, for markup this form does not have. What was
 * actually taken from it, and nothing else:
 *   · THE FIELD IS A WELL. A 1px perimeter, a sunk ground, a radius, and the
 *     label stacked above it (`.form-group { display: grid }`).
 *   · THE CONTROL PERIMETER IS THE BORDER, NOT THE FILL — which is doubly true
 *     here, where every ground token sits within 1.25:1 of the page (measured:
 *     `--input` on `--bg` is 1.13:1). A well you can see is a well you can see
 *     the EDGE of.
 *   · DISABLED IS NOT DIMMED. form.scss's `&[disabled], &[readonly] {
 *     background-color: transparent }` changes the ground and never the ink.
 *     See §DISABLED below, which takes that further for a reason of its own.
 *   · THE BOX IS SQUARE. Christian's own ruling in that file, 2026-08-14 —
 *     *"the box is square, so height tracks width"* — kept for the radios,
 *     which are native here rather than PlotOS's `<span class="radio">`
 *     construction: this layout's markup is semantic and inventing spans to
 *     decorate it is the vocabulary that layout deliberately did not write.
 *
 * ⚠️ AND IT NEVER SUBMITS, WHICH IS A STYLING CONSTRAINT AND NOT ONLY A
 * WIRING ONE. The whole control set is inside `<fieldset disabled>` — see
 * layouts/support-form.yml's header for why — so EVERY rule below is read in
 * the disabled state, and a convention that dims one dead control among live
 * ones would here dim the entire form. §DISABLED is where that is settled.
 */

/* ── the words above the form ────────────────────────────────────────────── */

/* ⭐ `.report-title` GETS NO RULE, DELIBERATELY. It is a real <h2> and base.css
 * already sets the h2 tier — `--font-display`, `--text-bright`, clamp(1.5rem,
 * 3.5vw, 2.25rem), tight tracking — one step under the page's <h1> ("Support"),
 * which is the hierarchy this section wants. A class rule here would be a second
 * place the heading scale is decided, for no change. Recorded rather than left
 * blank so the next reader does not read the silence as an omission. */

/* The lede. One tier up from body, `--text-secondary` — the register
 * `.docs-blurb` already sets for the opening words of a docs card. */
.report-intro { font-size: 1.0625rem; color: var(--text-secondary); }

/* 🔴 THE LOAD-BEARING SENTENCE ON THIS PAGE. A disabled fieldset is REMOVED
 * FROM THE TAB ORDER (support-form.yml's header), so a keyboard reader never
 * meets the form at all and this paragraph is the only thing that explains the
 * silence. It is the one paragraph here set in the full text tier.
 *
 * ⭐ AN EDGE RULE, NOT AN ALERT BOX. A filled warning panel is the SaaS
 * dashboard register design.md recoils from, and the site already owns a
 * quieter device that says exactly "this line is the one that counts": the
 * accent rule stood on its end against a row's leading edge, which is what the
 * rail's current-page marker is (src/css/nav.css) and what `.nav`'s tombstone
 * above records as the idea that survived the header's deletion. Reused rather
 * than reinvented.
 *
 * ⚠️ `border-inline-start`, not `border-left` — the rule has to be on the
 * reading edge, and `_shell.html` sets `lang` from content/app.json's
 * `languages`, which is a list this site expects to grow. */
.report-notice {
  border-inline-start: 2px solid var(--accent);
  padding-inline-start: var(--space-2);
  color: var(--text-primary);
}

.report-instead { color: var(--text-secondary); }

/* ── the form ────────────────────────────────────────────────────────────── */

.report-form { margin-top: var(--space-4); }

/* 🔴 `min-width: 0` IS THE WHOLE REASON THIS RULE EXISTS. A <fieldset> has an
 * intrinsic minimum width of its content's max-content — it is the one element
 * the normal min-width:auto rules do not describe — so a fieldset holding a
 * long <legend> or a wide row refuses to shrink and pushes the page sideways
 * instead. That is WCAG 1.4.10 Reflow at 320px, which this codebase has now
 * paid for twice: `main`'s auto-margin stretch (base.css) and the footer's
 * four-link legal nav (its tombstone above). Measured at 320px after this rule:
 * no horizontal overflow on /support.
 * The rest is the UA's own fieldset chrome, which this form draws itself. */
.report-form fieldset {
  min-width: 0;
  margin: 0;
  padding: 0;
  border: 0;
}

/* Both legends. `padding: 0` is the UA reset — a <legend> ships with inline
 * padding that nothing here wants — and `float: none` keeps Safari from taking
 * the element out of flow once the fieldset's own border is gone. */
.report-form legend { padding: 0; float: none; }

/* ⚠️ NOT HIDDEN, AND NOT A SECOND HEADING EITHER. support-form.yml refuses
 * `.visually-hidden` outright (the site carries no such utility, so the class
 * would hide nothing and ship a stray line), and this legend names the fieldset
 * for a screen reader — it has to be real, visible text. But `.report-title`
 * above it already says the same thing louder, so it is set as the section
 * marker the file already uses twice (`.os-tree-summary`, `.docs-group h2`):
 * uppercase, small, tracked, muted. It reads as a label on the form rather than
 * as a rival to the <h2>. `--text-muted` on `--bg` is 5.80:1 — over the 4.5:1
 * body floor, so the tier holds even at this size. */
.report-legend {
  display: block;
  margin-bottom: var(--space-3);
  font-size: 0.75rem;
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-muted);
}

/* A field is its label stacked over its control — PlotOS's `.form-group {
 * display: grid }`, which is the whole of that rule there and the whole of it
 * here. `--measure` keeps a text field off the full 72rem column: a 1,100px
 * single-line input is a dare, not an affordance. */
.report-field {
  display: grid;
  gap: var(--space-1);
  margin-bottom: var(--space-3);
  max-width: var(--measure);
}

/* One class, two elements — a <span> inside `.report-field` and the <legend>
 * inside `.report-kinds`. Both are the question being asked, so both are set
 * the same: full text tier (15.25:1 on `--bg`), because a label a reader has to
 * work at is a form they fill in wrong. */
.report-label {
  font-size: 0.9375rem;
  font-weight: 500;
  color: var(--text-primary);
}

/* The optional pair. `min(…, 100%)` inside the minmax for the same reason the
 * three grids far above carry it: a bare minmax minimum is a HARD floor and the
 * track overflows rather than shrinking. 320px, WCAG 1.4.10. */
.report-optional {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(14rem, 100%), 1fr));
  gap: 0 var(--space-3);
  max-width: var(--measure);
}

.report-hint {
  margin-bottom: var(--space-3);
  font-size: 0.875rem;
  color: var(--text-muted);
  max-width: var(--measure);
}

/* ── the controls ────────────────────────────────────────────────────────── */

/* ⭐ ONE WELL, THREE ELEMENTS. An input, a textarea and a select are one
 * control family and are drawn by one rule, so they cannot drift apart.
 *
 * 🔴 THE BORDER IS `--border-bright`, AND THAT IS NOT A TASTE CALL. WCAG 2.1
 * 1.4.11 asks 3:1 of a control's own boundary, and on this palette:
 *     --border        on --bg   1.23:1   ✗   (and 1.08:1 against the well)
 *     --border-bright on --bg   3.42:1   ✓   (3.01:1 against the well)
 * theme/tokens.css names that token for exactly this job in its own comment —
 * *"a widget's own perimeter… this token is the widget perimeter AND the focus
 * ring, and WCAG 2.1 1.4.11 sets 3:1 for both"* — and floors it at 3:1 so a
 * future theme cannot reintroduce the defect. `--border` stays what it is
 * everywhere else on the site: a rule between things, which has no such floor
 * because it identifies no control.
 *
 * ⚠️ `font: inherit` IS LOAD-BEARING. Form controls do not inherit the
 * document's family or size — they fall back to the UA's own, which on this
 * page means a 13.33px system font inside a 16px Inter column. Three properties
 * in one, deliberately: size, family and line-height all reset together.
 *
 * ⚠️ `--input` IS NOT IN base.css's FALLBACK BLOCK, which is the floor for the
 * window where a site has built but not yet run `npm run tokens`. The chain
 * gives that window `--surface`, which is in it. Every other token below is.
 *
 * `min-height: 2.75rem` is 44px — the tap-target floor, the same constant
 * `.btn` states above. */
.report-form input[type="text"],
.report-form input[type="email"],
.report-form textarea,
.report-form select {
  font: inherit;
  letter-spacing: inherit;
  width: 100%;
  min-height: 2.75rem;
  padding: 0.5rem var(--space-2);
  color: var(--text-primary);
  background: var(--input, var(--surface));
  border: 1px solid var(--border-bright);
  border-radius: var(--radius);
  transition: border-color var(--motion-duration) var(--motion-ease);
}

/* The one control that is a box rather than a line. `resize: vertical` and no
 * horizontal: a textarea a reader can drag wider than its column is a reflow
 * failure they inflicted on themselves. */
.report-form textarea { resize: vertical; line-height: 1.6; }

/* ⭐ THE SELECT'S PROMPT IS PLACEHOLDER TEXT BY FUNCTION, whatever the element
 * calls it. `<option value="" selected disabled>Choose where</option>` is the
 * one unanswered state on this form, and the prompt should say so — but an
 * <option> is not a `::placeholder` and cannot be coloured from inside, so the
 * SELECT is asked whether its empty option is the chosen one and tints its own
 * text. `:has()` ships in all three engines; where it does not, the prompt
 * simply renders at the full text tier, which is a quieter loss than a fragile
 * `appearance: none` rebuild of a native control would be.
 *
 * 🔴 `--text-placeholder`, NOT an opacity. It is 5.11:1 on the well — still over
 * the 4.5:1 body floor — where the usual ghost-text convention (50% of the ink,
 * which is what PlotOS's form.scss does) would land near 2:1 and fail. Muted is
 * not a licence to be illegible; design.md's own line is that when a muted tier
 * fails contrast you darken the tier rather than argue the aesthetics.
 *
 * ⚠️ NO `::placeholder` RULE HERE, and that is not an omission either: not one
 * control on this form carries a `placeholder` attribute, because
 * support-form.yml refuses them — the explanation "has to be real text in the
 * page… and not a placeholder or a tooltip". A rule with no caller is the
 * abstraction this file's neighbours keep warning about. */
.report-form select:has(option[value=""]:checked) {
  color: var(--text-placeholder, var(--text-muted));
  -webkit-text-fill-color: var(--text-placeholder, var(--text-muted));
}

/* ⭐ THE PERSON-CHECK (2026-10-02, when the form began to send). The issue
 * reporter's own Turnstile page, framed by src/pages/support/report.js.
 *
 * 🔴 THE FRAME IS THE WIDGET'S SIZE, NOT THE FORM'S. The reporter's page
 * centres Cloudflare's widget (300 × 65, its fixed "normal" size) in whatever
 * box it is given; given the form's full width it floated in the middle of an
 * empty band while every field above it sat flush left — which is most of why
 * it read as foreign (Christian: *"improve the captcha alignment and design to
 * not feel so foreign"*). At 300px the centring lands flush left with the
 * fields, and `max-width: 100%` keeps a 320px screen from overflowing.
 * Nothing INSIDE the frame is ours to style — it is another origin — so the
 * rest is placement: a caption in the form's own label voice, and the box
 * reserved before the frame arrives so the form does not jump under it.
 *
 * ⚠️ `color-scheme: light` ON A DARK PAGE IS DELIBERATE, and copied with its
 * reason from the films server's card (server/src/index.js `.check`): a frame
 * whose scheme differs from its document's gets an opaque white ground from the
 * browser, and the check page declares none (so: light). */
.report-check { margin-top: var(--space-2); }
.report-check > .report-label { display: block; margin-bottom: var(--space-1); }
.report-check iframe {
  display: block;
  border: 0;
  inline-size: 300px;
  /* ⚠️ NOT capped at 100%: Cloudflare's widget does not shrink below 300px, so
     a capped frame CUT it — at 390px the column is 286px and the logo went.
     Uncapped, it borrows the column's right gutter: whole from 380px up
     (measured: right edge at 380px). At 375px (iPhone SE, mini) the widget's
     last 5px — its right border — is clipped. The real fix is the
     reporter offering Turnstile's `compact` size (150 × 140), which its
     challenge page does not take today. */
  max-inline-size: none;
  block-size: 65px;
  color-scheme: light;
  background: transparent;
  /* The fields' corner, so the widget is not the one square thing on the form.
     An iframe's radius clips the other origin's pixels without touching them;
     the widget's own hairline border is drawn 1px in, so the clip follows it. */
  border-radius: var(--radius);
  overflow: clip;
}
/* Air before Send, the same step as between fields. */
.report-check { margin-bottom: var(--space-3); }
/* Before the script runs there is nothing to check, so nothing is drawn. */
.report-check:empty { display: none; }

/* What happened after Send — `role="status"`, so it is announced as well as
 * shown. Primary ink: an outcome is the thing the reader is waiting to read. */
.report-status { margin-top: var(--space-2); color: var(--text-primary); }
.report-status:empty { display: none; }

/* ⭐ THE FIELD ITSELF ANSWERS THE CARET, not only the ring around it. base.css
 * draws every focus indicator on this site (`:focus-visible`, 2px `--accent`,
 * 9.24:1 on the ground — nothing here duplicates it, and a duplicate would be
 * the second place focus is decided). This adds the other half: the well's own
 * perimeter comes up to the accent, so the active field reads as active at a
 * glance and not only at the outline. A state change, under 200ms, ease-out —
 * `--motion-duration` is 160ms and tokens.css zeroes it under
 * prefers-reduced-motion, so this needs no media query of its own. */
.report-form input:focus-visible,
.report-form textarea:focus-visible,
.report-form select:focus-visible { border-color: var(--accent); }

/* ── the kind ─────────────────────────────────────────────────────────────
 * The switch itself is §THE SEGMENTED SWITCH below, shared with /releases. */
.report-form .report-kinds { margin-bottom: var(--space-3); max-width: var(--measure); }
.report-kinds > .report-label { display: block; margin-bottom: var(--space-1); }

/* ── DISABLED ────────────────────────────────────────────────────────────
 *
 * 🔴 NOTHING HERE IS DIMMED, AND THAT IS THE DECISION RATHER THAN AN OMISSION.
 *
 * The usual convention — half-opacity, grey ink — says *"this control is off
 * while the rest of the form is live."* This form has no rest: `<fieldset
 * disabled>` wraps all of it and there is no state in which any of it is
 * enabled (support-form.yml: there is nowhere to send a report, and wiring it
 * up is "an `action`, a handler and deleting the `disabled` attribute"). So
 * the convention would say nothing while costing every label, every field and
 * every option its legibility — and a form nobody can read is a worse answer
 * than an unstyled one. WCAG exempts an inactive component from the contrast
 * floors; that is permission to dim, not a reason to.
 *
 * ⭐ THE FORM SAYS IT IN WORDS INSTEAD, WHICH IS WHERE IT WAS ALREADY SAID.
 * `.report-notice` is a real paragraph above the form, in the page's reading
 * order, keyboard or not — put there precisely because a disabled fieldset is
 * out of the tab order and a tooltip or a placeholder could not carry it. The
 * state is stated once, in language, at the full text tier. Painting it a
 * second time in grey would be the weaker half of the same message.
 *
 * So the only disabled affordance is the POINTER, which costs no contrast.
 *
 * ⚠️ AND THREE UA DEFAULTS HAVE TO BE UNDONE FOR THE ABOVE TO BE TRUE, because
 * `color` alone does not win them:
 *   · `-webkit-text-fill-color` outranks `color` in WebKit and is what actually
 *     greys a disabled control there — so on Safari, and on every iPhone, a
 *     rule that only sets `color` renders dimmed anyway. This is an iOS app's
 *     site; that is the browser that matters most.
 *   · `opacity` is applied to disabled controls by some platform UAs.
 *   · `cursor` is the one signal being KEPT — `not-allowed`, on the label rows
 *     too, since the label is the target there and a disabled input inside it
 *     does not give its wrapper the cursor. */
.report-form :disabled,
.report-form :disabled + span,
.report-form .segment:has(:disabled) { cursor: not-allowed; }

.report-form :disabled {
  opacity: 1;
  color: var(--text-primary);
  -webkit-text-fill-color: var(--text-primary);
}

/* The button is the one disabled control that does read quieter, and for a
 * different reason: `.btn--primary` fills it with `--accent`, which is the
 * loudest surface on the page and would claim to be the thing to do next. The
 * accent moves from the FILL to the EDGE and the label — 9.24:1 on the ground,
 * against 9.24:1 the other way round when filled, so the ink is as legible
 * either way and only the volume changes. `--accent` on `--bg` also clears the
 * 3:1 boundary floor with room over. */
.report-form .btn--primary:disabled {
  background: transparent;
  color: var(--accent);
  -webkit-text-fill-color: var(--accent);
  border-color: var(--accent);
}

/* ═══ THE TREE BECOMES A BOTTOM DRAWER ON NARROW SCREENS ═══════════════════
 * ../../PLAN.md §"THE TREE BECOMES A BOTTOM DRAWER ON NARROW SCREENS"
 * (Christian, 2026-09-22). The markup, the gesture and the reasoning behind
 * every rule here are in src/html/_tree-rail.html, which is where the whole
 * component lives — one aside, re-laid-out; nothing duplicated, nothing moved
 * by script.
 *
 * ⭐⭐ THE BREAKPOINT IS MEASURED, NOT CHOSEN, and the arithmetic is exact —
 * which is why it is an odd number and must stay one:
 *
 *     2 × 24px   .docs-shell's own full-bleed gutter, max(16px, --space-3)
 *     2 × 24px   .docs-deck's padding, --space-3
 *       350px    the rail's floor — `.docs-deck > .os-card`'s min-width, which
 *                reaches the aside too and beats its 17rem flex-basis
 *        24px    the deck's gap, --space-3
 *       350px    the document card's floor, the same min-width
 *     ────────
 *       820px    the narrowest viewport at which the rail and the document
 *                still share a row
 *
 * Verified by bisection on all four tree-page shapes (/docs, /legal, a docs
 * leaf, a legal document): every one of them stacks at 819 and sits side by
 * side at 820, to the pixel. So the drawer takes over at ≤819px — the exact
 * band where the rail has stopped being a sidebar and become a box sitting on
 * top of the document.
 *
 * 🔴 AND THE NUMBER IS ASSERTED, NOT TRUSTED. `--tree-sheet-bp` below is read
 * by the script (which therefore never types it a second time) and by
 * scripts/check-tree-sheet.sh, which re-measures the stack threshold in a real
 * browser and fails if the two have drifted. Change the card floor or either
 * gutter and that check goes red naming both numbers — the alternative is a
 * literal in three files that nothing compares.
 *
 * ⚠️ WHAT THIS IS NOT: the sitewide `.site-rail` icon column (nav.css). That is
 * fixed, 64px, and present at every width; this is the docs/legal TREE, the
 * `aside.os-card` inside the deck. They are different elements and the drawer
 * insets itself past the rail rather than covering it — `inset-inline-start:
 * var(--rail-w)` below — because a drawer that buried the site's own navigation
 * would be taking more than it was asked for.
 */
:root {
  /* Stated ONCE. A media query cannot read a custom property, so the literal
     below is unavoidable — but nothing else repeats it: the script reads this,
     and the check script compares this against the measured threshold. */
  --tree-sheet-bp: 819px;
}

/* ── the no-JS floor, which is also a latent bug fixed ────────────────────
 * 🔴 THE FLEX-BASIS-AS-HEIGHT TRAP, FOR THE THIRD TIME IN THIS FILE — and the
 * block at §THE DOCS DECK already names the first two (the nested .docs-panes
 * column, and .docs-deck > .os-card here). It was still live on the ASIDE:
 * `.docs-deck > aside.os-card-ground { flex: 0 1 var(--docs-rail-width) }` is a
 * 272px WIDTH basis in a row and a 272px HEIGHT in a column, and the ≤720px
 * query turns the deck into a column — so the rail rendered as a 272px-tall box
 * with its 538px tree scrolling inside it, on every phone, since the query was
 * written. `.docs-deck > .os-card { flex: 0 0 auto }` above does not reach it:
 * `.docs-deck > aside.os-card-ground` is the more specific selector and wins.
 *
 * ⚠️ IT MATTERS EVEN THOUGH THE DRAWER REPLACES THIS LAYOUT, because the drawer
 * only exists where there is script. Without JS this IS the page, and "the tree
 * is reachable with JavaScript disabled" (the AI-first floor, design.md) has to
 * mean readable, not technically-present-inside-a-272px-window. */
@media (max-width: 720px) {
  .docs-deck > aside.os-card-ground { flex: 0 0 auto; }
}

/* ── none of the drawer's three controls exists by default ───────────────
 * 🔴 THIS IS THE NO-JS FLOOR, AND IT IS ONE RULE. The FAB, the scrim and the
 * sheet's ✕ are in the markup of every tree page, and they are `display: none`
 * until BOTH halves of the gate are true: the script has run (the attribute on
 * <html>) and the viewport is narrow (the query below). So a reader with no
 * script never meets a button that does nothing — the failure design.md's
 * AI-first floor names by hand — and a desktop reader never sees a dismiss
 * control on a rail that cannot be dismissed. Nothing here needs JS to hide it,
 * which is why there is no `hidden` attribute on any of them: a second gate
 * would be a second thing that can be out of step with the first. */
.tree-fab, .tree-scrim, .tree-sheet-close { display: none; }

/* ── everything below exists only where there is script AND the viewport is
 *    narrow. The gate is one attribute, set by the partial's own script. ──── */
@media (max-width: 819px) {

  /* ── the sheet ────────────────────────────────────────────────────────── */
  html[data-tree-sheet] .docs-deck > aside.docs-rail {
    position: fixed;
    /* ⚰️ WAS 16, under .site-rail's 20 — the sheet insetting past the rail
     * rather than covering it. REVERSED 2026-09-23 (Christian): the sheet now
     * spans the true device width, rail included, so it has to paint OVER it.
     * `.site-rail`'s own z-index (20) is untouched; the scrim (below, now 21)
     * clears it too, so nothing of the rail shows through either layer. */
    z-index: 22;
    inset-block: auto 0;
    /* Full device width, rail included — see the z-index note above. The rail
     * is made `inert` while the sheet is open (src/html/_tree-rail.html) so a
     * keyboard can no longer reach what a pointer can no longer touch either;
     * before this change the rail was deliberately exempted from both. */
    inset-inline: 0;
    flex: none;
    /* ⚠️ AND THE FLOOR HAS TO GO WITH THE FLEX. `.docs-deck > .os-card` carries
     * `min-width: min(350px, 100%)` — the card floor Bounce shipped a device
     * report about — and `100%` resolves against the FLEX LINE, which a fixed
     * element is no longer in. So the sheet came out 350px wide inside a 334px
     * slot and hung 16px off the right edge of a 390px phone. Measured, not
     * spotted: the first probe read x 56, w 350 on a 390 viewport. */
    min-width: 0;
    /* ⚰️ WAS `min(72svh, 32rem)`, deliberately short of full-screen ("still
     * leaves a little of the content behind above it" — his words then).
     * RAISED 2026-09-23 (Christian): min-height 95vh. `svh` over `vh` for the
     * same reason as everywhere else in this file — the small viewport height
     * so a collapsing browser toolbar doesn't resize the sheet under a reader's
     * thumb. */
    min-height: 95svh;
    height: min(72svh, 32rem);
    max-height: none;
    /* "rounder at top left and top right" — the house corner at both, square at
     * the bottom because the bottom is the edge of the glass. */
    /* ⚠️ `--border-bright` AND NOT `--border`, only here. The desktop card sits
     * on the page's own ground and a quiet edge is right; the sheet sits over a
     * veiled page, where the two grounds are close enough that the shape needs
     * stating. 3.25:1 against the sheet — over 1.4.11's 3:1 for the boundary of
     * a control surface — and it is the same hairline the FAB wears, so the two
     * read as one family. */
    border: 1px solid var(--border-bright);
    border-bottom: 0;
    border-radius: var(--radius-lg) var(--radius-lg) 0 0;
    /* ⚠️ `os-card-ground` paints this the PAGE's ground, which is right for a
     * card that must read as part of the page and wrong for a sheet that must
     * read as being in front of it. --surface is the site's one raised tier. */
    background: var(--surface);
    /* THE GRIP STRIP. It is padding and not an element: the ::before pill draws
     * in it, and — the load-bearing part — a pointerdown that lands here is
     * outside `.os-card-scroll`, which is exactly how the script tells a drag
     * on the handle from a drag on the tree. A DOM node for the strip would
     * work; padding cannot get out of step with what the pill is drawn on. */
    padding-top: 22px;
    transform: translateY(100%);
    visibility: hidden;
    transition:
      transform var(--motion-duration) var(--motion-ease),
      visibility 0s linear var(--motion-duration);
  }

  /* "a drag line symbol in the top middle using the ::before selector" —
   * verbatim. `corner-shape: round` because a pill is not a corner (base.css
   * §THE CURVE says so in as many words); without it the universal
   * superellipse turns a 999px radius into a blob. */
  html[data-tree-sheet] .docs-deck > aside.docs-rail::before {
    content: "";
    position: absolute;
    top: 9px;
    left: 50%;
    translate: -50% 0;
    width: 2.25rem;
    height: 4px;
    border-radius: 999px;
    corner-shape: round;
    background: var(--border-bright);
  }

  html[data-tree-sheet="open"] .docs-deck > aside.docs-rail {
    /* `--sheet-drag` is the live finger offset while a dismiss drag is engaged,
     * set by the script and removed on release. At rest it resolves to 0. */
    transform: translateY(var(--sheet-drag, 0px));
    visibility: visible;
    transition-delay: 0s;
  }

  /* A dragged sheet follows the finger and cannot also be easing somewhere. */
  html[data-tree-sheet] .docs-deck > aside.docs-rail.is-dragging {
    transition: none;
  }

  /* The tree fills what the grip strip leaves, and its overscroll stops at its
   * own edge. ⚠️ `overscroll-behavior: contain` is not a nicety here: it is what
   * keeps a pull at the top of the tree from chaining into the page's own
   * scroll, which would hand the gesture to the compositor and cancel the
   * pointer stream the dismiss is measured from. */
  html[data-tree-sheet] .docs-deck > aside.docs-rail > .os-card-scroll {
    flex: 1 1 auto;
    min-height: 0;
    overscroll-behavior: contain;
  }

  /* ── the ✕ ────────────────────────────────────────────────────────────── */
  html[data-tree-sheet] .docs-deck > aside.docs-rail > .tree-sheet-close {
    display: grid;
    place-items: center;
    position: absolute;
    top: 0;
    inset-inline-end: 0;
    /* 44px of target for a 14px glyph — the AA floor, met by the hit area
     * rather than by drawing a big ✕. */
    width: 44px;
    height: 44px;
    padding: 0;
    border: 0;
    background: none;
    color: var(--text-muted);
    cursor: pointer;
  }
  html[data-tree-sheet] .docs-deck > aside.docs-rail > .tree-sheet-close:hover { color: var(--text-bright); }

  /* ── the scrim ────────────────────────────────────────────────────────── */
  /* ⚠️ DERIVED FROM A TOKEN, never a literal: `rgb(0 0 0 / .3)` would be a
   * colour outside base.css's fallback block, and it would be the wrong colour
   * in half the themes besides.
   *
   * 🔴 AND IT IS THE GROUND, NOT THE INK — the first spelling was
   * `--text-primary` at 32%, which on this dark theme is a LIGHT veil: measured,
   * the page behind went from near-black to #5B5347/#CCB59A, the sheet came out
   * DARKER than everything around it at 2.49:1, and the depth cue inverted — the
   * sheet read as a hole rather than as a surface in front. The focus ring in
   * the veiled region fell from 8.79:1 to 4.13:1 with it.
   *
   * ⭐ THE GROUND IS THE ONE TOKEN THAT RECEDES IN EVERY THEME. Veiling content
   * toward the page's own background fades it toward *nothing* — darker on a
   * dark theme, washed out on a light one — and either way `--surface` stays on
   * the same side of it that a card is on. One declaration, no theme branch. */
  html[data-tree-sheet] .tree-scrim {
    display: block;
    position: fixed;
    /* ⚰️ WAS `inset-inline-start: var(--rail-w)`, leaving the rail outside the
     * veil so it stayed visible and clickable while the sheet was open.
     * REVERSED 2026-09-23 with the sheet itself — full `inset: 0`, and the
     * rail is `inert` under it (src/html/_tree-rail.html) rather than exempt. */
    inset: 0;
    z-index: 21;                      /* under the sheet's 22, over .site-rail's 20 */
    background: color-mix(in srgb, var(--bg) 72%, transparent);
    /* Nothing behind the scrim should scroll under a finger. Safe here in a way
     * it is not on the sheet: the scrim has no descendants to starve. */
    touch-action: none;
    opacity: 0;
    visibility: hidden;
    transition:
      opacity var(--motion-duration) var(--motion-ease),
      visibility 0s linear var(--motion-duration);
  }
  html[data-tree-sheet="open"] .tree-scrim {
    opacity: 1;
    visibility: visible;
    transition-delay: 0s;
  }

  /* ⚠️ AND THE PAGE BEHIND HOLDS STILL. The scrim's `touch-action: none` stops
   * a finger and does nothing about a wheel, so at these widths on a desktop —
   * a narrow window, not only a phone — a trackpad scrolled the covered page
   * under the sheet. With the background inert to the keyboard and sealed to the
   * pointer, a scroll was the one way left to move something nobody can see.
   * ⭐ ON <html> RATHER THAN <body>: the scroll position is preserved across the
   * change in every current engine, so the peek does not jump when the sheet
   * arrives — measured rather than assumed, and the check asserts it. */
  html[data-tree-sheet="open"] { overflow: hidden; }

  /* ── the FAB ──────────────────────────────────────────────────────────── */
  /* "a hamburger icon hovering in the bottom right of the screen in a rounded
   * overlay". `--os-unit` tall, which is the site's row unit and a 48px target;
   * `corner-shape: round` for the same reason the pill has it — a disc is not a
   * corner, and the universal superellipse turns one into a blob. */
  html[data-tree-sheet] .tree-fab {
    display: grid;
    place-items: center;
    position: fixed;
    z-index: 14;
    inset-block-end: var(--space-3);
    inset-inline-end: var(--space-3);
    width: var(--os-unit);
    height: var(--os-unit);
    padding: 0;
    border: 1px solid var(--border-bright);
    border-radius: 50%;
    corner-shape: round;
    background: var(--surface);
    color: var(--text-bright);
    cursor: pointer;
    /* 🔴 NO DELAY IN THIS DIRECTION, AND IT IS A FOCUS BUG IF THERE IS ONE. A
     * transition takes its timing from the state it is going TO, so this rule's
     * delay is the one used on the way BACK to closed — and `closeSheet` focuses
     * this button the instant the attribute flips. `Element.focus()` on a
     * `visibility: hidden` element is a no-op, so a delay here silently dropped
     * focus to <body> every time ESC or the ✕ closed the sheet. Caught by
     * asserting the focus return rather than by watching the animation, which
     * is where it is invisible. The delay belongs on the OPEN state only. */
    transition: visibility 0s linear;
  }

  /* 🔴 "that is covered by the drawer when opened" — and covered is not enough
   * on its own. The sheet's z-index already draws over it, but a button that is
   * merely hidden BEHIND something is still in the tab order, so a keyboard
   * would land a focus ring on an invisible control (WCAG 2.4.11's subject).
   * `visibility: hidden` takes it out of both at once — and the delay holds it
   * on screen for the length of the slide, so it does not blink out from under
   * a sheet that has not arrived yet. */
  html[data-tree-sheet="open"] .tree-fab {
    visibility: hidden;
    transition-delay: var(--motion-duration);
  }

  /* Three lines from one element: the bar itself plus two shadows of it. The
   * technique the deleted `.rail-bars` used; not the element, which item 13
   * removed along with the whole toggle-rail pattern. */
  .tree-fab-bars {
    width: 18px;
    height: 2px;
    border-radius: 1px;
    corner-shape: round;
    background: currentColor;
    box-shadow: 0 -6px 0 currentColor, 0 6px 0 currentColor;
  }
}

/* ⚠️ THE MOTION IS THE ONE EXCEPTION THIS SITE'S CSS-ONLY RULE ALLOWS, and the
 * PLAN entry argues it: a sheet a reader opened on purpose is ordinary UI, not
 * the page's own scroll being hijacked. It still owes 2.3.3 — a reader who asks
 * for less motion gets the sheet's arrival, not its slide. Duration first, then
 * the visibility delays that were timed to it; leaving those at 160ms would
 * hold the FAB and the scrim on screen for a slide that no longer happens. */
@media (prefers-reduced-motion: reduce) {
  html[data-tree-sheet] .docs-deck > aside.docs-rail,
  html[data-tree-sheet] .tree-scrim,
  html[data-tree-sheet] .tree-fab,
  html[data-tree-sheet="open"] .tree-fab {
    transition-duration: 0s;
    transition-delay: 0s;
  }
}

/* ═══ THE SEGMENTED SWITCH ═══════════════════════════════════════════════════
 *
 * ⭐ ONE TRACK, N SEGMENTS, THE CHOSEN ONE FILLED (Christian, 2026-10-02: the
 * support form's radios "more like a switch-slider", and the same control for
 * /releases' iOS | tvOS). Born on the support form; hoisted here the moment
 * the second page wanted it, so the two cannot drift.
 *
 * 🔴 ALWAYS NATIVE RADIOS UNDERNEATH. Each input is laid transparent over its
 * whole segment — not hidden, not `display: none`, not a `.visually-hidden`
 * this site refuses — so it stays focusable, arrow keys move the choice, the
 * value is the browser's, and a screen reader says "radio, 1 of 3". Nothing
 * here re-implements a control; it only paints one.
 *
 * The track wears the form's well (`--input` inside a `--border-bright`
 * outline). The chosen segment takes the brightest ink as its fill and the
 * ground as its type — 17.2:1 — the inverse of everything around it.
 *
 * Equal columns, as many as there are segments (`grid-auto-flow: column`), so
 * they never jitter as the choice changes. `min-height: 2.75rem` is the 44px
 * target floor, on the SEGMENT. */
.segmented {
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: 1fr;
  gap: 0.25rem;
  max-width: 26rem;
  padding: 0.25rem;
  background: var(--input, var(--surface));
  border: 1px solid var(--border-bright);
  border-radius: calc(var(--radius) + 0.25rem);
}

.segment {
  position: relative;
  display: grid;
  place-items: center;
  min-height: 2.75rem;
  padding: 0 var(--space-1);
  border-radius: var(--radius);
  color: var(--text-secondary);
  font-weight: 500;
  cursor: pointer;
  transition: background-color var(--motion-duration) var(--motion-ease),
              color var(--motion-duration) var(--motion-ease);
}
.segment:hover { color: var(--text-bright); }

/* The radio, transparent over its whole segment: the segment IS the control. */
.segment input[type="radio"] {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  margin: 0;
  opacity: 0;
  cursor: inherit;
}

.segment:has(input:checked) {
  background: var(--text-bright);
  color: var(--bg);
}

/* The focus ring goes on the SEGMENT, since the input under it is invisible —
 * the same 2px `--accent` outline base.css draws on everything else. */
.segment:has(input:focus-visible) {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

/* nav.css — the primary navigation: the aside rail, its one control, and the
 * push that moves the page out of its way.
 *
 * @docs ../../README.md
 *
 * Concatenated into public/site.css AFTER base.css and components.css
 * (pipeline/emit.mjs, filename order — b, c, n). That order is why the three
 * rules in here that reach outside this component — the page's push, and the
 * `overflow-x: clip` it costs on <html> and <body> — need no !important and
 * fight nothing. Everything else is `.rail-*` and `.site-rail`. §THE CURRENT
 * PAGE READS one thing outside the component — <body>'s `data-page` stamp —
 * but still only ever styles a `.rail-*`, so the rule above is unchanged.
 *
 * 🔴 NO LITERAL COLOURS. Same rule as both neighbours: colour comes from theme
 * tokens, a hex below is a bug. ⚠️ ONE COLOUR HERE IS NOT A THEME TOKEN and
 * that is deliberate — §THE HOME MARK reads `--cam-red`, a HOUSE CONSTANT
 * (base.css §the house colours) whose whole job is to NOT track the theme. It
 * is still a var, so this rule holds as written; the distinction is why the
 * disc is the one thing in this rail that looks the same in every mode.
 *
 * ═══ WHAT THIS IS ════════════════════════════════════════════════════════
 * ../../PLAN.md item 10, Christian, 2026-09-21: "THE MAIN MENU COMES OUT. In
 * its place a two-line hamburger that rotates its two lines into an X, opening
 * a slide-in sidebar that pushes the whole page right — keeping the square
 * centred… an aside rail of icons — a book for docs, a speech bubble for
 * support. Wide enough, titles sit to the right of the icons; at icon width,
 * titles appear on hover as tooltips."
 *
 * ⚰️ THE RAIL HAS ONE WIDTH SINCE 2026-09-22 — ../../PLAN.md §"The corrections
 * of 2026-09-22" item 13: "THE HAMBURGER AND THE EXPAND-ON-CLICK RAIL COME
 * OUT." What is left is the second half of the sentence above, and it is the
 * half that was always doing the work:
 *
 *   one width   4rem   the icons alone; a title flies out on hover or focus
 *
 * The first half — a 15rem expanded state, its checkbox, and a push that grew
 * with it — is gone. §THE TOGGLE below is the record of why it existed and why
 * it did not need to; the short version is that `.rail-row:hover >
 * .rail-title` flew out a title for EVERY row independent of the checkbox, so
 * the wide state never revealed anything the narrow one was hiding. It added a
 * second state to reason about, a second set of metrics, a phone-width
 * exception, and a control at the top of the column — for a column of words
 * the reader could already summon one at a time by pointing at a row.
 *
 * ⭐ WHAT SURVIVES IT IS THE ⭐ OF THE BRIEF: the icons never move, and the
 * story's square stays centred in the glass the rail is not on. That was true
 * of the collapsed state before, it is true of the only state now, and §THE
 * PUSH holds the measurements.
 *
 * ⚠️ The class contract is PlotOS's (`.os-aside-rail`,
 * plot-os/specs/components/aside-rail.yml) and the stylesheet is ours —
 * the same split layouts/docs-shell.yml already rules for `.os-deck`, and for
 * the same reason: importing os-card.scss would put a second token system and
 * an icon font on an editorial page. Matching the names keeps adopting the real
 * stylesheet later a <link> rather than a rewrite.
 */

/* ── the metrics, stated once ───────────────────────────────────────────── */

:root {
  /* ⭐ THE RAIL'S WIDTH IS DERIVED, NOT CHOSEN: one `--os-unit` row plus its
   * gutters. base.css §THE PlotOS UNIT makes `--os-unit` the page's one row
   * height ("a card's header and footer rows align across cards BECAUSE their
   * height derives from this unit"), and a rail is a column of rows, so it
   * measures itself the same way. 3rem is also over the 44px tap-target floor
   * with room to spare. Change --os-unit and the rail rescales with the cards. */
  --rail-gutter: var(--space-1);
  --rail-w: calc(var(--os-unit) + var(--rail-gutter) * 2);   /* 4rem / 64px */
  /* The glyph inside the row's square cell. 22px on a 48px cell is the same
   * air the app leaves round the marks in its own 3rem controls. */
  --rail-glyph: 1.375rem;
  /* ⚰️ `--rail-w-open: min(15rem, 62vw)` — DELETED 2026-09-22 with the toggle
   * (item 13, §THE TOGGLE below). It was the expanded rail's width, read by
   * exactly three rules — the rail's own `width`, the body's push, and the
   * ≤36rem overlay that took the push back — and all three went with it. */
}

/* ═══ THE PUSH ════════════════════════════════════════════════════════════
 *
 * 🔴 PADDING ON <body>, NOT A TRANSFORM ON THE PAGE, and the difference is the
 * ⭐ of the brief — "keeping the square centred". `translateX` moves the page's
 * centre right along with everything else, so the square would sit half a rail
 * off-centre with the page's right edge pushed off-screen. Padding makes the
 * page NARROWER, and everything the page centres — main, the footer, and
 * through them the story — re-centres inside what is left. (It centred the
 * header too, until that bar was deleted on 2026-09-22 — ../../PLAN.md item 5.)
 *
 * ⭐ IT ALSO REACHED THE STORY'S SQUARE WITH NO CHANGE TO home.css — and that
 * ⚰️ IS THE HALF OF THIS ARGUMENT ../../PLAN.md ITEM 28 REVERSED, 2026-09-22.
 * It is kept in full rather than deleted, because it is still the reasoning
 * that decides where any full-bleed thing on this site lands; only the
 * CONCLUSION changed. The story escapes main's column with the standard
 * full-bleed hatch, `width: 100vw` + `margin-inline: calc(50% - 50vw)` — and
 * that `50%` is half of MAIN's width, so the 100vw box always centres on
 * main's centre, wherever main is. Main is centred in body's content box;
 * body's content box is the viewport minus the rail. So:
 *
 *     story centre = main centre = (100vw + shift) / 2 = centre of the glass
 *                                                        the rail is not on
 *
 * The square is `place-items: center` inside that, so it landed dead centre of
 * what a reader can actually see, at every width. MEASURED, 2026-09-21, square
 * centre against `(clientWidth + shift) / 2`, shut / open:
 *     1440   752.0 / 840.0     0px drift
 *     1024   544.0 / 632.0     0px
 *      768   416.0 / 504.0     0px
 *      375   215.5 / 215.5     0px  (below 36rem the rail overlaid instead of
 *      320   188.0 / 188.0     0px   pushing — that query is gone too)
 * ⚠️ THE SECOND COLUMN IS HISTORY AS OF 2026-09-22 — there is no open state
 * any more (item 13, §THE TOGGLE). The SHUT column was the live one, and it is
 * the one that had to hold: the identity above does not care what `--rail-w`
 * is, only that the shift and the padding are the same number, which is why
 * every row reads 0px drift in both columns.
 *
 * 🔴 CHRISTIAN LOOKED AT A PHONE AND ASKED FOR THE OTHER CENTRE (item 28):
 * "the rounded-video-box would also be aligned in the center left-right."
 * Two defensible readings of "centred", and the table above is the one the
 * site had picked — centred in the space the rail leaves. He wants centred on
 * the GLASS, rail or no rail. So home.css's escape now cancels the half-rail
 * explicitly (§FULL-BLEED at the head of pages/home/home.css, where the
 * algebra and the cost live) and the square's centre is the viewport's centre:
 * measured after, both engines, seven widths 1920→320, 0.1px drift and left
 * and right air equal to 0.2px.
 * ⚠️ THE IDENTITY ABOVE STILL GOVERNS EVERY OTHER PAGE, which is why the
 * correction is scoped to the story and not written here: /docs and the legal
 * tree use the same hatch and still want to centre in the glass beside the
 * rail, because their content is TEXT and text must not go under it.
 *
 * ⚰️ "CAN WE JUST DROP THE PUSH ON THE HOME PAGE" WAS REFUSED HERE, MEASURED,
 * AND IS NOW BUILT — see §AND THE STORY PAGE IS OUT OF IT below. The refusal
 * read: with `body[data-page="home"] { padding-inline-start: 0 }` the square
 * does centre, and every other section slides under the rail with it — `#how`,
 * `#cta-final` and their body text 32px under THE OPAQUE COLUMN at 400/360/320
 * and 40px at 768, the rail painting at z-index 20 over the lot.
 * 🔴 ITS PREMISE WAS DELETED THREE RULES LATER THE SAME DAY. Item 31 made the
 * rail `background-color: transparent`; there is no opaque column. What is
 * left is the glyphs, a 22px-wide run of ink rather than a 56px slab of it, and
 * `main`'s own gutter growing to the rail's width pays for that without
 * moving the centre. ⚠️ THE LESSON IS THE SHAPE, NOT THE NUMBER: two correct
 * changes, hours apart, and the second silently invalidated the first's
 * measurement. Nothing in this file or the build could have noticed. When a
 * refusal cites a property, re-read the property before quoting the refusal.
 *
 * ⚠️ AND THE COST THIS SECTION USED TO NAME IS GONE WITH THE SAME CHANGE: the
 * 100vw box was half a rail WIDER than the space it centred in, so it hung
 * `shift / 2` past the right edge — unclipped, 32px of sideways scroll at
 * 1440 and 28px on a phone, a WCAG 1.4.10 Reflow failure. Centred on the glass
 * the box spans exactly 0 → 100vw, and the overhang is zero: measured with
 * `overflow-x` forced back to `visible`, max scrollX 0 at all seven widths in
 * both engines, against 28–32px before.
 * 🔴 THE CLIP STAYS ANYWAY, and deliberately — it is a floor, not a patch for
 * one element. `.story`'s own children (the radar most of all) are absolutely
 * positioned and unclipped by design, item 23 only recently stopped the radar
 * overflowing, and the next full-bleed thing will not re-derive this. It is
 * the same instrument home.css uses on `.story` itself and for the same stated
 * reason: it clips WITHOUT making a scroll container, so `position: sticky`
 * and the scroll-driven animations still measure against the real scroller.
 * `overflow-y` stays visible — that pair is legal and is the one combination
 * that does not compute to `auto`.
 *
 * 🔴 ON BOTH <html> AND <body>, AND NEITHER ONE ALONE — MEASURED, because
 * each obvious spelling silently does nothing. Chrome 153, 2026-09-21,
 * `window.scrollTo(9999, 0)` on the home page at 1440:
 *     body only   → scrollX 32   (no clip)
 *     html only   → scrollX 32   (no clip)
 *     both        → scrollX  0   ✓
 * The reason is the propagation rule. While html's overflow is `visible`, the
 * BODY's overflow is what the viewport uses — so a `clip` written on the body
 * is taken off the body (it propagated) and dropped by the viewport (which
 * does not scroll-clip), and applies nowhere. Writing it on html as well stops
 * the propagation: html now carries the viewport's overflow, and the body's
 * own declaration goes back to clipping the body BOX, which is the box the
 * full-bleed story actually overflows. Two lines that look redundant and are
 * not; delete either and the sideways scroll is back.
 * ⚠️ The whole document therefore loses horizontal scrolling. On a site whose
 * one wide element is a full-bleed story that must never scroll sideways that
 * is the intent rather than the side effect — but it is a page-level
 * consequence of a nav decision, so it is stated here rather than buried. It
 * also swallows the half-scrollbar of overflow the full-bleed hatch has always
 * had on a classic scrollbar.
 * ⚠️ AND A CLIP HIDES REFLOW BUGS RATHER THAN FIXING THEM: anything that
 * genuinely does not fit is now cut off instead of reachable by scrolling,
 * which is a worse failure of the same criterion. The rail costing the page
 * 64px found one immediately — the footer's legal nav could not fit four links
 * at 320px and had been overflowing by 32px before this change — and it was
 * fixed at the source in components.css (`.legal-nav` wrapped) rather than
 * papered over here.
 * ⚠️ THAT ROW NO LONGER EXISTS (2026-09-22, ../../PLAN.md item 3): the four
 * links are the legal tree at /legal now, reached from §THE END ZONE below,
 * and the footer is down to two short items. The lesson outlives the row —
 * re-check the narrow widths after anything that adds a fixed-width one,
 * because the clip above will hide the failure rather than show it.
 */
html,
body { overflow-x: clip; }

/* ⚠️ ONE NUMBER, AND NO TRANSITION SINCE 2026-09-22. The shift used to animate
 * over `--motion-duration`, because it changed every time the rail opened. It
 * does not change any more — `--rail-w` is a constant per viewport, and the one
 * thing that still moves it is the phone gutter at the foot of this file, on a
 * resize nobody watches. A transition that only ever fires while a window is
 * being dragged is dead weight pretending to be motion design. */
body { padding-inline-start: var(--rail-w); }

/* ═══ AND THE STORY PAGE IS OUT OF IT — ../../PLAN.md §"The rail stops pushing
 * the stage" (2026-09-22, Christian, on an iPhone in portrait) ═══════════════
 *
 * *"looks like the aside-rail still pushes the main stage, if we position the
 * rail absolute we don't have this problem."*
 *
 * 🔴 THE DIAGNOSIS IS RIGHT AND THE INSTRUMENT IS NOT, and §THE RAIL's own
 * tombstone says why: the rail has been `position: fixed` all along, which is
 * ALREADY out of flow. Nothing is pushed BY the rail. The push is this
 * declaration — `body`'s padding — and that is the thing to scope off.
 * ⚠️ `position: absolute` would additionally stop the rail tracking the
 * viewport during scroll, so on the home page's very long story it would
 * scroll away almost immediately and undo item 22's "reachable throughout the
 * scroll". Taking the literal instruction would have fixed the centring and
 * lost the navigation; this fixes the centring and keeps it.
 *
 * ⚰️ AND IT OVERTURNS THE REFUSAL WRITTEN ABOVE (§the identity, "CAN WE JUST
 * DROP THE PUSH ON THE HOME PAGE IS ANSWERED, MEASURED"). That measurement was
 * honest and is now stale: it read *"every other section slides under the
 * OPAQUE column"* — and item 31 deleted the opaque column the same day, three
 * rules down (`background-color: transparent`, §THE RAIL). A refusal whose
 * premise has been removed is not a refusal any more, and nothing warned that
 * the two changes had met. ⚠️ THE COST DID NOT VANISH WITH THE BACKGROUND,
 * only shrank: the GLYPHS are still opaque. Re-measured with the push dropped,
 * `#cta-final`'s body text ran 15px under the glyph column at 375/390/428 and
 * 19px at 768 — so the padding below is what pays for it, and the rule the
 * page now follows is stated once here:
 *
 *   🔴 TEXT CLEARS THE RAIL. PICTURES CLEAR THE GLASS.
 *
 * The story's square, its satellites, its reel and its radar may pass behind
 * the rail — item 31 chose that outright (*"the story's picture now shows
 * through the rail instead of being painted out by it"*). Words may not, ever,
 * on either side: `main`'s own gutter grows to the rail's width, symmetrically,
 * so the column stays centred on the GLASS and still starts clear of the
 * glyphs. MEASURED after, 375/390/428/768: deepest text ink 56/56/56/64 against
 * a glyph column ending at 39/39/39/43 — clear by 17px on every phone and 21 at
 * 768, where it was 15 and 19 UNDER.
 *
 * ⚠️ SCOPED TO THE STORY PAGE AND IT MUST STAY THAT WAY. /docs, /legal and
 * their leaves run the `os-deck` layout, which is built to sit BESIDE the rail
 * and whose content is text end to end; they keep the push, untouched. The
 * identity above (shift == padding) still governs every one of them. */
body[data-page="home"] { padding-inline-start: 0; }
body[data-page="home"] > main { padding-inline: max(var(--space-3), var(--rail-w)); }
/* 🔴 AND THE FOOTER PAYS THE SAME GUTTER, because it is OUTSIDE `main` and the
 * line above therefore never reached it. Christian, 2026-09-25, with a
 * screenshot: the colophon ran under the rail's glyphs — measured at 1004px,
 * `© Manolab, LLC` sat at x=24 against a glyph column ending at 43, i.e. 19px
 * UNDER. On /docs and /legal it was never wrong, because those pages keep
 * `body`'s push; this is a home-only debt created by removing it.
 *
 * ⚠️ PADDING, NOT THE MARGIN CHRISTIAN NAMED, AND THE DIFFERENCE IS VISIBLE.
 * `.site-footer` already carries `padding: var(--space-4) var(--space-3)` and
 * `margin: 0 auto` for its own centring. A `margin-inline-start: var(--rail-w)`
 * would clear the rail — his requirement — but it ADDS to that existing padding,
 * putting the colophon at ~88px while `main`'s first text sits at 64: clear of
 * the rail and 24px out of step with every heading above it. It would also
 * fight the auto-centring. Matching `main`'s own declaration lands the colophon
 * at exactly 64 — the same number, by the same expression, so the two cannot
 * drift apart when `--rail-w` changes.
 *
 * ⚠️ AND THIS IS THE THIRD TIME `main`'S GUTTER HAS FAILED TO PAY FOR SOMEBODY
 * — see §the metrics above (`#cta-final`'s body text, 15px under at 375) and
 * PLAN item 41. Every element that is a SIBLING of `main` on this page owes the
 * same expression; there is no inherited push left to fall back on.
 *
 * 🔴 AND IT ONLY MOVED THE WORDS. Christian, an hour later, with three more
 * screenshots: *"notice how the footer on other pages look — the dividing
 * boarder should also not reach behind the rail."* He is right and the reason
 * is mechanical: PADDING DOES NOT MOVE THE ELEMENT'S BOX. `.site-footer`'s
 * `border-top` is drawn on the border edge, and on home that edge is still at
 * x=0 because `body`'s push is gone. Measured at 1004px, before this block:
 *
 *   page       footer box left   padding-inline-start   border-top starts at
 *   /support   64 (body's push)  24                     64  ✓
 *   /          0                 64                      0  ✗ behind the rail
 *
 * ⭐ SO THE BORDER MOVES TO THE CONTENT BOX, AND THE BOX DOES NOT MOVE AT ALL.
 * The obvious instrument is the one Christian named first — `margin-inline-
 * start: var(--rail-w)` — and it is still refused, now for THREE measured
 * reasons rather than the two above: it lands the colophon at 88 where every
 * heading on the page starts at 64; it kills the left `auto` of `margin: 0
 * auto`, so past ~1216px the footer drifts left of `main` instead of staying
 * centred with it; and it would move the border to 64 at 1004 but to `boxLeft
 * + 64` at 1440, which is NOT where the text is either. A line that starts
 * where the text starts is the thing being asked for, and the content box is
 * the box that already knows where that is — at every width, `main`'s
 * centring included, with no second expression to keep in step.
 *
 * ⭐ AN ABSOLUTELY POSITIONED `::before`, AND THE TWO ALTERNATIVES ARE WORSE.
 * A `linear-gradient` background would need `background-origin: content-box`
 * to get the inline inset — and that also moves the line DOWN by the footer's
 * `--space-4` of block padding, which is a vertical change nobody asked for;
 * inset it by hand instead (`background-position: <gutter> 0`) and the gutter
 * is written twice in a form no inspector shows you. A wrapping element is not
 * available: the markup is src/html/_footer.html and this rule is home-only.
 * An abs-positioned pseudo is out of flow, so `.site-footer`'s `display: flex`
 * never sees it as an item, and its containing block is the PADDING box — so
 * `inset-inline: <gutter>` lands it on the content edge by construction.
 *
 * ⚠️ `border-top-color: transparent`, NOT `border-top: 0`, AND THE 1px MATTERS
 * TWICE. Removing the border would shorten the footer's box by a pixel and
 * lift every word in it by one — a home-only 1px drift against /support for no
 * reason. Keeping the border and not painting it leaves the box identical, and
 * `inset-block-start: -1px` puts the drawn line on the exact scanline the
 * border used to occupy (the padding box starts one pixel below the border
 * edge). ⭐ IT IS ALSO THE BETTER FORCED-COLORS FALLBACK: in that mode a
 * pseudo-element's `background-color` is overridden away while the border is
 * repainted in a system colour, so a reader who needs the separator most gets
 * the full-width border back rather than nothing at all.
 *
 * ⚠️ ONE GUTTER, ONE DECLARATION. `--footer-gutter` exists so the padding and
 * the line cannot drift; it is scoped to this rule, so no other page can read
 * it, and the fallback for every other page is the base `padding` in
 * components.css, untouched. 🔴 THIS RULE IS HOME-SCOPED AND MUST STAY THAT
 * WAY — /docs, /legal, /support and the four legal documents keep `body`'s
 * push, so their footer box already starts clear of the rail and their border
 * is correct exactly as it is. Measured unchanged after this change at 1004
 * and 1440: box 64 / 176, colophon 88 / 200, border at the box edge. */
body[data-page="home"] .site-footer {
  --footer-gutter: max(var(--space-3), var(--rail-w));
  padding-inline: var(--footer-gutter);
  position: relative;                 /* the containing block the line hangs on */
  border-top-color: transparent;      /* kept for the box height; drawn below */
}
body[data-page="home"] .site-footer::before {
  content: "";
  position: absolute;
  inset-block-start: -1px;            /* the transparent border's own scanline */
  inset-inline: var(--footer-gutter);
  block-size: 1px;
  background-color: var(--border);
}

/* ⚠️ AND EVERY OTHER PAGE DROPS ITS OWN INLINE-START PADDING, so all twelve
 * footers read the same. Christian, 2026-09-26, with two screenshots: *"there
 * is an extra padding in footer left on other pages - i want the footer to look
 * just like the home page one."*
 *
 * 🔴 THE COMMENT ABOVE SAID THOSE PAGES WERE "CORRECT EXACTLY AS THEY ARE", AND
 * MEASURED AGAINST THE RAIL THEY WERE — the box already clears it. What nobody
 * measured is the footer against ITSELF: on every page but home, `body`'s push
 * puts the box (and therefore the border) at the rail's edge, and then the base
 * `padding: … var(--space-3)` adds 24px MORE to the text alone. So the line
 * starts at 64 and the colophon at 88, and the two disagree by exactly the
 * padding. Home escaped it because its body push is 0 and its whole gutter is
 * that padding, so line and text come out of one number.
 *
 * ⭐ THE GENERAL SHAPE, and it is the third time today: A RULE CAN BE RIGHT
 * ABOUT THE THING IT WAS WRITTEN TO CHECK AND WRONG ABOUT THE THING BESIDE IT.
 * "Clears the rail" was the question asked; "agrees with its own border" was
 * never asked of any page.
 *
 * Zeroing only the INLINE-START is deliberate: `body`'s push is inline-start
 * only, so that is the one side being paid for twice. The block padding and the
 * end padding are the footer's own and stay. Nothing here touches the border,
 * which is already at the box edge on these pages. */
body:not([data-page="home"]) .site-footer { padding-inline-start: 0; }

/* ═══ THE TOGGLE — ⚰️ REMOVED 2026-09-22, AND WHY IT WAS RIGHT UNTIL IT WASN'T
 *
 * ../../PLAN.md §"The corrections of 2026-09-22" item 13: "THE HAMBURGER AND
 * THE EXPAND-ON-CLICK RAIL COME OUT." What stood here was a long argument for
 * WHY A CHECKBOX AND NOT A BUTTON, and that argument was correct — it is kept
 * in substance below, because it is the reasoning that decides the NEXT
 * stateful control this site grows, not a defence of a control that is gone:
 *
 *   · 🔴 THE SITE'S LAW IS THAT NOTHING IS BEHIND A SCRIPT (_shell.html's
 *     header: "Everything a reader — human, screen reader, or agent — needs is
 *     in this markup before a single script runs"). A <button> would have been
 *     a control that does nothing until a script arrives — a dead surface, and
 *     the one affordance a reader was most likely to try first. A checkbox
 *     holds its state in the document where CSS can read it, so it worked
 *     before, during and without JavaScript.
 *   · ⭐ AND THE SEMANTICS WERE HONEST, which is the part of the checkbox hack
 *     that usually is not: it revealed no navigation — every link was present,
 *     visible and tabbable in both states — it toggled a display preference,
 *     titles or no titles, and a checkbox is the right element for a two-state
 *     preference. Its name came from its own <label>, with no ARIA at all.
 *
 * 🔴 WHAT MADE IT UNNECESSARY IS ONE RULE THAT WAS ALWAYS THERE. §THE TITLE
 * below flies a title out on `:hover` and `:focus-visible` for EVERY row, and
 * it never consulted the checkbox to do it. So the wide state revealed nothing
 * the narrow state was withholding: it showed all four words at once instead of
 * one at a time, and charged a state machine for the difference — a checkbox, a
 * label row at the top of the column, a second width, a second push, a
 * ≤36rem exception where the push stopped being a push, and two scripted keys.
 * Christian, item 13, having had it built and used it: it comes out.
 *
 * ⚠️ THE PART THAT DID NOT COME OUT IS THE ACCESSIBILITY DEBT THE TOOLTIP OWES.
 * A hover-shown title over page content still has to be dismissable without
 * moving the pointer (WCAG 2.1 AA 1.4.13), and it still is — see §THE TITLE's
 * `body[data-tips="off"]` rule and the script in _shell.html that sets it. That
 * script kept exactly that one branch when its other two (ESC collapses the
 * rail, Enter toggles the checkbox) died here. Deleting it with them would have
 * been a regression, not a tidy-up.
 *
 * What was deleted, by name, so a grep for any of it finds this block instead
 * of nothing: `#rail-state` / `.rail-state` (the visually-hidden checkbox and
 * its focus-ring pair), `.rail-toggle` (the <label> row), `.rail-bars` (the two
 * lines and their rotation into an X), `.rail-title--tip`, `--rail-w-open`, the
 * `body:has(.rail-state:checked)` push — the one rule in this file that ever
 * needed `:has()` — and the `@media (max-width: 36rem)` overlay that answered
 * it. Nothing keys off a state any more; the rail is a <nav> of links at one
 * width, which is a stricter reading of the site's own law than the checkbox
 * ever was. */

/* ═══ THE RAIL ════════════════════════════════════════════════════════════ */

/* ═══ THE RAIL ARRIVES WITH BEAT B — HOME PAGE ONLY ══════════════════════
 * ../../PLAN.md items 22 part A and 40/42. Christian, 2026-09-25: *"we have to
 * move the fade in of the aside-rail up to the timing of the 02 B-gate
 * projector shift."* Until item 22 this rail rendered from the page's first
 * frame on every page, with no opacity rule anywhere in this file.
 *
 * ⭐⭐ IT IS ONE SCALAR AND THERE IS NO SECOND MECHANISM. `--lift` is the whole
 * of the opening — 0 at beat A (the big centred square, no copy, no chrome) and
 * 1 at beat B (the square up in the top two-thirds, the copy in the bottom
 * third) — and home.css §--lift already computes `--centre`, and through it the
 * gate, the radar, the reel and the words, from nothing else. The rail's
 * opacity is now the SAME number read a fourth time:
 *
 *     opacity: var(--lift)
 *
 * so there is no timer here, no listener here, no keyframe here and nothing
 * that can drift from the square. ⚠️ THE ALTERNATIVE WAS A SECOND SCALAR, and
 * two numbers that must agree is the bug home.css has already paid for twice
 * (§--share and §--seam-open both carry the tombstone).
 *
 * ⭐ AND ITEM 22 PART A SURVIVES FOR FREE, which is the reason this shape was
 * chosen over a `rail-in` keyed to the playback cue. When the reader's finger
 * takes over during beat A, home.js hands `--lift` straight to
 * `lift-on-scroll` — a `scroll()` animation over `0 50svh` — so the rail fades
 * in UNDER THE THUMB exactly as Christian first asked ("when they scroll… the
 * aside-rail fades in"), with no second rule. Both readings of the opening are
 * one declaration.
 *
 * ⚠️ `--lift` HAS TO BE READ FROM `<body>`, not from `main`: this rail is a
 * SIBLING of `main` and a custom property declared inside `main` never reaches
 * it. That promotion is the whole of the work — home.css §THE OPENING'S THREE
 * BEATS and home.js's `introHost` carry it.
 *
 * ⭐ THE DESIGN CALL, NAMED because it is the first time this rail has been
 * anything but permanently present: this is NOT the "never behind a toggle"
 * law bending. That law (src/html/_nav.html's header) is about the rail's
 * CONTENT never being gated behind a CLICK — a control you must find and press
 * before the navigation exists. A scroll-revealed rail is gated behind nothing:
 * every link is in the markup, in the tab order and in the accessibility tree
 * from the first byte, and scrolling is the same mechanic every other element
 * on this page already answers to. What changes is when it is PAINTED.
 *
 * 🔴 AND IT IS SCOPED TO THE HOME PAGE. On /docs, /legal or /support there is
 * no opening composition to protect and no scroll to wait for — a rail that
 * faded in there would just be a rail that is missing when the page loads.
 *
 * 🔴 FOCUS OVERRIDES IT, AND THAT IS NOT OPTIONAL. A control that is invisible
 * but still focusable is a WCAG 2.4.7 failure with a worse-than-usual symptom:
 * the caret lands somewhere the reader cannot see, on the FIRST tab stop after
 * the skip link. `:focus-within` puts the whole rail back at full opacity the
 * moment anything inside it takes focus, so a keyboard reader never meets the
 * hidden state at all — they tab, and it is there.
 *
 * ⚠️ INSIDE `@supports`, so an engine without scroll-driven animation gets the
 * rail it has always had rather than one that never appears. The property's
 * own default stays `opacity: 1`; this only ever takes it down and back, and
 * the failure mode of every path that is not covered here is a rail that is
 * simply THERE. Named consequence, because it is a real population rather than
 * a hypothetical: a Safari without scroll timelines runs the projector on
 * home.js's clock (the `[data-intro]` rules sit outside every `@supports` gate
 * on purpose) but gets the rail from the first frame. That is the weaker of the
 * two right answers and it is deliberately the safe one.
 *
 * 🔴 AND IT STAYS INSIDE `no-preference`, WHICH IS NOT A DETAIL. Under `reduce`
 * beat B arrives as a STEP — at 2.5s on the watchdog, or on the first scroll
 * (home.js `INTRO_WATCHDOG_REDUCED_MS`) — so a rail driven by `--lift` there
 * would be the site's navigation invisible for two and a half seconds. A reader
 * who asked for less motion gets the rail present, immediately, always.
 * `prefers-reduced-motion` is a second composition, not a disabled one.
 *
 * ⚰️ WHAT WENT: `animation: rail-in linear both` on `scroll()` over `0 40svh`,
 * and its `@keyframes rail-in`. It said the same thing this says, from its own
 * clock, over its own range, on its own timeline — a second mechanism that had
 * to be kept in step with the square by hand. There are no keyframes here now,
 * so the house rule about identity at both ends (never `none`, the Safari rule
 * the story's keyframes follow) has nothing left to apply to. */
@supports (animation-timeline: scroll()) {
  @media (prefers-reduced-motion: no-preference) {
    body[data-page="home"] .site-rail { opacity: var(--lift); }
    /* 🔴 FOCUS OUTRANKS THE SCALAR — one extra pseudo-class of specificity, and
     * it is the whole of WCAG 2.4.7 for this element. A reader who tabs during
     * beat A finds the rail already lit. */
    body[data-page="home"] .site-rail:focus-within { opacity: 1; }
  }
}

.site-rail {
  position: fixed;
  inset-block: 0;
  inset-inline-start: 0;
  /* Above everything the page paints — the story's own stacked layers top out
   * at 2 (pages/home/home.css) — so a row's title can fly out ACROSS the page
   * rather than slide under it; below the skip link (100), which must always be
   * first to the eye and the keyboard. Those two are the whole constraint: the
   * site has no other stacked chrome (the sticky header that used to sit at 10
   * was deleted 2026-09-22, ../../PLAN.md item 5, and 10 is unused now). */
  z-index: 20;
  width: var(--rail-w);
  /* ⭐ ONE GUTTER, ALL FOUR SIDES — ../../PLAN.md item 13, and the third
   * reason the same number has stood here in a month.
   *   · It was `--space-3` on the block axis to match .site-header's padding,
   *     so the hamburger and the wordmark shared a centre line at 48px. Item 5
   *     deleted that bar and the coupling with it (see the tombstone in
   *     components.css); nothing in this file was waiting on it.
   *   · It stayed `--space-3` for a smaller reason — the page frame's own
   *     gutter, `main`'s inline padding and `.site-footer`'s — so the rail
   *     wore the same inset as the page beside it, and the control at the top
   *     was not flush against the edge of the glass.
   *   · ⚰️ THAT CONTROL IS GONE, and with it the last thing the extra 16px at
   *     the top was air FOR. The first row is now a destination (the home
   *     mark), the rail's own gutter is what every row already sits in
   *     inline, and stating one number on all four sides is both the simpler
   *     rule and the one that keeps the column centred in its own width.
   *     It also hands 32px of block height back, which is what let the
   *     short-viewport query at the foot of this file go — see it for the
   *     arithmetic.
   *
   * (The rail's rows are still `--os-unit` tall, and that coupling is real and
   * untouched: a rail is a column of rows, measured by the page's row unit —
   * §the metrics above derives `--rail-w` from it for the same reason.) */
  padding: var(--rail-gutter);
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
  /* ⚰️ `background-color: inherit` — REPLACED BY `transparent` 2026-09-22
   * (../../PLAN.md item 31, Christian). The argument for `inherit` was that the
   * home page's ground is true black where every other page wears the theme's
   * near-black, so a rail naming either literal would show a band on the other,
   * and inheriting <body>'s own computed colour is the one spelling that cannot
   * drift.
   *
   * ⭐ THAT ARGUMENT IS BEST ANSWERED BY PAINTING NOTHING. `inherit` solved a
   * seam by matching the colour on both sides of it; `transparent` removes the
   * seam, because there is no second colour to match. Strictly better against
   * its own stated concern, and it is also what item 28's other half was
   * about — an opaque column over a full-bleed story is the thing that HID
   * content there. This is the same fix from the other side: the story's
   * picture now shows through the rail instead of being painted out by it.
   *
   * 🔴 THE PRICE IS THAT THE GLYPHS NO LONGER SIT ON A KNOWN GROUND, so
   * WCAG 1.4.11's 3:1 for non-text content has to be measured against whatever
   * the story happens to be drawing there rather than assumed. Measured on
   * rendered pixels, not computed styles — see the report in the commit. If a
   * future scene paints anything bright down the left edge of the glass, this
   * is the rule that has to be re-checked, and nothing will warn you. */
  background-color: transparent;
  /* ⚰️ No `transition: width` any more: `width` is `--rail-w` and nothing
   * changes it (item 13, §THE TOGGLE). */
}

/* ⚠️ NO `overflow` ON THE RAIL OR ITS NAV, EVER. A title flies out
 * past the rail's right edge; `overflow: auto` — the reflex for a column that
 * might one day be long — would clip every one of them. If the nav ever grows
 * past a screen, scroll the ROWS' container and let the titles escape some
 * other way. */
.rail-nav {
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
  /* ⭐ IT SPANS THE RAIL — 2026-09-25, and this one declaration is what lets
   * the mark sit at the top and the destinations at the foot WITHOUT a second
   * landmark between them. The rail is a flex column and this is now its only
   * child, so `flex: 1` hands the whole height to the one <nav>; §THE END ZONE
   * then pins `.rail-end` to the bottom of it with the auto margin it has
   * always used. Before this the <nav> hugged its own rows at the top and
   * `.rail-end` was the rail's SECOND child — see that section's tombstone for
   * why the zone moved inside.
   * ⭐ IT IS ALSO WHAT PlotOS WOULD DO: `.os-aside-rail > *` is `flex: 1 1
   * auto` in aside-rail.scss, so the day that stylesheet is adopted this line
   * is what it already says. Stated here because we do not import it. */
  flex: 1 1 auto;
  /* ⚰️ `margin-block-start: var(--space-2)` — DELETED 2026-09-22 with the
   * toggle. It read "air between the control and the destinations: they are
   * not one list", and it was the whole of that list's separation from the
   * hamburger above it. There is no control above it any more: this <nav> is
   * the rail's first child, so the air would have been a 16px indent of the
   * home mark from the top gutter with nothing on the other side of it. Item
   * 13's "move the top rail icons up" is this line and the block padding
   * above, not a new rule. */
}

/* ═══ THE JUMP CONTROLS — the rail's top, and its only buttons ═══════════
 * Christian, 2026-09-25: *"the top is to hold the 'camcorder' icon as well as
 * li-direction-n and li-direction-s … the buttons that will allow to jump to
 * the next scene."*
 *
 * ⭐ THERE IS ALMOST NOTHING HERE, AND THAT IS THE POINT. A jump row IS a rail
 * row — same `--os-unit` height, same glyph column, same radius, same ink,
 * same hover, same flown-out title — so it wears `.rail-row .rail-item` and
 * this block only pays the difference between an <a> and a <button>: the UA's
 * own button chrome, which is not inherited from anything.
 *
 * ⚠️ `font: inherit` IS NOT DECORATION. A <button> takes the system UI font
 * and the UA's own size, neither of which `.rail-row`'s `font-size` overrides
 * — so without it the two titles in this group would be set in a different
 * FAMILY from the four beside them, which is exactly the sort of thing nobody
 * sees until they see it in one screenshot forever.
 *
 * 🔴 AND THE DISABLED STATE IS A `:disabled` CONSEQUENCE, NEVER A CLASS.
 * home.js sets and clears the ELEMENT's `disabled` property and writes no
 * class anywhere, so the fade cannot drift from the behaviour — the control is
 * inert, out of the tab order and faded because of ONE fact about it. A class
 * plus a handler-that-refuses is the other spelling and it is the one this
 * site's own rules recoil from: faded-but-focusable-and-inert is the WCAG
 * 2.4.7 shape §THE RAIL ARRIVES WITH BEAT B's `:focus-within` rule exists to
 * refuse.
 *
 * ⭐ THE FADE IS 0.55 AND IT WAS COMPUTED, NOT CHOSEN BY EYE. The row's ink is
 * `--text-secondary` (#BCA081 in the shipped theme), which is 8.49:1 on the
 * story's true black. At `opacity: 0.55` the composite is #675A4B-ish and the
 * ratio is 3.07:1 — still over WCAG 1.4.11's 3:1 floor for a non-text control,
 * and clearly the weaker of the two states beside an 8.49:1 neighbour.
 * ⚠️ THE FLOOR IS CLEARED RATHER THAN CLAIMED: 1.4.3 and 1.4.11 both EXEMPT an
 * inactive control, so a disabled row owes no ratio at all. It is held to one
 * anyway because a reader still has to see that the control is THERE in order
 * to understand why the other one is the live half of a pair. Muted is not a
 * licence to disappear.
 * ⚠️ AND THE GROUND IS NOT GUARANTEED — §THE RAIL's own 🔴 says it: the rail
 * paints nothing, so every glyph in it is measured against whatever the story
 * happens to be drawing behind it. 8.49 and 3.07 are against the page's
 * ground. A scene that paints something bright down the left edge of the glass
 * breaks both numbers at once and nothing will warn you.
 *
 * ⚠️ HOVER HAS TO BE UNDONE EXPLICITLY. `.rail-row:hover` still matches a
 * disabled <button> in both engines (pointer events are suppressed for
 * activation, not for the hover pseudo-class), so without the rule below a
 * dead control would light up and grow a ground under the pointer — an
 * affordance that lies. The title goes with it: a tooltip naming what a dead
 * control would have done is noise, and the name stays in the accessibility
 * tree regardless because `opacity: 0` never takes it out (§THE TITLE).
 */
.rail-jump {
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
}
/* 🔴 AND IT IS `hidden` UNTIL A SCRIPT TAKES IT — see _nav.html §THE TWO
 * DIRECTIONS for the argument (a control whose whole job is relative movement
 * cannot exist without a script, and two permanently dead glyphs are worse
 * than none). `hidden` rather than a class because it is the platform's own
 * word for it and it takes the pair out of the tab order and the accessibility
 * tree in one attribute.
 * ⚠️ THE RULE IS NOT REDUNDANT WITH THE UA'S. `[hidden]` is `display: none` in
 * the UA stylesheet, which ANY author `display` beats — and `.rail-jump` above
 * declares `display: flex` two lines up. Without this line the attribute would
 * be inert and the buttons would render for a reader with no JavaScript, which
 * is the exact failure it is there to prevent. Same trap `.rail-row`'s own
 * `display: flex` sets for anything that tries to hide a ROW this way. */
.rail-jump[hidden] { display: none; }
/* ⭐ THE RESET IS THE WHOLE OF THIS RULE, and Christian asked for it by sight
 * (2026-09-25: *"the box around the next and prev scenes buttons is not
 * wanted"*). Nobody drew that box — it is the UA's <button> chrome, which the
 * four <a> rows beside these never had. Every line below exists to delete one
 * piece of it, so a jump control and a link row are the same box at rest.
 * ⚠️ `padding-block`, NEVER `padding`. The shorthand would clobber
 * `.rail-row`'s `padding-inline`, which is what centres the glyph in the
 * `--os-unit` cell — and the two rules have EQUAL specificity, so this one
 * wins by coming later. The buttons would still look fine until somebody
 * measured them: the glyph column would be 6px (the UA's inline padding) off
 * the four rows above and below it.
 * ⚠️ `font: inherit` RESETS font-size TOO, so `.rail-row`'s is restated after
 * it rather than relied on — again, equal specificity and this rule is later.
 * ⚠️ AND SO IS `color`. A <button> takes `buttontext` from the UA sheet, and
 * although any author rule beats that, `font: inherit` above does NOT carry
 * colour with it — so the row's own ink is restated rather than left to the
 * ordering of two equal-specificity rules. The rows either side are
 * `--text-secondary`; so is this one, exactly.
 * ⭐ NOTHING IS DONE TO `:focus-visible` HERE, ON PURPOSE. base.css's global
 * `outline: 2px solid var(--accent); outline-offset: 2px` is the site's one
 * focus indicator and it lands on these the moment they are keyboard-focused
 * — the rail's controls being invisible to the caret would be exactly the
 * WCAG 2.4.7 failure §THE RAIL ARRIVES WITH BEAT B's `:focus-within` rule was
 * written for. Resetting a button's RESTING chrome and deleting its focus ring
 * are one `appearance: none` apart, and only the first was asked for. */
.rail-jump-row {
  appearance: none;
  -webkit-appearance: none;
  background: none;
  border: 0;
  padding-block: 0;
  font: inherit;
  font-size: 0.9375rem;              /* .rail-row's, restated past `font:` */
  color: var(--text-secondary);      /* .rail-row's, restated past `buttontext` */
  cursor: pointer;
}
.rail-jump-row:disabled { opacity: 0.55; cursor: default; }
.rail-jump-row:disabled:hover {
  color: var(--text-secondary);
  background-color: transparent;
}
.rail-jump-row:disabled:hover > .rail-title { opacity: 0; }

/* ⚠️ THE RAIL HAS TO FIT, AND ON HOME IT IS NOW SEVEN ROWS TALL. The rail is
 * `inset-block: 0`, so its height IS the viewport's, and `.rail-end`'s auto
 * margin takes only POSITIVE free space — under the column's own height the
 * end zone stops being pushed down and the last row runs off the bottom of a
 * FIXED element nobody can scroll. That is the failure the short-viewport
 * query deleted by item 13 used to guard, and item 13's own arithmetic (the
 * tombstone at the foot of this file) warned in writing that a third bottom
 * row would not fit an iPhone SE in landscape at 320px tall.
 *
 * 🔴 THE ARITHMETIC THAT STOOD HERE WAS WRONG, AND IT WAS WRONG BEFORE THE
 * SEVENTH ROW — found 2026-09-25 by measuring instead of re-deriving. It read
 * "160 the three destinations", counting two 8px gaps between them. THERE ARE
 * NO GAPS IN THE END ZONE: `gap: var(--space-1)` is declared on `.rail-nav`,
 * whose children are the mark, `.rail-jump` and `.rail-end` — `.rail-end`
 * itself is a plain block (§THE END ZONE: one declaration, `margin-block-
 * start: auto`), so its rows stack FLUSH. The three were 144, not 160, and the
 * column was 328, not 344. Nobody could have seen that in the number; it took
 * a browser. ⚠️ Every figure below is measured in Chromium and WebKit at
 * 568px wide, not computed from this file.
 *
 *   8 the rail's gutter · 48 the mark · 8 the nav's gap · 104 this pair
 *   (48 + 8 + 48) · 8 the nav's gap · 192 the end zone (FOUR flush 48s) ·
 *   8 the rail's gutter  =  376px, and 264px with this pair hidden.
 *
 * Proved by sweeping the viewport with the pair forced visible: 376 fits
 * exactly, 375 puts the last row 1px past the rail's bottom gutter, 370 puts
 * it 6px past. The download row (src/html/_nav.html §THE DOWNLOAD ROW) is
 * what took the end zone from 144 to 192 and the column from 328 to 376.
 *
 * ⭐ SO THE GROUP THAT GOES IS THIS ONE, and it is the only one that CAN go:
 * the destinations are the site's navigation, the mark is its way home and the
 * download row is the page's call to action, while these two do nothing a
 * finger on the page does not already do. Taking them off hands back 112px —
 * 264px, which clears an iPhone SE in landscape (568 × 320) by 56px with every
 * row keeping its full `--os-unit` tap target. ⚠️ THE NEXT ROW COSTS 48 MORE
 * (312 with the pair hidden, which still clears 320 by 8) AND THE ONE AFTER IT
 * DOES NOT FIT AT ALL. Two rows of headroom, and this is where they are
 * counted — re-measure here rather than adding to the sum above, because the
 * sum above has now been wrong once.
 *
 * ⚠️ AND THE THRESHOLD MOVES WITH IT: 26rem (416px), where it was 24rem (384).
 * The policy is the old block's own — "so the cut happens with air to spare
 * rather than at the millimetre it stops fitting", which was +40px over the
 * requirement as that block understood it. 416 is +40 over the 376 this one
 * MEASURED. Left at 384 the pair would have survived down to a viewport with
 * 8px of slack, which is inside the error bar of a browser's own chrome
 * appearing mid-scroll. */
@media (max-height: 26rem) {
  .rail-jump { display: none; }
}

/* ═══ THE END ZONE — the destinations, held at the foot of the column ═════
 * ../../PLAN.md §"The corrections of 2026-09-22" item 3: the rail "grows a
 * bottom zone", and the info-circle in it "opens a docs-shaped tree".
 * Christian's third pass, 2026-09-25, filled it: *"the bottom section in this
 * order 'docs', 'about' and 'support'."* Three rows now, in an order that
 * lives in wireframe.yml's `nav:` registry and nowhere else.
 *
 * ⚰️ IT HELD ONE ROW UNTIL THEN — /legal alone — and the block that stood here
 * said so. Its own ⚠️ named the trigger: *"THE TRIGGER TO REVISIT IS A SECOND
 * ROW. The moment this zone holds two destinations… it is a group, and a group
 * of destinations is a <nav> with a label."* The trigger fired and the answer
 * was already on the page: this zone is now INSIDE `.rail-nav`, the one
 * `<nav aria-label="Primary">`, so it has its landmark and the site still has
 * one to triage. ⚠️ IT IS THEREFORE NO LONGER THE RAIL'S LAST CHILD but the
 * NAV's — which is why `.rail-nav` above had to grow `flex: 1`, or the auto
 * margin below would have had no free space to eat and the zone would sit
 * flush under the mark.
 *
 * ⚠️ THE ROW READS "About" AND EVERYTHING IN THIS FILE STILL SAYS `legal` —
 * deliberate, not drift. Item 11 (2026-09-22) changed the WORD the row shows,
 * and only the word: Christian asked for the label, not the route. The path is
 * still /legal, so the page id is still `legal`, so `body[data-page="legal"]`
 * and the `.rail-legal` hook it styles are still spelled that way, and so is
 * the tree file they lead to. Renaming them to match the word would break the
 * one binding that is real — the stamp — to cosmetically agree with a literal
 * that lives in _nav.html. Leave them.
 *
 * ⭐ `margin-block-start: auto`, AND BOTH ALTERNATIVES ARE WORSE. The rail is
 * already a flex column (§THE RAIL), so an auto margin on the last child eats
 * whatever height is left above it and the zone comes to rest on the rail's own
 * bottom padding — in the flow, in the gap, in the width transition, with one
 * declaration. `position: absolute` would take it OUT of all three: it would
 * stop shrinking the nav's available height, it would need its inline offsets
 * restated for both rail widths, and it would sit still while the rail animates
 * between them. `justify-content: space-between` would move the nav's air as
 * well, which is the one thing §THE RAIL's "the icons DO NOT MOVE between the
 * two" forbids.
 *
 * ⭐ AND LAST IS THE POSITION PlotOS'S OWN CONTRACT NAMES FOR IT. aside-rail
 * .scss (plot-os, packages/styles/src/sass/components/aside-rail.scss): "A
 * rail's direct children are its zones, read from DOM order — no -top/-bottom
 * class, no spacer element… `:last-child:not(:only-child)` sized to its own
 * content, structurally pinned to the end edge." This zone IS that last child,
 * so the day the real stylesheet is adopted its `flex: 0 0 auto` lands here
 * with nothing renamed — which is the whole point of wearing PlotOS's class
 * names (§the header). `.rail-end` is a hook for OUR rule in the site's own
 * `.rail-*` namespace, NOT one of the three zone classes that file pins the
 * absence of (`.os-aside-rail-top` / `-bottom` / `-spacer`); nothing collides.
 * ⭐ AND THE ONE DIVERGENCE FROM THAT CONTRACT CLOSED ON 2026-09-22. This block
 * used to warn that the toggle would not survive adopting the real stylesheet:
 * `.os-aside-rail > *` makes EVERY direct child `flex: 1 1 auto`, and the
 * rail's first child was a bare `<label>` row rather than a wrapping zone — the
 * exact shape that file warns against ("a bare <button> dropped directly as a
 * rail child would itself receive the grow rule above and stretch"). Item 13
 * deleted that row. Both of this rail's children are now real zones — a <nav>
 * and this <div> — so the grow rule lands where PlotOS means it to, and the
 * adoption is a <link> rather than a repair. Keep it that way: anything added
 * to this rail goes inside a zone, never as a bare control beside them.
 *
 * 🔴 NO `overflow`, per the law stated above `.rail-nav`: a title flies out
 * past the rail's edge from this row exactly as it does from those. */
.rail-end { margin-block-start: auto; }

/* ⚠️ `.rail-download` HAS NO RULE IN THIS FILE, AND THAT IS THE DESIGN. The
 * zone's first row (src/html/_nav.html §THE DOWNLOAD ROW, 2026-09-25) is an
 * `.rail-row .rail-item` like every other: same 48px height, same 22px glyph on
 * the same optical column, same ink, same hover ramp, same focus ring. A call
 * to action drawn LOUDER than the navigation around it is the one thing this
 * rail has never done — the app's red is a mark and not a state (§THE CURRENT
 * PAGE), and the site's register is type-led, not colour-led. The class is
 * carried anyway, exactly as `.rail-home` and `.rail-legal` are, because it is
 * the handle the store-URL change will reach for if that row ever does need to
 * read differently from its neighbours. Do not give it one before somebody asks.
 * ⚠️ It is also NOT marked current by anything: `aria-current` says "you are on
 * this page", and this row points at a SECTION of one. The `#cta-final` it aims
 * at is on home, where the reader may well already be — that is still not a
 * page, and a rail that claimed otherwise would be lying to a screen reader. */

/* ── a row ──────────────────────────────────────────────────────────────── */

.rail-row {
  position: relative;                 /* the anchor a flown-out title hangs on */
  display: flex;
  align-items: center;
  min-height: var(--os-unit);
  /* Centres the glyph in a `--os-unit` square inside the rail's own gutter.
   * It used to do a second job — holding the icons at exactly that x while the
   * rail animated open, which is what kept them standing still — and it is
   * stated the same way now that there is only one width, because the square
   * it centres in is `--rail-w` minus that gutter either way. */
  padding-inline: calc((var(--os-unit) - var(--rail-glyph)) / 2);
  border-radius: var(--radius);
  color: var(--text-secondary);
  text-decoration: none;
  font-size: 0.9375rem;
  /* ⚰️ `cursor: pointer` came out with the toggle (item 13). It was here for
   * the <label>, which is not a link and gets no pointer of its own; every
   * `.rail-row` on the page is now an <a href>, which the UA already draws a
   * pointer for. */
  transition: color var(--motion-duration) var(--motion-ease),
              background-color var(--motion-duration) var(--motion-ease);
}
.rail-row:hover { color: var(--text-bright); background-color: var(--surface); }

.rail-glyph { flex: none; display: block; }

/* ⭐ THE DISC IS 0.70 OF ITS ROUNDED BOX, BECAUSE THE APP ICON IS 0.70 OF ITS OWN.
 * Christian, 2026-10-02: *"make sure the rail icon has the same red-dot size
 * relation to the main app icon in the rounded box."*
 *
 * It was already 0.70 — of the wrong box. `scripts/icon.swift:19` puts the disc at
 * `0.70` of the WHOLE icon, and `assets/brand/camcorder.icon.svg` carries the same
 * number (r 179.2 of 512). The rail drew r 7.7 in a 22 viewBox, which is also 0.70
 * — but of the 22px GLYPH COLUMN, while the rounded box a reader actually compares
 * against is the row, `--os-unit` = 48px. So the drawn relation was 15.4/48 =
 * **0.3208**, less than half the icon's, and the mark read small beside it.
 * [C] 0.70 × 48 = 33.6px, so r 16.8 in a 48 viewBox, leaving 7.2px of ground each
 * side — the same ratio of black to red the home screen shows.
 *
 * ⭐ THEN TRIMMED 5% BY EYE, and the eye is the right instrument here. Christian,
 * 2026-10-02: *"red dot in the rail icon feels about 5% too big."* So 0.665 of the
 * box — r 15.96 — not 0.70. The arithmetic was right and the match still read
 * heavy, which is what an iOS icon mask does to this comparison: the home screen
 * crops the square to a squircle, so the ground a phone shows around the disc is
 * LESS than the 15% a flat square gives, and matching the flat number overshoots
 * what the icon actually looks like in the hand. ⚠️ Do not "restore" 16.8 to make
 * it agree with `icon.swift` — the two numbers describe different boxes on purpose,
 * and this one was judged against the phone.
 *
 * ⚠️ THE NEGATIVE MARGIN IS NOT A NUDGE. `.rail-row`'s `padding-inline` is
 * `(--os-unit - --rail-glyph) / 2`, computed for a 22px glyph; a 48px one would be
 * pushed off-centre by exactly that padding on each side. Cancelling it with the
 * same expression negated keeps the disc concentric with the box it is measured
 * against, and leaves every other rail glyph's column untouched.
 * ⚠️ The rounded box itself only paints on the home page
 * (`body[data-page="home"] .rail-home`); elsewhere the disc stands alone and is
 * simply larger than its outline-icon siblings. That is the cost of matching the
 * icon rather than the column, and it is the comparison he asked for. */
.rail-disc {
  inline-size: var(--os-unit);
  block-size: var(--os-unit);
  margin-inline: calc((var(--rail-glyph) - var(--os-unit)) / 2);
}

/* ═══ THE HOME MARK — the app's record disc ═══════════════════════════════
 * ../../PLAN.md §"The corrections of 2026-09-22" item 2: the site's one way
 * home is "camorder icon (red solid dot)", in the rail, under the hamburger.
 * It replaces .site-header's wordmark, deleted the same day by item 5 — one
 * change, not two. _nav.html's header carries the reasoning; this is the
 * drawing. (Item 13 then deleted the hamburger it was under, later the same
 * day, so the mark is the FIRST row in the rail rather than the second. Which
 * is what item 13 meant by "move the top rail icons up" — it falls out of the
 * deletion; nothing here moved it.)
 *
 * ⭐ AN <svg> CIRCLE, NOT A CSS DISC, AND THE REASON IS IN base.css. §the
 * curve applies `corner-shape: superellipse(1.6)` UNIVERSALLY — `*, *::before,
 * *::after` — and its own comment records what that does to a round box: "a
 * disc (50%) and a pill (999px) come out as blobs under it… a caught disc
 * fills 0.58 of its corner box where a circle fills 0.31." A `border-radius:
 * 50%` span here would need an explicit `corner-shape: round` opt-out to stay
 * a circle, which is one more thing to get wrong; an SVG circle is outside
 * that rule's reach entirely. It also lands in the SAME 22px box as
 * `.rail-glyph` — it wears that class — so the mark sits on the icons'
 * optical column structurally rather than by a restated number.
 *
 * ⭐ THE DIAMETER IS THE APP ICON'S OWN FRACTION. scripts/icon.swift, which
 * generates the real app icon: `let disc: CGFloat = 0.70  // diameter as a
 * fraction of the icon`. 0.70 of the 22-unit viewBox is 15.4, so r = 7.7. The
 * rail's home mark is therefore the app icon at rail scale rather than a dot
 * sized by eye — the same "a fraction, never a fixed radius" discipline the
 * corner already follows (PLAN.md §The house constants).
 *
 * ⭐ AND IT DOES NOT ANSWER HOVER. `.rail-row:hover` lifts the row's ink and
 * ground; the fill is stated here and not `currentColor`, so the disc is the
 * one thing on the row that never changes — which is the app's own law about
 * it ("the disc never changes colour", PLAN.md §The house constants).
 *
 * `.rail-home` itself needs no declarations — `.rail-row` and `.rail-item`
 * already give it the row's height, padding, radius, ink, hover and focus. It
 * exists as the hook §THE CURRENT PAGE below hangs the you-are-here ink on. */

/* THE COLOUR IS THE APP'S, AND IT IS NOT A THEME TOKEN. `--cam-red` (#EB362C,
 * scripts/icon.swift) lives on `:root` in base.css §the house colours, beside
 * `--corner-fraction` and for the same reason: a constant read off the app's
 * own source, stated once for the whole site, explicitly NOT something
 * theme/tokens.css should repaint. PLAN.md §The house constants: "the disc
 * never changes colour."
 *
 * ⚠️ IT WAS PAGE-SCOPED UNTIL 2026-09-22. home.css declared it on `.story`
 * under a comment reading "stated once on this page and nowhere else", and the
 * rail is not a descendant of `.story`, so it resolved nowhere here — on any
 * page. It was hoisted to base.css the same day, by the lane that owns that
 * file, for exactly this mark; base.css's own comment records the move. No
 * fallback is written below on purpose: if that declaration ever goes away the
 * disc should fail visibly rather than quietly wear the theme's accent. */
.rail-disc { fill: var(--cam-red); }

/* THE CURRENT PAGE. The old horizontal nav marked it with a 2px accent rule
 * under the word; a rail marks it with the same rule stood on its end, against
 * the row's leading edge. Accent — never the app's red or amber as a STATE
 * colour: those two are the camera's and the film's, they mean something in
 * the story, and a chrome that borrowed one to mean "you are here" would be
 * spending a house constant on a UI state (../../PLAN.md §The house
 * constants). ⚠️ AMENDED 2026-09-22: the red does now appear in this rail, as
 * the home mark below — but as the app's MARK, drawn once, never changing, not
 * as a state. The distinction is the whole of the rule; §THE HOME MARK says it
 * again where it is paid.
 * Colour is not the only carrier: `aria-current="page"` is in the markup and
 * the ink lifts a tier as well.
 *
 * ⚠️ THE TWO AUTHORED ROWS ARE HERE ON SELECTORS OF THEIR OWN, AND THOSE ARE
 * THE VISUAL HALF ONLY. The `{{#nav}}` rows get `aria-current` from resolveNav's
 * `isCurrent`; the home mark and the end zone's legal row are authored markup,
 * and the page model exposes nothing that is truthy only on `/` or only on
 * `/legal`, so neither can carry the attribute today (_nav.html's own comments
 * have the one-line model fix, at both rows). `body[data-page="…"]` is the
 * shell's existing stamp — `{{page.id}}`, so `home` and `legal`, read off
 * wireframe.yml rather than assumed — and it is the honest stand-in for the ink.
 * But CSS cannot put anything in the accessibility tree, so on those two pages
 * the row is visually current and programmatically not. Written down rather
 * than glossed: a real, if small, gap, and it closes in model.mjs, not here.
 * ⚠️ AND `isCurrent` IS AN EXACT PATH MATCH (resolveNav: `target.path ===
 * page.path`), so Docs is not marked current on /docs/one-control. These two
 * follow it: /legal is marked, its four leaves are not. Keep them agreeing —
 * a `data-page` list that marked the leaves would make the rail the only place
 * on the site where "you are here" meant "somewhere under here". */
.rail-item[aria-current="page"],
body[data-page="home"] .rail-home,
body[data-page="legal"] .rail-legal {
  color: var(--text-bright);
  /* The "lifted black" — the same ground as the tree's current row
     (components.css `.docs-leaf.is-current`), Christian 2026-10-02: one lift
     on the site, so "you are here" looks the same in the rail and the tree. */
  background-color: var(--surface-lift);
}
.rail-item[aria-current="page"]::before,
body[data-page="home"] .rail-home::before,
body[data-page="legal"] .rail-legal::before {
  content: "";
  position: absolute;
  inset-inline-start: 0;
  top: 50%;
  translate: 0 -50%;
  width: 2px;
  height: 1.25rem;
  border-radius: 1px;
  background-color: var(--accent);
}

/* ⚰️ ── the two lines, and the X — DELETED 2026-09-22 ─────────────────────
 * `.rail-bars` drew the hamburger: two 18px lines in a 22px cell, on the same
 * optical column as the icons under it, rotating into an X on `:checked`. It
 * went with the control it drew (item 13, §THE TOGGLE). Nothing else on the
 * site draws a two-line glyph, so the technique goes with it rather than
 * waiting somewhere for a caller — one detail worth keeping if it is ever
 * wanted again: `translate` and `rotate` were their own properties rather than
 * a `transform` shorthand, because the two animated independently and either
 * would have clobbered the other, the same trap home.css §THE LAYER CENTRES,
 * THE SQUARE WEAVES pays for at the other end of the page. */

/* ═══ THE TITLE — THE ROW'S NAME, AND THE ONLY THING THAT MOVES ═══════════
 *
 * 🔴 IT IS NEVER `display: none` AND NEVER `visibility: hidden`. Both of those
 * take the word out of the accessibility tree, and the word is the row's name —
 * a rail of unnamed glyphs is a WCAG 1.1.1 failure. `opacity: 0` leaves it
 * named and unread.
 *
 * ⚰️ IT USED TO HAVE TWO MODES: a tooltip while the rail was collapsed, the
 * same span inline once the rail was open (`.rail-state:checked ~ .site-rail
 * .rail-title:not(.rail-title--tip)` — static position, no bubble, opacity 1).
 * The inline half went with the toggle on 2026-09-22 (item 13, §THE TOGGLE).
 * The tooltip is not "the default" any more; it is what a title is. ⭐ WHICH
 * IS ALSO WHY THE TOGGLE WAS REMOVABLE AT ALL: this block never consulted the
 * checkbox to fly a title out, so nothing about discoverability changed.
 *
 * ⭐ IT IS INSIDE THE <a>, WHICH IS WHAT MAKES IT HOVERABLE (WCAG 1.4.13): the
 * pointer can travel from the icon onto the flown-out title without the title
 * vanishing, because arriving on it is still arriving on the link. That is also
 * why `pointer-events` flips to `auto` only while it is shown — invisible, it
 * would be a 100px dead patch floating over the page.
 *
 * ⭐ AND THE KEYBOARD GETS IT TOO, which is the measured gap in PlotOS's own
 * rail doc ("Keyboard reachability is the consumer's job once the caption goes
 * hover-only… a consumer that doesn't separately make the label focusable
 * strands keyboard users with no way to reach the tile's name at all"). Ours
 * are real links, in the tab order by being links, so `:focus-visible` shows
 * the title with nothing added. The gap closes by choosing the right element.
 */
.rail-title {
  white-space: nowrap;
  position: absolute;
  inset-inline-start: 100%;
  top: 50%;
  translate: 0 -50%;
  margin-inline-start: var(--space-1);
  padding: 0.375rem var(--space-2);
  border-radius: var(--radius);
  background-color: var(--surface-lift);
  color: var(--text-bright);
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--motion-duration) var(--motion-ease);
}
.rail-row:hover > .rail-title,
.rail-row:focus-visible > .rail-title {
  opacity: 1;
  pointer-events: auto;
}

/* ESC dismisses a hovered title without the pointer moving — WCAG 1.4.13's
 * "dismissable" clause, which a hover tooltip over page content does owe.
 * _shell.html's script sets the attribute and clears it on the next pointer
 * move. ⚠️ Focus-shown titles are deliberately NOT suppressed: a keyboard
 * reader dismisses one by tabbing off it, and hiding the name of the row they
 * are standing on would be the opposite of the favour. */
body[data-tips="off"] .rail-row:hover > .rail-title {
  opacity: 0;
  pointer-events: none;
}

/* ⚰️ TWO RULES ABOUT THE OPEN RAIL STOOD HERE AND WENT WITH IT (item 13): the
 * one that held the toggle's own "Menu" tooltip down while the rail was wide
 * (240px out over the story, naming a menu that was plainly open beside it),
 * and the one that turned every OTHER title inline — the `.rail-title--tip`
 * class existed solely to tell those two apart, and has no second reader, so
 * it is gone from the markup as well. */

/* ── the phone ──────────────────────────────────────────────────────────
 * The rail costs the page 64px everywhere, which is 17% of a 375px
 * screen, so the gutter halves below 30rem and gives 8px of it back. It stays
 * a 44px-plus tap target: the row keeps its `--os-unit` height and only the
 * air around the column narrows.
 *
 * ⚠️ KNOWN, AND NOT FIXABLE FROM THIS FILE: home.css sizes the story's square
 * off `min(78vw, 42svh)` on phones — 78% of the WHOLE viewport, which no
 * longer knows that 56px of it belongs to the rail. At 320px that leaves the
 * square about 7px of air a side instead of 35. It does not overflow (the clip
 * above and .story's own see to that) and it is one number in a file this lane
 * does not hold; it is in the handoff for whoever owns home.css next. */
@media (max-width: 30rem) {
  :root { --rail-gutter: 0.25rem; }
}

/* ⚰️ ── the ≤36rem overlay, and the short-viewport query — BOTH DELETED
 *      2026-09-22 (item 13), and neither is a loss ─────────────────────────
 *
 * THE OVERLAY existed because a push is only a push while what is left is
 * still a page: at 375px, shifting by the OPEN rail left 143px of content,
 * which set the hero's buttons one word per line. So at and below 36rem the
 * open rail drew over the page instead, wearing `--surface` so the content it
 * covered read as a drawer rather than as text cut off mid-word. Both of its
 * rules keyed off `:checked`; with one width there is nothing to overlay, the
 * page is pushed by the same 64px at every size, and the rail is flush with
 * the ground it inherits again.
 *
 * THE SHORT-VIEWPORT QUERY handed back 32px of block padding, because the
 * column did not fit an iPhone SE in landscape (568 × 320). The rail is
 * `inset-block: 0`, so its height IS the viewport's, and an auto margin takes
 * only POSITIVE free space — under that height the end zone collapsed into the
 * nav and the last row ran off the bottom of a fixed element nobody can
 * scroll. The arithmetic that made it necessary was 336px: 48 the toggle, 8
 * the rail's gap, 16 the nav's own air, 160 the three rows, 8, 48 the end
 * zone, inside `--space-3` top and bottom.
 *
 * ⭐ ITEM 13 TOOK 104px OFF THAT IN PASSING — the toggle row (48), the gap
 * under it (8), the nav's air (16) and 32 of block padding — so the column now
 * stands 216px and 232 with its gutters: 160 (three 48px rows and two 8px
 * gaps), 8 the rail's gap, 48 this zone, inside `--rail-gutter` top and bottom.
 * That clears 320 by 88px with every row keeping its full `--os-unit` tap
 * target. ⚠️ A SECOND END-ZONE ROW COSTS 56 MORE (288, still clear) AND A
 * THIRD 56 AFTER THAT (344, which is NOT) — re-do this arithmetic before
 * adding rows, rather than assuming the headroom is infinite because the query
 * that used to guard it is gone.
 * 🔴 THAT LAST SENTENCE WAS TAKEN AND THE NUMBERS IN IT WERE WRONG. An
 * end-zone row costs 48, not 56: the "+8" is a gap between rows that does not
 * exist, because `.rail-end` is a plain block and only `.rail-nav` declares a
 * `gap`. Measured 2026-09-25 — see §the short-viewport query above (restored
 * with the download row, at 26rem), which carries the sweep. The instruction
 * stands; only its arithmetic was fiction, which is the more dangerous half. */

/* palette.css — THE SITE WEARS THE APP'S BLACK, NOT A VENDORED THEME.
 *
 * @docs ../../README.md
 *
 * Christian, 2026-10-02 (/design-shotgun, variant A2): *"black on black and
 * simply have a large red dot with the word Camcorder"*. The amber theme read
 * as borrowed warmth — brown-black ground, cream and amber ink — and none of
 * it is Camcorder's. The app's screen is black, its one control is the red
 * disc, and nothing else in it is coloured. So the site's ground is a neutral
 * off-black, every ink tier is a neutral grey with NO warm or cool cast, and
 * the only colour on the page is `--cam-red` inside the mark.
 *
 * 🔴 WHY THIS IS A SITE FILE AND NOT A THEME. The theme pipeline wears only
 * vendored `theme/*.ColorTheme.json`, byte-identical to dbo-contracts, and none
 * of the thirteen has a neutral black (the closest, `operator` #1A1B23 and
 * `refined-calm-night` #1C1A17, are blue- and brown-cast). So this block
 * overrides `theme/tokens.css` — same `:root` specificity, and site.css loads
 * after tokens.css (theme/head.html links that first), so later wins.
 * ⏳ THE CLEAN END STATE is a `viewfinder.ColorTheme.json` in dbo-contracts,
 * synced here and named in theme.config.json; then this file is deleted. Until
 * then ⚠️ `npm run tokens` still writes amber into tokens.css, the theme-color
 * meta and manifest-colors.json (#0C0907 — near enough to this ground that the
 * browser chrome does not show the seam), and THIS file is what the page wears.
 *
 * ⚠️ IT OVERRIDES EVERY COLOUR TOKEN tokens.css DECLARES, not only the ones the
 * holding page uses. A token left amber is a token some page will one day reach
 * for, and it would be the one warm thing on a neutral site. Semantic colours
 * (success / warning / error) are kept: they mean something, and none is amber.
 *
 * ── 🔴 CONTRAST, COMPUTED (relative luminance, sRGB linearised) ────────────
 *                      on --bg    on --surface  on --surface-lift
 *   --text-bright      17.22:1    16.71:1       15.84:1
 *   --text-primary     13.25:1    12.86:1       12.19:1
 *   --text-secondary    9.28:1     9.01:1        8.54:1
 *   --text-muted        6.93:1     6.72:1        6.37:1      (AA 4.5 ✓)
 *   --border-bright     3.56:1     3.46:1        3.28:1      (1.4.11 3:1 ✓)
 *   --on-scrim-quietest 4.89:1 on the scrim (= --bg)
 * ⚠️ THE LIFT IS NEARLY INVISIBLE ON PURPOSE: --surface-lift on --bg is ~1.2:1.
 * It is the "lifted black" of the selected tree row and the rail's home tile —
 * texture, not a shape — and neither carries meaning by it: the tree row has
 * its weight and ink, the rail tile has the red disc. Do not lean on it to
 * separate anything that has no other cue. */
:root {
  --bg: #0E0E0E;
  --surface: #121212;
  --surface-high: #1C1C1C;
  --surface-lift: #181818;
  --terminal: #000000;
  --border: #262626;
  --border-bright: #6A6A68;
  --input: #1C1C1C;
  --scrim: #0E0E0E;

  /* The accent is the brightest ink, not a hue: the focus ring, the current
     row's rule and links are off-white. One colour on the page means the red
     keeps its meaning — the camera's, never the chrome's. */
  --highlight: #F2F2F0;
  --accent: #F2F2F0;
  --accent-bright: #FAFAF8;
  --accent-dim: #3A3A39;
  --secondary: #B4B4B0;
  --secondary-bright: #D6D6D3;
  --link: #F2F2F0;
  --gold: #B4B4B0;

  --text-primary: #D6D6D3;
  --text-bright: #F2F2F0;
  --text-secondary: #B4B4B0;
  --text-muted: #9B9B98;
  --text-code: #C8C8C5;
  --text-selection: #3A3A39;
  --text-placeholder: #9B9B98;
  --text-on-scrim: #F2F2F0;
  --on-scrim-quiet: #A1A1A1;
  --on-scrim-quietest: #868686;
  --accent-on-scrim: #F2F2F0;
  --secondary-on-scrim: #B4B4B0;
  --ink-on-accent: #0E0E0E;
  --ink-on-secondary: #0E0E0E;
  --ink-on-success: #0E0E0E;
  --ink-on-warning: #0E0E0E;

  /* ⭐ SF ROUNDED FOR THE LARGE TYPE, INTER FOR READING (Christian, same day).
   * `--font-display` is h1–h3 (base.css), so every heading on every page.
   * ⚠️ `ui-rounded` IS THE ONLY WAY A VISITOR GETS IT, and only Safari honours
   * it (macOS and iOS — which is this app's whole audience). Chrome and Firefox
   * skip it and land on Inter. That is permanent, not a bug: Apple's licence
   * forbids serving SF fonts as web fonts, so there is nothing to self-host.
   * ⚠️ `"SF Pro Rounded"` by NAME matches only a Mac with Apple's developer
   * fonts installed — so a designer's Chrome shows rounded headings that a
   * visitor's Chrome never will. Judge the fallback in Chrome on a clean Mac. */
  --font-display: ui-rounded, "SF Pro Rounded", Inter, system-ui, -apple-system, "Helvetica Neue", Arial, sans-serif;
  --font-body: Inter, system-ui, -apple-system, "Helvetica Neue", Arial, sans-serif;
}

/* ⚠️ base.css §THE LIT ROOM re-tunes two ink tiers on `body[data-page="home"]`
 * for the story's #282826 ground, in amber. Same selector, later file, so this
 * wins: on that ground muted is 5.30:1 (AA ✓) and the border
 * 2.73:1 — under 1.4.11, owed again when the story comes back. */
body[data-page="home"] {
  --text-muted: #9B9B98;
  --border-bright: #6A6A68;
}

::selection { background: var(--text-selection); color: var(--text-bright); }

/* splash.css — THE HOLDING PAGE, and the footer row it brings with it.
 *
 * @docs ../../README.md
 *
 * Concatenated (after base.css, components.css and nav.css — filename order)
 * into public/site.css by pipeline/emit.mjs. Last in that order, which is what
 * lets the two overrides below win without `!important`.
 *
 * ⚠️ IT IS IN src/css/ AND THEREFORE ON EVERY PAGE, WHICH IS NOT WHERE A
 * ONE-PAGE STYLESHEET WOULD NORMALLY GO. The home page already has its own
 * bundle — `src/pages/home/`, emitted to `/pages/home.css` and linked from that
 * page alone — and the story's 2,500 lines live there for exactly this reason.
 * This file is not in it because that directory was outside this lane's write
 * lease. Every selector here is gated on `.splash` or `.footer-nav`, neither of
 * which any other page carries, so the cost is bytes rather than behaviour.
 * ⏳ WHEN THE STORY COMES BACK (../../wireframe.yml §THE TWO LISTS THE HOME PAGE
 * CAN WEAR — one token), this file is a candidate to move into that bundle
 * wholesale, and the `.footer-nav` block is the only part that would have to
 * stay behind: the footer is site chrome, not the home page's.
 *
 * 🔴 NO LITERAL COLOURS. base.css states the rule and this file keeps it: every
 * colour here is a token, so the page repaints with the theme and nothing is
 * hand-picked. The one black-and-red thing on the screen is the app's icon, and
 * it is an `<img>` — its pixels, not this stylesheet's.
 *
 * ── 🔴 CONTRAST, COMPUTED RATHER THAN EYEBALLED ────────────────────────────
 * The holding page sits on the LIT ROOM, `#282826`, not on `--bg` — that ground
 * is `src/pages/home/25-scenes.css` §the ground, which is still linked from this
 * page because `src/pages/home/` is the switch and the directory still exists.
 * So the tiers in play are `body[data-page="home"]`'s (base.css §THE LIT ROOM),
 * and every one of them was recomputed for this page: relative luminance, sRGB
 * linearised, compared before any rounding (design.md: *the muted/fade text
 * tiers must be verified with math, never eyeballed*). On `#282826`:
 *   · `--text-bright`    #FFF4E2  13.5683:1   the wordmark          (AA ✓)
 *   · `--text-primary`   #EDE0CC  11.3489:1   the tagline           (AA ✓)
 *   · `--text-secondary` #BCA081   5.9671:1   the sentence, the links (AA ✓)
 *   · `--link`/`--accent` #F0A030   6.8762:1   the address, the focus ring (AA ✓)
 *   · `--text-muted`     #B28C63   4.8001:1   copyright + colophon  (AA ✓)
 *   · `--border-bright`  #9D6B33   3.2246:1   link underlines (1.4.11 ✓)
 * All six clear their floor (4.5:1 body, 3:1 large text and non-text), and they
 * agree to four places with the numbers base.css §THE LIT ROOM recorded when the
 * ground was lifted — computed again here from first principles rather than
 * quoted, because that file's own note says a value nobody can re-check safely is
 * not a floor. 🔴 NOTHING HERE MOVES THE GROUND. design.md: when a tier fails,
 * move the TIER. Nothing failed, so nothing moved.
 */

/* ═══ ONE SCREEN, AND THE FOOTER IS THE FLOOR ══════════════════════════════
 * `base.css` §page frame already builds the sticky-footer column —
 * `body[data-frame-footer="sticky"]` is a min-100vh flex column and `> main` is
 * `flex: 1 0 auto`. So `main` is already exactly the viewport minus the footer on
 * a short page; all this needs is for the section inside it to FILL `main` and
 * centre its content there. No viewport maths, no `calc()` against a footer
 * height nobody can measure in CSS.
 *
 * ⚠️ `:has()` RATHER THAN A PAGE SELECTOR, DELIBERATELY. `body[data-page="home"]`
 * would catch the story page too — the page id is `home` under either list — and
 * the story's `main` must stay exactly as `base.css` leaves it. Keying on the
 * SECTION means these rules exist only while the splash is the thing mounted, and
 * the one-token swap in wireframe.yml turns them off with nothing to remember.
 * Supported in both engines this site is checked in (Chromium, WebKit 15.4+).
 *
 * 🔴 `flex-direction: column` IS LOAD-BEARING AND THE FIRST VERSION OF THIS RULE
 * OMITTED IT, WHICH IS A WCAG 1.4.10 FAILURE THAT HID ITSELF — measured, and the
 * exact trap components.css's ⚰️ `.legal-nav` tombstone warns about ("nav.css
 * §THE PUSH clips sideways overflow, so a failure of this kind hides rather than
 * shows"). Without it `main` is a flex ROW, so `.splash` is a row item and its
 * `flex: 1 0 auto` reads as *do not shrink below max-content*: the section came
 * out **463.6px wide inside a 390px viewport** (and inside 320px), and
 * `documentElement.scrollWidth` still equalled `innerWidth` because the clip
 * swallowed it. A page that reads as fine and is 144px too wide. Measure the
 * ELEMENT, never the document.
 *
 * ⚠️ AND `padding-bottom` GOES TO ZERO RATHER THAN DOWN. `main`'s own `--space-6`
 * is right for a page of bands with air under the last one; here the air belongs
 * to the section (`padding-block` below) so that it is the SAME air whether the
 * content fits the screen or overruns it. Two sources would disagree at 320px. */
main:has(> .splash) {
  display: flex;
  flex-direction: column;
  padding-bottom: 0;
}

main:has(> .splash) > .splash {
  /* base.css gives every `main > section` --space-6 beneath it; there is nothing
     beneath this one. */
  margin: 0;
  flex: 1 0 auto;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  text-align: center;
  /* design.md: generous whitespace, one main idea per screen. The air between
     the mark and the words is the composition, not a gap. */
  gap: var(--space-3);
  /* 🔴 THE AIR, AND IT IS WHAT MAKES `justify-content: center` SAFE. A centred
     flex column whose content is TALLER than its box overflows BOTH edges, and
     the start-edge overflow of a scroll container is unreachable — at 320×568 the
     mark sat flush against y=0 with nothing above it. Symmetric block padding is
     centred away when the content fits (it is inside the box being centred in)
     and becomes the air when it does not. `safe center` would be the direct
     expression and is not in WebKit. */
  padding-block: var(--space-4);
  /* ⚠️ THE FLOOR AT 320px. Every text block below carries its own `max-width`,
     and `min-width: 0` keeps this box from inheriting a wide child's intrinsic
     minimum (the same trap `main` pays for in base.css §page frame, found by the
     2026-09-21 accessibility audit, WCAG 1.4.10). */
  min-width: 0;
}

/* ── the mark ──────────────────────────────────────────────────────────────
 * The app's icon at the app's own corner. `--corner-square` is base.css §THE
 * CORNER — the fraction of the side, not a pixel radius, so this box and the
 * square in the film are one shape at two sizes; the `@supports` block there
 * turns it into the continuous curve on an engine that has `corner-shape`.
 * ⚠️ THE FILE IS A FULL-BLEED SQUARE WITH NO CORNER OF ITS OWN (its own header
 * says why: every consumer masks it, and a corner drawn in is rounded twice), so
 * the rounding has to happen here — and `overflow: clip` is what makes the
 * radius actually cut the raster rather than just round an invisible box.
 * ⚰️ ALL OF THE ABOVE DESCRIBES THE ICON <img>, REPLACED 2026-10-02 BY THE
 * BARE DISC (layouts/splash.yml §THE MARK). Kept as the record.
 * The size is deliberately modest. design.md's register is editorial and
 * type-led; the mark identifies the page, the words carry it. */
.splash-mark {
  inline-size: clamp(5rem, 20vmin, 8.25rem);
  block-size: auto;
  fill: var(--cam-red);
  /* The disc and the wordmark were one gap apart and read as a list; a little
     more air under the disc makes them a mark and its name. */
  margin-block-end: var(--space-1);
}

/* ⭐ BLACK ON BLACK (2026-10-02, variant A2). The parked story paints its lit
 * room, `#282826`, on `body[data-page="home"] main` (src/pages/home/
 * 25-scenes.css), and that bundle is still linked from `/`. The holding page
 * stands on the site's own ground instead — the app's black. Keyed on the
 * section like every rule here, and one attribute more specific than the
 * story's selector so it wins without `!important`. ⚠️ BODY AS WELL AS MAIN:
 * the story paints both, and `main` is a 960px column, so painting only it
 * left the room showing either side of it on a desktop. */
body[data-page]:has(.splash),
body[data-page] main:has(> .splash) { background: var(--bg); }

/* ── the words ─────────────────────────────────────────────────────────────
 * ⚠️ `h1`'s own `clamp(2rem, 5vw, 3.25rem)` in base.css is the page-heading
 * size, and it is right here: this IS the page's heading. What it does not get is
 * base.css's `main > h1` margin block, because this h1 is not a direct child of
 * `main` — so the column's `gap` is the only spacing, which is the point. */
.splash-wordmark {
  margin: 0;
  /* SF Rounded comes from `--font-display` (palette.css). The holding page's
     wordmark is the one heading set a size above base.css's h1 clamp: it is
     the page, not a title on one. */
  font-size: clamp(2.5rem, 6vw, 4rem);
  /* design.md: tighter than the framework default — tight reads confident. The
     wordmark is the one place on the page where that is worth a step further
     than the house `--tracking-tight`. */
  letter-spacing: -0.02em;
}

/* ⚰️ `.splash-tagline` — DELETED 2026-10-02 with the line it set
 * (layouts/splash.yml). The page is the disc and the wordmark. */

/* The one sentence of substance (content/copy.json ▸ pages.home.splash.line).
   Narrower than `--measure` on purpose: 46rem of centred text at this size reads
   as a paragraph of prose, and this is one line of argument. */
.splash-line {
  margin: 0;
  max-width: 46ch;
  color: var(--text-secondary);
  text-wrap: pretty;
}

/* The page's only affordance. It gets the accent because it is the one thing to
   do here — design.md: one muted accent, used sparingly. `a`'s underline comes
   from base.css and its colour from `--border-bright`, which is the non-colour
   cue that keeps the link identifiable under WCAG 1.4.1 and clears 1.4.11 at
   3.2246:1 on this ground. */
.splash-contact {
  margin: 0;
  max-width: none;
  color: var(--text-muted);
  font-size: 0.9375rem;
}
.splash-contact a {
  color: var(--link);
  /* ⚠️ A mailto wraps badly at 320px: `break-word` lets it split rather than
     push the column wide, and there is no better break point in an address. */
  overflow-wrap: break-word;
}

/* ═══ THE RAIL'S "DOWNLOAD" ROW COMES OFF THE HOLDING PAGE ═════════════════
 * 🔴 IT POINTS AT A SECTION THAT IS NOT MOUNTED. `src/html/_nav.html` authors
 * `<a class="rail-row rail-item rail-download" href="/#cta-final">`, and its own
 * comment says why the href is root-relative: *"the page's real download CTA,
 * `#cta-final` — the section wireframe.yml declares"*. With the story parked
 * there is no `#cta-final` on `/`, so the row is a link to a fragment that does
 * not exist, labelled **Download**, on a page whose whole message is that there
 * is nothing to download yet (`app.json`: *"a prototype on TestFlight; not a
 * released product"*). A false promise is worse than a missing affordance.
 *
 * ⚠️ THIS IS A STYLESHEET FIXING A MARKUP PROBLEM, AND IT IS NAMED AS SUCH.
 * The real fix is a guard in `src/html/_nav.html`, which was outside this lane's
 * write lease. `display: none` is the one CSS removal that is honest — it takes
 * the element out of the accessibility tree and the tab order as well as off the
 * screen, so a screen-reader user and a keyboard user see exactly what a sighted
 * mouse user sees. ⚠️ WHAT IT DOES NOT DO is remove it from the raw HTML, so an
 * agent reading the markup still meets a "Download" link that goes nowhere —
 * which is an AI-first defect (design.md rule 1) that only the markup can close.
 * ⏳ Flagged for whoever holds _nav.html.
 *
 * ⚠️ KEYED ON THE SPLASH, NOT ON THE PAGE. `body[data-page="home"]` would take
 * the row off the story page too, where it is correct and where `#cta-final` is
 * exactly what it aims at. This rule exists only while the holding page is the
 * thing mounted, and leaves with it. */
body:has(main > .splash) .rail-download { display: none; }

/* ═══ THE FOOTER'S ROW OF PAGES ════════════════════════════════════════════
 * src/html/_footer.html ▸ `{{#page.isHome}}` — the holding page's bottom, and
 * nothing else's. Its header carries the whole argument for why it is guarded.
 *
 * 🔴 `flex: 1 0 100%` IS THE LINE BREAK, and it is the shape `.colophon`'s
 * `width: 100%` used to have before that declaration was deleted (its ⚰️ note in
 * components.css). `.site-footer` is a wrapping flex row with `align-items:
 * baseline`; this row claims a line of its own so the copyright and the colophon
 * keep the single baseline that tombstone was written to protect.
 *
 * 🔴 AND `flex-wrap: wrap` IS A WCAG 1.4.10 FIX, NOT TIDINESS. components.css's
 * ⚰️ `.legal-nav` tombstone records exactly this failure: FOUR links in a nowrap
 * row needed 352px with padding and overflowed a 320px screen by 32 — 88 once the
 * rail took its 56. There are FIVE now. Re-measured rather than assumed: on home
 * the footer's gutter is `max(--space-3, --rail-w)` = 64px a side (nav.css ▸
 * `--footer-gutter`), so the row has 192px at 320px wide and wraps onto three
 * lines inside it. ⚠️ nav.css §THE PUSH clips sideways overflow, so a failure of
 * this kind hides rather than shows — measure, never look.
 *
 * The ink ramp is the rail's and the deleted `.legal-nav`'s: `--text-secondary`
 * going `--text-bright`, underline on hover only, so the row reads as quiet
 * chrome until it is approached. 5.9671:1 on `#282826`. */
.footer-nav {
  flex: 1 0 100%;
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-1) var(--space-3);
  min-width: 0;
}
.footer-nav a {
  color: var(--text-secondary);
  text-decoration: none;
}
.footer-nav a:hover {
  color: var(--text-bright);
  text-decoration: underline;
  text-decoration-color: currentColor;
  text-underline-offset: 0.15em;
}


/* 🔴 SCOPED TO THE SECTION, NOT THE PAGE — and that is a correction to my own
 * first version, which keyed every rule below on `body[data-page="home"]`. That
 * attribute is equally true when the one-device story is mounted, so restoring
 * the story (`sections: *splash` → `*story` in wireframe.yml) would have dragged
 * the holding page's footer with it: watermarked, un-dividered, absolutely
 * positioned over a page that scrolls. `:has(.splash)` is true only while the
 * holding page is the thing on screen, which is what every rule here means.
 * The site already relies on `:has()` (`main:has(> .splash)` below). */
/* ══ ⭐ THE COLOPHON AS A WATERMARK — bottom right, lifted off the ground ═════
 * Christian, 2026-10-02: *"align the © Manolab, LLC / development build to the
 * bottom right of the splash as a small offset lift color from the bg … its like
 * a watermark."*
 *
 * 🔴 AND HERE IS THE ONE THING THAT WAS NOT FREE. A watermark wants to be barely
 * there; `docs/design.md` makes WCAG AA a LAUNCH BLOCKER (BFSG, June 2025) and is
 * explicit about which way to resolve the tension: *"when a muted tier fails
 * contrast, darken the tier — don't shrink its usage or argue the aesthetics."*
 * A true lift-off-the-ground — say #3A3A38 on #282826 — measures about **1.3:1**
 * against a floor of 4.5. So this is as quiet as it is allowed to be and no
 * quieter: `--text-muted` #B28C63, measured **4.8001:1**, at the smallest size
 * the type scale offers, set apart by PLACE and SIZE rather than by fading.
 * ⚠️ If it still reads too loud, the honest lever is the SIZE and the margin,
 * never the colour — or Christian rules that a build id is incidental text and
 * takes that decision on the record.
 *
 * Positioned, not floated: `position: absolute` inside the splash's own
 * containing block, so it cannot disturb the centred stack above it and cannot
 * reflow the page when the colophon's SHA changes length. It stays inside the
 * rail's gutter on narrow screens, where `--rail-w` is the only thing the footer
 * has ever had to clear. */
/* ⚠️ THE CONTAINING BLOCK HAS TO BE DECLARED, or `absolute` resolves against the
 * initial containing block and the mark drifts the moment anything scrolls. The
 * splash is one screen with no scroll by construction, so `body` is the honest
 * anchor — and it is scoped to the home page, because the other six pages DO
 * scroll and their footer must stay in flow at the end of the document. */
body:has(.splash) { position: relative; }

body:has(.splash) .site-footer {
  position: absolute;
  inset-block-end: var(--space-2);
  inset-inline-end: max(var(--space-2), var(--space-1));
  inset-inline-start: auto;
  display: flex;
  flex-wrap: wrap;
  justify-content: flex-end;
  align-items: baseline;
  gap: 0 var(--space-1);
  margin: 0;
  padding: 0;
  text-align: end;
}

body:has(.splash) .site-footer .copyright,
body:has(.splash) .site-footer .colophon {
  margin: 0;
  font-size: var(--step--2, 0.6875rem);
  /* ⭐ A NEUTRAL GREY, AND THIS IS THE QUIETEST ONE THE LAW ALLOWS.
   * Christian: *"smaller and a grey offset."* The site's muted tier is warm
   * (#B28C63) and read as copy rather than as a watermark, so this is off the
   * palette on purpose — the only grey on the page.
   * [C] Derived rather than picked: the ground #282826 has a relative luminance
   * of 0.02110, so AA's 4.5:1 needs 0.2699, which is sRGB 0.556 — #8E8E8E, bang
   * on the floor at 4.51:1. #919191 is one step up for sub-pixel headroom and
   * measures **4.69:1**. Anything quieter fails, and a true lift-off-the-ground
   * (#3A3A38) would be about **1.3:1**.
   * 🔴 design.md is explicit about which way this tension resolves: *"when a
   * muted tier fails contrast, darken the tier — don't shrink its usage or argue
   * the aesthetics."* So it is set apart by PLACE, SIZE and HUE, never by fading
   * below the floor. If it still reads loud, the lever is the size. */
  color: #919191;               /* 4.69:1 on #282826, 6.13:1 on the holding page's #0E0E0E since 2026-10-02 — computed */
}

/* ⚰️ AND THE RULE ABOVE IT COMES OFF. Christian: *"the divider line on the footer
 * can also be removed."* A watermark does not get a hairline introducing it — and
 * on the holding page there is nothing above it to divide from. Scoped to home,
 * so the six scrolling pages keep theirs. */
body:has(.splash) .site-footer {
  border-block-start: 0;
  border: 0;
}

/* ⚠️ AND THE LINE IS A PSEUDO-ELEMENT, so `border: 0` above does not reach it.
 * `nav.css` draws it as `.site-footer::before` — an absolutely-positioned 1px
 * band inset to the footer's own gutter — precisely so it can start after the
 * rail instead of running under it. Removing the border alone left it on screen,
 * which is what the 1440 screenshot showed. */
body:has(.splash) .site-footer::before { content: none; }
