/* bands.css — the band system BELOW the hero: a run of full-width SECTIONS
 * standing on the page's own ground, each one a column-structured stage.
 * Nothing here is a box any more: no fill, no radius, no inset. The hero
 * (`.toy` / `.scene-figure--hero`, a different class entirely) and every band
 * share one ground — the warm cream `--bg` — which is also the ground the
 * crew artwork is drawn on, so a scene now bleeds into the page with no seam.
 *
 * @docs ../../../plot-os/specs/sections/stage-component.md — "The section
 *   stage, a reusable primitive": §3.1 is elbow-room, §4 the count-driven
 *   column grid. That document is the SOURCE OF RECORD for the layout below.
 *   ../../../terminal-c/site/src/css/page.stage.css — the pilot the spec was
 *   written from; the unprefixed variable names used here (`--edge-pad`,
 *   `--elbow-cap`, `--elbow-room`) are its names, line for line. PlotOS's own
 *   framework-prefixed spellings (`--stage-elbow-*`) are for a FRAMEWORK;
 *   Touchtone is a consumer, so it takes the pilot's names (§0.3).
 *   ../../docs/designs/site-home-sell-the-app.md.
 *
 * Concatenated by pipeline/emit.mjs's writeStylesheet, which sorts src/css/*
 * by filename (`bands` < `base` < `components` < `toy`) — this file therefore
 * loads FIRST. That is safe here specifically because every selector below is
 * a brand-new class (`.band`, `.say`, `.sub`, `.plate`, `.spec`, `.shot`,
 * `.row`, `.facts-list`, `.cta`) that nothing in base.css/components.css/
 * toy.css also targets, so there is no cascade-order tie to win or lose —
 * confirmed by reading all three before adding this file, not assumed. The
 * one genuine override this page needs (`.cta-final-actions` no longer wants
 * components.css's rounded-card `.cta-final` look) is made IN components.css
 * itself, not fought for here — see that file's own "closing cta / cta band"
 * section.
 *
 * 🔴 NO COLOUR LITERALS BELOW. Every fill is a token: `--void`/`--ink`/
 * `--structure`/`--bg` (theme/law.css). `--void` MUST stay exactly `#000000`
 * — the one band that uses it (`#world`) draws the app's own play surface,
 * and the hero's own law is "pitch black, not near-black." `--bg` (the page
 * ground) is a warm cream since 2026-09-26 ("the black page background does
 * not feel kid-friendly"); the two are DELIBERATELY DECOUPLED, not aliases
 * of each other the way they were when the whole page was black.
 *
 * ══════════════════════════════════════════════════════════════════════════
 * 🔴 THE CARD SYSTEM IS RETIRED — 2026-09-29, OWNER CALL.
 *    READ THIS BEFORE RE-ADDING A FILL, A RADIUS OR AN INSET TO `.band`.
 * ══════════════════════════════════════════════════════════════════════════
 *
 * WHAT IS GONE, and what it was. From 2026-09-26 until this change every
 * band was an inset, rounded, TINTED rectangle floating on the page ground —
 * `--card-inset: clamp(12px,2.8vw,40px)` / `--card-radius: clamp(8px,1.5vw,
 * 12px)` / `--card-gap: 24px` on `:root`, plus `place-items: center` and
 * `text-align: center` on `.band`, plus four tone fills
 * (`.band.paper` `--theme-keys`, `.band.steel` `--steel`, `.band.accent`
 * `--theme-drums`, `.band.void` `--void`). It was measured off frame.work/de/de
 * and it was a faithful port. It is deleted anyway. Git history has every
 * line if it is ever wanted back.
 *
 * WHY. The owner, looking at the built page: "I need to remove the cards and
 * align more like we did in terminal-c — using the section with col structure
 * and elbow-room… we have too many colors that clash — less is best here."
 * Three separate faults, one shape:
 *
 *   1. SIX CARDS in one scroll read as six posters stacked, not as one
 *      spread. Air between boxes is a gutter; air inside a section is rhythm.
 *   2. `place-items: center` + `text-align: center` FORBADE a column
 *      structure by construction. A `.scene-band` is exactly two children —
 *      copy and picture — and could never be two columns while the parent
 *      centred everything into one stack.
 *   3. THE TINTS BROKE THE SITE'S OWN LAW. theme/law.css, quoting the app:
 *      "White is structure, colour is content. Colour means a note. Always —
 *      never decoration." `.band.accent` was `--theme-drums`, the app's DRUMS
 *      voice-identity colour; `--paper` was `--theme-keys`, the KEYS colour;
 *      `--accent` was `--theme-flute`. Three app-voice colours conscripted as
 *      mood tint behind a headline. A band does not mean a note. (`--steel`
 *      was worse in a different way — a bare Riffmix literal, a COLD grey on
 *      a warm cream page, with no counterpart in the app at all.)
 *
 * THE ARTWORK SETTLES IT. The crew scenes (site/_proto/crew/) are governed by
 * their own stated rule: "Kids stay muted; the coloured note discs are the
 * only saturated thing in every frame, so law.css's 'colour means a note,
 * never decoration' survives the move to cream." A #FF9E33 band behind a
 * picture whose entire compositional discipline is "the notes are the only
 * saturated things" cancels the picture. And the scenes are drawn on #FAECD7
 * — `--bg` EXACTLY — so with the tints gone every scene bleeds into the page
 * with no seam, which is also why `.band .scene-img`'s border-radius could go
 * (see that rule's own note further down).
 *
 * 🔴 `.band.void` NOW HAS NO CONSUMER, 2026-09-29 (later the same day). The
 * sentence that stood here — "ONE TONE SURVIVES: `.band.void`. `#world` draws
 * the app's real play surface, white-on-black" — became false the moment
 * `#world` was retired (owner: "the stage behind 'Push past a limit' and the
 * three kids is the zoom-in-out animation"). The app's real play surface is
 * still on the page and still white-on-black, but it is a PANEL inside
 * `no-wrong-way`'s picture now, not a section: its black is `src/css/
 * wall.css`'s `.wall-surface`, and nothing on the page fires law.css's
 * dark-surface override any more. The declaration below is kept — one line,
 * inert, and the correct home for the next band that genuinely needs the
 * app's ground — but it is documentation of an intent, not a live rule.
 *
 * ⚠️ THE HONEST COST, stated up front so the next reader does not "fix" it:
 * the page is cream → cream → cream → cream → cream → cream and it reads
 * FLATTER at first glance than the tinted version did — flatter still now
 * that the one black band is gone. That is expected and it is the trade. The
 * rhythm comes from air, scale and the column structure below — NOT from a
 * fifth ground. If it still reads flat, the next move is more air and more
 * scale contrast, never another tint, and never a second copy of the world
 * animation put back purely to be dark. */

/* ── the band itself — a stage, not a card ──────────────────────────────── */

/* Ported from PlotOS `specs/sections/stage-component.md` §3.1, via the
 * terminal-c pilot. `--edge-pad`, `--elbow-cap` and `--elbow-room` are
 * declared once in base.css's `:root` (beside `--page`/`--page-wide`, with
 * the full derivation of each) rather than here, because the illustration
 * hero has to land on the SAME column to the pixel and two copies of that
 * arithmetic is exactly how it drifted before.
 *
 * `overflow: clip` is terminal-c's own guard (`page.stage.css:342`) and it is
 * not decoration: `--elbow-room` is computed from `100vw`, which includes the
 * classic scrollbar on some engines, so a bled child could in principle out-
 * reach the real viewport by a scrollbar's width at one narrow band of sizes.
 * Clipping costs nothing here (no sticky descendant, no shadow that needs to
 * escape) and makes a horizontal scrollbar impossible rather than unlikely.
 *
 * 🔴 KEEP `margin: 0`. It is load-bearing, not tidy-up: base.css's
 * `main > section, main > header { margin: 0 0 var(--space-6); }` otherwise
 * wins on margin-block. A class selector outranks those two bare type
 * selectors, so this is order-independent however bands.css sorts among its
 * siblings. (Unchanged reasoning, carried across the card's retirement — the
 * `margin-inline`/`margin-block` re-sets that used to follow it are gone,
 * this line is not.)
 *
 * 🔴 `align-items`/`align-content: center`, NOT `place-items: center`. The
 * block axis is the one the stage actually wants centred; the inline axis
 * must stay `stretch` or a two-column split collapses to two shrink-wrapped
 * lumps in the middle of the page. terminal-c's `page.stage.css:308-340`
 * carries two documented reversals on this exact line — read them before
 * changing it a third time. Inline ALIGNMENT (as opposed to sizing) is the
 * children's business, never the container's: see the positional block below.
 *
 * `grid-column: full` is unchanged — `main` is a 5-track full-bleed grid
 * (base.css) and `.band` still spans it edge to edge. What changed is that
 * the band no longer steps back in from those edges; its PADDING does the
 * work the old `margin-inline: var(--card-inset)` did, so the ground runs
 * unbroken behind everything. */
.band {
  grid-column: full;
  position: relative;
  overflow: clip;
  margin: 0;
  padding-inline: var(--edge-pad);
  /* 🔴 FLAT 23px — owner, 2026-09-30: "the padding-block of .band can just be
   * 23px cap". Third value today and each one tighter: `clamp(72px, 11vh,
   * 150px)` → halved to `clamp(36px, 5.5vh, 75px)` → this.
   *
   * A FLAT VALUE, NOT A CLAMP, and that is the literal reading of the
   * instruction rather than a liberty: `clamp(36px, 5.5vh, 23px)` is
   * degenerate — when the floor exceeds the ceiling CSS returns the floor, so
   * it would have silently shipped 36px everywhere and looked like the cap
   * was being ignored. One number says what it means.
   *
   * ⚠️ WHAT THIS SPENDS. bands.css's own card-retirement argument at the top
   * of this file rests on "air between boxes is a gutter; air inside a
   * section is rhythm" — there is now 23px of that air, down from 150px at
   * the widest. The sections are still stages rather than cards, so the
   * argument holds, but essentially none of the page's rhythm can come from
   * vertical air any more: it has to come from scale contrast and from the
   * pictures. Worth re-reading that block before adding anything back. */
  padding-block: 23px;
  display: grid;
  align-items: center;
  align-content: center;
  gap: var(--space-4);
}

/* Unbound since 2026-09-29 — `#world`, its only consumer, is retired. Kept
 * as the declared home of the app's own ground. See the header. */
.band.void { background: var(--void); color: var(--structure); }

/* ── the count-driven column grid — stage-component.md §4 ────────────────── */

/* The shape follows from HOW MANY children the section has, with no layout
 * class to author and nothing for wireframe.yml to get wrong.
 *
 * 🔴 `:not(:has(> .plate))` ON EVERY SELECTOR BELOW IS LOAD-BEARING, not
 * noise: the count counts STAGE CHILDREN, and an absolutely-positioned
 * corner plate is chrome, not one. See the note on `.band:has(> .plate)`
 * further down for what it cost to learn that, and for why the guard lives
 * here rather than in an override.
 *
 *     1 → full width      2 → two columns      3 → one across, two below
 *     4 → 2×2             5 → one across, then two rows of two
 *
 * Only 1 and 2 are exercised by today's home page (`scene-band` is exactly
 * two children — copy + figure — so the split it never had is free, with no
 * markup change). 3/4/5 are stated now so the next band does not rebuild the
 * block: terminal-c did exactly this and said so (`page.stage.css:2382-2385`).
 *
 * Below 900px `.band` declares no columns at all and its children stack on
 * their own — same boundary terminal-c uses, and no undo rule to write. */
.band:not(:has(> .plate)):has(> :nth-child(1):last-child) { grid-template-columns: 1fr; }
@media (min-width: 900px) {
  .band:not(:has(> .plate)):has(> :nth-child(2):last-child) {
    grid-template-columns: 1fr 1fr;
    grid-template-rows: minmax(0, 1fr);
  }
  .band:not(:has(> .plate)):has(> :nth-child(3):last-child) {
    grid-template-columns: 1fr 1fr;
    grid-template-rows: auto minmax(0, 1fr);
    grid-template-areas: "a a" "b c";
  }
  .band:not(:has(> .plate)):has(> :nth-child(3):last-child) > :nth-child(1) { grid-area: a; }
  .band:not(:has(> .plate)):has(> :nth-child(3):last-child) > :nth-child(2) { grid-area: b; }
  .band:not(:has(> .plate)):has(> :nth-child(3):last-child) > :nth-child(3) { grid-area: c; }
  .band:not(:has(> .plate)):has(> :nth-child(4):last-child) {
    grid-template-columns: 1fr 1fr;
    grid-template-rows: 1fr 1fr;
  }
  .band:not(:has(> .plate)):has(> :nth-child(5):last-child) {
    grid-template-columns: 1fr 1fr;
    grid-template-rows: auto minmax(0, 1fr) minmax(0, 1fr);
    grid-template-areas: "a a" "b c" "d e";
  }
  .band:not(:has(> .plate)):has(> :nth-child(5):last-child) > :nth-child(1) { grid-area: a; }
  .band:not(:has(> .plate)):has(> :nth-child(5):last-child) > :nth-child(2) { grid-area: b; }
  .band:not(:has(> .plate)):has(> :nth-child(5):last-child) > :nth-child(3) { grid-area: c; }
  .band:not(:has(> .plate)):has(> :nth-child(5):last-child) > :nth-child(4) { grid-area: d; }
  .band:not(:has(> .plate)):has(> :nth-child(5):last-child) > :nth-child(5) { grid-area: e; }
}
/* Grid items default to `min-width: auto`, which lets a long unbroken string
 * or a wide image push a track past its share. `0` makes `1fr` mean `1fr`. */
.band > * { min-width: 0; }

/* ── per-child positional alignment — stage-component.md §4.3 ───────────── */

/* This is what replaces `text-align: center` on the container, and it is the
 * single biggest reason the page used to read as a stack of posters rather
 * than a spread. The owner's own rule, quoted in the spec: "the .container
 * should not have .text-left — the children get those classes."
 *
 * The default is POSITIONAL: a full-span child centres, a real left column
 * aligns left, a real right column aligns right.
 *
 * 🔴 It rides the SAME `:has(> :nth-child(N):last-child)` selectors the shape
 * is keyed on — never `:first-child`/`:last-child`. stage-component.md §3.1
 * [C6] is emphatic, and states the rule generally: NEVER GUESS POSITION
 * INDEPENDENTLY A SECOND TIME. Two independent guesses drift the first time
 * a count changes. */
.band:not(:has(> .plate)):has(> :nth-child(1):last-child) > :nth-child(1) { text-align: center; }
@media (min-width: 900px) {
  .band:not(:has(> .plate)):has(> :nth-child(2):last-child) > :nth-child(1),
  .band:not(:has(> .plate)):has(> :nth-child(3):last-child) > :nth-child(2),
  .band:not(:has(> .plate)):has(> :nth-child(4):last-child) > :nth-child(1),
  .band:not(:has(> .plate)):has(> :nth-child(4):last-child) > :nth-child(3),
  .band:not(:has(> .plate)):has(> :nth-child(5):last-child) > :nth-child(2),
  .band:not(:has(> .plate)):has(> :nth-child(5):last-child) > :nth-child(4) { text-align: start; }
  .band:not(:has(> .plate)):has(> :nth-child(2):last-child) > :nth-child(2),
  .band:not(:has(> .plate)):has(> :nth-child(3):last-child) > :nth-child(3),
  .band:not(:has(> .plate)):has(> :nth-child(4):last-child) > :nth-child(2),
  .band:not(:has(> .plate)):has(> :nth-child(4):last-child) > :nth-child(4),
  .band:not(:has(> .plate)):has(> :nth-child(5):last-child) > :nth-child(3),
  .band:not(:has(> .plate)):has(> :nth-child(5):last-child) > :nth-child(5) { text-align: end; }
  /* the header row of an odd count spans both tracks, so it centres */
  .band:not(:has(> .plate)):has(> :nth-child(3):last-child) > :nth-child(1),
  .band:not(:has(> .plate)):has(> :nth-child(5):last-child) > :nth-child(1) { text-align: center; }
}

/* ── elbow-room — stage-component.md §3.1, pilot names ──────────────────── */

/* A child wearing `.elbow-room` may grow OUTWARD past the page column, up to
 * `--elbow-cap` per side, and not at all once the viewport is at or below the
 * page column. A bare `.elbow-room` bleeds both ways; a real left or right
 * column cancels its INWARD edge so the gutter between the two columns stays
 * a gutter.
 *
 * 🔴 DO NOT also port `.col-bleed`. stage-component.md:478-501: the two "must
 * never be worn together — two negative margins on one box is two bleeds."
 * Touchtone has no page-wide column image yet; add it when one exists. */
.band > .elbow-room { margin-inline: calc(var(--elbow-room) * -1); }
@media (min-width: 900px) {
  .band:not(:has(> .plate)):has(> :nth-child(2):last-child) > :nth-child(1).elbow-room,
  .band:not(:has(> .plate)):has(> :nth-child(3):last-child) > :nth-child(2).elbow-room,
  .band:not(:has(> .plate)):has(> :nth-child(4):last-child) > :nth-child(1).elbow-room,
  .band:not(:has(> .plate)):has(> :nth-child(4):last-child) > :nth-child(3).elbow-room,
  .band:not(:has(> .plate)):has(> :nth-child(5):last-child) > :nth-child(2).elbow-room,
  .band:not(:has(> .plate)):has(> :nth-child(5):last-child) > :nth-child(4).elbow-room { margin-inline-end: 0; }
  .band:not(:has(> .plate)):has(> :nth-child(2):last-child) > :nth-child(2).elbow-room,
  .band:not(:has(> .plate)):has(> :nth-child(3):last-child) > :nth-child(3).elbow-room,
  .band:not(:has(> .plate)):has(> :nth-child(4):last-child) > :nth-child(2).elbow-room,
  .band:not(:has(> .plate)):has(> :nth-child(4):last-child) > :nth-child(4).elbow-room,
  .band:not(:has(> .plate)):has(> :nth-child(5):last-child) > :nth-child(3).elbow-room,
  .band:not(:has(> .plate)):has(> :nth-child(5):last-child) > :nth-child(5).elbow-room { margin-inline-start: 0; }
}

/* Focus rings: NO per-band override needed (and none lives here as of
 * 2026-09-26). base.css's global `:focus-visible` reads `var(--text-bright)`
 * — the ADAPTIVE token (dark ink by default, white only inside `.toy,
 * .band.void`, theme/law.css) — which already resolves correctly on every
 * ground with zero per-band CSS — RE-CONFIRMED 2026-09-29 after the card
 * system was retired, by tabbing the whole page and reading
 * `getComputedStyle` on all six focusables: every one computes `outline:
 * solid 2px rgb(17, 17, 17)` (`--ink`) on the cream ground, and the token
 * still flips to a light value inside `.band.void`. (The original check was
 * made on the closing CTA when it was a `.band.steel` card; the tone is gone,
 * the conclusion is not.) A scoped `.band:not(.void) :focus-visible { outline-color:
 * var(--ink) }` used to live here, written before base.css's own fix landed
 * sitewide; removed once confirmed redundant rather than kept as
 * belt-and-suspenders — two sources of the same fact is how they drift. */

/* ── .say / .sub — variant-R, verbatim ───────────────────────────────────── */

/* 🔴 `color: var(--text-bright)`, 2026-09-29 — REVERSING this rule's own
 * `color: inherit`, and the reversal is instructive rather than a correction
 * of a mistake. `inherit` was RIGHT while the tone grounds existed: each
 * `.band.<tone>` declared its own `color` (ink on paper/steel/accent,
 * structure on void), and base.css's global `h1, h2, h3 { color:
 * var(--text-bright) }` targets `.say` DIRECTLY, which beats inheritance from
 * an ancestor no matter what that ancestor's specificity is. Without
 * `inherit` every headline took `--text-bright`'s then-dark-theme `#dadada`
 * and measured 2.33:1 on the red band — below even the large-text floor.
 *
 * Two things changed. The tone grounds are gone (this file's header), so
 * there is no longer an ancestor colour worth inheriting; and `--text-bright`
 * is now the ADAPTIVE token (theme/law.css scopes it per surface: `--ink` on
 * the light ground, a light value inside `.band.void`), which is exactly the
 * job `inherit` was standing in for. Measured, not assumed: `inherit` was
 * resolving to `<body>`'s `--text-primary` (#444444 then, #4a433b since the
 * 2026-09-29 warm-neutral pass — same luminance, see theme/law.css) — 8.37:1,
 * passing, but the
 * SAME value as the body copy beneath it, so the page's largest type carried
 * no more weight of colour than a sentence. `--text-bright` measures 16.23:1
 * and restores the hierarchy the `<h1>` already had. */
/* 🔴 RE-CUT 2026-09-29 with the one-family type change (theme/law.css's
 * Type note carries the owner call). Four changes, each with its own reason:
 *
 *   weight 800 → 600   `--disp` is General Sans now, not Cabinet Grotesk; 800
 *                      does not exist in it and 600 is the cut this site
 *                      newly ships. It is also the right WEIGHT: the crew
 *                      artwork's heaviest mark anywhere is a 2px hairline,
 *                      and a slab of ExtraBold over dusty-sage trousers read
 *                      as a magazine cover pasted onto a picture book.
 *
 *   text-transform     GONE. Forcing lowercase was softening applied to the
 *                      wrong variable — the problem was weight and contrast,
 *                      never case — and it silently contradicted
 *                      content/copy.json, which authors these lines in
 *                      sentence case. The rendered page now says what the
 *                      content file says.
 *
 *   line-height .92    → 1.02. `.92` set the headline as a dense brick, the
 *                      exact opposite of the artwork's defining generous
 *                      negative space. Just over 1 lets a two-line headline
 *                      breathe without opening into a gap.
 *
 *   font-size          `clamp(2.3rem, 7.4vw, 5.2rem)` →
 *                      `clamp(2rem, 4.6vw, 3.6rem)`. The old scale was
 *                      measured for a headline that had the FULL page width
 *                      to itself, which is what a centred card gave it. In
 *                      the two-column stage a `.say` gets half a column
 *                      (556px at 1440), where 5.2rem wrapped every headline
 *                      to three and four lines. The `vw` term drops with it,
 *                      since the viewport is now roughly twice the box. */
.say {
  font-family: var(--disp);
  font-weight: 600;
  letter-spacing: var(--tracking-display);
  line-height: 1.02;
  font-size: clamp(2rem, 4.6vw, 3.6rem);
  max-width: 17ch;
  /* 🔴 `margin-inline: 0`, NOT `0 auto` — changed with the card's retirement.
   * `max-width: 17ch` is narrower than a half-column at 1440, so an `auto`
   * inline margin re-CENTRED the headline inside a left column and silently
   * undid the positional alignment above. `text-align` alone cannot fix that:
   * it aligns the text inside the box, and the box was already floating in
   * the middle of the track. Same trap on `.sub` and `.toy-description`. */
  margin: 0;
  margin-inline: 0;
  color: var(--text-bright);
}

/* 🔴 OFF MONO, 2026-09-29. This was `var(--mono)` at 11px/+0.14em/`.62`
 * opacity — a laboratory instrument panel under every headline on the page,
 * and the single most-used wrong face on it. It is a SENTENCE: it gets the
 * body face, a readable size, and the body's own tracking.
 *
 * 🔴 COLOUR, NOT OPACITY — the same correction `toy.css`'s
 * `.toy-description` already made and for the same reason: an opacity is a
 * contrast number you cannot look up, it changes meaning the moment the
 * ground under it changes, and it took TWO per-band patch rules to keep AA
 * (`.band.steel .sub` at `.72`, `.band.accent .sub` at `.75`) — both now
 * deleted with the tints. `--text-muted` is a real token, solved against
 * this ground (4.89:1, see theme/law.css), and it ALREADY adapts on the one
 * dark band via law.css's `.band.void` override. One value, measured once.
 *
 * At ≥16px this also stops being "small text" under WCAG 1.4.3 — but the
 * token clears the stricter 4.5:1 floor anyway, so nothing here depends on
 * that carve-out. */
.sub {
  font-size: clamp(1rem, 1.5vw, 1.15rem);
  line-height: 1.55;
  letter-spacing: var(--tracking-tight);
  color: var(--text-muted);
  /* see `.say` above for why the inline margin is 0 and not `auto` */
  margin: 20px 0 0;
  max-width: 42ch;
}
/* 🔴 TWO CONTRAST PATCHES DELETED HERE, 2026-09-29, and they are worth a
 * note because they are the clearest evidence the tints were costing more
 * than they earned. `.band.steel .sub { opacity: .72 }` existed because
 * `--ink` on `--steel` at the base `.62` measured 4.37:1 — an AA miss.
 * `.band.accent .sub { opacity: .75 }` existed because `--ink` on
 * `--theme-drums` measured 4.01:1 — a worse one. Two per-band overrides, both
 * propping up a decorative ground. With the tints retired the base `.62` is
 * measured on `--bg` and there is one number to keep, not three. See the
 * measured table in this file's `.sub` note above. */

/* ── .plate — corner labels naming what's on screen ──────────────────────── */

/* 🔴 NOW PASSES AA OUTRIGHT, 2026-09-29 — and the rule it replaces was a
 * DECISION, not an omission, so here is why the decision is withdrawn rather
 * than overturned. `.plate` used to be `opacity: .55`, and the note here
 * argued (correctly, on the evidence it had) that raising it to clear 4.5:1
 * "would mean a visibly heavier corner label on EVERY band, including
 * `--void` where it is already comfortably compliant (~6:1+) — the wrong
 * trade." That objection was entirely about the MECHANISM: an opacity is one
 * number applied to every ground at once, so fixing the worst case
 * over-corrects the best one.
 *
 * `--text-muted` is not one number. theme/law.css scopes it per surface, so
 * the light ground gets #6B665E and `.band.void` keeps its own #848484 — the
 * exact value already measured at 6.25:1 there. The objection's premise is
 * gone, so the carve-out is no longer needed and is not claimed. MEASURED on
 * the built page: was 2.72:1 on the cream ground (worse than the 3.57:1 the
 * old note recorded on `--steel`, because the ground got lighter); now
 * 4.89:1 on cream, 6.25:1 on void. design.md's standing rule decides it —
 * "Muted ≠ illegible … darken the tier, don't argue the aesthetics."
 *
 * The `aria-hidden="true"` on every `.plate` in the markup is UNCHANGED and
 * still correct for its own reason: none of them carries information not
 * already stated in plainer text (the view name is in the screenshot's `alt`,
 * the platforms line repeats `app.json`). It is simply no longer doing double
 * duty as a contrast excuse. */
.plate {
  font-family: var(--mono);
  font-size: 11px;
  letter-spacing: var(--tracking-mono);
  color: var(--text-muted);
  pointer-events: none;
}
/* Direct children of a band (or the toy hero) are true corner plates —
 * variant-R's own `.bl`/`.br`. A `.plate` living inside `.shot-figure`
 * (below) is a per-screenshot caption instead and gets its own, in-flow
 * position there — see that rule's comment for why. */
.band > .plate.bl,
.toy > .plate.bl {
  position: absolute;
  left: 20px;
  bottom: 18px;
}
.band > .plate.br,
.toy > .plate.br {
  position: absolute;
  right: 20px;
  bottom: 18px;
}

/* ── `.plate--requirement` — the platform line, centred under the buttons ───
 * Owner, 2026-09-30: "move iOS 17+ to the center under the buttons and change
 * to 'Requires iOS 17 or newer'. and som distance above and below it with a
 * small text."
 *
 * It keeps `.plate`'s FACE (mono, 11px, the mono tracking) and drops its
 * PLACEMENT: a corner plate is absolutely positioned, this is in flow. The
 * class is still `.plate` on purpose — `.band:not(:has(> .plate))` is what
 * keeps this four-child section as one centred column instead of a 2×2 grid;
 * see the layout's own comment.
 *
 * `--text-secondary`, not `.plate`'s `--text-muted`: this is the only place
 * the site states a system requirement, so it is information a visitor may
 * need rather than a decorative chip. Measured on the page ground — muted is
 * 4.89:1 and secondary is 6.11:1, both pass, and the brighter one is right
 * for something meant to be read.
 *
 * `pointer-events` is re-enabled for the same reason: `.plate` disables them
 * because a decorative chip should never eat a click, but text a user might
 * want to select should not be unselectable. */
.plate--requirement {
  position: static;
  margin-block: var(--space-3) var(--space-2);
  text-align: center;
  color: var(--text-secondary);
  pointer-events: auto;
}
/* See `.spec`'s own comment just below — same fix, same reason: `.toy` sets
 * no `color` of its own, so its corner plate ("↓") needs an explicit
 * `--structure` rather than inheriting `<body>`'s ordinary grey. */
.toy > .plate.bl,
.toy > .plate.br {
  color: var(--structure);
}

/* ── .spec — the hero's terse, cited fact column (toy.yml only) ─────────── */

/* `color: var(--structure)` is explicit here (and on `.toy > .plate.br`
 * above) for the same reason `.say` needs `color: inherit` — measured, not
 * assumed: `.toy` itself never sets `color` (only `.toy-scene` does, scoped
 * to the SVGs), so without this `.spec`/`.plate` fell back to `<body>`'s own
 * `--text-primary` (`#b3b3b3`, base.css's ordinary body-copy grey) rather
 * than the hero's actual white structure colour — confirmed via
 * `getComputedStyle` before this fix (`rgb(179, 179, 179)`, not `#fff`). */
.spec {
  position: absolute;
  left: 20px;
  /* 🔴 BOTTOM-LEFT, NOT VERTICALLY CENTRED. variant-R pins its spec column at
   * `top:50%; transform:translateY(-50%)`, and that is correct THERE because
   * its hero is a 1:1 video with dead space at the left edge. Ours is the live
   * 91% ring, which at a centred left edge runs straight through the column —
   * owner instruction 2026-09-25: "not overlaying the outer circle". Bottom
   * `18px` matches `.plate.bl`'s own offset, so the spec column and the corner
   * plates sit on one baseline; the hero uses only `.br`, so `.bl` is free and
   * there is no collision. A DECLARED DIVERGENCE from the approved variant,
   * driven by our hero's geometry rather than by taste. */
  bottom: 18px;
  font-family: var(--mono);
  font-size: 11px;
  line-height: 2.2;
  letter-spacing: var(--tracking-mono);
  opacity: 0.62;
  text-align: left;
  pointer-events: none;
  color: var(--structure);
}
/* variant-R joins its facts with literal `<br>`s in one text node;
 * layouts/toy.yml instead wraps each in its own `<span>` (mustache has no
 * "join with separator" form, and a trailing `<br>` after the last item would
 * add an asymmetric blank line). ⚠️ The original reason given here was that a
 * blank line would pull a VERTICALLY CENTRED column off-centre — that is now
 * stale, since the column is bottom-anchored and a trailing blank line would
 * instead lift the whole block off its baseline. Same conclusion, different
 * mechanism; the `<span>` form is still the right one. */
.spec span { display: block; }

/* ── .shot / .row — screenshots, void bands only (THE LAW) ──────────────── */

.shot {
  width: min(66vmin, 470px);
  height: auto;
  display: block;
}

.row {
  display: flex;
  gap: clamp(16px, 3.4vw, 44px);
  flex-wrap: wrap;
  justify-content: center;
  align-items: flex-start;
}

/* ⚠️ UNBOUND since 2026-09-29 — `layouts/shot-band.yml`, the only layout that
 * ever emitted `.shot-figure`, is deleted. Kept because the pattern it
 * states is still the right one for the next multi-image band: one
 * screenshot + its own view-naming caption, in flow rather than absolutely
 * pinned to a corner, because it captions ONE image among several rather
 * than the whole band. Two inert rules; nothing reads them today. */
.shot-figure {
  margin: 0;
}
.shot-figure .plate {
  display: block;
  margin-top: 10px;
}

/* ── .facts-list — the five parent facts (facts-band.yml only) ──────────── */

/* 🔴 OFF MONO, 2026-09-29, and this one mattered most. These are the five
 * parent facts — docs/designs/site-home-the-toy.md calls them "the single
 * highest-value non-hero element on the page" — and they were set at 13px in
 * IBM Plex Mono with +0.14em tracking and `.85` opacity, i.e. as a READOUT.
 * An evaluating parent scanning for "free / no ads / no accounts / what is
 * the microphone for" was being handed telemetry. They should read as
 * sentences, because they are sentences.
 *
 * The old note said the mono voice made this band read as "one more band in
 * the same system rather than a bespoke fourth thing." That was true and it
 * is still the goal — the system is simply a different one now: every band
 * on the page shares ONE face, so belonging costs nothing and no longer
 * requires borrowing the instrument-panel voice to get it.
 *
 * Opacity dropped rather than converted: at this size and importance the
 * facts want the body's own `--text-primary` (8.37:1 on the cream ground),
 * not a muted tier. */
.facts-list {
  list-style: none;
  margin: 24px 0 0;
  padding: 0;
  font-size: 1.0625rem;
  letter-spacing: var(--tracking-tight);
  line-height: 1.7;
  max-width: 38ch;
}
.facts-list li { margin: 0; }
/* 🔴 A CHILD'S OWN OVERRIDE, which the positional default explicitly allows
 * (stage-component.md §4.3's three-step resolution: "the part's own align …
 * wins outright"; only step 3 is positional). `parent-facts` is two children,
 * so the list lands in the RIGHT column and the positional default would
 * right-align it. That answer is right for a figure — the spec's own measured
 * example is a figure, where alignment is meaningless — and wrong for five
 * sentences: a ragged LEFT edge on a list makes the reader re-find the start
 * of every line. Weighs (0,3,0) to answer the positional rule at its own
 * weight, and sits below it in the file. */
.band:not(:has(> .plate)):has(> :nth-child(2):last-child) > .facts-list { text-align: start; }

/* ── .cta — one button style for every intent, live or not ──────────────── */

/* variant-R's own `.cta`: a bordered box, mono, no fill — the SAME look
 * whether the intent is disabled (content/cta.json's coming-soon badge,
 * rendered as a non-navigating `<span aria-disabled="true">`, the same rule
 * src/html/_footer.html's Riffmix cross-link and this schema's own
 * `isDisabled` already follow) or live (`<a>`, e.g. the /support link) —
 * REPLACING components.css's `.btn`/`.badge` variant system for this layout
 * (layouts/cta-final.yml no longer branches on `{{variant}}`; see that
 * file's own header for why). `color: inherit` + `border-color: currentColor`
 * so the same rule is correct on every band tone with zero per-tone override.
 *
 * 🔴 PILL, 2026-09-26 — owner-approved. `border-radius: 999px` replaces the
 * squared corners: `.cta` is site chrome, not the play surface, so THE LAW's
 * "circles only" doesn't forbid it — a pill is arguably CLOSER to
 * circles-only than a rectangle was, and it removes the last hard-cornered
 * element on a page that, at the time, was otherwise all soft corners (the
 * Framework-inspired cards). ⚠️ THOSE CARDS ARE GONE as of 2026-09-29 and the
 * pill SURVIVES them, deliberately: the second half of the original argument
 * (a pill is chrome, and closer to circles-only than a rectangle) never
 * depended on the cards and still holds. It is now the only rounded thing on
 * the page, which reads as a deliberate control rather than as a leftover.
 * Both states re-checked on the one remaining ground: the disabled
 * `app-coming-soon` span and the live `/support` link. `border-color:
 * currentColor` means the border tracks whatever `color` the state sets —
 * see the `[aria-disabled]` rule below, where that is load-bearing. */
/* 🔴 OFF MONO, 2026-09-29. Every button on `/` was 12px machine-tracked
 * IBM Plex Mono — so the one moment the page asked a parent to act, it did so
 * in the voice of an engineering readout. docs/designs/site-home-sell-the-app.md
 * had already named the CTA as the page's problem without naming the typeface
 * as the cause. Body face, 500 (a control wants a touch more weight than the
 * prose around it), 1rem, body tracking.
 *
 * THE PILL STAYS — owner-approved 2026-09-26, see the note above. It is the
 * one rounded thing left on a page that used to be all soft corners, and that
 * is fine: a pill is chrome, and arguably closer to the app's circles-only
 * law than a rectangle ever was. */
.cta {
  display: inline-block;
  font-size: 1rem;
  font-weight: 500;
  letter-spacing: var(--tracking-tight);
  padding-block: 14px;
  color: inherit;
  border: 1px solid currentColor;
  border-radius: 999px;
  padding-inline: 22px;
  text-decoration: none;
  transition: background var(--motion-duration) var(--motion-ease);
}
/* A faint currentColor wash, not a hard invert — the only hover treatment
 * that is correct on every band tone (void/paper/steel/red) without knowing
 * which one it's on: inverting to a FIXED colour (e.g. always var(--bg))
 * would render invisible text-on-background the moment currentColor itself
 * is dark (paper/steel's `--ink`). color-mix keeps the same foreground,
 * lightly filled, regardless of tone. */
a.cta:hover { background: color-mix(in srgb, currentColor 14%, transparent); }
/* 🔴 `color`, NOT `opacity: .75` — changed 2026-09-29 because the measured
 * number failed. The disabled CTA is a `<span aria-disabled="true">` carrying
 * REAL, non-decorative text ("Touchtone — coming to the App Store"), so the
 * full 4.5:1 applies to it; `--text-primary` (#444444 then, #4a433b since the
 * 2026-09-29 warm-neutral pass — identical luminance, so this number stands)
 * at `.75` over the cream
 * ground composites to rgb(114,110,105) and measures 4.35:1. `--text-muted`
 * measures 4.89:1, adapts on `.band.void` by itself, and dims the 1px border
 * with it for free since that reads `currentColor` — which also keeps the
 * control's own boundary past 1.4.11's 3:1. A disabled control still has to
 * be readable: it is telling the visitor what is coming, which is the only
 * reason it is on the page at all. */
/* 🔴 NO LONGER DIMS THE INK — 2026-09-30. This used to set `--text-muted`,
 * which was right on a fill-free bordered box (the button had the page's own
 * ground behind it, and muted-on-cream measured 4.89:1). The buttons have a
 * COLOURED FILL now, and `--text-muted` on #de7969 measures 1.91:1 — it was
 * shipped and caught by an in-browser sweep, not by reading the CSS. The
 * disabled state is already carried by `cursor` and by the element being a
 * non-navigating `<span aria-disabled="true">`, which is what assistive tech
 * reads; dimming the text on top of that only made it illegible to everyone.
 * ⚠️ This rule is more specific than `.cta`'s own `color`, so anything set
 * here WINS — do not reintroduce a colour without measuring it on the fill. */
.cta[aria-disabled="true"] { cursor: default; }

/* ── .compact — the PROMOTED, near-top CTA (layouts/cta-final.yml, reused
 * verbatim at a second placement: wireframe.yml's `cta-top` section) ────── */

/* Owner correction, 2026-09-25: "the CTA should be after the hero, or it is
 * hidden... promote the app." The SAME layout/markup as the closing
 * cta-final band, scaled down here so it reads as a quick reminder mid-page
 * rather than repeating the full closing moment's weight this early — `.say`
 * drops from the ~37-83px headline scale to a body-adjacent 1.4-2.2rem, the
 * band's own vertical padding shrinks, and the corner plate is hidden
 * entirely (redundant so soon after the hero's own spec column, and at this
 * reduced scale it would crowd the button). The two CTAs read as the same
 * instrument at two volumes, not two different treatments — which as of
 * 2026-09-29 is literally true, since neither carries a tone any more (both
 * were `steel`; see this file's header on the card's retirement). */

/* 🔴 A CORNER PLATE IS CHROME, NOT A STAGE CHILD — the one place the
 * count-driven shape above needs telling, and the reason every selector in
 * that block carries `:not(:has(> .plate))`.
 *
 * Three sections on this page end in an absolutely-positioned, `aria-hidden`
 * corner label: `cta-top` and `cta-final` (`{{app.platformsLine}}`, from
 * layouts/cta-final.yml) and `#world` (the journey caption). The element takes
 * no grid space — but `:nth-child()` counts DOM POSITION, not layout
 * participation, so `cta-final` read as FOUR children and matched the 2×2
 * rule, dealing headline / line / buttons into three separate quadrants.
 *
 * ⚠️ MEASURED, TWICE, BECAUSE THE FIRST TWO FIXES WERE WRONG — written down
 * so the next person does not repeat either:
 *   1. A bare `.band.cta-final { grid-template-columns: 1fr }` silently did
 *      NOTHING. `:has()` takes the specificity of its most specific ARGUMENT,
 *      and `> :nth-child(4):last-child` is two pseudo-classes, so the count
 *      rules weigh (0,3,0) — not the (0,2,0) a quick reading suggests.
 *   2. Winning on `grid-template-columns` alone was still not enough: the
 *      3-child rule ALSO assigns `grid-area: a/b/c` to the children. Named
 *      areas that no longer exist become IMPLICIT ones, so the band grew a
 *      third column out of nowhere — measured `655.6px 0px 416.4px`. Reset
 *      the shape as a whole or do not reset it.
 * Guarding the count block itself is what finally holds, because then there
 * is no competing declaration left to out-weigh. It also states a true and
 * general rule instead of naming one layout.
 *
 * A band with a corner plate is a single centred column: that is right for
 * both CTAs (a call to action is one centred moment, not a split) and
 * harmless for `#world`, whose only real child is taken out of flow entirely
 * (`position: absolute; inset: 0`, see scenes.css). */
.band:has(> .plate) {
  grid-template-columns: 1fr;
  grid-template-areas: none;
  justify-items: center;
}
.band:has(> .plate) > * { text-align: center; }

.cta-final.compact {
  padding-block: clamp(40px, 6vw, 70px);
}
.cta-final.compact .say {
  font-size: clamp(1.4rem, 4vw, 2.2rem);
  max-width: 24ch;
}
.cta-final.compact .plate {
  display: none;
}

/* ══════════════════════════════════════════════════════════════════════════
 * SCENE BANDS — the crew illustrations (layouts/scene-band.yml, 2026-09-29)
 * ══════════════════════════════════════════════════════════════════════════
 * A `.band` that carries a picture as well as a line. Every tone class
 * (`paper`/`steel`/`accent`/`void`) still applies exactly as it does to a
 * say-band, so these inherit the card treatment and the contrast work already
 * done on each ground — nothing about colour is re-decided here.
 *
 * WHY THE ART IS NOT INSET IN A FRAME. The scenes are drawn ON the page's own
 * cream — #FAECD7, which is `--bg` EXACTLY (theme/law.css) — so giving them a
 * border, a card or a radius would put a visible box around a picture whose
 * whole point is that it sits on the paper.
 *
 * 🔴 SUPERSEDED 2026-09-29, and this is the whole argument for the card's
 * retirement in miniature. Until this change there WAS an exception: on a
 * TINTED band (`.band.accent`'s orange, `.band.paper`'s near-cream) the
 * artwork's own cream was unavoidably a lighter rectangle, so `.band
 * .scene-img` carried `border-radius: clamp(10px,1.4vw,20px)` to make that
 * rectangle read as a deliberate sheet rather than a seam. That was a card
 * inside a card — a radius invented to paper over a tint nothing needed. The
 * tints are gone; the rectangle is gone with them; the rule is DELETED, not
 * moved. There is nothing left to round. */

/* 🔴 `.scene-band` NO LONGER DECLARES A LAYOUT. It was
 * `display: flex; flex-direction: column; align-items: center` — copy stacked
 * above a centred, shrink-wrapped picture, at every width. The section's own
 * count-driven grid (above) is the layout now: a scene-band is exactly two
 * children, so it becomes two real columns at ≥900px for free, with no markup
 * change and no layout class. Below 900px it stacks, as it always did.
 * Nothing replaces the old `gap` either — `.band`'s own `gap: var(--space-4)`
 * is the gutter, one value for every band. */

/* Copy first by default; `class: scene-band--flip` in wireframe.yml puts the
 * picture first. A per-PLACEMENT decision, not a property of the layout.
 * `order` rather than `flex-direction: column-reverse` now that the parent is
 * a grid: same effect in both axes, and it works in the stacked case too. */
.scene-band--flip > .scene-band-copy { order: 2; }

.scene-figure {
  margin: 0;
  width: 100%;
}

.scene-img {
  display: block;
  width: 100%;
  height: auto;   /* with the intrinsic width/height attributes this keeps the
                     aspect ratio AND lets the browser reserve the box, so
                     nothing below shifts as a scene loads. */
  /* 🔴 THE HEIGHT CEILING IS GONE — `max-height: min(62vh, 560px)` plus
   * `width: auto; margin-inline: auto` used to shrink-wrap every scene. It
   * existed for one reason, recorded honestly at the time: at FULL COLUMN
   * WIDTH the 3:2 scenes stood ~900px tall, each one ate more than a laptop
   * viewport, and the page measured 7,492px. In half a column that pressure
   * is gone — the picture is half as wide and roughly half as tall — so the
   * cap is no longer buying anything and the shrink-wrap was only ever there
   * to give the deleted border-radius something to hug. Re-measure page
   * height after any change here; if it climbs back past ~6,000px the
   * two-column split has stopped doing its job. */
}

/* ── The hero's scene ────────────────────────────────────────────────────
 * `.toy` still supplies the section's grid placement and ground (the class is
 * reused deliberately — see layouts/hero-scene.yml's header on why the toy's
 * class names survive it being parked); `.hero-scene` is the class that means
 * "this one, the illustration hero" and never reaches the parked toy.
 *
 * 🔴 THE HERO JOINS THE PAGE COLUMN, 2026-09-29. It used to sit on its own
 * `margin-inline: clamp(12px, 3vw, 56px)` — the inset the black `.toy-stage`
 * had, kept so the swap would not move the rhythm. That was defensible while
 * every band below was an inset card on a different inset again; with the
 * cards gone it is just a third width on a page that should have one. The
 * hero now takes `--edge-pad` like every band, and the PICTURE bleeds back
 * out by exactly `--elbow-room` — so it is the widest thing on the page by a
 * stated amount (96px per side at ≥1344px, tapering to 0 by the time the
 * viewport reaches the page column) instead of by coincidence. It is the
 * page's first and largest beat; earning that with the bleed rather than an
 * unrelated clamp is the whole point of porting elbow-room.
 *
 * `.toy-copy` drops its own 24px inline padding here ONLY — the parked
 * toy.yml still renders `.toy-copy` over a full-bleed black stage where that
 * padding is the only thing keeping the heading off the screen edge. */
/* 🔴 THE HERO MEASURES ITSELF NOW — `--toy-copy-h` IS NO LONGER IN THE SUM.
 *
 * The art's height used to be `100dvh - header - copy - peek`, where `copy`
 * was `--toy-copy-h: 190px`, described in toy.css's own comment as "an
 * ESTIMATE, not a measurement". Measured on the built page at 768×1024 it
 * renders 229px — 39px out — and the error lands exactly where it is most
 * visible: `--hero-peek` exists so the next band shows at the fold (owner
 * correction, 2026-09-25), and at 768 the next band began at 1049px in a
 * 1024px viewport. Nothing peeked. The reserve was spent on the estimate's
 * own error.
 *
 * A grid with `1fr auto` rows removes the estimate rather than re-tuning it:
 * the copy takes whatever height it actually needs, the art takes the rest,
 * and the hero is exactly `100dvh - --hero-peek` tall by construction at
 * every width and every copy length. One less number that can drift out of
 * agreement with the page it describes — the same class of coupling bug that
 * `sizes` had with `.scene-img`'s cap, fixed the same way: delete the second
 * copy of the fact instead of re-syncing it.
 *
 * `min-height` and not `height`: if the copy ever grows past the row it is
 * given, the hero grows rather than clipping it. */
.hero-scene {
  padding-inline: var(--edge-pad);
  display: grid;
  grid-template-rows: 1fr auto;
  min-height: calc(100dvh - var(--hero-peek));
}
/* 🔴 THE ART FILLS ITS ROW BY `object-fit`, NOT BY `max-height: 100%`.
 *
 * The obvious version — `min-height: 0` on the figure and `max-height: 100%`
 * on the image — was built first and measured WORSE than what it replaced
 * (hero 1070px tall in a 1024px viewport, against 953px before). The reason
 * is a percentage-resolution rule, not a tuning error: a percentage height
 * resolves against the parent's height, the figure's height is `auto`, so
 * `max-height: 100%` resolves to nothing and is DROPPED. The image then sizes
 * itself from `max-width: 100%` alone — 691px wide × 1.2166 = 841px tall —
 * and pushes the row open instead of fitting into it.
 *
 * Taking the image out of flow removes the circular dependency: the `1fr`
 * row is sized by the grid from the container's own min-height, the figure
 * stretches to that row, and `object-fit: contain` fits the drawing inside
 * whatever box it is handed without distorting it or cropping it. The art is
 * still centred, still uncropped, still one file at any DPR.
 *
 * `min-height: 0` stays and is still load-bearing: a grid item's automatic
 * minimum size is its content, so without it the row refuses to shrink. */
.scene-figure--hero {
  min-height: 0;
  position: relative;
}
.scene-figure--hero .scene-img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  max-width: none;
  max-height: none;
  object-fit: contain;
  margin: 0;
}
.hero-scene > .toy-copy { padding-inline: 0; }
.scene-figure--hero {
  margin-inline: calc(var(--elbow-room) * -1);
  max-width: none;
  width: auto;
}

/* ══════════════════════════════════════════════════════════════════════════
 * 🔴 THE HERO IS PORTRAIT NOW — 2026-09-30. DESIGNED FOR, NOT ABSORBED.
 * ══════════════════════════════════════════════════════════════════════════
 * The owner replaced the generated hero raster with his own drawing,
 * `assets/artwork/crew-overhead.svg`, viewBox 1441×1753. That is 0.822:1
 * PORTRAIT where the picture it replaces was 1.79:1 landscape, and asked
 * directly whether the flip was intended he said: "yes - the flip is on
 * purpose." So this block treats portrait as the shape of the opening, not
 * as a constraint to squeeze the art through.
 *
 * ── WHY TWO COLUMNS AT ≥900px ────────────────────────────────────────────
 * A landscape hero wants the full width and the copy above it; that is why
 * this section was a plain block stack. A PORTRAIT hero does not: at the
 * full 1344px page-plus-elbow width the drawing stands 1635px tall, more
 * than a laptop viewport and a half, and capping it back down to behave
 * leaves a narrow column of art marooned in a sea of cream. Standing the
 * figure BESIDE the copy is the composition the new aspect actually wants —
 * four children around an iPad, read at a believable size, with the headline
 * holding the left half. It is also what every band below already does
 * (src/css/bands.css's count-driven grid, ≥900px, two children → two
 * columns); the hero was the one section not taking it, for a reason that
 * expired with the landscape art.
 *
 * Reproduced here rather than borrowed from that grid on purpose: the hero
 * is a `<header class="toy hero-scene">`, not a `.band`, so none of the
 * `.band:not(:has(> .plate))` selectors reach it. Giving it `.band` to
 * inherit them would drag the band padding, the tone classes and the plate
 * machinery along with the columns. Three declarations is cheaper than that,
 * and the 900px breakpoint is deliberately THE SAME NUMBER so the page
 * changes shape once, not twice.
 *
 * ── THE HEIGHT BUDGET: `--hero-peek` IS LIVE AGAIN ───────────────────────
 * src/css/toy.css records an owner correction of 2026-09-25 — "the main hero
 * section should never be 100vh... we need to somehow show part of 'First
 * touch makes sound'" — and the two tokens that serve it, `--hero-peek` and
 * `--toy-copy-h`. Both went INERT when the playable toy was parked: they
 * were read only by `.toy-stage`, which no longer renders. The landscape
 * illustration did not need them (it was short enough that the fold looked
 * after itself, measured at the time). A portrait one does, so they are read
 * again here, by the element that now owns the fold. This is the tokens'
 * original job, not a new one invented for them.
 *
 * TWO BUDGETS, BECAUSE THE COPY MOVES:
 *   stacked (<900px)  the copy sits ABOVE the figure, so its height is spent
 *                     out of the same viewport — subtract `--toy-copy-h`.
 *                     This is `.toy-stage`'s own formula, unchanged.
 *   columns (≥900px)  the copy sits BESIDE the figure and costs it no
 *                     vertical budget at all, so that term comes out. Keeping
 *                     it would shrink the desktop figure by 190px for a
 *                     reason that stops being true at exactly this
 *                     breakpoint.
 *
 * ── WHY `max-height` AND NOT `max-width` ─────────────────────────────────
 * For a portrait image HEIGHT is the binding dimension — it is what runs out
 * of viewport first, at every width tested. Constraining the axis that is
 * actually scarce keeps one rule doing the work; a width cap would have to
 * be re-derived per breakpoint from the aspect ratio and would say nothing
 * about the fold, which is the thing being protected.
 *
 * `width/height: auto` + `max-width: 100%` + `max-height` is the pair that
 * makes the browser honour BOTH caps while preserving the ratio (CSS2.1
 * §10.4's constraint-violation table). `max-width: 100%` is load-bearing and
 * not belt-and-braces: without it `width: auto` resolves to the SVG's own
 * 1441px intrinsic width and the picture overflows its column.
 *
 * MEASURED AFTER THE CHANGE (built page, not predicted) — see the report.
 * If a future pass changes the hero art's aspect again, re-measure the three
 * viewports rather than trusting these numbers; they are consequences of
 * 0.822:1, not constants. */
/* The sizing rule that used to stand here (`width/height: auto` +
 * `max-width: 100%` + a `max-height` budget) is REPLACED by the out-of-flow
 * `object-fit` rule further up this file — see its own note for why the
 * percentage budget could never have worked. Kept as a marker rather than a
 * silent deletion: three separate passes on 2026-09-29/30 each wrote a
 * `max-height` here, and each one was dropped by the same CSS rule.
 *
 * 🔴 `--toy-header-h` IS DELIBERATELY ABSENT FROM THE HERO'S HEIGHT SUM, and
 * that part survives the rewrite. Every other hero budget in this file
 * subtracted the masthead because it sat ABOVE the art. On the home page it
 * does not: the header is transparent and overlays the drawing so the logo
 * can land in the wedge between the two upper children's legs (owner,
 * 2026-09-30 — the overlay and the measured wedge are in components.css's
 * `.site-header` home block). Art sized `100dvh - header - …` would reserve a
 * band of empty cream exactly where the logo is meant to sit ON the picture. */

/* 🔴 THE TWO-COLUMN HERO IS GONE — OWNER INSTRUCTION, 2026-09-30.
 *
 * A pass earlier the same day read "portrait art + a wide viewport" and built
 * a ≥900px spread: copy in the left column, the standing figure in the right,
 * on the reasoning that a portrait hero does not want the full page width the
 * way a landscape one did. That reasoning was sound and it was still wrong,
 * because it was answering a question the owner had already answered:
 *
 *   "the top hero, use stack with the hero image above the text"
 *
 * So the hero stacks at EVERY width. In the stage vocabulary this is
 * `arrange: stack` beating the count-driven default for two children
 * (plot-os/specs/sections/stage-component.md §4) — a declared arrangement
 * wins outright over the undeclared one it would otherwise get. Kept as a
 * dated note rather than deleted, because "portrait art wants two columns on
 * a wide screen" is a genuinely reasonable thing for the next reader to
 * propose, and this records that it was built, looked at, and overruled.
 *
 * The consequence to watch: stacked portrait art on a wide viewport is
 * height-bound, not width-bound — the budget below is what stops it running
 * past the fold, and `--hero-peek` is what leaves the next band showing. */
@media (min-width: 900px) {
  /* Nothing to widen and nothing to re-order: the stack is the shape. The
   * figure keeps its symmetric elbow bleed from the base rule above, so the
   * drawing is allowed past the page column on both sides while the copy
   * under it stays in the column where it is read. */
  /* Nothing to override: the object-fit rule already fits the art to
   * whatever height the grid row hands it, at every width. */
}

/* THE LAW does not reach the marketing page's motion, but design.md does:
 * under 200ms, ease-out, and `prefers-reduced-motion` honoured — which here
 * means the scenes simply appear, with no entrance at all. */
@media (prefers-reduced-motion: no-preference) {
  .scene-img {
    animation: scene-in 180ms ease-out both;
  }
  @keyframes scene-in {
    from { opacity: 0; }
    to   { opacity: 1; }
  }
}

/* ══════════════════════════════════════════════════════════════════════════
 * PER-PLACEMENT ARRANGEMENTS — 2026-09-30, owner, band by band
 * ══════════════════════════════════════════════════════════════════════════
 * Three classes the PAGE applies (wireframe.yml's `class:`), never the
 * layout. Same division rule the flip above already follows: a layout says
 * what a band IS, a placement says how this one instance sits.
 */

/* ── `scene-band--stack` — arrange: stack ───────────────────────────────────
 * Owner, on `no-wrong-way`: "I want that figure to be also above the text as
 * a stack, making it larger."
 *
 * This is the stage spec's `arrange:` override (stage-component.md §4): a
 * DECLARED arrangement beats the count-driven default outright rather than
 * composing with it. Two children would otherwise become two columns at
 * ≥900px and the picture would get half the width; stacked, it gets all of
 * it, which is the "making it larger" — no scale factor, just a column that
 * stopped being shared.
 *
 * ⚠️ THE SELECTOR MATCHES THE COUNT RULE'S FULL SHAPE, AND THAT IS NOT
 * COSMETIC — the first version of this rule was BUILT AND DID NOTHING.
 * `.band:not(:has(> .plate)):has(> :nth-child(2):last-child)` scores (0,4,0):
 * `.band` 1, `:not()` takes its argument's 1, and `:has()` takes its most
 * specific argument's — `:nth-child(2):last-child` — which is 2.
 * `.band.scene-band--stack:not(:has(> .plate))` scores only (0,3,0) and lost,
 * so the band stayed in two columns with no warning of any kind. Carrying the
 * `:has(> :nth-child(2):last-child)` too takes it to (0,5,0) and answers the
 * count rule at its own weight rather than reaching for `!important`. */
.band.scene-band--stack:not(:has(> .plate)):has(> :nth-child(2):last-child) {
  grid-template-columns: 1fr;
  grid-template-rows: auto auto;
  grid-template-areas: none;
}
.scene-band--stack > .scene-figure { order: 1; }
.scene-band--stack > .scene-band-copy { order: 2; text-align: center; }

/* ── the two picture columns are not equal ─────────────────────────────────
 * Owner, 2026-09-30: "increase the x-kids column making them about 10%
 * larger and decrease the boy column above, but keep the boy size."
 *
 * TWO DIFFERENT ASKS THAT LOOK LIKE ONE. The X-kids get a BIGGER COLUMN AND
 * A BIGGER DRAWING — the picture fills its column, so widening the column
 * is the whole change. The boy gets a SMALLER COLUMN AND THE SAME DRAWING,
 * which means his width percentage has to go UP by exactly as much as his
 * column goes down, or "decrease the column" would silently shrink him too.
 *
 * Measured at 1440 before the change: both bands `556px 556px`, the boy
 * rendering 371px (66.7% of his column), the X-kids filling theirs at 556px.
 *
 * ⚠️ BOTH SELECTORS CARRY THE COUNT RULE'S FULL SHAPE — `.band` +
 * `:not(:has(> .plate))` + `:has(> :nth-child(2):last-child)` — for the same
 * reason `scene-band--stack` does: the two-column rule weighs (0,4,0) and a
 * bare class at (0,1,0) loses to it silently, leaving the band looking
 * untouched. And they sit inside the SAME `min-width: 900px` query, because
 * below it there is only one column and nothing to re-balance. */
@media (min-width: 900px) {
  /* first-touch. Figure column 556 → 444.8 (0.8fr against 1.2fr). Tracks are
   * written in VISUAL order: `scene-band--flip` moves the copy to track 2
   * with `order`, so track 1 is the figure here. */
  .band.scene-band--fig-narrow:not(:has(> .plate)):has(> :nth-child(2):last-child) {
    grid-template-columns: 0.8fr 1.2fr;
  }
  /* 371 ÷ 444.8 = 83.4%. This number EXISTS ONLY TO CANCEL the column change
   * above — it is not a taste value. If the 0.8fr moves, re-derive it as
   * (old rendered width ÷ new column width) or the boy changes size, which
   * is the one thing this pair of rules promises not to do. */
  .scene-band--fig-narrow > .scene-figure > .scene-img {
    width: 83.4%;
    margin-inline: auto;
  }

  /* no-wrong-way. Figure column 556 → 611.6 (1.1fr against 0.9fr) = the 10%
   * asked for. Tracks in DOM order here: no flip, so copy is track 1. The
   * drawing already fills its column at 100%, so it grows with it and needs
   * no second rule. */
  .band.scene-band--fig-wide:not(:has(> .plate)):has(> :nth-child(2):last-child) {
    grid-template-columns: 0.9fr 1.1fr;
  }
}

/* ── `.crew-figure` — the crew close out the page ───────────────────────────
 * Owner: "The image is to be 100vw with the max width of the centered content
 * of 1024px" (later 769px), then "move the crew out of the cta scene so it
 * can follow that rule."
 *
 * 🔴 THE ESCAPE HATCH IS GONE, and that is the point of the move. While this
 * lived inside `cta-final` it had to break out of a `.band` — a stage that
 * holds its children to the page column — with `width: calc(100% + 2 *
 * --edge-pad)` and a matching negative margin, plus a second rule switching
 * the parent's bottom padding off so the two would touch. The width was
 * therefore described in two places: here, and in the parent it was
 * escaping. `layouts/crew-band.yml` is its own section with no page-column
 * padding to cancel, so the rule below is simply the contract, stated once.
 *
 * It is the SAME contract the hero panel uses (src/css/wall.css) — full
 * bleed to 769px, then centred, switching at the derived 860 — and the two
 * resolve to the identical box at every viewport.
 *
 * NO BOTTOM GAP, still load-bearing: the cropped knees have to meet the
 * footer's colour change or the crop reads as a severed drawing rather than
 * as occlusion. `base.css` drops main's floor padding when this is present. */
.crew-band { padding: 0; }
.crew-figure {
  width: 100%;
  margin: 0;
  display: block;
}
.crew-figure > .scene-img { width: 100%; }

@media (min-width: 860px) {
  .crew-figure {
    width: 769px;
    max-width: 769px;
    margin-inline: auto;
  }
}

/* ── `.hero-actions` — the opening's own call to action ─────────────────────
 * Owner, 2026-09-30: the hero's text "is followed by a Touchtone — coming to
 * the App Store button - allowing us to remove the cta-top section." So the
 * action that used to be a band of its own is a row under the hero copy.
 * Sizing and the border come from `.cta` (above) so the opening's button and
 * every other button on the page cannot drift apart; this only places it. */
.hero-actions {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2);
  justify-content: center;
  margin-block-start: var(--space-3);
}

/* ══════════════════════════════════════════════════════════════════════════
 * 🔴 THE BUTTONS GET COLOUR — owner, 2026-09-30
 * ══════════════════════════════════════════════════════════════════════════
 * "CTA buttons get #de7969 as bg color and #25222b as border and #fefaf0 as
 * text, and secondary button #ceb6c3 as bg"
 *
 * This reverses the `.cta` rule above, which was deliberately FILL-FREE — one
 * bordered, mono box whether the intent was live or disabled. That uniformity
 * had a purpose (a disabled span and a live link looked identical, so nothing
 * false was implied by weight alone) and it is being traded for hierarchy:
 * the store button now reads as the primary action.
 *
 * ⚠️ ONE OF THE FOUR SPECIFIED COLOURS FAILS AA AND IS NOT SHIPPED AS GIVEN.
 * Computed, sRGB relative luminance:
 *     #fefaf0 on #de7969  =  2.86:1   ← specified pair, FAILS the 4.5 floor
 *     #25222b on #de7969  =  5.25:1   ← what ships instead
 *     #25222b on #ceb6c3  =  8.27:1   ← secondary, passes comfortably
 *     #fefaf0 on #ceb6c3  =  1.81:1   ← light text on the secondary: hopeless
 * design.md makes AA a launch blocker under BFSG and says in as many words:
 * "When a muted tier fails contrast, darken the tier — don't shrink its usage
 * or argue the aesthetics." So the BACKGROUNDS are exactly as specified and
 * the text is the dark ink on both. The alternative that keeps light text is
 * a darker coral at the same hue and saturation, #ce432d (4.51:1) — offered
 * to the owner; if he takes it, swap `--cta-ink` back to #fefaf0 HERE and
 * change `--cta-fill` there, and nothing else moves. */
.cta {
  --cta-fill: #de7969;
  --cta-ink: #25222b;
  /* 🔴 THE HOVER IS ITS OWN PAIR NOW — owner, 2026-09-30: "the hover effect
   * on the CTA can be #d83426 and #362c81 for the questions button bg".
   * This REPLACES the inversion that shipped an hour earlier (fill and ink
   * swapping places). The rest state is unchanged: a pastel with dark ink.
   * The hover is now a saturated ground with LIGHT ink, which is a bigger,
   * more deliberate change of state than the swap was.
   *
   * ⚠️ THE LIGHT INK IS NOT OPTIONAL — it is what makes these two colours
   * legal. Measured:
   *     #25222b on #d83426 = 3.31:1  FAIL      #fefaf0 on #d83426 =  4.54:1
   *     #25222b on #362c81 = 1.37:1  FAIL      #fefaf0 on #362c81 = 10.98:1
   * Keeping the dark ink through the hover would have failed AA on BOTH
   * buttons, and the indigo one catastrophically. So hovering flips the ink
   * as well as the ground — which also finally puts #fefaf0 to work, the
   * off-white specified for these buttons on 2026-09-29 that could not be
   * used at rest because it measured 2.86:1 on the coral.
   * ⚠️ #d83426 clears the 4.5 floor by 0.04. There is no room to lighten it. */
  --cta-hover-fill: #d83426;
  --cta-hover-ink: #fefaf0;
  background: var(--cta-fill);
  color: var(--cta-ink);
  /* 🔴 NO BORDER — owner, 2026-09-30: "for the buttons remove the border -
   * doesnt look right". The `#25222b` edge he specified earlier the same day
   * is gone; the fill defines the shape now. `border: 0` rather than
   * `border-color: transparent` so the button does not keep reserving 1px of
   * box it no longer draws — otherwise the two buttons in a row would sit 2px
   * wider than they look and the padding would read asymmetrically. */
  border: 0;
  transition:
    background-color 140ms ease-out,
    color 140ms ease-out,
    translate 140ms ease-out;
}

/* ── the hover ─────────────────────────────────────────────────────────────
 * Owner: "add a cool hover effect on those."
 *
 * The button INVERTS: the fill becomes the ink and the ink becomes the fill.
 * Chosen over a lift-and-shadow or a brightness shift for three reasons —
 *   1. it introduces NO new colour, which matters on a site whose own law is
 *      "colour means a note, never decoration" (theme/law.css). Both halves
 *      of the swap are already this button's own two colours.
 *   2. it is legible by construction: an inversion preserves the contrast
 *      ratio exactly, so the hover state cannot fail AA if the rest state
 *      passes. Measured both ways — #de7969 on #25222b is 5.25:1, #ceb6c3 on
 *      #25222b is 8.27:1, identical to the rest states.
 *   3. design.md: "motion exists to communicate state change, not to
 *      delight… under 200ms, ease-out, no bounces." 140ms, ease-out, and the
 *      1px lift is a nudge rather than a bounce.
 *
 * 🔴 `a.cta` ONLY — never the disabled span. A hover on a control that
 * cannot be activated is a lie told on contact, and it is exactly what a
 * coming-soon pill must not do. `.cta[aria-disabled]` keeps `cursor: default`
 * and stays completely still. */
a.cta:hover,
a.cta:focus-visible {
  background: var(--cta-hover-fill);
  color: var(--cta-hover-ink);
  translate: 0 -1px;
}
a.cta:active { translate: 0 0; }

/* The lift is motion; the colour swap is the message. Under `reduce` the
 * message survives and the movement does not — the site measures zero running
 * animations under that query and this must not be the one that breaks it. */
@media (prefers-reduced-motion: reduce) {
  .cta { transition: none; }
  a.cta:hover,
  a.cta:focus-visible { translate: none; }
}
/* The SECOND action in a group is the quieter one. Keyed off order rather
 * than a variant flag because `content: { cta: [...] }` in wireframe.yml
 * already states the order, and a layout that had to know which button was
 * primary would be a second copy of that fact. */
.cta ~ .cta {
  --cta-fill: #ceb6c3;
  --cta-hover-fill: #362c81;
}

/* ── `.accent` — the one inline emphasis the content may ask for ────────────
 * Owner, 2026-09-30: "so can we have it in bold letters somehow within 'all
 * of it lives in the app.'"
 *
 * 🔴 THIS CLASS WAS RENDERING NOTHING AT ALL. `{{accent:…}}` has been in
 * `content/copy.json` since the hero was written and pipeline/template.mjs
 * has always wrapped it in `<span class="accent">` — but the only rule that
 * ever styled it was `.hero-title .accent`, and `.hero-title` was deleted on
 * 2026-09-29 with the card system. Measured before this rule existed: the
 * span computed `font-weight: 400` and `rgb(107,102,94)` — byte-identical to
 * its parent. "Make Music." in the hero has been marked up as emphasis and
 * displayed as ordinary text ever since.
 *
 * WEIGHT, NOT COLOUR, and that is not a preference: theme/law.css holds that
 * "colour means a note, never decoration", so tinting a phrase of body copy
 * to make it stand out is exactly what this site forbids. Weight carries the
 * emphasis and `--text-bright` supplies the contrast step, which is an INK
 * change rather than a hue — the same move the warm-neutral pass used.
 *
 * Measured: `--text-bright` on the page ground is 16.22:1 against the muted
 * 4.89:1 it replaces, so the emphasis reads and cannot fail AA. It is also
 * adaptive — inside `.band.void`/`.toy` the token resolves to white, so an
 * accent on a dark ground keeps working with no second rule. */
.accent {
  font-weight: 600;
  color: var(--text-bright);
}

/* A heading is already 700 and already `--text-bright`, so the rule above
 * would make an accented phrase LIGHTER than the words around it — the exact
 * inverse of emphasis. Headings opt out and keep whatever they set.
 * ⚠️ Consequence worth knowing: `{{accent:…}}` in a heading is therefore
 * still a no-op. The hero's "Make Music." is marked and renders plain. That
 * is now a deliberate null rather than a broken rule — if the hero wants the
 * phrase to separate, it needs its own treatment (a different weight step, or
 * a line break), not this class. */
.toy-heading .accent,
.say .accent {
  font-weight: inherit;
  color: inherit;
}

/* 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;
  /* 🔴 769px — owner, 2026-09-30: "lets change the container max-width from
   * 1024 to 769px", then, measuring the built page, "but the sections above
   * are still wider than 769 - for no-wrong-way i am measuring 1464px?"
   *
   * THAT SECOND MESSAGE IS THE CORRECTION THAT MATTERS, and the first pass
   * got it wrong: 769 was applied only to the two full-bleed PICTURES (the
   * hero panel and the closing crew), while `--page` stayed at 72rem = 1152px
   * — so every band's reading column was still 1152 and the `<section>`
   * elements themselves still spanned the viewport. The cap belongs HERE, on
   * the page column itself, which is the one place every band, the footer and
   * the grid's own `content` track all read from.
   *
   * A px value, not rem, deliberately: a container cap is a LAYOUT
   * measurement and must not scale with a user's root font size the way the
   * reading measure above it should. `--measure` stays in rem for exactly the
   * opposite reason.
   *
   * ⚠️ IT FEEDS THREE DERIVED TOKENS BELOW — `--edge-pad`, `--elbow-cap` and
   * `--elbow-room` are all expressed against it, so changing this moves the
   * band padding and the bleed ceiling too. That is intended (they were
   * written to track the column, not to be independent), but it means the
   * effect of this line is much wider than one max-width. */
  --page: 769px;
  /* The WIDER frame a bled child is measured against — never the reading
     column, only the ceiling on how far something may grow outward past it.
     `--elbow-cap` in bands.css is half the difference between the two
     (PlotOS specs/sections/stage-component.md §3.1); terminal-c/site/src/css/
     base.css:101 carries the same 84rem, and the pair of values is what makes
     that arithmetic port verbatim rather than get re-derived. PlotOS itself
     records this token as its own UNMET dependency — it has no page column —
     which is why the bleed classes never shipped there and can here. */
  --page-wide: 84rem;
  --tracking-tight: -0.011em;

  /* ── the page column and the bleed, PlotOS stage-component.md §3.1 ──────
     Declared ONCE here rather than per-section (terminal-c declares them on
     each stage it uses) because this site has exactly one page column, and
     two consumers that must agree about it to the pixel: `.band`
     (src/css/bands.css) and the illustration hero (`.hero-scene`,
     src/css/toy.css). Two copies of this arithmetic is how the hero ends up
     a different width from the bands for no stated reason — which is what it
     was before 2026-09-29.

     --edge-pad    ONE var owns the page column: the larger of the edge
                   padding and whatever centres `var(--page)`. No max-width
                   wrapper element is needed anywhere — the padding IS the
                   column.
     --elbow-cap   the CEILING. The most a bled child may EVER take, per
                   side, at any viewport: half the difference between the
                   wide frame and the page column. 96px at these values.
     --elbow-room  what a bled child actually gets right now.
                   🔴 The `max(0px, …)` floor is LOAD-BEARING and is why
                   there is no second condition to write: it delivers
                   "ignored once the viewport is at or below the page
                   column" for free, with no media query
                   (stage-component.md:399-414).

     ⚠️ These are the one place `vw` survives base.css's own zero-viewport-
     units rule (see `main`, below). It is safe HERE and was not there: the
     old breakout cancelled a `vw`-derived box against a percentage one, and
     iOS Safari's layout/visual viewport split made the two disagree. These
     resolve to PADDING (a `max()`, so a `vw` overshoot only ever makes the
     column narrower, never wider) and to a capped negative margin. `.band`
     carries `overflow: clip` as the belt to that braces. */
  --edge-pad: max(clamp(20px, 5vw, 72px), calc((100vw - var(--page)) / 2));
  --elbow-cap: calc((var(--page-wide) - var(--page)) / 2);
  --elbow-room: min(var(--elbow-cap), max(0px, calc((100vw - var(--page)) / 2)));
}

*, *::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);
}

/* 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 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.
 *
 * The ring is WHITE (--structure, theme/law.css), not --accent. Owner decision
 * 2026-09-25: colour is only ever a note, never decoration — and the corollary
 * an implementer must not get wrong is that "no decorative colour" never
 * licenses "no focus indicator" (WCAG 2.4.7). A focus ring is a structural
 * line, so it is white like every other structural line.
 * See theme/law.css and docs/designs/site-home-the-toy.md. */
:focus-visible {
  /* 🔴 `--text-bright`, NOT `--structure`. `--structure` is pinned white
   * forever (`toy.css` hardcodes it for the hero's own structural lines) —
   * so once the page ground went light on 2026-09-26 a white ring became
   * invisible on every light surface. `--text-bright` is the ADAPTIVE token:
   * dark ink by default, white inside `.toy, .band.void`. See theme/law.css.
   * (The nav's own `mix-blend-mode: difference` — the other reason
   * `--structure` used to matter here — is gone as of the SAME day's
   * "separate the header onto its own cream ground" correction; the header
   * is opaque now and needed no ring change of its own, see components.css.) */
  outline: 2px solid var(--text-bright);
  outline-offset: 2px;
  border-radius: 2px;
}

.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. Auto side margins on main keep it centred inside the
 * flex column (they win over the default stretch, so max-width still holds).
 * `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; }

/* Full-bleed grid, zero viewport units. `main` used to be a centred,
 * padded column (`max-width: var(--page); margin: 0 auto; padding: 0
 * var(--space-3) var(--space-6);`) and every full-bleed element (`.toy`,
 * `.band`) escaped it with a `100vw` breakout — `calc(100vw - 2 *
 * var(--card-inset))` + `margin-left/-right: calc(50% - 50vw + …)`. That
 * technique is exact in headless engines (where the layout and visual
 * viewports are identical) but overshoots on real iOS Safari, where `vw`
 * tracks the LAYOUT viewport, not the visual one, while `%`/`fr` do not.
 *
 * The fix: `main` IS the breakout now, as a 5-track grid. The outer two
 * tracks are `minmax(var(--space-3), 1fr)` — the SAME floor the old
 * padding used, but as a flexible track instead of a fixed inset, so they
 * grow to fill whatever's left of the physical viewport edge. The centre
 * track is `minmax(0, var(--page))`, the old `max-width` cap, still
 * capping the measured column at wide viewports. Ordinary content sits in
 * `[content-start … content-end]` (`main > * { grid-column: content; }`,
 * below); `.toy`/`.band` opt into `[full-start … full-end]` instead, which
 * spans the whole grid, physical edge to physical edge — no `vw`, no `50%
 * - 50vw` cancellation, nothing that can diverge from the visual viewport.
 * `padding-bottom` is the one thing this cap doesn't replace: the outer
 * tracks give left/right for free, but the page still wants breathing room
 * under the last band. */
main {
  display: grid;
  grid-template-columns:
    [full-start] minmax(var(--space-3), 1fr)
    [content-start] minmax(0, var(--page)) [content-end]
    minmax(var(--space-3), 1fr) [full-end];
  padding-bottom: var(--space-6);
}

/* 🔴 NO FLOOR GAP WHEN THE PAGE ENDS ON A PICTURE — 2026-09-30.
 * (Keyed on `.crew-band` since the frieze became its own section; it was
 * `.cta-frieze` while the picture lived inside the closing CTA.)
 * Owner: the closing frieze tucks behind the footer "so that the cut off legs
 * make sense". `crew-frieze.svg` is cropped at the knee, and the illusion is
 * that the footer occludes the children — which needs the picture and the
 * footer's ground to TOUCH.
 *
 * `.cta-final`'s own bottom padding was already dropped (bands.css) and the
 * figure carries no margin, and the gap survived anyway: it was THIS rule's
 * `padding-bottom: var(--space-6)` — 96px of main, below the last section and
 * outside anything the section could reach. Measured, not guessed: the
 * section ended at 537px and the footer began at 633px.
 *
 * Scoped by presence rather than by page so it can never apply to a page that
 * ends on text, where the floor gap is correct and wanted. */
body:has(.crew-band) main { padding-bottom: 0; }

main > * { grid-column: content; }

/* 🔴 THE CLOSING CREW SPANS THE WHOLE GRID, AND CARRIES NO FLOOR MARGIN.
 * Added 2026-09-30, immediately after the frieze became its own section.
 * Moving it out of `cta-final` cost it two things it had been getting from
 * the negative-margin escape it used in there, and BOTH regressed on the
 * first build — measured at 390: the picture rendered 342px wide inset 24px
 * instead of edge-to-edge, and sat 48px above the footer instead of touching.
 *   - `grid-column: full` — `main > *` defaults every child to the CONTENT
 *     track, which is inset by `--space-3`. This section is one of the two
 *     full-bleed elements on the site and has to reach the viewport edge.
 *   - `margin-bottom: 0` — `main > section` carries the inter-section gap
 *     (`--space-6 / 2`), which is right between bands and wrong under the
 *     last one: the cropped knees must MEET the footer's colour change or the
 *     crop reads as a severed drawing rather than as occlusion. */
main > .crew-band {
  grid-column: full;
  margin-bottom: 0;
}

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

/* 🔴 CENTRE THE READING COLUMN INSIDE THE PAGE COLUMN — 2026-09-30.
 * Owner: "do that to the rest of the pages too - center the content at a max
 * 769".
 *
 * `--page` (769px) is the PAGE column and `--measure` (46rem = 736px) is the
 * READING column; they are different things on purpose, and `--measure` stays
 * in rem because a line length should scale with a user's font size while a
 * container cap should not. But a 736px block sitting at the start of a 769px
 * track is left-aligned by 33px — measured on /support, where `#contact`
 * landed at 336 while the h1 above it and the footer below it both landed on
 * 769 centred. Three different left edges on one page.
 *
 * Auto inline margins put the narrower block in the middle of the wider one,
 * so every page now centres on a single axis whatever its inner cap is. Home
 * is unaffected: its bands are full-width stages that centre their own
 * content already. */
main > section,
main > article,
main > .legal,
main > .issue-report { margin-inline: auto; }

/* One main idea per screen (design.md: roomy, low density by default). Bands
 * are separated by air, not by rules. */
main > section,
main > header {
  /* 🔴 HALVED with the band padding, 2026-09-30 (owner: sections are twice as
   * far apart as wanted). `--space-6` is 6rem and is used elsewhere, so this
   * halves the GAP here rather than redefining the token under everything
   * that shares it. The two halvings compound: a seam between two bands was
   * 2×padding + this margin, and every term is now half.
   * `calc(--space-6 / 2)` = 3rem literally, NOT `--space-5` (4rem) — that
   * would have been two thirds and called itself a half. */
  margin: 0 0 calc(var(--space-6) / 2);
}

/* 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;
  }
}

/* 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 (src/html/_header.html) ──────────────────────────────────── */

/* 🔴 RESTRUCTURED per the header-mark exploration (2026-09-28): this was a
 * horizontal nav bar (wordmark + a `.nav` link list from _nav.html) through
 * several earlier passes (see git history for the fixed/blend-mode phase,
 * then the 2026-09-26 "own cream ground, position: sticky" phase). The nav
 * bar is gone — Support and any future page live in the footer only
 * (_footer.html's footerNav) — and what's left is a centered nameplate: the
 * real assets/brand/touchtone.masthead.svg, inlined directly in
 * _header.html. `.nav`/`.nav a` are deleted along with it: nothing renders
 * a `<nav class="nav">` element on this page any more, `_footer.html` uses
 * its own `.footer-links`/`.legal-nav` classes.
 *
 * `position: sticky` is now CONDITIONAL on `data-frame-header="sticky"`
 * (wireframe.yml's `frame.header`, stamped onto <body> by _shell.html) --
 * the same pattern `body[data-frame-footer="sticky"]` already uses in
 * base.css. Previous rounds hardcoded `position: sticky` unconditionally and
 * a comment claimed it "restored data-frame-header's original meaning"
 * without the rule actually reading the attribute -- harmless before now
 * because the site's own `frame.header` value happened to already be
 * "sticky", so the gap was never exercised. It is NOW: this site's
 * wireframe.yml sets `frame.header: static`, a once-per-page nameplate
 * scrolling away with the content rather than pinning a nav bar that no
 * longer exists. `toy.css`'s `--toy-header-h` (currently `0px`) is NOT in
 * this lease -- re-measure it if the masthead's height needs to feed
 * anything there. */
/* 🔴 `--bg`, NOT `--paper` — 2026-09-29, with the card system's retirement
 * (src/css/bands.css's header carries the argument). `--paper` resolved to
 * `--theme-keys` #F2EDD9, the app's KEYS voice-identity colour, 8/15/2 per
 * channel away from the page's own #FAECD7. It was never read as a second
 * ground; it read as a near-miss — and it meant the page opened AND closed on
 * a smudge. It also breached theme/law.css's own rule that colour means a
 * note, never decoration. This rule's own 2026-09-26 sibling on `.site-footer`
 * described the intent as "another cream lift"; with `--bg` that is now
 * literally true rather than approximately. */
.site-header {
  background: var(--bg);
  color: var(--ink);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
  padding: 32px 20px;
}
body[data-frame-header="sticky"] .site-header {
  position: sticky;
  top: 0;
  z-index: 10;

  /* The nameplate is a column-centred stack; adding a CTA to it would push
   * the wordmark off-centre if it joined that flow. So on the sticky header
   * — the only place a CTA exists — the bar becomes a three-part row and the
   * CTA is pulled to the inline end, leaving the mark optically centred.
   * Padding shrinks too: pinned chrome has to cost less vertical space than
   * a once-per-page masthead does. */
  flex-direction: row;
  justify-content: center;
  padding-block: 14px;
}

/* `.cta--header` — the pinned call to action, 2026-09-29. Inherits every
 * visual rule from `.cta` (bands.css: 1px solid currentColor, mono, no fill)
 * so the header's action and the bands' actions cannot drift apart; this
 * class only handles PLACEMENT and the smaller pinned scale. */
.cta--header {
  position: absolute;
  right: clamp(12px, 3vw, 56px);
  top: 50%;
  transform: translateY(-50%);
  font-size: 0.82rem;
  padding: 8px 16px;
}

/* Below the wordmark rather than beside it once there is no room to sit in
 * the margin — at narrow widths an absolutely-positioned CTA would overlap
 * the mark itself. */
@media (max-width: 640px) {
  body[data-frame-header="sticky"] .site-header {
    flex-direction: column;
    gap: 10px;
    padding-block: 12px;
  }
  .cta--header {
    position: static;
    transform: none;
  }
}
/* No per-element focus-ring override needed here: base.css's global
 * `:focus-visible` already reads the ADAPTIVE `var(--text-bright)` (dark ink
 * outside `.toy`/`.band.void`, white inside), which resolves to `--ink` on
 * this header's permanent cream ground with zero extra CSS — confirmed by
 * tabbing onto `.wordmark` and reading `getComputedStyle`:
 * `outline-color: rgb(17, 17, 17)`. */

/* The masthead SVG carries its own currentColor structure (see that file's
 * header comment on why it must stay inlined, never `<img src>`); this rule
 * is only sizing + the link's own reset. 120px wide keeps it a quiet
 * nameplate, not a second hero -- the exploration's lockup 3 at a much
 * bigger scale was a presentation frame, not the shipped size. */
/* ══════════════════════════════════════════════════════════════════════════
 * 🔴 HOME PAGE: THE MASTHEAD IS TRANSPARENT AND SITS *IN* THE DRAWING
 * ══════════════════════════════════════════════════════════════════════════
 * Owner, 2026-09-30: "the nav above it holding the logo can be transparent so
 * the logo sits between the legs of the top left and top right child".
 *
 * `assets/artwork/crew-overhead.svg` draws four children lying in an X around
 * a portrait iPad, heads to the centre, legs splaying to the four corners.
 * The two UPPER children's legs frame an empty wedge at top-centre, and the
 * wordmark nests into it. Measured off the rendered SVG (900px-wide raster,
 *height 1095, background-matched sampling), as FRACTIONS of the art box so
 * they survive any width:
 *
 *   depth into the art   wedge spans        wedge width
 *   ─────────────────────────────────────────────────────
 *    2.7%                18.1% .. 78.3%     60.2% of art
 *    8.2%                24.6% .. 73.4%     48.8%
 *   13.7%                30.0% .. 69.8%     39.8%
 *   19.2%                35.4% .. 65.9%     30.5%
 *   24.7%                42.8% .. 61.7%     18.9%
 *   30.1%                48.7% .. 53.1%      4.4%   ← the V closes here
 *
 * So the usable pocket is the top ~20% of the drawing, and it NARROWS fast.
 * The mark is centred (which the wedge already is) and both its width and its
 * top offset are viewport-scaled so it stays inside the V rather than being
 * pinned to one width with a magic pixel value — the failure mode being
 * avoided is a logo that sits correctly at 1440 and lands on a child's shin
 * at 390.
 *
 * ⚠️ SPECIFICITY, stated because it is doing real work: `body:has(#hero)`
 * scores an id, so (1,1,1), which beats `body[data-frame-header="sticky"]`'s
 * (0,2,1) above and turns the pin off here without touching `frame.header`.
 * `frame:` in wireframe.yml is SITE-wide, not per-page, so it could not have
 * expressed "static on the home page only" — this is the scoping tool that
 * could. Only the home page has `#hero`.
 *
 * WHY NOT STICKY-AND-TRANSPARENT: a transparent bar that pins would drag an
 * invisible 104px box down over every band, and body copy would run under the
 * wordmark. Nothing is lost by unpinning — the header pins today only so a
 * persistent CTA could ride it, and `frame.headerCta` is commented out
 * because every candidate intent is currently false (see wireframe.yml). When
 * a true CTA exists this decision has to be re-opened, not quietly kept.
 *
 * `pointer-events` is split: the bar itself is inert so it cannot swallow
 * clicks on the drawing underneath, and the link re-enables itself. */
/* 🔴 THE OVERLAY IS RETIRED — 2026-09-30, AND SO IS THE WEDGE IT SERVED.
 * The rule that stood here pulled this header out of flow, made it
 * transparent and tucked the mark into the gap between two children's legs in
 * `crew-flooor.svg`. It worked, it was measured, and it is gone — because the
 * owner then swapped that drawing off the opening entirely ("swap the
 * no-wrong-way figure as the new hero under the logo"). The hero is a black
 * panel now; there is no wedge to nest into, and an overlaid mark would sit
 * on the panel instead of above it. The owner's word was "UNDER the logo", so
 * the header takes its own space again and the picture begins below it.
 * The depth table that justified the old offsets is in git history at this
 * line; it is not carried forward, because it described a drawing that no
 * longer opens the page.
 *
 * The one thing kept from that pass: the header does NOT pin on the home
 * page. `body:has(#hero)` still scores an id (1,1,1) and still beats
 * `body[data-frame-header="sticky"]`'s (0,2,1) above, which is how one page
 * opts out of a SITE-wide `frame:` setting. A doubled mark pinned to every
 * scroll position would be heavy chrome over a page whose own argument is
 * that the pictures carry it — and `frame.headerCta` is still commented out,
 * so there is nothing in the bar that needs to stay reachable. */
body:has(#hero) .site-header {
  position: static;
  background: transparent;
  /* 🔴 MORE AIR UNDER THE MARK — owner, 2026-09-30: "the wordmark needs more
   * breathing room before the panel." The masthead now sits against a hard
   * black edge rather than against more cream, so the gap that read as
   * generous over an illustration read as tight over the panel. Only the
   * BLOCK-END grows; the top keeps `.site-header`'s own 32px so the mark does
   * not drift down the page. */
  padding-block-end: clamp(28px, 4vw, 56px);
}

.wordmark {
  display: block;
  width: 120px;
  color: inherit;
}

/* 🔴 DOUBLED — owner, 2026-09-30: "the main logo can become twice the size".
 * Straight 2× of the wedge-constrained clamp this replaces
 * (96/8.5vw/120 → 192/17vw/240). It can be this big precisely BECAUSE the
 * wedge is gone: the mark no longer has to fit between two drawn legs, it
 * simply sits above the panel with the page's own air around it. */
body:has(#hero) .wordmark { width: clamp(192px, 17vw, 240px); }
.wordmark svg {
  display: block;
  width: 100%;
  height: auto;
}

/* The theme mount point. In single and paired modes nothing ever renders into
 * it — an empty seam should cost no layout, so it collapses rather than
 * reserving a gap that never gets used. */
.site-theme-picker:empty { display: none; }

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

/* 🔴 OWNER CORRECTION, 2026-09-26: "the footer to not be separated by a
 * line, instead another cream lift expanding full viewport width." Two
 * changes at once: `border-top` is GONE outright (the owner does not want a
 * rule — the cream ground itself is what separates it now), and the SURFACE
 * is full-bleed while the CONTENT stays measured/centred — the same split
 * the hero already draws between its own full-bleed ground and its centred
 * SVG scene. `.site-footer` (this rule) is now just the cream surface: no
 * `max-width`, no `margin: 0 auto`, no `border-top`, `color` moved down to
 * `.footer-inner` with everything else that used to live here. `.site-footer`
 * needs no explicit width rule at all — it is a block-level `<footer>`, a
 * direct child of `<body>` (outside `<main>`'s own max-width column
 * entirely, confirmed by reading src/html/_shell.html before relying on
 * this), and `<body>` has no padding, so it is ALREADY full viewport width
 * with zero breakout hack needed — unlike the bands (`src/css/bands.css`),
 * which live inside `main`'s narrower, padded column and genuinely need the
 * negative-margin trick to escape it.
 *
 * `.footer-inner` — renamed from `.site-footer-inner` (2026-09-26), porting
 * bounce/site/www's own `_footer.html` structure onto this one: same split,
 * matching name. */
/* `--bg`, not `--paper` — see `.site-header` above for the full reasoning;
 * the owner's own words for this element were "another cream lift", and the
 * cream it lifts to is the page's. */
/* 🔴 A COLOUR LIFT, AND IT IS DOING COMPOSITIONAL WORK — 2026-09-30.
 * Owner: "the following footer needs to be a color lift where the crew edge
 * is tucking behind, so that the cut off legs make sense."
 *
 * `crew-frieze.svg` is cropped at the knee and it now sits flush against the
 * top of this element (bands.css's `.cta-frieze`, and the `padding-block-end:
 * 0` that keeps them touching). On one flat ground a cropped figure reads as
 * a drawing that got cut off. Against a ground that CHANGES exactly on the
 * cut line, the same crop reads as occlusion — the children are standing
 * behind the footer.
 *
 * `--surface` is the lift: `#faefdc` against the page's `#FAECD7`, both warm,
 * ~0.1% apart in luminance. That is deliberately almost nothing — enough to
 * declare an edge, not enough to read as a band. It is also the token that
 * was warmed on 2026-09-29 specifically so a panel would stop sitting COOLER
 * than the page it floats on, which is the property this use depends on: a
 * cool lift here would read as a seam, not as depth.
 *
 * ⚠️ THE EDGE IS THE FEATURE. If a future pass adds a margin above this
 * footer, a border, a radius, or padding to the figure above it, the tuck
 * breaks and the legs go back to looking severed. Whatever else changes here,
 * the frieze and this ground have to keep touching. */
.site-footer {
  /* 🔴 `#ab9a91` — owner, 2026-09-30, a literal and a deliberate exception to
   * this file's own "a literal colour here is a bug" rule. It is declared as
   * a token so it is nameable and so the footer's text can be solved against
   * it; if it earns a second consumer it belongs in theme/law.css instead.
   *
   * It supersedes the `--surface` lift of 2026-09-29 (a ~0.1% luminance step,
   * chosen to be *almost nothing*). This is the opposite: 2.32:1 against the
   * page ground, a real edge. The compositional job is unchanged and now much
   * stronger — `crew-frieze.svg` is cropped at the knee and tucks flush
   * against this ground (bands.css's `.cta-frieze`), so the crop reads as the
   * children standing BEHIND the footer rather than as a severed drawing.
   *
   * ⚠️ IT FORCED THE FOOTER'S TEXT DARKER, and that is not cosmetic. On the
   * old near-cream lift the footer ran `--text-secondary` #5e574f; against
   * #ab9a91 that measures 2.63:1 and `--text-muted` measures 2.11:1 — both
   * fail AA outright. `--ink` #161009 measures 6.98:1 and is what the footer
   * uses now. Computed, not eyeballed. */
  --footer-ground: #ab9a91;
  background: var(--footer-ground);
  color: var(--ink);
}
/* Every footer text tier is re-pointed at the ink, because the tiers that
 * carried the hierarchy before (`--text-secondary`, `--text-muted`) are both
 * illegible on this ground. The hierarchy survives through size and weight
 * instead of through lightness — which is the trade the owner's own rule
 * demands: "Muted ≠ illegible. When a muted tier fails contrast, darken the
 * tier — don't shrink its usage or argue the aesthetics." */
.site-footer .footer-inner,
.site-footer a,
.site-footer .footer-note,
.site-footer .colophon,
.site-footer .legal-nav a,
.site-footer h2 {
  color: var(--ink);
}
.footer-inner {
  max-width: var(--page);
  margin: 0 auto;
  padding: var(--space-4) var(--space-3);
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-3);
  align-items: baseline;
  font-size: 0.875rem;
  /* 🔴 `--text-secondary`, NOT `--text-muted` — measured, not carried over.
   * `--text-muted` (the base colour this rule used to set) was fine against
   * the old black/`--bg` ground but computes 4.04:1 against the new
   * `--paper` cream (getComputedStyle + a real relative-luminance calc,
   * 2026-09-26) — below the 4.5:1 small-text floor, and `.colophon`/
   * `.copyright` (the only two children that rely on this inherited value
   * rather than setting their own) are 0.75rem, unambiguously small text.
   * `--text-secondary` measures 6.06:1 on `--paper` — comfortable margin —
   * and every OTHER child in this footer (`.legal-nav a`, `.footer-links a`,
   * `.footer-note`) already sets that exact token explicitly, so this is
   * really just removing a silent exception rather than introducing a new
   * colour. */
  color: var(--text-secondary);
}

/* ── `.footer-groups` — ONE labelled group per idea, NOT COLUMNS ──────────
 *
 * Ported 2026-09-26 from riffmix/site/src/css/components.css's own
 * `.footer-groups`/`.footer-group`/`.footer-group-title`/`.footer-links`
 * (that file's header comment is this rule's spec) — the STRUCTURE only,
 * not its dark-ground colour system (`--plate-alpha`, `data-band`): this
 * site has no such token and stays on the paper/ink pair already
 * established below, per _footer.html's own "DO NOT ADOPT" note.
 *
 * `width: 100%` forces its own row before `.legal-nav`, same device
 * `.colophon`/`.copyright` already use. The two groups it holds — the
 * site's own pages, and the Riffmix mention — sit side by side while
 * there is room and wrap to their own line once there is not; neither
 * needs a media query, `flex-wrap` on the parent is what does it. */
.footer-groups {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-4);
  width: 100%;
}
.footer-group { flex: 0 1 auto; }
/* 26rem: wide enough for the Riffmix blurb's own sentence length without
 * inheriting `--measure`'s 68ch prose column, which would let this plate
 * sprawl the full width of a wide viewport once it has the slack to grow
 * into (`flex-grow: 1`, below). `min-width: 0` lets it actually shrink
 * below that basis on a narrow one instead of overflowing its row. */
.footer-sibling { flex: 1 1 26rem; min-width: 0; }

/* A real heading, not a `<p>`/`<div>` — see _footer.html's own note on why.
 * Titles BOTH groups now: the pages group's is always `{{app.name}}`
 * (never a per-group authored `label:` — wireframe.yml's `footerNav`
 * comment explains why that field is gone), the sibling group's is the
 * companion app's own name, one-field-flippable into a link (below). */
.footer-group-title {
  margin: 0 0 var(--space-1);
  font-size: 0.75rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-bright);
}
/* Only reachable the day `app.siblingApp.url` stops being null — see that
 * field's own note in content/app.json. Until then this selector matches
 * nothing, the same "CSS for an unreachable element" tradeoff Riffmix's own
 * stylesheet accepted for its wordmark fallback, kept here because deleting
 * it would delete the day-one styling for the link this heading becomes. */
.footer-group-title a { color: inherit; text-decoration: none; }
.footer-group-title a:hover { text-decoration: underline; }

.footer-links { list-style: none; margin: 0; padding: 0; }
.footer-links li { margin-bottom: var(--space-1); }
/* `--text-secondary` measures 2.63:1 on the footer's #ab9a91 ground and was
 * still winning over the blanket `.site-footer a` rule above on specificity —
 * caught by an in-browser sweep on 2026-09-30, not by reading the CSS. The
 * ink is 6.98:1. */
.footer-links a { color: var(--ink); text-decoration: none; }
.footer-links a:hover,
.footer-links a[aria-current="page"] { color: var(--text-bright); text-decoration: underline; }

/* The Riffmix cross-link's prose (content/app.json's `app.siblingApp.blurb`
 * — see that field's own note; there is no hardcoded sentence left in
 * _footer.html to style around). No `strong` rule: the old hardcoded prose
 * bolded "Riffmix" inline because the name had nowhere else to live; now it
 * IS the heading above this paragraph (`.footer-group-title`), so the
 * paragraph needs no emphasis of its own. */
.footer-note { margin: 0; color: var(--text-secondary); }

.legal-nav { display: flex; gap: var(--space-3); flex: 1; }
.legal-nav a { color: var(--text-secondary); text-decoration: none; }
.legal-nav a:hover { color: var(--text-bright); text-decoration: underline; }

.colophon { font-family: var(--font-mono); font-size: 0.75rem; width: 100%; }

/* Same size and colour as .colophon (both inherit .footer-inner's
 * `color: var(--text-secondary)`) — body face instead of mono is the only
 * difference, per PLAN.md §6. Last child of <footer>, straight after the
 * colophon, so the deploy SHA sits above the copyright. */
.copyright { font-family: var(--font-body); font-size: 0.75rem; width: 100%; }

/* Design law (PLAN.md §4): "colour is only ever a note, never decoration" —
 * the corollary is that a focus ring is a structural line, not an accent, so
 * every link in the legal floor and the footer keeps a WHITE ring.
 *
 * RESOLVED 2026-09-25. This rule was provisional and pointed at `--text-bright`
 * (the brightest rung of the ramp) because no literal white token existed. The
 * owner then killed the "stage" ColorTheme and its decorative blue/orange, and
 * theme/law.css now defines `--structure: #ffffff` as the real thing. This rule
 * and the site-wide ring in base.css both point at it. */
.legal a:focus-visible,
.legal-nav a:focus-visible {
  /* ⚠️ 2026-09-26: `--text-bright`, not `--structure`. `--structure` is pinned
   * white forever; the page ground is no longer black, so a white ring is
   * invisible on every light surface. `--text-bright` adapts per surface. */
  outline-color: var(--text-bright);
}

/* ── 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);
}

/* 🔴 DELETED 2026-09-29: `.hero-title .accent { color: var(--structure) }`.
 * A DEAD HOOK, and a latent bug — `.hero-title` belongs to layouts/hero.yml,
 * which no page mounts (the home `<h1>` is `.toy-heading`), so this rule has
 * never matched anything. Had it ever matched after the 2026-09-26 ground
 * flip it would have painted the emphasised word `#ffffff` on cream, i.e.
 * invisible: `--structure` is pinned white forever, and the adaptive token is
 * `--text-bright`.
 *
 * ⚠️ THE REASONING IT CARRIED IS STILL RIGHT AND NOW HAS NO HOME, which is
 * worth flagging rather than quietly inheriting. The home `<h1>` DOES render
 * the marker — `<h1 class="toy-heading">Tap anywhere. <span class="accent">It
 * sings.</span></h1>`, from content/copy.json's `{{accent:…}}` — and as of
 * this pass NOTHING in src/css styles `.toy-heading .accent`. The emphasis a
 * copy author asked for is inert. Deliberately not invented here: on the
 * site's single most visible sentence the treatment is an owner call, not an
 * implementer's. When it is made, the constraint is the 2026-09-25 decision —
 * the only thing colour may ever mean on this site is a NOTE, so emphasis has
 * to come from weight or value, never from a hue. */

.hero-sub {
  font-size: clamp(1.0625rem, 2vw, 1.25rem);
  color: var(--text-secondary);
  max-width: var(--measure);
}

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

.hero-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);
  border-radius: 6px;
  font-size: 0.9375rem;
  /* ✅ This weight FINALLY EXISTS as of 2026-09-29. It has been declared here
   * since the Riffmix type import without `general-sans-600.woff2` ever
   * shipping, so every `.btn`/`.badge` on the non-home pages was rendering
   * synthetic-bold or silently falling back to 500. The 600 cut was added for
   * the display face (theme/law.css) and closes this by the same stroke. */
  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 {
  /* ⚠️ Both tokens here are ADAPTIVE, so this inverts correctly on either
   * ground: dark button + light text on the light page, white button + black
   * text inside `.toy`/`.band.void`. It used `--structure` (pinned white)
   * against `--bg` — correct while the page was black, and a white button with
   * near-white text the moment the ground flipped on 2026-09-26. */
  background: var(--text-bright);
  color: var(--bg);
  border: 1px solid var(--text-bright);
}
.btn--primary:hover { background: var(--text-secondary); border-color: var(--text-secondary); }

.btn--ghost {
  border: 1px solid var(--border-bright);
  color: var(--text-primary);
}
.btn--ghost:hover { border-color: var(--text-bright); 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 ───────────────────────────────────────────────────────────── */

/* OWNER CORRECTION, 2026-09-25: the rounded-card look (`background:
 * var(--surface); border; border-radius; padding`) that used to live here is
 * GONE — layouts/cta-final.yml's own `.band.void` now owns that section's
 * full-bleed background and padding (src/css/bands.css), matching every other
 * band on the page instead of being the one bespoke card. `.cta-final` is
 * kept as a bare hook (the section id-adjacent class layouts/cta-final.yml
 * still writes) in case a future placement wants to target it, but carries no
 * rules of its own today. */
.cta-final-actions { justify-content: center; }

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

.showcase-frames {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
  gap: var(--space-3);
}

.showcase-frame {
  margin: 0;
  padding: var(--space-3);
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 10px;
}
.showcase-frame h3 { margin-bottom: var(--space-2); }
.showcase-shot { border-radius: 6px; 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 ───────────────────────────────────────────────────────────────── */

.steps-list {
  counter-reset: step;
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
  gap: var(--space-4);
}

/* 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(--structure);
  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 {
  background: var(--surface);
  border-block: 1px solid var(--border);
  padding: var(--space-4) var(--space-3);
  border-radius: 10px;
}

.feature-cards {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(17rem, 1fr));
  gap: var(--space-3);
}

.feature-card {
  padding: var(--space-3);
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: 10px;
}
.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); }
.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;
}

/* ── legal (layouts/legal.yml) ───────────────────────────────────────────── */

/* One column of prose, read top to bottom (layouts/legal.yml's own header) —
 * no figure, no accent, no placement variant. `--measure` caps the line
 * length the same as body copy anywhere else on the site. */
.legal { max-width: var(--measure); }

.legal-signature { color: var(--text-muted); font-size: 0.875rem; }
.legal-signature--close { margin-top: var(--space-5); }

.legal-intro { color: var(--text-secondary); }

.legal-section { margin: var(--space-5) 0; }
.legal-heading { margin-bottom: var(--space-2); }

.legal-list {
  margin: 0 0 var(--space-2);
  padding-left: 1.25em;
  color: var(--text-secondary);
}
.legal-list li { margin-bottom: var(--space-1); }

/* A section's links are a list of destinations (an authority, a policy), not
 * words mid-sentence — see layouts/legal.yml's own header on why links here
 * are structured data rather than inline HTML from copy.json. */
.legal-links { list-style: none; margin: 0 0 var(--space-2); padding: 0; }
.legal-links li { margin-bottom: var(--space-1); }

/* scenes.css — the world-journey scene (layouts/scene-band-live.yml's paired
 * half): one persistent SVG world, one `<g class="camera">` stepping
 * through four framings, and three arms driven off one shared clock so a
 * galaxy reads as a song and each solar system as one of its instruments.
 * See layouts/scene-band-live.yml's header for the full geometry, the exact
 * second-by-second timeline and why each number is what it is; this file
 * is the choreography half.
 *
 * 🔴 THE WORLD MOVED, 2026-09-29 — OWNER CORRECTION, VERBATIM: "the stage
 * behind 'Push past a limit' and the three kids is the zoom-in-out animation
 * - you are inventing new things." This choreography used to drive the
 * standalone full-bleed `#world` band (`layouts/shot-band.yml`, now deleted)
 * while a SECOND, bespoke 8-second sweep ran behind the children one band
 * below. There is one loop again and it is the one behind the children:
 * every selector in this file now matches inside
 * `layouts/scene-band-live.yml`'s knockout. NOT ONE CLASS NAME OR KEYFRAME
 * CHANGED in the move — deliberately. See this file's own reduced-motion
 * block: renaming `.camera` or any `.j-*` to `.wall-*` would stop it
 * matching and the page would gain thirteen running animations under
 * `reduce` with no error anywhere.
 *
 * @docs layouts/scene-band-live.yml, src/css/wall.css (the composite half —
 *   the panel rect, its black, the stroke weight and the caption's position),
 *   ../../docs/designs/site-home-the-toy.md,
 *   src/css/toy.css (the sibling this file's stroke/opacity conventions are
 *   ported from).
 *
 * Concatenated by pipeline/emit.mjs's writeStylesheet, sorted by filename —
 * `bands` < `base` < `components` < `scenes` < `toy`. Every selector below
 * is a brand-new `.scene-*`/`.j-*` class nothing else in src/css/ targets.
 *
 * NO COLOUR LITERALS. Note fills are set per-circle in the markup
 * (`fill="var(--note-N)"`); the one instrument marble that isn't TONES uses
 * `var(--theme-drums)` (`theme/theme-colors.css`, generated from
 * `SystemTheme.ringColor` — a real instrument colour, not an invented one).
 * This file only ever sets structure (currentColor, from
 * `var(--structure)`) and opacity/motion.
 *
 * ── THE PIVOT PROBLEM, AND HOW IT WAS ACTUALLY RESOLVED ─────────────────
 * A `<g>` nested inside a parent `<g transform="translate(cx cy)">`, given
 * a bare `transform: rotate(Ndeg)` with NO `transform-box`/`transform-
 * origin` override, correctly pivots around the LOCAL (0,0) origin the
 * parent's translate already established — TESTED directly (a minimal
 * two-case HTML file, rendered and screenshotted), not assumed from spec
 * reading, because SVG's `transform-box`/`transform-origin` defaults
 * genuinely differ across engines and CSS Transforms levels. Explicitly
 * setting `transform-box: fill-box; transform-origin: 0 0` on the SAME
 * element was tested alongside it and is WRONG — it pivots around the
 * shape's own bounding-box origin instead. So every `.j-arm-*` below
 * carries NO transform-box override — the default already pivots correctly
 * around each system's own centre. `.j-marble--sel`'s scale-in-place is the
 * OPPOSITE case (a plain circle with its own non-zero local cx/cy, no
 * ancestor translate establishing its centre as the local origin) and DOES
 * need `transform-box: fill-box; transform-origin: center` — ported
 * unchanged from `toy.css`'s own `.toy-marble--sel`.
 *
 * ── ONE MASTER TIMELINE, THREE DIFFERENT TEMPOS, ZERO DRIFT ──────────────
 * `.j-arm-1`/`-2`/`-3` are each a dedicated, full-loop `@keyframes` — not a
 * shared shape reused via `animation-delay` (that would only shift WHEN a
 * bump happens, not encode three genuinely different constant angular
 * velocities). All three share the exact same 18s document-timeline
 * animation with no delay, so they can never drift relative to each other
 * — CSS's own document timeline IS the shared clock `globalTime` stands in
 * for. Each system's `sweepPeriod` (8s / 4s / 16s — chosen because each
 * divides the loop's 16-second RUNNING window with no remainder) sets a
 * literal degrees-per-second constant baked into these keyframes by hand:
 *   System 1: 360/8  = 45°/s  → 2 whole revolutions across 16s running
 *   System 2: 360/4  = 90°/s  → 4 whole revolutions
 *   System 3: 360/16 = 22.5°/s → 1 whole revolution
 * Because every system completes a WHOLE number of revolutions in the same
 * 16s window, each one's angle at the loop's 100% keyframe equals its angle
 * at 0% exactly (720°/1440°/360° travelled respectively — all multiples of
 * 360°) — a mathematically seamless loop, not an approximation that merely
 * looks close. Raw, un-reduced degree values are used throughout (e.g.
 * "735deg" rather than "15deg") so linear interpolation between consecutive
 * stops represents genuinely constant angular velocity with no wraparound
 * discontinuity mid-segment.
 *
 * 🔴 THE FREEZE IS BAKED IN, NOT A PAUSED ANIMATION. `animation-play-state`
 * cannot itself be driven by a DIFFERENT animation's keyframes — there is
 * no CSS mechanism to say "pause animation A during this span of animation
 * B" without JS. So the freeze (`guard galaxy.isAudible else { running:
 * false }`) is reproduced by making the FIRST and LAST two keyframe stops
 * of each arm's own animation hold an IDENTICAL angle (a flat segment,
 * exactly like every camera "hold" and marble "fade" elsewhere in this
 * file) rather than by literally stopping and restarting anything. The
 * result is indistinguishable from a real pause-and-resume: the arm sits
 * exactly where a continuously-advancing, occasionally-frozen clock would
 * leave it.
 *
 * ── SCROLL-TRIGGER: DROPPED, STATED WHY ──────────────────────────────────
 * This is an 18-second LOOP, not a one-shot reveal — a `view()`-gated
 * infinite loop would leave it paused at an arbitrary, possibly mid-
 * transition frame the entire time the band is off-screen, worse for a
 * loop than simply always running from page load. `animation-timeline`
 * stays at its default (`auto`, the document timeline).
 *
 * 🔴 `prefers-reduced-motion` gives a COMPLETE, COMPOSED STILL — Galaxy A's
 * own framing, at each system's RESTING angle (no mid-rotation freeze
 * frame): this is the one altitude where the whole thesis is visible at
 * once — three system rings, three arms, one galaxy. */

/* ── NO SECTION-SIZING RULES LIVE HERE ANY MORE ─────────────────────
 * Four rules stood here until 2026-09-29 — `.shot-band` (full-bleed,
 * `padding: 0`, `height: min(74vh, 820px)`), `.shot-band .row` (absolutely
 * positioned out of the grid, to escape a percentage-height circularity in an
 * auto-sized grid track), `.scene-figure--journey` and `.journey-svg`. All
 * four sized the retired `#world` SECTION. They are deleted rather than
 * re-pointed, because the world no longer gets a section: it is a layer
 * inside `layouts/scene-band-live.yml`'s figure, and `src/css/wall.css`'s
 * `.wall-surface` / `.wall-svg` give it its box — a definite,
 * absolutely-positioned percentage rect off the artwork contract, so neither
 * the grid-track circularity nor the shrink-to-fit width bug those rules
 * existed to fix can arise in the first place.
 *
 * `.journey-svg`'s one real declaration (`color: var(--structure)`, the
 * source every `currentColor` below resolves from) is now `.wall-svg`'s,
 * verbatim, in that file.
 *
 * 🔴 WHAT THE DELETION COST, recorded so nobody re-adds it by reflex:
 * `#world` was the page’s ONLY full-bleed element and its only ground change
 * — this file used to call it "the single full-bleed element on the page and
 * the one strong beat in an otherwise unbroken cream run." That beat is gone
 * and the cream now runs uninterrupted. It is a real cost and it is not
 * recoverable by anything below; it is still the right trade, because
 * shipping the same 18-second loop twice, 40vh apart, is the page arguing
 * with itself. If the beat is wanted back it has to come from somewhere
 * else — not from a second copy of this animation. */

/* ── the camera — one transform, four framings ───────────────────────────
 * `matrix(scale,0,0,scale,tx,ty)` per framing — see layouts/scene-band-live.yml's
 * header (and content/showcase.json ▸ world-altitudes, kept as the derivation
 * record) for how tx/ty were derived. Percentages are the timeline's own
 * second-marks / 18 (the loop's total duration). A HOLD is two identical
 * keyframe values in a row; a MOVE is two DIFFERENT values 0.5s (2.78% of
 * 18s) apart, `ease-in-out`, matching `GameScene.swift`'s own
 * `animateCamera` exactly. */
.camera {
  transform: matrix(0.0669682, 0, 0, 0.0669682, 50, 50);
  animation: journey-camera 18s infinite;
}
@keyframes journey-camera {
  0%      { transform: matrix(0.0669682, 0, 0, 0.0669682, 50, 50); animation-timing-function: ease-in-out; }         /* Universe (hold start) */
  8.3333% { transform: matrix(0.0669682, 0, 0, 0.0669682, 50, 50); animation-timing-function: ease-in-out; }         /* Universe (hold end) -> move to Galaxy */
  11.1111%{ transform: matrix(0.2857495, 0, 0, 0.2857495, 124.295, 87.147); animation-timing-function: ease-in-out; } /* Galaxy A (arrive, hold start — the payoff shot) */
  21.2963%{ transform: matrix(0.2857495, 0, 0, 0.2857495, 124.295, 87.147); animation-timing-function: ease-in-out; } /* Galaxy A (hold end) -> move to Solar 1 */
  24.0741%{ transform: matrix(1, 0, 0, 1, 365, 210); animation-timing-function: ease-in-out; }                        /* Solar 1 (arrive; half-turn dwell, camera still) */
  46.2963%{ transform: matrix(1, 0, 0, 1, 365, 210); animation-timing-function: ease-in-out; }                        /* Solar 1 (departure) -> move to Galaxy */
  49.0741%{ transform: matrix(0.2857495, 0, 0, 0.2857495, 124.295, 87.147); animation-timing-function: ease-in-out; } /* Galaxy A (arrive, return leg) */
  59.2593%{ transform: matrix(0.2857495, 0, 0, 0.2857495, 124.295, 87.147); animation-timing-function: ease-in-out; } /* Galaxy A (hold end) -> move to Solar 2 */
  62.037% { transform: matrix(1, 0, 0, 1, 260, 215); animation-timing-function: ease-in-out; }                        /* Solar 2 (arrive, a DIFFERENT system) */
  84.2593%{ transform: matrix(1, 0, 0, 1, 260, 215); animation-timing-function: ease-in-out; }                        /* Solar 2 (departure) -> move to Galaxy */
  87.037% { transform: matrix(0.2857495, 0, 0, 0.2857495, 124.295, 87.147); animation-timing-function: ease-in-out; } /* Galaxy A (arrive) */
  97.2222%{ transform: matrix(0.2857495, 0, 0, 0.2857495, 124.295, 87.147); animation-timing-function: ease-in-out; } /* Galaxy A (hold end) -> move to Universe */
  100%    { transform: matrix(0.0669682, 0, 0, 0.0669682, 50, 50); }                                                  /* Universe (arrive) -> loops to 0% */
}

/* ── knob/weight — hidden ONLY at Universe altitude, sharing the exact same
 * on/off boundary as the arms' own freeze (both are the same `mode` flip
 * in the app). The arm LINE is never hidden — only the knob and weight.
 *
 * 🔴 CONSTANT APPARENT SIZE, NOT A SHRINKING FILL — owner: "the dot-handles
 * on the arm are too small when zoomed out." Unlike a stroked path, a
 * filled circle has no `vector-effect="non-scaling-stroke"` equivalent —
 * its RADIUS scales with the camera like everything else in world-space,
 * so at Galaxy altitude (1/3.5 scale) the hero-matched r=1.8/r=1.3 render
 * at a fraction of a screen pixel. The app's own answer to "does this
 * shrink with the camera" is `StrokeShaders.swift`'s constant 4pt hairline
 * at every zoom — the knob and weight are the same class of thing (the
 * tempo/phase CONTROLS, not decoration), so they get the same treatment,
 * reproduced here by animating the CSS `r` property (a real SVG geometry
 * property, CSS-animatable in every current engine) in exact lockstep with
 * `journey-camera`'s own scale — same 13 keyframe stops, so the apparent
 * (on-screen) size is held EXACTLY constant at 90.9%-of-short-side
 * altitudes and only shrinks back to the hero's own r=1.8/r=1.3 during an
 * actual Solar dwell, where hero-parity is the more important rule.
 * `6.3 = 1.8 / 0.2857495` and `4.55 = 1.3 / 0.2857495` exactly (since
 * 0.2857495 = 45.5/159.25 = 1/3.5) — full cancellation of the Galaxy
 * camera's own scale factor, not a partial "floor." Animating `r` (not a
 * `transform: scale()`) is deliberate: it changes size WITHOUT moving the
 * circle's own `cx`/`cy`, so the handle still tracks the arm's rotation
 * and length exactly, only its rendered size differs. The Universe-altitude
 * value is never actually seen (opacity 0 there) but is held at the SAME
 * 6.3/4.55 as Galaxy so there is no value to visibly "pop" at the instant
 * `hide-at-universe`'s opacity crosses from 0 to 1 exactly as Galaxy is
 * reached. Seamless-loop property preserved by the same construction as
 * everything else in this file: identical value at 0% and 100%. */
.j-knob {
  animation: hide-at-universe 18s infinite, handle-radius-knob 18s infinite;
}
.j-weight {
  animation: hide-at-universe 18s infinite, handle-radius-weight 18s infinite;
}
@keyframes hide-at-universe {
  0%      { opacity: 0; }
  8.3333% { opacity: 0; }
  8.48%   { opacity: 1; }
  97.07%  { opacity: 1; }
  97.2222%{ opacity: 0; }
  100%    { opacity: 0; }
}
@keyframes handle-radius-knob {
  0%      { r: 6.3; }
  8.3333% { r: 6.3; }
  11.1111%{ r: 6.3; }
  21.2963%{ r: 6.3; }
  24.0741%{ r: 1.8; }
  46.2963%{ r: 1.8; }
  49.0741%{ r: 6.3; }
  59.2593%{ r: 6.3; }
  62.037% { r: 1.8; }
  84.2593%{ r: 1.8; }
  87.037% { r: 6.3; }
  97.2222%{ r: 6.3; }
  100%    { r: 6.3; }
}
@keyframes handle-radius-weight {
  0%      { r: 4.55; }
  8.3333% { r: 4.55; }
  11.1111%{ r: 4.55; }
  21.2963%{ r: 4.55; }
  24.0741%{ r: 1.3; }
  46.2963%{ r: 1.3; }
  49.0741%{ r: 4.55; }
  59.2593%{ r: 4.55; }
  62.037% { r: 1.3; }
  84.2593%{ r: 1.3; }
  87.037% { r: 4.55; }
  97.2222%{ r: 4.55; }
  100%    { r: 4.55; }
}

/* ── the three arms — one master timeline, three tempos, never restarting.
 * See this file's own header for the full derivation. Angles are RAW
 * (un-reduced) degree values — rotate(735deg) renders identically to
 * rotate(15deg), so no visual discontinuity, but keeping the raw
 * accumulating number makes every intermediate stop a straightforward
 * linear interpolation of genuinely constant angular velocity. */
.j-arm-1 { animation: arm-system-1 18s linear infinite; }
@keyframes arm-system-1 {
  0%      { transform: rotate(15deg); }
  8.3333% { transform: rotate(15deg); }    /* frozen through Universe, unfreezes exactly as Galaxy move begins */
  11.1111%{ transform: rotate(37.5deg); }
  21.2963%{ transform: rotate(120deg); }
  24.0741%{ transform: rotate(142.5deg); } /* arrive Solar 1 */
  46.2963%{ transform: rotate(322.5deg); } /* Solar 1 departure — exactly 180° travelled during the 4s dwell */
  49.0741%{ transform: rotate(345deg); }
  59.2593%{ transform: rotate(427.5deg); }
  62.037% { transform: rotate(450deg); }   /* arrive Solar 2 (off-camera for System 1) */
  84.2593%{ transform: rotate(630deg); }
  87.037% { transform: rotate(652.5deg); }
  97.2222%{ transform: rotate(735deg); }   /* freeze begins — 735 = 15 + 720, two whole revolutions, seamless */
  100%    { transform: rotate(735deg); }
}

.j-arm-2 { animation: arm-system-2 18s linear infinite; }
@keyframes arm-system-2 {
  0%      { transform: rotate(250deg); }
  8.3333% { transform: rotate(250deg); }
  11.1111%{ transform: rotate(295deg); }
  21.2963%{ transform: rotate(460deg); }
  24.0741%{ transform: rotate(505deg); }   /* System 1 is at Solar; System 2 keeps running off-camera */
  46.2963%{ transform: rotate(865deg); }
  49.0741%{ transform: rotate(910deg); }
  59.2593%{ transform: rotate(1075deg); }
  62.037% { transform: rotate(1120deg); }  /* arrive Solar 2 — its own dwell */
  84.2593%{ transform: rotate(1480deg); }  /* Solar 2 departure — a FULL 360° travelled during its 4s dwell, its 4s period fitting exactly once (a faster instrument completes a whole cycle in the same dwell time System 1 only half-turns in) */
  87.037% { transform: rotate(1525deg); }
  97.2222%{ transform: rotate(1690deg); }  /* freeze — 1690 = 250 + 1440, four whole revolutions, seamless */
  100%    { transform: rotate(1690deg); }
}

.j-arm-3 { animation: arm-system-3 18s linear infinite; }
@keyframes arm-system-3 {
  0%      { transform: rotate(110deg); }
  8.3333% { transform: rotate(110deg); }
  11.1111%{ transform: rotate(121.25deg); }
  21.2963%{ transform: rotate(162.5deg); }
  24.0741%{ transform: rotate(173.75deg); }
  46.2963%{ transform: rotate(263.75deg); }
  49.0741%{ transform: rotate(275deg); }
  59.2593%{ transform: rotate(316.25deg); }
  62.037% { transform: rotate(327.5deg); }
  84.2593%{ transform: rotate(417.5deg); }
  87.037% { transform: rotate(428.75deg); }
  97.2222%{ transform: rotate(470deg); }   /* freeze — 470 = 110 + 360, one whole revolution, seamless */
  100%    { transform: rotate(470deg); }
}

/* ── notes an arm crosses during its own system's Solar dwell — a brief
 * scale pulse, timed by hand to when each system's own (linear, constant-
 * rate) sweep actually passes that note's angle. Only notes whose angle
 * falls inside the HALF the circle a half-turn dwell actually covers get a
 * pulse (System 1: 2 of 4; System 2's dwell happens to be a full
 * revolution, so its notes are covered similarly — 2 of 3, the third
 * falling exactly on the dwell's own start/end boundary). `transform-box:
 * fill-box; transform-origin: center` IS needed here (unlike the arm) —
 * these are plain circles with their own non-zero local cx/cy, the same
 * case `.j-marble--sel` is in. */
.j-note--strikable {
  transform-box: fill-box;
  transform-origin: center;
  animation: note-strike 18s linear infinite;
}
@keyframes note-strike {
  0%   { transform: scale(1); }
  1.5% { transform: scale(1.35); }
  4%   { transform: scale(1); }
  100% { transform: scale(1); }
}

/* ── the hub bowl's marbles — chrome, never a note colour (the instrument
 * and random marbles stay `currentColor`/a real instrument colour; only
 * the solid marble is allowed a note colour, previewing a specific note a
 * placement would use). Numbers ported from `.toy-marble`/`.toy-marble--
 * sel` (`src/css/toy.css`) — `BrushHub.swift`'s own `dimmedAlpha = 0.5`,
 * `selectedScale = 1.35`. `.j-marbles-1`/`.j-marbles-2` fade in only for
 * their own system's Solar dwell — see `focus(on:)`'s reparent. */
.j-marble { opacity: 0.5; }
.j-marble--instrument { opacity: 1; }
.j-marble--sel {
  opacity: 1;
  transform-box: fill-box;
  transform-origin: center;
  transform: scale(1.35);
}
.j-marbles-1 { animation: marbles-1-visible 18s infinite; }
@keyframes marbles-1-visible {
  0%      { opacity: 0; }
  22.8%   { opacity: 0; }
  24.0741%{ opacity: 1; }
  46.2963%{ opacity: 1; }
  47.5%   { opacity: 0; }
  100%    { opacity: 0; }
}
.j-marbles-2 { animation: marbles-2-visible 18s infinite; }
@keyframes marbles-2-visible {
  0%      { opacity: 0; }
  60.8%   { opacity: 0; }
  62.037% { opacity: 1; }
  84.2593%{ opacity: 1; }
  85.5%   { opacity: 0; }
  100%    { opacity: 0; }
}

/* ── the caption — names whichever altitude the camera has just arrived at.
 * This file owns only the STACKING and the crossfade; the caption's box, its
 * corner and its colour are `src/css/wall.css`'s, because the corner changed
 * with the move.
 *
 * 🔴 IT IS NO LONGER A `.plate.br` ON THE SECTION. Under `#world` the stack
 * was a DIRECT CHILD of `<section>` carrying `class="plate br"`, inheriting
 * `bands.css`'s `.band > .plate.br { position: absolute; right: 20px; bottom:
 * 18px }` verbatim. Neither half survives the move and both would break it:
 * `.band:has(> .plate)` collapses a band to one centred column and knocks it
 * out of the count-driven two-column grid, which would flatten this band's
 * copy/figure stage; and the BOTTOM of this panel is where the children are.
 * The stack is a child of the FIGURE now, positioned top-right of the panel
 * by wall.css. `.plate`'s type face is still inherited — that part was always
 * the point.
 *
 * Three spans stacked in ONE grid cell (`grid-area: 1 / 1`, the modern idiom
 * for "overlap without absolute positioning + a guessed height") so exactly
 * one is opaque at a time; each animation's "on" window starts at the EXACT
 * SAME percentage `journey-camera`'s own keyframes arrive at that altitude —
 * the caption can never read differently from what the picture is doing,
 * because it is driven by the identical 18s timeline. */
.j-caption-stack {
  display: grid;
}
.j-caption {
  grid-area: 1 / 1;
  opacity: 0;
}
.j-caption--universe { animation: caption-universe 18s infinite; }
@keyframes caption-universe {
  0%      { opacity: 1; }
  10.6%   { opacity: 1; }
  11.1111%{ opacity: 0; }
  100%    { opacity: 0; }
}
.j-caption--galaxy { animation: caption-galaxy 18s infinite; }
@keyframes caption-galaxy {
  0%      { opacity: 0; }
  10.6%   { opacity: 0; }
  11.1111%{ opacity: 1; }
  23.6%   { opacity: 1; }
  24.0741%{ opacity: 0; }
  48.6%   { opacity: 0; }
  49.0741%{ opacity: 1; }
  61.6%   { opacity: 1; }
  62.037% { opacity: 0; }
  86.6%   { opacity: 0; }
  87.037% { opacity: 1; }
  100%    { opacity: 1; }
}
.j-caption--solar { animation: caption-solar 18s infinite; }
@keyframes caption-solar {
  0%      { opacity: 0; }
  23.6%   { opacity: 0; }
  24.0741%{ opacity: 1; }
  48.6%   { opacity: 1; }
  49.0741%{ opacity: 0; }
  61.6%   { opacity: 0; }
  62.037% { opacity: 1; }
  86.6%   { opacity: 1; }
  87.037% { opacity: 0; }
  100%    { opacity: 0; }
}

/* ── reduced motion: a complete, composed still — Galaxy A's own framing,
 * every system at its own RESTING angle (not a paused mid-rotation frame):
 * this is the one altitude the whole thesis is visible at once — three
 * system rings, three arms, one galaxy, one song. */
@media (prefers-reduced-motion: reduce) {
  .camera {
    animation: none;
    transform: matrix(0.2857495, 0, 0, 0.2857495, 124.295, 87.147);
  }
  .j-knob {
    /* Galaxy altitude is never Universe altitude — knob visible, at its
       Galaxy-altitude counter-scaled size (6.3), not the hero's Solar r=1.8. */
    animation: none;
    opacity: 1;
    r: 6.3;
  }
  .j-weight {
    animation: none;
    opacity: 1;
    r: 4.55;
  }
  .j-arm-1 { animation: none; transform: rotate(15deg); }
  .j-arm-2 { animation: none; transform: rotate(250deg); }
  .j-arm-3 { animation: none; transform: rotate(110deg); }
  .j-note--strikable {
    animation: none;
    transform: scale(1);
  }
  .j-marbles-1,
  .j-marbles-2 {
    /* No system has camera focus in the Galaxy-A still — the hub bowl, per
       `focus(on:)`, belongs to whichever system IS focused, and at Galaxy
       altitude that's nobody. */
    animation: none;
    opacity: 0;
  }
  /* The meaning must not be reachable only through motion — all three
   * captions shown together, in plain stacked flow (not overlapping grid
   * cells), rather than freezing on whichever one the Galaxy-altitude
   * still would otherwise land on alone. Still corner-anchored — `src/css/
   * wall.css`'s `.j-caption-stack` is `position: absolute` regardless of
   * this block, so unstacking grows the group DOWNWARD from the panel's
   * top-right into the band the artwork contract says no hand ever reaches
   * (above 41% of panel height). Rendered and checked at 390px specifically
   * (the tightest corner) before deciding this was legible enough to keep,
   * rather than assumed. */
  .j-caption-stack {
    display: block;
  }
  .j-caption {
    display: block;
    animation: none;
    opacity: 1;
  }
}

/* toy.css — the Solar play surface (layouts/toy.yml's paired half).
 *
 * @docs ../../docs/designs/site-home-the-toy.md, ../../_proto/ipad-screens/README.md
 *
 * Concatenated last (pipeline/emit.mjs's writeStylesheet sorts src/css/*.css
 * by filename — base, components, toy), so a rule here can win over
 * components.css/base.css by specificity or by order without !important.
 *
 * NO LOCAL COLOUR TOKENS HERE, LITERAL OR OTHERWISE — read `theme/law.css`
 * before adding one. `--bg` (pitch black, `#000000`) and `--structure`
 * (`#ffffff`) are ALREADY the site's real, sitewide tokens: the owner's
 * 2026-09-25 ruling ("ground is pitch black, not near-black... colour means
 * a note, always, never decoration... focus rings are white") is implemented
 * once, globally, in `theme/law.css`, loaded after `theme/tokens.css` in
 * every page's `<head>` (`theme/head.html`, `theme/build-tokens.mjs`'s
 * `renderHead`) — confirmed by reading the built `public/index.html` and
 * `public/site.css` directly, not assumed. `body { background: var(--bg) }`
 * (base.css) is therefore ALREADY pitch black sitewide; this file only
 * needs `var(--bg)`/`var(--structure)`, same as base.css's own
 * `:focus-visible` rule and components.css's `.hero-title .accent` already
 * do. The four brush marbles and the breathing circle stay on `--structure`
 * (never `--note-*`) because they are chrome/unplayed, not a struck note —
 * "colour means a note, always" cuts both ways. */

/* Full-bleed black under the sticky header: escape main's centred column
 * via base.css's full-bleed GRID (`main { display: grid; grid-template-
 * columns: [full-start] … [full-end]; }`, `main > * { grid-column: content;
 * }`) rather than the old `100vw`/`calc(50% - 50vw)` negative-margin
 * breakout — `.toy` opts into the grid's `full` column instead, so the
 * ground reaches both physical viewport edges the way the app's own play
 * surface does, not just the article column's edges, with no viewport unit
 * involved. `main > header` still gets its normal bottom margin from
 * base.css for the section rhythm below. `width: auto` replaces the old
 * `width: 100vw`: an unset width on a grid item already stretches to fill
 * its track, so this is a statement of intent, not new behaviour. An
 * explicit `background` here is belt-and-suspenders over `body`'s (also
 * `var(--bg)`), not a second source of truth — same value, same token. */
.toy {
  grid-column: full;
  position: relative;
  width: auto;
  margin-inline: 0;
  background: var(--bg);
}

/* `--toy-header-h` — OWNER CORRECTION, 2026-09-25: now 0, not a computed
 * header height. components.css's `.site-header` is `position: fixed` now
 * (the riffmix-design-system-20260925 blend-mode nav), which removes it from
 * normal flow entirely — nothing above `.toy-surface` pushes it down anymore,
 * for ANY value of `frame.header`. The bug this token used to fix (the ring's
 * bottom arc landing below the first viewport under a sticky, flow-occupying
 * header) cannot recur under a fixed one. Kept as a named token rather than
 * deleted and inlined as a bare `100dvh` below, so a future layout mode that
 * reintroduces a real, flow-occupying header is a one-line change back
 * instead of a search-and-replace across this file. */
:root {
  /* ⚠️ BACK TO A REAL VALUE, 2026-09-26. It was 0 while the header was
   * `position: fixed` + `mix-blend-mode: difference` — genuinely out of flow,
   * occupying no space. The owner then asked for the header to be "separated
   * over the same warm cream", so it is now an opaque `position: sticky` cream
   * bar in NORMAL FLOW and does occupy space again. Measured on the built page
   * at 45.59px, identical at 1280x900 and 390x844 because it is padding- and
   * line-height-driven, not width-driven. Left at 0 the hero overshoots and the
   * deliberate ~110px peek of the next band collapses to ~64px. */
  /* 🔴 46px → 104px, 2026-09-30. MEASURED 103.97px on the built page, at
   * 390×844, 768×1024 AND 1440×900 alike — it is padding- and line-height-
   * driven, not width-driven, so one value is still correct at every width.
   *
   * WHY IT WAS 58px WRONG. 46px was measured correctly on 2026-09-26, and the
   * masthead has changed twice since: the Solar badge + Space Grotesk wordmark
   * landed (commit 51d8533) and the nav moved out to the footer, which made
   * the bar a two-line lockup instead of a one-line strip. Nothing caught it,
   * because between the toy being parked and 2026-09-30 this token had NO
   * consumer on `/` — the stale value cost nothing, so nothing surfaced it.
   * It became load-bearing again the moment the portrait hero started
   * budgeting the fold against it (src/css/bands.css), and the error showed up
   * immediately as a 13px peek where 110px was intended.
   *
   * ⚠️ IF THE MASTHEAD CHANGES AGAIN, RE-MEASURE THIS. It is the one number in
   * the hero's height budget that is still an estimate of another element, and
   * the estimate is only checked by looking. The hero's OTHER former
   * estimate, `--toy-copy-h`, was removed from that budget in the same change
   * — the copy now takes its own height and the figure gets what is left, so
   * there is nothing to keep in sync there. This one could not be done the
   * same way: the masthead is not inside the hero, and CSS gives an element no
   * way to read its own distance from the top of the viewport. */
  --toy-header-h: 104px;
}

/* `--hero-peek` — OWNER CORRECTION, 2026-09-25: "the main hero section
 * should never be 100vh... we need to somehow show part of 'First touch
 * makes sound'." A full-viewport black surface gives no signal that
 * anything follows it; the `↓` corner plate (layouts/toy.yml, `.spec`'s
 * sibling) was carrying that whole job alone. `.toy-surface` now falls
 * `--hero-peek` short of the full viewport so the very next band's own
 * background colour — and, depending on viewport height, the very top of
 * its `.say` headline — reads at the bottom edge before any scroll.
 *
 * 110px, not a percentage: chosen so the reveal is dominated by "the colour
 * changes" (band padding-top is `clamp(60px,10vw,130px)`, so 110px mostly
 * lands INSIDE that padding at both tested widths, rarely clipping into the
 * headline glyphs themselves) rather than "half a band" — checked by
 * rendering BOTH 1280×900 and 390×844 (not assumed to scale the same way a
 * `vh`-based peek would): at 1280×900 the paper band's own padding-top is
 * clamped to 128px (10vw), so a 110px reveal shows padding + a hairline of
 * the glyphs' own top edge; at 390×844 padding-top is 60px (10vw=39px,
 * floored at 60), so 110px reveals padding plus roughly the top third of
 * the headline's first line — a clear, legible hint of both colour AND
 * text on the smaller screen, never a shrunken hero. `dvh`, not `vh`, per
 * the owner's own instruction, so mobile browser chrome hiding/showing on
 * scroll doesn't jump this measurement. */
/* ✅ BOTH TOKENS BELOW ARE LIVE AGAIN AS OF 2026-09-30 — the paragraph that
 * stood here declared them INERT, and that is no longer true. It is corrected
 * rather than deleted, because the reason they went inert and the reason they
 * came back are the same reason, and it is about the ART, not the tokens.
 *
 * ── WHAT WAS TRUE, AND WHEN ──────────────────────────────────────────────
 * 2026-09-29 → 2026-09-30: they were read by exactly ONE rule, `.toy-stage`'s
 * `height: calc(100dvh - …)` further down, and `.toy-stage` belongs to
 * `layouts/toy.yml`, which is PARKED (site/BACKLOG.md §1). The live home hero
 * is `layouts/hero-scene.yml`, which renders `.toy-copy` + a
 * `.scene-figure--hero` and no stage at all — so for that day nothing on `/`
 * reserved `--hero-peek` of anything. Measured at 390×844 at the time: the
 * hero ended at 554px, `#first-touch` opened at 650px and its headline sat at
 * 743px, comfortably above the fold with no reserve needed. That worked
 * because the hero illustration was LANDSCAPE (1.79:1) and simply too short
 * to threaten the fold.
 *
 * ── WHAT CHANGED ─────────────────────────────────────────────────────────
 * The owner replaced the hero art with his own PORTRAIT drawing, 0.822:1
 * (`assets/artwork/crew-overhead.svg`, and "yes - the flip is on purpose").
 * A portrait hero does threaten the fold — uncapped it stands taller than the
 * viewport at every width tested — so the fold needs an explicit reserve
 * again, which is exactly what these two tokens were introduced for. They are
 * now read by `.scene-figure--hero .scene-img` in src/css/bands.css, in the
 * same shape `.toy-stage` reads them: `calc(100dvh - --toy-header-h -
 * --toy-copy-h - --hero-peek)` when the copy stacks above the figure, minus
 * the `--toy-copy-h` term at ≥900px where the copy sits beside it instead.
 *
 * 🔴 SO THE OLD ADVICE IS REVERSED. It read: "do not re-tune them for a hero
 * that never reads them." A hero reads them now. Both tokens are load-bearing
 * on `/` again, and `--toy-copy-h`'s own "ESTIMATE, not a measurement" caveat
 * below is now a caveat about the LIVE page, not about a parked one — if the
 * hero copy's height drifts, the fold drifts with it. They still ALSO serve
 * `.toy-stage` if the toy comes back, and the two consumers want the same
 * numbers for the same reason, so there is nothing to split. */
:root {
  --hero-peek: 110px;

  /* `--toy-copy-h` — added 2026-09-29 with the visible hero copy. An
   * ESTIMATE, not a measurement: the copy block is `clamp()`-driven type, so
   * its height moves with the viewport. Measured on the built page at
   * 1440×900 (≈188px) and 390×844 (≈166px); 190px is the larger, taken so
   * the peek is never SMALLER than intended — over-subtracting shortens the
   * black surface a little, under-subtracting would eat the next band's
   * reveal, and only one of those two failures is visible. */
  --toy-copy-h: 190px;
}

/* ── THE HERO COPY IS VISIBLE NOW (2026-09-29) ───────────────────────────
 * 🔴 SUPERSEDES `docs/designs/site-home-the-toy.md:201-203` ("A visually-
 * hidden `<h1>` and one descriptive sentence sit in the markup — THE LAW
 * governs the play surface, not `<head>`"). Both were
 * `clip-path: inset(50%)`, 1×1px, screen-reader-only.
 *
 * WHY THAT INVERTED THE PAGE'S JOB. THE LAW ("no conventional UI, no text on
 * the play surface") is right IN THE APP, where the user has already chosen
 * to be there. On an acquisition surface the visitor has chosen nothing and
 * spends about one thumb-flick deciding. Extending the law here meant the
 * only visible copy in the hero was the 11px `.spec` telemetry — `solar`,
 * `arm — 8s / rev`, `12 notes`, `b3 – f5` — over a silent black rectangle,
 * with no invitation to touch the one genuinely persuasive thing on the
 * site: a playable Touchtone (src/js/toy-audio.js, four real soundcore
 * voices, zero payload) that nobody was told was there.
 *
 * THE LAW still governs the play surface itself: no buttons, no labels, no
 * chrome INSIDE the black. This is a scoped exemption for the copy ABOVE it,
 * of the same kind already granted to the hold-drawers and the dev console.
 *
 * The heading is still the page's ONLY `<h1>` (layouts/toy.yml declares
 * `keywords.role: [opening]`, so _shell.html suppresses its own) — it is the
 * same element, now shown rather than a second visible copy beside a hidden
 * one. */
.toy-copy {
  /* Sits in normal flow above the surface; --toy-copy-h feeds the surface's
   * own height calc below so the ~110px peek of the next band survives. */
  padding: clamp(28px, 5vh, 56px) 24px clamp(20px, 3vh, 32px);
  text-align: center;
}

.toy-heading {
  margin: 0;
  max-width: 18ch;
  margin-inline: auto;
  font-size: clamp(2.4rem, 7vw, 4.4rem);
  line-height: 1.02;
  /* 🔴 `var(--tracking-display)`, not a literal — 2026-09-29. The hard-coded
   * `-0.03em` tracked this `<h1>` 0.005em LOOSER than every `<h2>` beneath it
   * (`--tracking-display` was `-0.035em`), for no stated reason, which is what
   * a literal buys you. It matters more now: the token moved to `-0.02em` with
   * the face change (Cabinet Grotesk → General Sans, theme/law.css), and a
   * literal would have left the page's largest heading tracked for a typeface
   * the site no longer ships. */
  letter-spacing: var(--tracking-display);
  text-wrap: balance;
}

.toy-description {
  margin: clamp(12px, 2vh, 20px) auto 0;
  max-width: 46ch;
  font-size: clamp(1rem, 1.6vw, 1.15rem);
  line-height: 1.5;
  /* 🔴 COLOUR, not opacity — and MEASURED, not eyeballed (design.md: "muted
   * tiers must be verified with math"). The first pass here was
   * `opacity: .74` with a comment claiming 9.1:1; that number was computed
   * against `--ink` (#111111 then, #161009 since the 2026-09-29 warm-neutral
   * pass — same luminance, so every figure here still holds), but this
   * element inherits the lighter body
   * text token, so the real composite measured **4.31:1** on #F6EFE1 — under
   * AA's 4.5:1. design.md's rule for exactly this case is to darken the tier
   * rather than shrink its usage or argue the aesthetics, so it now takes
   * `--ink` explicitly at an opacity that clears the bar with room to spare. */
  color: var(--ink);
  opacity: 0.78;
}

/* The play surface itself: a real, focusable, NAMED control ("Add a note"),
 * not a bare <div> — see layouts/toy.yml's header note on why. Reset to
 * button-chrome-zero so it reads as nothing but the black canvas. This is
 * LAYER 1 (back) — see layouts/toy.yml's header note on the four-layer
 * stack (ring < notes < arm < hub+marbles) and why it takes three siblings
 * plus this button to reproduce app parity once notes had to be real
 * `<button>`s. */
/* ── THE STAGE: the black play area as a BOX on a cream page ─────────────
 * Added 2026-09-29 with the ground flip. Owner call: "cream page, black play
 * surface only." In the app the play surface IS pitch black, so this is the
 * one place black is telling the truth — and on a warm page it becomes the
 * single dark thing on screen, which is what makes it read as a screen
 * asking to be touched rather than a decorative panel.
 *
 * It owns the HEIGHT that `.toy-surface` used to own, because it is now the
 * element that has to leave `--hero-peek` of the next band showing. Every
 * term stays named rather than collapsed to one number so each one's reason
 * stays attached:
 *   --toy-header-h  the masthead's flow space
 *   --toy-copy-h    the hero copy, VISIBLE as of 2026-09-29 (new term)
 *   --hero-peek     110px, the owner's 2026-09-25 correction — the hero is
 *                   NEVER a full 100dvh, so the next band always shows at
 *                   the bottom edge before any scroll
 *
 * `position: relative` so the absolutely-positioned play layers (the two
 * `.toy-scene` SVGs, `.toy-notes`, the hidden a11y controls) anchor to the
 * stage rather than to `.toy` — they used to anchor to `.toy`, which was the
 * black region before this change. */
.toy-stage {
  position: relative;
  height: calc(100dvh - var(--toy-header-h) - var(--toy-copy-h) - var(--hero-peek));
  /* Inset from the page edges so cream shows down both sides: full-bleed, the
   * stage reads as a slab the page happens to be sitting on; inset, it reads
   * as a SCREEN lying on paper — which is what it is, and what the crew
   * scenes (site/_proto/crew/) draw literally. Clamped rather than a
   * percentage so the margin stays a believable paper border on a phone
   * instead of collapsing to nothing. */
  margin-inline: clamp(12px, 3vw, 56px);
  background: var(--void);
  border-radius: clamp(14px, 2vw, 26px);
  overflow: hidden;   /* the ring crops at the stage edge, per toy-scene.js's
                         own scale-to-fit-then-crop rule — without this it
                         would spill onto the cream. */
}

.toy-surface {
  display: block;
  width: 100%;
  /* Fills its stage; the stage owns the height (see `.toy-stage` above). */
  height: 100%;
  margin: 0;
  padding: 0;
  border: 0;
  background: transparent;
  color: inherit;
  font: inherit;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;

  /* `none` removes every default touch gesture the browser would otherwise
   * run on this element — panning AND double-tap-to-zoom both fall under
   * "default touch behavior" per the touch-action spec, so this one
   * declaration satisfies both required behaviours at once. The interactive
   * upgrade owns gesture recognition itself (pointer events), so nothing
   * native may intercept a touch here first. */
  touch-action: none;

  /* Contains scroll/rubber-band chaining that originates on this element.
   * Full-page rubber-band suppression on iOS Safari additionally wants
   * `overscroll-behavior-y: none` on html/body — that's base.css's file, out
   * of this change's lease; flagged in the handoff for whoever owns it. */
  overscroll-behavior: none;
}

/* base.css's global `:focus-visible { outline: 2px solid var(--structure);
 * outline-offset: 2px; }` already gives this button a white ring — the
 * 2026-09-25 law's "focus rings are white" is implemented once, sitewide, in
 * theme/law.css + base.css, not re-declared per component. The only thing
 * worth overriding here is the OFFSET: `.toy-surface` is a viewport-filling
 * button, so a ring drawn 2px OUTSIDE it (the site default) risks sitting
 * right at the physical viewport edge and being sub-pixel clipped; pulling
 * it inward keeps it fully visible without touching colour or width. Every
 * OTHER focusable control on this surface (`.toy-note`, `.toy-hub-tap`,
 * `.toy-weight`) gets the same treatment below, same reason. */
.toy-surface:focus-visible {
  outline-offset: -6px;
}

/* Both scenes share the exact same box and fit — `.toy-scene--back` lives
 * inside `.toy-surface` (100% of that button, as before); `.toy-scene--front`
 * is now a SIBLING (layouts/toy.yml's header note explains why: `.toy-notes`
 * has to sit BETWEEN the ring and the arm/hub, and a sibling can't inherit
 * `.toy-surface`'s sizing for free), so it gets the exact same box explicitly
 * via `inset: 0`, and `pointer-events: none` — it draws the arm/hub/marbles,
 * it never intercepts a touch. `.toy-hub-tap`/`.toy-weight` (below) are the
 * real hit targets for that layer's content and re-enable their own
 * pointer-events individually. */
.toy-scene {
  display: block;
  width: 100%;
  height: 100%;
  color: var(--structure); /* every currentColor stroke/fill in
                               layouts/toy.yml's SVG resolves from here */
}
.toy-scene--front {
  position: absolute;
  inset: 0;
  pointer-events: none;
}

/* THREE brush marbles resting in the hub bowl (the app's fourth, vinyl/
 * record, is REMOVED — a declared divergence, see layouts/toy.yml's header
 * note). All three are real controls now. Random/solid toggle a mutually
 * exclusive "selected brush" (`BrushHub.swift:18-19`, verbatim:
 * `selectedScale: CGFloat = 1.35`, `dimmedAlpha: CGFloat = 0.5`) —
 * `.toy-marble--sel` is toggled by toy-scene.js between them (never both,
 * never neither); the no-JS still ships it hard-coded on the random marble
 * in layouts/toy.yml, matching `BrushHub`'s own default (`selected: Brush =
 * .random`), so the static picture and the JS-driven initial state agree
 * without toy-scene.js needing to touch the DOM at load just to match it.
 * The instrument marble is DELIBERATELY EXCLUDED from this dim/bright pair
 * — `BrushHub.setSelected` only ever iterates the three BRUSH candidates
 * (`[Brush.random, .solid, .record]`), never the instrument marble, so in
 * the app it stays full brightness regardless of which brush is selected;
 * `.toy-marble--instrument` overrides the base dim back to full opacity for
 * the same reason, not because it's unimplemented (it is — see the marbles'
 * own comment). */
.toy-marble { opacity: 0.5; }
.toy-marble--instrument { opacity: 1; }
.toy-marble--sel {
  opacity: 1;
  /* Same SVG scale()-origin bug `.toy--hub-pulse` documents below — a plain
   * `transform: scale()` on an SVG shape pivots around the viewBox origin,
   * not the shape's own centre, without this pair. */
  transform-box: fill-box;
  transform-origin: center;
  transform: scale(1.35);
}

/* The one breathing circle — Open Question 1, resolved 2026-09-25: this IS
 * the page's ambient invitation, replacing "the sweeping arm is the only
 * hint" now that the arm ships parked in the no-JS still. Uses the app's own
 * hold-to-breathe idiom (GameScene.swift:1246-1254 cycles a planet's radius
 * min<->max while held) as a wordless "touch me", via a pure-CSS scale/opacity
 * pulse — no script required for this half of the invitation to exist.
 *
 * Positioned on the RING in layouts/toy.yml (`cx="68" cy="38"`), not at the
 * hub — a hub that breathes already means PAUSED in this app
 * (`SystemNode.swift:189`), and centring it there also crowded it visually
 * against the selected marble (same size class, same white). This rule only
 * needs the animation itself, not a position — `transform-box: fill-box` +
 * `transform-origin: center` scale around the circle's OWN centre wherever
 * it is drawn. */
.toy-breathe {
  transform-box: fill-box;
  transform-origin: center;
  animation: toy-breathe 3.2s ease-in-out infinite;
}
@keyframes toy-breathe {
  0%, 100% { transform: scale(1); opacity: 0.5; }
  50% { transform: scale(1.7); opacity: 1; }
}

/* Required to degrade to a STATIC circle, not merely a slower one (design
 * doc, Open Question 1's resolution). base.css already forces every
 * animation to a single, near-instant frame under reduced motion (its
 * global `*, *::before, *::after` rule), which would otherwise leave this
 * circle stranded on whatever the animation's LAST keyframe happens to be —
 * an accident, not a decision. This rule makes the static state a decision:
 * no animation at all, resting at a legible, deliberately fixed size. */
@media (prefers-reduced-motion: reduce) {
  .toy-breathe {
    animation: none;
    transform: scale(1.3);
    opacity: 0.85;
  }
}

/* Retired once the visitor places their first real circle (toy-scene.js) —
 * its job (the invitation) is done the moment there is a real note on the
 * surface to look at instead. A fade, not a jump-cut; stays retired even if
 * every placed note later expires past the 24-circle cap, since "you've
 * already touched it" doesn't reset. */
.toy-breathe.is-done {
  animation: none;
  transition: opacity 0.6s ease;
  opacity: 0;
}

/* ── the interactive layer (toy-scene.js) ────────────────────────────────── */

/* LAYER 2 — a SIBLING of `.toy-surface`, not a child — see layouts/toy.yml's
 * header note: a `<button>` cannot legally contain another interactive
 * control, and placed notes are real `<button>`s. Positioned to cover the
 * EXACT same box as `.toy-surface` (both are children of `.toy`, which has
 * no padding, so `inset: 0` lands on the same edges `.toy-surface`'s own
 * `calc(100dvh - var(--toy-header-h) - var(--hero-peek))` computes).
 * `pointer-events: none` on
 * the container lets a touch on empty space fall through to `.toy-surface`
 * beneath it (placing a new note); each `.toy-note` button re-enables its
 * own hit target so tapping an EXISTING note reaches that note, not a new
 * one underneath it. */
.toy-notes {
  position: absolute;
  inset: 0;
  pointer-events: none;
}

/* One placed note. Position/size are set inline by toy-scene.js per-circle
 * (left/top/width/height, computed from the same viewBox-to-pixel math both
 * SVGs' own `xMidYMid meet` fit uses) — nothing here is a literal placement,
 * only the shared shape every note gets. `background` is also set inline —
 * the ONE thing on this page allowed to be coloured — from
 * `window.Touchtone.NOTES` (see toy-scene.js's header: a dedicated
 * generated file now ships this, superseding the hand-copied hex array an
 * earlier version of this file carried; `var(--note-<index>)` also exists
 * on the page now, `theme/note-palette.css` having been linked in `<head>`,
 * but the JS object is still the one source toy-scene.js reads, since it
 * needs the numeric hex string for the `background` it sets in JS anyway).
 * `border: 0` — colour alone carries the note, no white ring on top of it;
 * a note is not structure. */
.toy-note {
  position: absolute;
  transform: translate(-50%, -50%);
  border-radius: 50%;
  border: 0;
  padding: 0;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  pointer-events: auto;
}
.toy-note:focus-visible {
  outline: 2px solid var(--structure);
  outline-offset: 2px;
}

/* The strike pulse — design doc: "scale to 1.12 over 0.08s, back to 1.0
 * over 0.18s" (`SystemNode.swift:449`). One 0.26s animation with the
 * PROPORTIONAL keyframe stop at 0.08/0.26 = 30.8% reproduces the asymmetric
 * timing without two separate declarations. toy-scene.js toggles this class
 * on strike (creation AND every re-trigger) and removes it once it ends;
 * skipped entirely in JS under `prefers-reduced-motion` ("circles don't
 * pulse") — base.css's global reduced-motion override
 * (`animation-duration: 0.01ms !important`) is belt-and-suspenders if that
 * check is ever missed. */
.toy-note--pulse {
  animation: toy-pulse 0.26s ease-out;
}
@keyframes toy-pulse {
  0% { transform: translate(-50%, -50%) scale(1); }
  30.8% { transform: translate(-50%, -50%) scale(1.12); }
  100% { transform: translate(-50%, -50%) scale(1); }
}

/* A retiring note (the 24-circle hard cap evicting the oldest) fades and
 * shrinks rather than vanishing — toy-scene.js adds this class, waits out
 * the transition, then removes the element. */
.toy-note--retiring {
  transition: opacity 0.35s ease, transform 0.35s ease;
  opacity: 0;
  transform: translate(-50%, -50%) scale(0.4);
}

/* LAYER 4 — real controls that must win a touch even over a note that
 * happens to be near them ("the tempo weight rides the arm; catch it
 * before anything else", GameScene.swift:637 — the SAME "catch it first"
 * priority the marbles get over the bare bowl beneath them, GameScene's own
 * touch-handling checks weight, then the marbles, then falls through to
 * "bare bowl = pause"). All SIX sit AFTER `.toy-notes` in DOM order (later
 * = higher paint/hit priority, no z-index needed) and each re-enables its
 * own `pointer-events` individually — `.toy-scene--front` above them stays
 * `pointer-events: none` throughout. All are invisible boxes: the VISUAL
 * dots/rim/marbles are already drawn in `.toy-scene--front`; these exist
 * only to be focusable, named, and correctly hit-tested. `.toy-hub-tap`
 * (the bare-bowl pause target) is sized to the FULL hub circle and sits
 * FIRST among these six, UNDER all three marble targets — so a tap that
 * lands on `.toy-marble-tap--random`/`--solid`/`--instrument` reaches that
 * marble, and every other point inside the hub falls through to pause. All
 * ship `hidden` in the markup — see layouts/toy.yml's header note, same
 * "no dead control before JS confirms it can respond" reasoning the old
 * corner mute button used. */
.toy-hub-tap,
.toy-marble-tap,
.toy-weight {
  position: absolute;
  transform: translate(-50%, -50%); /* left/top (toy-scene.js) mark the
                CENTRE of the hit target, same convention as .toy-note */
  border-radius: 50%;
  background: transparent;
  border: 0;
  padding: 0;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  pointer-events: auto;
}
.toy-hub-tap:focus-visible,
.toy-marble-tap:focus-visible,
.toy-weight:focus-visible {
  outline: 2px solid var(--structure);
  outline-offset: 2px;
}

/* The hub's own strike-adjacent feedback on every pause/resume tap —
 * `GameScene.swift`'s `togglePause()` runs the SAME pulse shape as a note
 * strike (0.08s/0.18s) but to 1.2, not 1.12 (`.scale(to: 1.2, duration:
 * 0.08)` then `.scale(to: 1.0, duration: 0.18)`), on the hub itself, every
 * toggle, both directions — not conditional on which way it toggled. Reuses
 * `.toy-scene--front`'s own hub-rim/marbles as the thing that visibly
 * pulses (the invisible `.toy-hub-tap` button has nothing to scale), so this
 * targets that SVG group via a class toy-scene.js toggles on `.toy`. */
.toy--hub-pulse .toy-hub-rim,
.toy--hub-pulse .toy-marble {
  /* 🔴 REQUIRED, and its absence is a real bug, not a nicety. An SVG element's
   * default transform-origin is the SVG USER-SPACE ORIGIN (0 0 = top-left of
   * the viewBox), NOT the element's own box — so `scale(1.2)` without these
   * two lines translates the hub and every marble away from the origin, i.e.
   * they visibly snap toward the bottom-right and back on every pause tap.
   * `.toy-breathe` above already carries the same pair for the same reason;
   * this rule was added later and missed it. Reported from the rendered page
   * 2026-09-25 ("the click on the inner circle makes it snap bottom-right and
   * back") — keep these together with any future scale() on an SVG node. */
  transform-box: fill-box;
  transform-origin: center;
  animation: toy-hub-pulse 0.26s ease-out;
}
@keyframes toy-hub-pulse {
  0% { transform: scale(1); }
  30.8% { transform: scale(1.2); }
  100% { transform: scale(1); }
}

/* Paused — the hub tap toggles this (`.toy` gets `.toy--paused`):
 *   - ⚠️ THE ARM IS DELIBERATELY *NOT* DIMMED. A previous revision ported the
 *     app's `arm.alpha = dimmed ? 0.25 : 1` (`SystemNode.swift:353`); the
 *     owner removed it 2026-09-25 ("dont dim the arm on pause"). A DECLARED
 *     DIVERGENCE from the app, not an omission — the arm simply stopping is
 *     the pause feedback here, alongside the hub's strike-pulse. Do not
 *     "restore parity" by re-adding the fade.
 *   - the breathing invitation pauses too (WCAG 2.2.2 also covers it —
 *     it's a second piece of auto-starting, continuous, >5s motion, and a
 *     plain CSS `animation` running independently of toy-scene.js's
 *     per-frame clock needs its own pause). `animation-play-state` rather
 *     than `animation: none` so it resumes from wherever it left off. */
.toy--paused .toy-breathe {
  animation-play-state: paused;
}

/* ── the ring as a hard boundary (owner correction, superseding the earlier
 * "decorative, not a boundary" call in docs/designs/site-home-the-toy.md —
 * see layouts/toy.yml's header note for the full reasoning) ────────────── */

/* The physical refusal for a touch outside the ring — THE LAW: "No error
 * states — refusals are physical (huddle-pulse + thud)"; the app's real
 * precedent is `refuseCreation()` (GameScene.swift:452-460), PORTED
 * verbatim: `.scale(to: 0.96, duration: 0.1)` then `.scale(to: 1.0,
 * duration: 0.18)` — the SAME two-phase shape as a strike pulse, shrinking
 * instead of growing. Applied to all three visual layers at once (the ring
 * button, the notes layer, the arm/hub layer) so the whole scene "huddles"
 * together, not just one piece of it — toy-scene.js toggles one class on
 * `.toy` and lets this rule fan it out. No `transform-box`/`-origin` needed
 * here (unlike the two SVG-internal `scale()` rules above): these three
 * targets are plain CSS boxes (a button, a div, an `<svg>` element scaled as
 * a whole), where the default `transform-origin: 50% 50%` already means
 * "this element's own centre" — the viewBox-origin bug is specific to
 * scaling a shape INSIDE an SVG's own coordinate system, not the SVG
 * element itself.
 *
 * Deliberately NOT gated on `prefers-reduced-motion`, unlike every other
 * decorative animation in this file: at 4% scale for 0.28s, this is a
 * brief, low-amplitude, MEANINGFUL state signal (the one and only feedback
 * a refused tap gets — see toy-scene.js's header note on why there is no
 * audio half of "huddle-pulse + thud" here), not ambient/looping motion —
 * closer to a form field's validation flash than to the breathing
 * invitation or the arm's rotation, the two things this file already
 * silences under reduced motion. */
.toy--huddle .toy-surface,
.toy--huddle .toy-notes,
.toy--huddle .toy-scene--front {
  animation: toy-huddle 0.28s ease-out;
}
@keyframes toy-huddle {
  0% { transform: scale(1); }
  35.7% { transform: scale(0.96); }
  100% { transform: scale(1); }
}

/* wall.css — the ONE live composite on the page: the app's real nested world
 * running inside the wall illustration, with the three children as a
 * knocked-out foreground plate over it.
 *
 * @docs layouts/scene-band-live.yml (the composite's shell, the two
 *   mechanical edits that moved the world here, and the aspect argument),
 *   src/css/scenes.css (the CHOREOGRAPHY half — the camera, the three arms,
 *   the captions, reduced motion; this file owns only the composite's
 *   plumbing and never touches a `.camera`/`.j-*` animation),
 *   ../../_proto/crew/scene-set.html (the artwork agent's measured contract —
 *   panel rect, aspect, corner radius, safe area),
 *   ../../content/showcase.json ▸ crew-wall-live.
 *
 * Concatenated by pipeline/emit.mjs's writeStylesheet, which sorts src/css/*
 * by FILENAME: `bands` < `base` < `components` < `scenes` < `toy` < `wall`.
 * This file therefore loads LAST, which is deliberate — the rules below that
 * override bands.css (the entrance fade, the figure's positioning context)
 * and the one that re-colours a `.plate` win on source order with no
 * specificity escalation. Every other selector here is a brand-new `.wall-*`
 * class nothing else in src/css/ targets.
 *
 * NO COLOUR LITERALS.
 *
 * ── WHY THIS BAND IS NOT `.void` ────────────────────────────────────────
 * A judgement call, made deliberately — and the argument had to be REWRITTEN
 * on 2026-09-29 when `#world` was retired, because it used to lean on
 * `#world`'s existence: "the page's ground rhythm is cream → cream → cream →
 * black → cream → cream, and bands.css is explicit that `.band.void` survives
 * for exactly one reason: `#world` draws the app's real play surface
 * full-bleed." `#world` is gone. There is no full-bleed black band left, so
 * that sentence no longer describes the page.
 *
 * The conclusion is unchanged, on its own merits: the black here belongs to
 * the PANEL, not to the section. It is the surface the children are touching,
 * exactly where the flattened illustration already put it. Making the section
 * `void` would delete the cream the foreground plate is drawn ON (#FAECD7 —
 * `--bg` exactly), which is the whole reason the crew scenes bleed into the
 * page with no seam, and it would do so to gain a ground change the page no
 * longer has anywhere else to be rhythmic against. So: cream band, black
 * panel. 🔴 THE HONEST COST, so nobody has to rediscover it: with `#world`
 * gone the page is an unbroken cream run, and its only remaining black is a
 * ~253px panel inside a picture. If the owner wants that beat back it has to
 * come from somewhere else — not from restoring a second copy of this loop.
 */

.scene-band--live .scene-figure--live {
  /* The positioning context the surface AND the caption resolve against. The
   * figure's box is the IMG's box — the `<img>` is its only in-flow child
   * (`width: 100%; height: auto` from bands.css's `.scene-img`, with the
   * intrinsic width/height attributes reserving the aspect before the lazy
   * load) and both other layers are taken out of flow. So a percentage here
   * is a percentage of the rendered picture, which is exactly what the
   * artwork contract's figures are percentages of. */
  position: relative;
  margin: 0;
  width: 100%;

  /* …and the CONTAINER the caption's type size is measured against. 🔴
   * `inline-size`, NOT `size`: the figure's height comes from the `<img>`'s
   * intrinsic aspect (`height: auto`), so full size containment would make it
   * independent of its contents and collapse it to nothing. Its inline size
   * is already imposed from outside (`width: 100%`), so containing that costs
   * nothing. The consequence is that `cqh` is NOT available here and the
   * caption sizes off `cqw` instead — see `.j-caption-stack`. Measured, not
   * assumed: the first version of that rule used `cqh` from up here, found no
   * size container at all, silently fell back to the VIEWPORT, and rendered
   * 11px on a 159px panel. */
  container-type: inline-size;
}

/* Layer 0 — the live world, on the panel's own rect.
 *
 * The four figures are the artwork agent's measured contract
 * (_proto/crew/scene-set.html, repeated in content/showcase.json ▸
 * crew-wall-live ▸ geometry.panel), NOT approximations. They are very
 * slightly LARGER than the foreground plate's actual alpha-0 hole (measured
 * on the shipped 2752w webp: 15.044% / 9.049% / 70.422% / 81.510%), which is
 * the right direction to err: the surface runs a hair UNDER the opaque cream
 * rather than leaving a hairline of cream showing inside the hole.
 *
 * ── THE PANEL'S OWN BLACK AND ITS CORNERS LIVE HERE NOW ─────────────────
 * The retired bespoke surface drew them as an SVG `<rect width="1920"
 * height="1241" fill="var(--void)" rx="81.2">`. The journey's viewBox is
 * rewritten by `src/js/scene-frame.js` on every resize, so a rect sized to it
 * could not stay the panel. Both numbers moved here instead, where they are
 * geometry-independent:
 *   background     `--void`, hardcoded true black in theme/law.css — the
 *                  app's law ("white is structure"), unaffected by the page's
 *                  cream ground.
 *   border-radius  4.25% of panel WIDTH, the artwork contract's corner. The
 *                  two-value slash form is required, not decorative: a bare
 *                  `4.25%` would give 4.25% of width horizontally and 4.25%
 *                  of HEIGHT vertically — an elliptical corner. 4.25 × 1.5477
 *                  = 6.578, so `4.25% / 6.578%` is a true circular arc on a
 *                  1.5477:1 box.
 *   overflow       clips the svg to that rounded box, so the camera can never
 *                  push geometry out past a corner the foreground plate's
 *                  knockout does not have.
 *
 * `container-type: size` makes this element the container the stroke weight
 * and the caption's type size are measured against (`cqh` = 1% of THIS box's
 * height). Legal here because the box is definite by construction —
 * absolutely positioned, with percentage width and height — so size
 * containment costs nothing and can never introduce a circularity. */
/* 🔴 THE PANEL IS THE SITE'S NOW, NOT THE PICTURE'S — 2026-09-30.
 *
 * Until today the illustration DREW the black play surface and this rule
 * aligned a live layer to the hole that had been knocked in it: left 15.01%,
 * top 8.98%, 70.49% × 81.61%, aspect 1.5477:1, every figure measured out of
 * `crew-wall-fg-2752w.webp`'s alpha channel.
 *
 * That contract is VOID. The owner replaced the plate with his own vector,
 * `assets/artwork/crew-wall.svg`, and it contains NO SCREEN AT ALL — just
 * three children on a fully transparent ground, reaching up at nothing. So
 * the rect below is AUTHORED against where their hands are, not derived from
 * an image; there is no longer any alpha to measure. Read it as a design
 * decision that can be re-tuned by eye, not as a measurement that must be
 * preserved. The owner's word for it was "the large screen behind the 3 kids
 * … making it larger", so it is deliberately generous.
 *
 * WHY IT STARTS AT THE VERY TOP: the middle child's raised hand reaches to
 * ~3.5% of the frame's height. A panel that began below that would have her
 * fingers over cream and her wrist over black, which reads as a mistake
 * rather than as a child touching a screen.
 *
 * The bottom edge crossing the children is the point, not a defect — it is
 * what puts them IN FRONT of the panel. Their legs continue below it onto the
 * page ground.
 *
 * ⚠️ No `aspect-ratio` here and none wanted: `src/js/scene-frame.js` rewrites
 * the world's viewBox to whatever box this element actually has, holding the
 * short axis at 100 units, which is what keeps the ring circular at every
 * width. That mechanism is why this rect is free to be any shape. */
.wall-surface {
  position: absolute;
  z-index: 0;
  left: 5%;
  top: 0%;
  width: 90%;
  height: 58%;
  background: var(--void);
  border-radius: 1.6% / 2.6%;
  overflow: hidden;
  container-type: size;
}

/* ── THE STROKE WEIGHT IS PANEL-RELATIVE, NOT A CONSTANT 4px ─────────────
 * The world's markup carries NO `stroke-width` attribute at all (the two
 * mechanical edits in layouts/scene-band-live.yml's header) — every stroked
 * element inherits this one declaration instead.
 *
 * WHY, stated as arithmetic rather than taste: `stroke-width: 4px` +
 * `vector-effect="non-scaling-stroke"` renders 4 SCREEN px whatever the
 * camera's 15× zoom is doing. That is correct in a band whose short side is
 * `min(74vh, 820px)` — 4/820 = 0.49% — which is the band this world came
 * from. This panel's short side is 253px at ≥1280, 315px at 768, 159px at
 * 390, so the SAME 4px would be 1.6% / 1.3% / 2.5% of the drawing: three to
 * five times the authored weight. At Universe altitude a system dot is 3% of
 * panel height across — 4.8px at 390px — and a 4px stroke would render it as
 * a white blob rather than a ring.
 *
 * 0.479% is not a chosen number. `_proto/crew/solar-surface.mjs`, the
 * generator that drew the flat plate this band composites with, emits
 * `stroke-width="5.94"` in a 1241-unit-tall viewBox: 5.94/1241 = 0.4787% of
 * panel height — the same number 4/820 is, arrived at independently. The
 * app's own law is "4pt at every zoom" on a 1024pt-tall iPad screen, i.e. a
 * constant FRACTION of the play surface; this panel is a picture of that
 * screen, so the fraction is what transposes, not the pixel count.
 *
 * THE FLOOR IS NOT A FUDGE — it is the same legibility floor the generator
 * already has (its own default is the literal `sw = R * 0.0086` = 4.85 and it
 * "takes the legibility floor from the caller", which is how the shipped
 * 5.94 got there). Below 1 CSS px a stroke is drawn as partial pixel
 * coverage, i.e. GREY — and "all lines white" is the one part of the app's
 * law that cannot be traded. 0.479cqh falls under 1px once the panel is
 * shorter than 209px, which happens at ≈460px of viewport and below; the
 * floor governs there and the relative value governs everywhere else. */
.wall-svg {
  display: block;
  width: 100%;
  height: 100%;
  /* Every `currentColor` stroke and fill in the layout's SVG resolves from
   * here — the one place this file sets it. The world is white-on-black
   * whatever the page ground does, because that is the app's law. */
  color: var(--structure);
  stroke-width: max(1px, 0.479cqh);
}

/* Layer 1 — the children. `position: relative` is what puts a NON-positioned
 * in-flow image above an absolutely-positioned sibling; without it the
 * surface (a positioned element, painted in step 8 of the painting order)
 * would cover the plate that is supposed to be in front of it. */
.wall-fg {
  position: relative;
  z-index: 1;
}

/* ── the per-altitude caption — TOP-right of the panel, not bottom ───────
 * A deliberate divergence from `#world`'s own bottom-right corner, forced by
 * the composite: the bottom of this panel is where the children are. The
 * artwork contract's safe area (content/showcase.json ▸ crew-wall-live ▸
 * geometry.safeArea) says a hand never reaches above 41% of panel height, so
 * the panel's top-right is the one corner that is both inside the black and
 * permanently clear — including under `prefers-reduced-motion`, where
 * scenes.css unstacks these three spans into plain block flow and all three
 * show at once.
 *
 * Positioned against the FIGURE (percentages of the rendered picture, like
 * `.wall-surface` above) rather than against `.wall-surface` itself, because
 * it has to paint OVER the children: `.wall-surface` is z-index 0, the plate
 * is z-index 1, this is 2. The panel's right edge is at 15.01 + 70.49 =
 * 85.51% of the figure, so `right: 18%` sits it ~2.5% of figure width — one
 * `.plate`-ish inset — inside that edge, and `top: 12%` clears the panel's
 * own 8.98% top by about the same.
 *
 * `.plate`'s face is inherited from bands.css (mono, `--tracking-mono`); its
 * `color: var(--text-muted)` is NOT — that token is a warm dark grey for
 * cream grounds (#6B665E) and this text sits on true black. `--structure` at
 * .55 computes to 6.27:1 on `--void` (blended sRGB 0.55 → linear ≈0.2636 →
 * (0.2636+.05)/(0+.05)), comfortably AA at this size.
 *
 * THE TYPE SIZE IS MEASURED AGAINST THE PICTURE, NOT THE PAGE, so the caption
 * holds the same optical scale as the drawing it sits on. `1.983cqw` is that
 * rule expressed in the only axis the figure can contain (see its own rule
 * above for why it is `inline-size`): panel height = 81.61% of figure height
 * and the figure's aspect is 2752/1536, so 4.35% of PANEL HEIGHT = 0.0435 ×
 * 0.8161 / 1.7917 = 1.983% of FIGURE WIDTH — the same number, re-expressed,
 * not an approximation. It resolves to 11.0px at the ≥1280 figure (556px),
 * 13.7px at 768 and 7.0px at 390, so the clamp does real work at both ends:
 * bands.css's own 11px is the ceiling and 9px the floor, because a 7px
 * caption on a 159px panel is a smudge, not a plate. */
.j-caption-stack {
  position: absolute;
  z-index: 2;
  top: 12%;
  right: 18%;
  text-align: right;
  color: var(--structure);
  opacity: 0.55;
  font-size: clamp(9px, 1.983cqw, 11px);
}

/* 🔴 DO NOT ANIMATE OFF-SCREEN FOREVER — and this band's answer is NOT
 * `animation-timeline: view()`.
 *
 * `src/css/scenes.css`'s own header dropped view()-gating for this world with
 * a stated reason: an 18-second COMPOSED loop parked at an arbitrary,
 * possibly mid-transition frame is worse than one that simply runs. That
 * argument is about a SCROLL-PROGRESS timeline and it still stands — it does
 * not conflict with what happens here, because pausing a document-timeline
 * animation is a different mechanism: the loop resumes exactly where it
 * stopped, at a frame it composed itself, and scroll position never touches
 * the arms' angles. (Tying them to scroll would be scroll-jacking, a named
 * anti-reference in the owner's design.md, not a nit.)
 *
 * "Run on the document timeline while visible, pause otherwise" is not
 * expressible in CSS alone. `src/js/wall-scene.js` toggles this class from an
 * IntersectionObserver on the figure; this rule is what the class means.
 * Strictly progressive: with no JS the class is never added and the world
 * runs continuously, which is what the band did before this file existed.
 *
 * 🔴 EVERY ANIMATED SELECTOR IN scenes.css MUST BE LISTED. The list is not
 * decorative — anything omitted keeps running while the band is off-screen,
 * and a partially-paused composite would also DRIFT: the arms are only
 * provably in sync with the camera because they share one document timeline,
 * so pausing the camera without pausing the arms breaks the one property the
 * whole drawing rests on. Keep this in step with scenes.css's own
 * `prefers-reduced-motion` block, which names the identical set. */
.wall-scene-paused .camera,
.wall-scene-paused .j-arm-1,
.wall-scene-paused .j-arm-2,
.wall-scene-paused .j-arm-3,
.wall-scene-paused .j-knob,
.wall-scene-paused .j-weight,
.wall-scene-paused .j-note--strikable,
.wall-scene-paused .j-marbles-1,
.wall-scene-paused .j-marbles-2,
.wall-scene-paused .j-caption {
  animation-play-state: paused;
}

/* ── the entrance fade belongs to the WHOLE composite, not to the plate ──
 * bands.css gives every `.scene-img` a 180ms opacity fade. On a two-layer
 * stack that fades the CHILDREN in over an already-opaque black panel — for
 * 180ms you see the bare surface with ghost children on it, which reads as a
 * screenshot with something pasted over it. Moving the fade to the figure
 * fades both layers together, so the picture arrives as one picture.
 *
 * `scene-in`'s @keyframes are declared inside bands.css's own
 * `(prefers-reduced-motion: no-preference)` block; keyframe names are global
 * once registered, and this block has the identical condition, so the name
 * resolves. Under `reduce` neither block applies and nothing fades — which is
 * bands.css's existing behaviour, unchanged. */
@media (prefers-reduced-motion: no-preference) {
  .scene-figure--live .scene-img { animation: none; }
  .scene-figure--live { animation: scene-in 180ms ease-out both; }
}

/* NOTHING BELOW. 🔴 This file used to end with a `prefers-reduced-motion:
 * reduce` block parking `.wall-sweep` at 0deg. Both it and the `.wall-sweep`
 * animation it parked were DELETED when the journey replaced the bespoke
 * sweep: `src/css/scenes.css` owns reduced motion for every animated element
 * in this band now, and a reduce rule naming a class that no longer exists is
 * a no-op that READS AS COVERAGE — worse than nothing, because the next
 * reader checking "is reduced motion handled here?" would find a rule and
 * stop looking. It is handled, in scenes.css, for all TEN selectors.
 *
 * ✏️ COUNT CORRECTED 2026-09-29 by the stale-record pass — this sentence
 * said "thirteen". Counted off `.wall-scene-paused` above (`:240-249`):
 * `.camera`, `.j-arm-1/2/3`, `.j-knob`, `.j-weight`, `.j-note--strikable`,
 * `.j-marbles-1/2`, `.j-caption` — ten. Only the number moved; the claim
 * this sentence is making — that scenes.css's `prefers-reduced-motion` block
 * covers every one of them — was re-checked against that block and is
 * unchanged and true. */

/* ══════════════════════════════════════════════════════════════════════════
 * 🔴 THE COMPOSITE IS THE HERO NOW — 2026-09-30
 * ══════════════════════════════════════════════════════════════════════════
 * Owner: "swap the no-wrong-way figure as the new hero under the logo, and
 * the panel behind the kids is as wide as the 100vw or max 1024px while the
 * width of the kids image is smaller than the panel behind, so the hand of
 * the kid dosent reach more that the half height of the panel."
 *
 * THE PANEL IS THE FIGURE NOW, not a rect inside it. Until today the figure's
 * box WAS the children's picture and `.wall-surface` was positioned inside it
 * as a percentage of that picture. That is backwards for this brief: the
 * panel is the thing with the stated size (100vw, capped 1024px) and the
 * children are the thing sized relative to IT. So the figure carries the
 * panel's box and the children become an absolutely-positioned child of it.
 *
 * ── WHY THE CHILDREN ARE 50% OF THE PANEL'S HEIGHT ───────────────────────
 * Arithmetic, not taste. Measured on `crew-wall.svg`: the middle child's
 * raised fingertip sits 3.6% of the drawing's own height below its top edge.
 * With the children bottom-aligned to the panel, the fingertip lands at
 *
 *     panelH − h + 0.036h  =  panelH − 0.964h
 *
 * and the owner's constraint is that this is at or below the half-way line:
 *
 *     panelH − 0.964h ≥ 0.5·panelH   →   h ≤ 0.5187·panelH
 *
 * 50% is that bound with a little room, and it puts the fingertip at 51.8%
 * of the panel — just past half, never above it, at every viewport, because
 * both terms are percentages of the same box.
 *
 * ⚠️ IF THE ARTWORK IS EVER RE-CUT, RE-MEASURE THE 3.6%. It is a property of
 * that specific drawing, not a constant. The bound moves with it.
 *
 * `max-width` on the children is a second, independent guard: on a very short
 * viewport the panel gets wide-and-flat, 50% of its height could exceed its
 * width, and the children would overflow sideways. */
.hero-live .scene-figure--live {
  /* 100vw until the cap, then centred. A MARGIN PULL, not `left: 50%` + a
   * translate: on a relatively positioned element `left` offsets from the
   * static position, which already starts at the band's inset edge, so the
   * padding gets counted twice and the box sits `--edge-pad` off centre.
   * (The closing crew needed the same trick until 2026-09-30, when it became
   * its own section — `.crew-figure` in bands.css — and stopped needing to
   * escape a page column at all. This panel still does, because it lives
   * inside the hero's own padded stage.) */
  width: calc(100% + 2 * var(--edge-pad));
  margin-inline: calc(-1 * var(--edge-pad));
  height: 100%;
  min-height: 0;
  position: relative;
}
/* 🔴 THE CONTENT CAP IS 769px — owner, 2026-09-30, down from 1024px.
 *
 * ⚠️ THE BREAKPOINT IS 860px AND IT IS DERIVED, NOT 769 + 80. The element is
 * full-bleed below the breakpoint (`100% + 2 * --edge-pad`) and capped-and-
 * centred above it, so the switch has to happen where a 769px box actually
 * FITS inside the padded column — otherwise `margin-inline: auto` is
 * over-constrained, both margins collapse to zero, and the box hangs off the
 * right edge. `--edge-pad` is itself viewport-relative
 * (`max(clamp(20px, 5vw, 72px), …)`, base.css), so at the switch:
 *     B = 769 + 2·(0.05·B)   →   0.9B = 769   →   B ≈ 854
 * 860 takes that with a few pixels of slack; at 860 the padding is 43px, the
 * column is 774px, and a 769px box sits inside it with 2.5px either side.
 * The previous cap of 1024 used 1104 by the same reasoning.
 * If the cap changes again, re-solve this — do not add 80. */
@media (min-width: 860px) {
  .hero-live .scene-figure--live {
    width: 769px;
    margin-inline: auto;
  }
}

/* The panel fills the figure exactly. `inset: 0` replaces the old
 * 5%/0/90%/58% rect, which described a panel drawn INSIDE the children's
 * picture — a shape that no longer exists. */
.hero-live .wall-surface {
  /* ⚠️ `inset: 0` ALONE — do not add `left: auto` / `top: auto` beside it.
   * The first version of this rule did, meaning to cancel the base rule's
   * `left: 5%; top: 0`, and collapsed the panel to zero width: `inset`
   * already sets all four sides, so re-setting two of them to `auto` left an
   * empty absolutely-positioned box with `width: auto` and nothing to size
   * itself from. The panel rendered as nothing at all and the children
   * floated on cream. `inset` wins over the base rule's `left`/`top` by
   * source order; `width`/`height` still need explicit `auto`. */
  inset: 0 0 auto 0;
  width: auto;
  /* 🔴 THE PANEL STOPS ABOVE THE KNEE — owner, 2026-09-30: "the hero panel
   * bottom should start above the knee level of the kids and the upper hand
   * of the girl in the middle should reach 75% into the panel."
   *
   * Both numbers are SOLVED, not tuned, from two measurements taken off
   * `crew-wall.svg` itself:
   *   fingertip  2.97% down the drawing's own height  (topmost opaque row)
   *   knee      ~75%  down it                         (read off a crop)
   *
   * With the children bottom-aligned to the figure at height α, and the panel
   * top-aligned at height β (both fractions of the figure):
   *   fingertip depth into the panel = ((1−α) + 0.0297α) / β  =  0.25
   *   panel bottom above the knee    =  β < (1 − 0.25α)
   * The first gives β = 4(1 − 0.9703α); substituting into the second needs
   * α > 0.826. α = 0.88 → β = 0.5844, and the knee then sits at 0.78 of the
   * figure, comfortably below the panel's 0.5844 bottom edge.
   *
   * ⚠️ BOTH CONSTANTS BELONG TO THIS DRAWING. Re-cut the artwork and the
   * 2.97% and the 75% both move, and these two numbers have to be re-solved
   * — they are not a style choice that can be nudged independently. */
  height: 58.44%;
  /* 🔴 SQUARE WHILE IT IS EDGE-TO-EDGE — owner, 2026-09-30: "when the
   * viewport is less than 1024, the panel behind the hero kids has no rounded
   * borders - as it is edge to edge - for bigger change to a radius of around
   * 26px." A radius on a full-bleed element rounds a corner against the
   * viewport edge, which reads as a rendering fault rather than as a shape.
   * The 26px arrives in the same breakpoint that stops the bleed, below. */
  border-radius: 0;
}

/* The children, bottom-aligned and centred, at half the panel's height. */
.hero-live .wall-fg {
  position: absolute;
  inset-block-end: 0;
  inset-inline-start: 50%;
  translate: -50% 0;
  /* 88% of the figure, bottom-aligned — see the panel's own note above for
   * where this and the panel's 58.44% come from. The children now run PAST
   * the panel's lower edge on purpose: the panel ends above their knees and
   * their legs continue down the page ground, which is what puts them in
   * front of the screen rather than inside it. */
  height: 88%;
  width: auto;
  max-width: 96%;
  object-fit: contain;
  object-position: bottom center;
}


/* ── the panel's corners ────────────────────────────────────────────────────
 * The radius and the width cap are ONE decision and share one breakpoint: the
 * panel gets corners exactly when it stops touching the viewport edges and
 * becomes an object sitting on the page. Owner, 2026-09-30.
 *
 * ⚠️ 1104, not the 1024 the owner named — deliberately. The cap is 1024 PLUS
 * both `--edge-pad`s, so the bleed really ends at 1104; rounding at 1024
 * would put corners on a panel that is still edge-to-edge for another 80px,
 * which is the exact artefact being avoided.
 *
 * 🔴 AND IT LIVES AT THE END OF THE FILE ON PURPOSE. The first attempt put
 * this inside the existing `min-width: 1104px` block higher up — which sits
 * ABOVE `.hero-live .wall-surface`'s `border-radius: 0`. Same specificity
 * (0,2,0), later rule wins, so the square value overrode the media query and
 * the panel shipped with no corners at any width. A media query does not beat
 * source order; only specificity and position do. */
@media (min-width: 860px) {
  .hero-live .wall-surface { border-radius: 26px; }
}
