/* ═══ THE HOME PAGE'S OWN STYLESHEET — the story, and nothing else ════════
 * The DIRECTORY is the switch: src/pages/home/ exists, so pipeline/emit.mjs
 * concatenates this into /pages/home.css, linked FROM THIS PAGE ONLY. site.css
 * is concatenated into every page, and none of what follows is any use to them.
 *
 * All five scenes are built here (2026-09-21); what is still owed is FOOTAGE.
 *
 * ⏳ EVERY PICTURE IS A PLACEHOLDER. There is no footage yet (../../PLAN.md
 * §Open, "where the footage comes from"), and the reference screenshots are the
 * owner's own home and never ship. `[data-placeholder]` is the only thing
 * drawing inside a gate — grep for it when the video arrives; it is meant to be
 * deleted, not tuned.
 */

/* ═══ WHAT ON THIS PAGE IS NOT A CORNER ═══════════════════════════════════
 * base.css §THE CORNER hands every rounded box on the site the app's curve in
 * ONE rule — the only way "every box takes the app's corner" stays true as a
 * page grows. The price of a rule that broad is that it also finds the things
 * that are round for a different reason, and this story is full of them: the
 * apparatus is drawn out of discs (the dial, the radar, the score ring and its
 * note, the maker disc) and the cut marks are stadium ends. A disc under a
 * superellipse comes out a blob.
 *
 * ⭐ ONE LIST FOR THE PAGE, rather than a line beside each shape: adding a disc
 * means remembering one name, in one place. `round` is the property's initial
 * value, so this costs nothing in an engine that never reads it. */
.radar,
.maker, .maker::after,
.disc, .tick, .knob { corner-shape: round; }

/* ═══ --turn — THE ONE NUMBER BOTH RINGS ARE MADE OF ══════════════════════
 * ⭐ WHERE THE RING HAS GOT TO, AS A REGISTERED PROPERTY. Everything the two
 * dial scenes do is a function of this angle: which glyph stands at the tick,
 * how bright the others are, how big the one at the tick is, which name is
 * being said, and — on scene 2 — which of the six pictures is up. So it is
 * stated ONCE, animated ONCE, and read as plain arithmetic everywhere else.
 *
 * That is not a trick to save lines; it is what keeps the parts honest. The
 * app's ring does exactly this (`ModeSwitch.swift` ▸ `opacity(at:lit:)` and
 * `scaleEffect` are both functions of the glyph's distance from the index), and
 * the alternative here — six hand-sliced `animation-range`s per ring, per
 * property — is four sets of numbers that can drift out of step with each
 * other. They cannot drift if there is only one.
 *
 * 🔴 IT MUST BE REGISTERED OR IT CANNOT BE ANIMATED. An unregistered custom
 * property interpolates DISCRETELY — it would jump from 0deg to its end value
 * at the halfway mark and the ring would not turn, it would flip. `@property`
 * with a syntax is what makes it a real angle the engine can tween.
 * `inherits: true` is the other half: the six pictures live in the gate layer
 * and the ring lives in a scene, so the only place one value can reach both is
 * their common ancestor, `.story`. */
@property --turn {
  syntax: "<angle>";
  inherits: true;
  initial-value: 0deg;
}

/* Whether the mode/theme name is still being said — 1 while the ring is
 * working, 0 once the scene has settled. ../../PLAN.md item 7: the name is not
 * a persistent label, and without this the LAST one never left (the ring stops
 * on its final detent and its name stayed lit for the rest of the scroll).
 * ⚠️ `initial-value: 1` is what makes the still honest everywhere this is not
 * animated — an engine with no scroll-driven animation, and every scene that
 * is not the dial or the score, gets the name up rather than blank. Registered
 * so it can be tweened at all; see §--turn above for that argument in full. */
/* ⭐ THE LINE THE COMPOSITION HANGS FROM, AND IT MOVES ONCE. ../../PLAN.md item
 * 22 part A: the page lands with the square DEAD CENTRE and large, and the move
 * to the top-two-thirds anchor is one animated length, not a second layout.
 *
 * ⚰️ WHAT CHANGED 2026-09-25 (../../PLAN.md item 40): THIS PROPERTY IS NO LONGER
 * ANIMATED. It used to carry `centre-rise` itself, on `scroll()`. The lift now
 * has a second possible trigger — the projector's own playback — and a length
 * that two clocks might want is exactly the thing this file already has a law
 * about: §--seam-open, "animate the scalar, compute the length." So `--lift`
 * below is the animated thing and this is an ordinary `calc()` on `.story`,
 * read at use time, where both engines agree.
 *
 * ⚠️ STILL REGISTERED, for the type check and for one other reason: an
 * unresolved first frame draws this initial value, and 50svh is the LANDING.
 * That is why `.story` declares the derived value unconditionally rather than
 * relying on the initial — see §--lift for the whole of that argument. */
@property --centre {
  syntax: "<length>";
  inherits: true;
  initial-value: 50svh;
}

/* ═══ --lift — HOW FAR THE PROJECTOR HAS WARMED UP, 0…1 ═══════════════════
 * ⭐⭐ 0 IS BEAT A (the landing: a large square, dead centre, no copy) AND 1 IS
 * BEAT B (the square up in the top two-thirds, the bottom third open for the
 * words). Everything vertical on this page hangs off `--centre`, and `--centre`
 * is now nothing but this number read through one `calc()`. ../../PLAN.md item
 * 40, Christian, 2026-09-25.
 *
 * 🔴 BEAT A IS A COMPOSED FRAME, NOT A LOADING STATE — Christian's amendment,
 * same day: *"I want to keep the large 01 rounded box center of screen when
 * loading for initial."* Centred in the VIEWPORT, large, nothing else on
 * screen with it, and the bottom third NOT reserved — that reservation is what
 * beat B buys. `--lift: 0` is that composition exactly: `--centre` resolves to
 * 50svh, which is the middle of the glass, and `--gate` (derived from it) comes
 * out at 653px against 353px settled, at 1440×900.
 *
 * ⚠️ SO THE REGISTERED INITIAL IS 0, AND ONE NORMAL DECLARATION CARRIES EVERY
 * FALLBACK — `body[data-page="home"] { --lift: 1 }` at §THE OPENING'S
 * THREE BEATS. It is on the SAME element every mechanism animates, so an
 * animation always outranks it, and it is what paints whenever no mechanism
 * is running. Traced through every combination, because this property has to
 * be right in six of them:
 *
 *     engine / reader            what drives --lift        landing?
 *     Chromium, no JS            lift-on-scroll (scroll())   yes
 *     Chromium, JS               the intro, on a clock       yes
 *     Chromium, reduce, no JS    nothing → the declaration   no, settled
 *     Chromium, reduce, JS       the intro, instantly        yes
 *     Firefox, no JS             nothing → the declaration   no, settled
 *     Firefox, JS                the intro, on a clock       yes
 *
 * ⭐ AND IT SUBSUMES ../../PLAN.md ITEM 32. That item declared the settled
 * `--centre` in a normal rule because Chromium applies a `scroll()` animation
 * one frame AFTER first paint, so a deep reload painted a 653px gate for one
 * frame with the radar and reel misaligned around it. The same declaration
 * does the same job here — an unresolved frame paints the SETTLED composition
 * — except that it now also answers Firefox and reduced motion, which the old
 * one did not. One line where there were three cases.
 *
 * 🔴 A READER WHO ASKED FOR LESS MOTION STILL GETS BEAT A, and that is the
 * amendment's third point rather than an oversight: base.css:194 forces every
 * `animation-duration` to 0.01ms under `reduce`, so the landing is ARRIVED AT
 * rather than animated into — no fade from black, no flicker, nothing fetched —
 * and the advance to beat B is a step rather than a slide. Same composition,
 * no movement in or out of it.
 *
 * ⚠️ REGISTERED, because an unregistered custom property cannot be tweened: it
 * would step from one value to the other at the halfway mark. Same argument as
 * `--turn` above. */
@property --lift {
  syntax: "<number>";
  inherits: true;
  initial-value: 0;
}

@property --say-out {
  syntax: "<number>";
  inherits: true;
  initial-value: 1;
}

/* How much of the added score has been laid down at this column — 0…1. Same
 * argument as above: registered, so it can be tweened at all. */
@property --score-in {
  syntax: "<number>";
  inherits: true;
  initial-value: 1;
}

/* ═══ --share — THE SEAM, AND THE ONLY NUMBER THE CUT IS MADE OF ══════════
 * ⭐ THE FIRST HALF'S SHARE OF THE FILM, 0…1 — and everything in scene 5 is
 * that one number: the two boxes' sides, the two runtimes above them, and
 * whether there are two of anything at all. It has to be one number, because
 * the scene's whole claim is that the sizes ARE the lengths. Two numbers kept
 * in step by hand would be a scene that says the sizes MATCH the lengths, which
 * is a weaker and more breakable thing.
 *
 * 🔴 1 IS THE UNCUT FILM, and that is why the initial value is 1 rather than
 * the split: the gate is shared by every scene, so its width formula is live on
 * all six, and at `--share: 1` it computes to exactly `--gate`. An engine that
 * never runs an animation gets the square it has always had. */
@property --share {
  syntax: "<number>";
  inherits: true;
  initial-value: 1;
}

/* The gap the cut opens between the two halves. Zero until there are two. */
/* ⭐ HOW FAR OPEN THE SEAM IS — 0…1, and it exists so that NO KEYFRAME HAS TO
 * NAME `--gate`. ../../PLAN.md item 22 introduced the bug this fixes: making
 * `--centre` animated made `--gate` (derived from it) animated too, and the
 * seam's keyframes held `calc(var(--gate) * 0.051)`.
 *
 * 🔴 THE TWO ENGINES THEN DISAGREED, AND THIS IS THE GOTCHA WORTH KEEPING: a
 * `calc()` INSIDE a keyframe that reads another ANIMATED custom property is not
 * resolved the same way by both. Measured mid-split at 1440×900, with both
 * engines reporting `--centre: 300px`: Chromium computed the seam against the
 * live gate (353 × 0.051 = 18.0px) and WebKit against the gate implied by
 * `--centre`'s REGISTERED INITIAL VALUE (653 × 0.051 = 33.3px) — a 15.3px
 * difference, consistent across the whole scene, in a seam the app's own
 * proportion fixes at 34px against a 674px square.
 *
 * ⭐ THE FIX IS TO KEEP THE LOOKUP OUT OF THE KEYFRAME. The animation now
 * carries a plain NUMBER and `--seam-gap` is computed in an ordinary rule,
 * where `var(--gate)` is read at use time and both engines agree. The general
 * form, worth remembering: animate the scalar, compute the length. */
@property --seam-open {
  syntax: "<number>";
  inherits: true;
  initial-value: 0;
}

@property --seam-gap {
  syntax: "<length>";
  inherits: true;
  initial-value: 0px;
}

/* ═══ --dev-turn — THE PHONE'S OWN ANGLE, AS A REGISTERED PROPERTY ════════
 * Owed to 20-device.css (lease F1) — `.device-frame`'s own header states why
 * this file is the only one allowed to register a property at all: the
 * custom-property namespace is global across the concatenated bundle, so
 * "do not name a property `--turn`" can only be enforced from ONE file.
 * G1 (`20-frame.js`) writes this on `.device-frame` from `place.figTurn`;
 * `.device-frame` reads it for `rotate`, and F2's counter-rotation and growth
 * on the picture inside the glass `calc()` from the same value.
 *
 * 🔴 MEASURED, NOT PREDICTED (COORDINATION.md, D1-device-css finding 3):
 * unregistered, it STEPS rather than turns — sampled at 1440×900, `rotate`
 * eased 2.7° → 90° over the full 620ms in both engines while `--dev-turn`
 * read 90deg from frame one. `20-device.css`'s own `rotate` chase (§THE
 * SCALE, THE ROTATION AND THE PIVOT) already costs a measured residual to
 * keep `rotate` tracking a REAL angle through the transition; an unregistered
 * `--dev-turn` would make that residual pointless, because anything reading
 * `--dev-turn` directly — the picture's counter-rotation, and the growth
 * `calc()`ed alongside it — would already be sitting at the target while the
 * visible rotation is still 2.7° in. That is defects 3e/3f again, by a new
 * route: the black wedge this bundle has already paid to close.
 *
 * ⚠️ INHERITS: TRUE, and the reasoning is NOT `--turn`'s (§--turn above),
 * copied blind — it is read here. `--turn` inherits because it is written on
 * `.story` and read by two SIBLINGS below it (the gate layer's pictures, the
 * ring), so the ancestor is the only place both can reach it. `--dev-turn` is
 * written on `.device-frame` ITSELF (20-device.css §THE CONTRACT, item 5) and
 * `.device-frame` reads its own value directly — that part needs no
 * inheritance at all. But 20-device.css's own header states the law this page
 * is built on: "everything the app draws is a DESCENDANT of [`.device-frame`],"
 * and the picture F2 counter-rotates is exactly that — a descendant, several
 * levels in, of the element G1 writes the angle onto. A custom property only
 * reaches a descendant through inheritance; there is no other channel a
 * `var()` three levels down can use to see a value set on an ancestor.
 * `inherits: false` would leave that picture reading the INITIAL `0deg`
 * regardless of what G1 wrote above it, silently. */
@property --dev-turn {
  syntax: "<angle>";
  inherits: true;
  initial-value: 0deg;
}

/* ═══ --half-split / --half-stack / --ar — THE HEIGHT-CAP TERMS, RESOLVED ══
 * Owed to 20-device.css (lease F1), §THE CONTRACT item 9. `:root` there
 * declares the real values (`--half-split: 84svh`, `--half-stack: 46svh`,
 * `--ar: calc(1 / var(--dev-ratio))`) and uses them directly in CSS `calc()`;
 * these registrations exist for a SECOND reader, `20-frame.js`'s
 * `targetBox()`, which needs the resolved pixel height and a bare number, not
 * the literal token `"84svh"` — `getComputedStyle` hands back the unparsed
 * string for an unregistered custom property.
 *
 * 🔴 THIS IS THE DEFECT E1-narrow-arm FOUND, NOT A TIDINESS ITEM
 * (COORDINATION.md, E1-narrow-arm, "A FOUR-ROUND-OLD ALARM NOBODY READ"):
 * without a registered, resolvable value here, `targetBox()` had to
 * RE-IMPLEMENT the pinned `min()` arithmetic in JS to get a usable number — a
 * third copy of the same formula 20-device.css's own header warns against
 * (§THE ELBOW: "the two terms meet in a `min()` in CSS... and the measurement
 * is written in JS, once" — this is that same discipline applied one level
 * down) — and the JS copy drifted from the CSS original at every viewport,
 * firing `assertBox`'s alarm for four rounds before anyone read it.
 * Registering the three terms the cap is built from removes the need to
 * reimplement the cap at all: G1 reads resolved px straight off the element.
 *
 * `initial-value` must be computationally independent (`00-props.css` may not
 * itself reference `--dev-ratio`), so these are plain safe fallbacks — never
 * meant to be seen, since `:root` in 20-device.css declares the real values
 * unconditionally. `inherits: true` because `.device-frame` both declares and
 * reads all three, and nothing here needs them to stop at a boundary. */
@property --half-split {
  syntax: "<length>";
  inherits: true;
  initial-value: 0px;
}

@property --half-stack {
  syntax: "<length>";
  inherits: true;
  initial-value: 0px;
}

@property --ar {
  syntax: "<number>";
  inherits: true;
  initial-value: 1;
}

/* ═══ THE PRIMITIVE — terminal-c's, adopted not invented ══════════════════
 * ../../PLAN.md §*The site's motion is Terminal C's section primitive*. THE
 * SECTION IS THE SCROLLER: it carries the scroll length. ITS .stage IS THE
 * STAGE: sticky, one viewport, so the picture holds still while the scroll
 * drives the clock. `--pin` is how many extra viewports of scroll a scene buys.
 *
 * 🔴 Kept to the ~30 lines that ARE the primitive. terminal-c's own
 * page.stage.css is 2,834 lines, nearly all of it that site's eight named
 * rungs; porting the file rather than the mechanism would import another
 * product's page. */

.scene {
  view-timeline-name: --scene;
  view-timeline-axis: block;
  /* Keep default scene travel compact; mechanics can buy more where needed. */
  height: calc(100svh * (1 + var(--pin, 1.4)));
  width: 100vw;
  margin-inline: calc(50% - 50vw);
}


/* ═══ THE GATE — the apparatus, and it belongs to the PAGE ════════════════
 * ../../PLAN.md: "The rounded square is the GATE. Flicker, the light falling on
 * a wall, gate weave — everything the APPARATUS does. It belongs to the page,
 * not to the footage, and it keeps going between scenes." ⭐ Which is also why
 * the flicker never has to be video: only what is INSIDE has to come from
 * somewhere. */


/* ═══ THE WORDS ══════════════════════════════════════════════════════════ */
.scene-title {
  font-size: clamp(1.05rem, 2.6vw, 1.5rem);
  letter-spacing: -0.02em;      /* design.md: tighter than the framework default */
  margin: 0 0 0.35em;
}
.scene-blurb {
  font-size: clamp(0.92rem, 1.9vw, 1.05rem);
  line-height: 1.5;
  color: #B9B2A8;               /* checked against #000: 8.6:1, past AA */
  margin: 0;
  text-wrap: pretty;
}

/* ═══ THE ONE DEVICE — the box, and nothing that goes in it ═══════════════
 * Lease F1 of docs/designs/refactor/port-plan.md (PART 4 step 4). Ported from
 * `site/_proto/refactor/kit/device.css` + `kit/stage.css` off a hashed snapshot
 * (that tree was being edited by another agent while this was written).
 *
 * ⭐⭐⭐ ONE DEVICE FOR THE WHOLE STORY. It lives once, sticky behind every
 * section, and MOVES + MORPHS as the sections pass. Christian: *"i wanted one
 * device for all stages … how that is having a stage that stays on screen"*.
 * The architecture this replaces drew the composition on a full-viewport plane
 * BESIDE a device edge; here `.device-frame` IS the device and everything the
 * app draws is a DESCENDANT of it.
 *
 * ── WHERE THE NUMBERS COME FROM ─────────────────────────────────────────────
 *   [R3]  docs/designs/refactor/research-app-truth.md — pixels off the
 *         1290×2796 screenshots, cross-checked against Camcorder/*.swift.
 *         The device is an iPHONE 15 PRO MAX, 430 × 932 pt. Highest authority.
 *   [R2]  research-site-anatomy.md §C.2/§C.4 — the device study.
 *   [SP]  the one-frame spike this travel model comes from.
 *   [R5]/[R6] verification-r5.md, fixes-r6.md — Playwright, Chromium 145 +
 *         WebKit 26, five viewports, panel open and closed.
 *
 * ── WHAT THIS FILE OWNS, AND WHAT IT DELIBERATELY DOES NOT ─────────────────
 * OWNS: `.framebar` · `.fb-in` · `.device-frame` and its travel · the glass
 * clip (`.screen`) · `container-type` · `.screen-app`'s no-rotation law ·
 * `.dev-island` · `.dev-home`.
 * DOES NOT: the composition inside the glass (`.gate`, `.reel`, `.dial`, the
 * satellites, the radar, `.scene-stack`) — lease F2, `30-composition.css`. Nor
 * the app's palette beyond the one colour `.screen` paints. Nor the narrow arm
 * (lease F8, `95-narrow.css`). Nor one line of JavaScript (lease G1,
 * `20-frame.js`); §THE CONTRACT WITH `20-frame.js` at the foot names every
 * property that file must write.
 *
 * ── 🔴 THE TWO LAWS THE PROTOTYPE CARRIES AND THIS PAGE HAS NEVER HAD TO OBEY
 * A. EVERY PLACEMENT IS A LENGTH, NEVER AN ALIGNMENT KEYWORD. See §THE ONE
 *    DEVICE. There is not one `justify-*`, `align-self`, `float` or `order` in
 *    this file, and there must never be one inside the travelling device.
 * B. SELECTION IS BY A READING LINE, NEVER `intersectionRatio`. Nothing here
 *    can enforce that — it is G1's — but `--avail` below is the length the line
 *    is measured against, so it is stated here.
 * ═══════════════════════════════════════════════════════════════════════════ */

:root {
  /* ══ THE RUNWAY — only the four tokens the BOX reads ════════════════════
   * `--stage-pin` / `--overlap` / `--stage-minh` are the SECTION primitive's
   * (10-primitive.css `.scene`, and the per-section values story.json carries);
   * they are deliberately not restated here.
   *
   * ⭐ `--nav-block` NAMES THE AXIS, NOT THE EDGE — renamed from `--hdr`
   * 2026-10-01 (coordinator's call, answering Christian's question about where a
   * nav's size enters this arithmetic). The two answers are different arithmetic
   * and one token cannot carry both:
   *   · chrome that is a TOP or BOTTOM bar costs BLOCK space, comes out of
   *     `--avail` below, and is this token;
   *   · chrome that is a SIDE rail costs INLINE space and enters the COLUMN
   *     arithmetic instead — `--content` / `--stage-edge-pad` / the elbow cap —
   *     and never touches `--avail`.
   * ⚠️ THERE IS DELIBERATELY NO `--nav-inline` OR `--rail` TOKEN. Nothing would
   * consume one: the rail's inline cost is already carried by `nav.css`'s own
   * `padding-inline-start` push, and a second name for it would be
   * configuration with no second client — paid on every read, collected never.
   * The day a side rail has to enter this file's arithmetic, that is the edit.
   *
   * 🔴 AND IT IS 0px ON THIS SITE, WHICH IS A MEASURED FINDING RATHER THAN THE
   * KIT'S UNCHANGED DEFAULT. port-plan §2.1 says in capitals to wire this token
   * to the real header's height, citing `wireframe.yml:63-67` (*"Both sticky …
   * the story page wants a header that does not scroll away"*).
   * ⚰️ THAT INSTRUCTION WAS ITSELF THE DEFECT, and the rename is the moment to
   * say so plainly: `.site-header` was DELETED on 2026-09-22 —
   * `src/html/_header.html` is gone, `_shell.html` no longer includes it, and
   * `src/css/components.css:5-35` is its tombstone. `frame: header: sticky` in
   * `wireframe.yml` is a registry key whose consumer went with the markup. So
   * following §2.1 would have WIRED THIS TOKEN TO A HEIGHT THAT NO LONGER
   * EXISTS, parked the device a header's worth too low and fired the reading
   * line a header's worth early — it would have INTRODUCED the bug it was
   * written to prevent.
   * The site's only chrome is `.site-rail` (`src/css/nav.css:501-512`):
   * `position: fixed; inset-block: 0; inset-inline-start: 0; width: --rail-w`
   * — a full-height column down the LEFT edge, so it is the INLINE case above
   * and costs exactly zero block space at every viewport (its two media queries,
   * `max-width: 30rem` and `max-height: 26rem`, change only its gutter and its
   * rows, never its axis).
   * If block-axis chrome ever returns, this is the one line, and G1's `--avail`
   * and reading line both follow from it with no second edit.
   * G1 asserts it: see §THE CONTRACT, item 8. */
  --nav-block: 0px;
  /* 🔴 EVERY STAGE HEIGHT IS A FRACTION OF WHAT IS LEFT, NOT OF THE VIEWPORT.
   * Derived once so the framebar's height, its cancelling margin, the reading
   * line and the peek can never disagree about how much room there is. */
  --avail: calc(100svh - var(--nav-block));

  /* The column the device is placed WITHIN — and it is the site's own number,
   * not the kit's 1100px. `src/css/base.css` defines `--page: 960px` — ⭐ it was
   * `72rem` (1152px) until 2026-10-01, when Christian dialled it; mech. 8's elbow
   * cap is written against that term, so a second copy here is the drift the
   * kit's own `--half-split`/`--ar` single-sourcing exists to stop. ⚠️ Which is
   * also why that one line moved every `figScale` threshold on the page: it is
   * the width term of the `min()` the cap below is the other half of. */
  --content: var(--page, 1100px);

  /* The stage's own gutter. ⚠️ DELIBERATELY NOT `--space-3`: that is the TEXT
   * page's flat gutter, and this page's settled law is *text clears the rail,
   * pictures clear the glass* (`nav.css:236`). A picture's gutter tracks the
   * viewport; a paragraph's does not. It is also the term the elbow's cap stops
   * at — see §THE ELBOW below. */
  --stage-edge-pad: clamp(1rem, 4vw, 28px);

  /* ══ THE DEVICE BOX ═════════════════════════════════════════════════════
   * 932 / 430 = 2.16744 [R3 §1]. The aspect is FIXED — unlike the spike, whose
   * frames morph between 16/9, 4/3 and 2/3, this one barely morphs at all.
   * What travels is POSITION and SIZE, which is why `aspect-ratio` is not in
   * the transition list below. */
  --dev-ratio: 2.1674;
  /* 55pt of 430 = 12.79% of the WIDTH; the SAME 55pt is 12.79 / 2.1674 = 5.90%
   * of the height, which is why the corner must be a PAIR — a single percentage
   * would resolve to two different lengths.
   * ⚠️ NOT `cqw`: an element cannot query its own container. Children may. */
  --dev-corner:   12.7907%;
  --dev-corner-y: 5.9014%;

  /* ⚠️ `--corner-fraction` AND `--corner-entry` ARE NOT DECLARED HERE. They are
   * already on `:root` in `src/css/base.css:319-323` at value-identical numbers
   * (0.1445, and 1 → 1.4 inside `@supports (corner-shape: superellipse(1.6))`),
   * and COORDINATION.md's wave-5 ruling on the duplicate pair is *"dedupe on
   * merge, never leave two."*
   * ⭐ AND THE KIT'S OWN `@supports` BLOCK IS REDUNDANT HERE TOO: base.css:348
   * already applies `corner-shape: superellipse(1.6)` to `*, *::before,
   * *::after`, so the fitted curve reaches this device for free. That universal
   * rule is also why `.dev-island` and `.dev-home` below must opt OUT of it. */

  /* Where the square's centre sits down the device — the app's own anchor, and
   * this file's `transform-origin` (see §THE PIVOT). 1018.5/2796; [R2] and [R3]
   * agree exactly.
   * ⏳ OPEN, AND NOT SETTLED HERE: port-plan §8.13 — the 2/3–1/3 rule says
   * 0.33333 and this measurement says 0.3643, 3‰ apart, one anchor under two
   * names. The measured value ships; nothing in this file picks a winner. */
  --sq-centre: 0.3643;

  /* The device's height cap, per arrangement — so the WIDTH term in `--fw`'s
   * `min()` yields and both arrangements stay ONE animatable expression [SP].
   * Read by this file and by `20-frame.js`'s `targetBox()`; declared once. */
  --half-split: 84svh;
  --half-stack: 46svh;
  /* the aspect as a width/height NUMBER, for the height cap: 1/2.1674 = 0.46138 */
  --ar: calc(1 / var(--dev-ratio));

  /* ══ THE TRAVEL ════════════════════════════════════════════════════════
   * ⭐ 300ms, AND IT IS CHRISTIAN TURNING THE DIAL THE LINE BELOW INVITED HIM TO
   * TURN — `one-device.dialled.json`, 2026-10-01, `--fig-dur: 0.3s`. It was
   * `.62s`; this halves the travel.
   * ⚠️ 300ms IS STILL ABOVE base.css's HOUSE RULE AND THAT IS STILL ON PURPOSE.
   * `--motion-duration: 160ms` and design.md's *"under 200ms, ease-out, done"*
   * govern STATE CHANGES. This is design.md's named exception — *"when motion
   * IS the meaning"* — because the phone travelling between two placements is
   * the mechanism the whole story is built on, and the spike's finding is that
   * *arriving on an ease* is the feel [SP]. It is a dial: it is one number, it
   * is here, and Christian has turned it.
   * ⭐ EVERY TWO-CLOCK MARGIN IN THE DESIGN RECORD IS NOW CONSERVATIVE, NOT
   * STALE. `docs/designs/refactor/measure-two-clocks.md` and `eject.md` were
   * computed at `.62s` — the scroll clock racing the transition clock — so a
   * SHORTER transition can only widen the margins they report. Nothing there
   * needs re-deriving on account of this line; it needs re-deriving the day this
   * number goes UP. ⚠️ Read against it: `20-frame.js`'s settle window is
   * `--fig-dur · 1000 + 90`, so it tracks this automatically (its own comment
   * says a settle shorter than `--fig-dur` cannot work) — do not hardcode it. */
  --fig-dur:  .3s;
  --fig-ease: cubic-bezier(.4, 0, .2, 1);
}

/* ══ THE FRAMEBAR ═════════════════════════════════════════════════════════
 * 🔴 NET-ZERO IN FLOW, AND THE NEGATIVE MARGIN IS THE WHOLE MECHANISM. A sticky
 * box still occupies its own height in the document, so a full-height framebar
 * pushes the first section a whole viewport down — the spike MEASURED its lead
 * starting at y = 1024. The margin cancels the height it claims: the device
 * still sticks below the header reserve, and the sections start where they
 * should. This is the same construction `.gate-layer` uses today
 * (`30-composition.css:265-270`: sticky `100svh`, `margin-bottom: -100svh`),
 * expressed against `--avail` instead of the raw viewport.
 *
 * ⭐ NO FULL-BLEED HATCH HERE, AND THAT IS NOT AN OMISSION. `.story` is already
 * `width: 100vw; margin-inline: calc(50% - 50vw); position: relative`
 * (`30-composition.css:241-243`), so a framebar inside it is ALREADY in the
 * glass's coordinate system and its `50%` is 50vw. Restating the hatch would
 * double it. ⚠️ It follows that the framebar MUST be a child of `.story` — the
 * same parent `.gate-layer` has today. Emitted anywhere else it loses both the
 * bleed and the sticky container, silently.
 *
 * 🔴 AND THE DEVICE MAY PASS BEHIND THE RAIL, BECAUSE IT IS A PICTURE. The
 * page's settled law is stated once in `nav.css:236`: *"TEXT CLEARS THE RAIL.
 * PICTURES CLEAR THE GLASS."* — Christian, 2026-09-22, after item 31 made the
 * rail transparent: *"the story's picture now shows through the rail instead of
 * being painted out by it."* So the framebar takes the whole glass and is NOT
 * inset by `--rail-w`. ⚠️ Named cost, because it is real: the rail's GLYPHS are
 * still opaque at `z-index: 20`, so at `figAlign: left` with an elbow the
 * phone's left edge runs under them. nav.css's own WCAG 1.4.11 note asks for
 * that contrast to be re-measured on rendered pixels whenever a scene paints
 * something bright down the left edge — a travelling phone is exactly that,
 * and it is owed.
 *
 * `z-index: 3` — over `.gate-layer`'s 1 and `30-composition.css:436`'s 2, so
 * the story passes BEHIND the phone, which is the point: the device is the one
 * solid object on the page and nothing crosses in front of it. Under the rail's
 * 20 and the skip link's 100, both deliberately (`nav.css:508-511`).
 * ⚠️ Which means section text flies behind an opaque black phone as it crosses.
 * That is the kit's intent and it is a legibility question for F2/B1, not a
 * geometry one; `check:device`'s page assertions are where it gets asserted. */
.framebar {
  position: sticky;
  top: var(--nav-block);
  height: var(--avail);
  margin-bottom: calc(-1 * var(--avail));
  z-index: 3;
  /* The bar is a plane the story scrolls under, not a surface. It holds nothing
   * focusable and every child is `aria-hidden`, so it contributes nothing to
   * the accessible sequence — which is also what keeps §THE NARROW ARM's future
   * visual reorder out of WCAG 1.3.2. ⚠️ If anything focusable is ever put
   * inside the device, both of those stop being true on the same day. */
  pointer-events: none;
  padding-inline: var(--stage-edge-pad);
  /* the fade over a band — a band carries no device, so the phone dissolves in
   * place rather than sliding off with the section. G1 writes the opacity. */
  transition: opacity .45s ease-out;
}

/* ══ 🔴🔴 WITH JS OFF THE PLANE IS NOT A STAGE, SO IT DOES NOT STICK ═══════
 * ONE DECLARATION, AND IT IS THE ANSWER TO A MEASURED REGRESSION, NOT A TASTE.
 * Measured 2026-09-30, full-page sweep, JS off, `<h1>`-vs-device visible
 * overlap at the worst scroll offset of the whole page:
 *   1440×900   41,713.93 px² Chromium · 41,158.03 px² WebKit  @ scrollY 14050
 *   1024×768   35,047.18 · 34,526.31                          @ 12000
 *   1024×1366  49,011.60 · 48,283.19                          @ 21550
 *   1280×600   27,809.28 · 27,438.69                          @  9300
 *   390×844    16,993.26 · 16,625.25                          @ 13200
 * — and at 760×1700, where the `<h1>` never meets it, the device instead took
 * 20,703 px² of a `.scene-blurb`. So this was never only the headline: the
 * JS-off device occludes running text at EVERY viewport.
 *
 * ⚠️ AND IT IS WHY THREE EARLIER REPORTS OF "0 px²" WERE HONEST ARITHMETIC AND
 * STILL WRONG. At 1440×900 the page's only `<h1>` lives in `#cta-final` at
 * document y 14750–14870 (it moved there on 2026-09-22 when `home.hero` was
 * deleted and its `titleLockup` went to `home.cta-final`), so it is not in the
 * viewport until scrollY ≥ 13850 — the last 1,861 px of a 15,711 px scroll,
 * 11.8% of the page. Every sample taken above that offset reads exactly 0.00,
 * truthfully, about a question narrower than the one it was asked. **Sweep the
 * page; never sample a position and report it as the page.**
 *
 * ── WHY THE STICK, AND NOT THE PAINT ORDER, IS THE FAULT ───────────────────
 * `#cta-final` is `position: relative; z-index: 2` with `margin-top: -200svh`
 * (`25-scenes.css:704`), DELIBERATELY pulled up inside `.story`'s trailing pad
 * so *"the gate is still pinned through this section"* — and its `z-index: 2`
 * is authored precisely so the pitch reads OVER the pinned picture. That
 * contract works for `.gate-layer` (z 1) and it works for `.scene > .stage`
 * (z 2, earlier in the DOM, so the later band wins on tie). `.framebar`'s
 * `z-index: 3` is the one thing in the story's stack that outranks it.
 * ⚰️ BUT MOVING THE FRAMEBAR TO 2 IS NOT THE FIX, because 2 is also the
 * scenes' stage: the same number cannot sit above one and below the other, and
 * dropping to 2 would put the phone behind every scene's text — the law at the
 * head of §THE FRAMEBAR, traded away to buy a band.
 *
 * ⭐ THE REAL OBSERVATION IS SIMPLER, AND IT IS ABOUT WHAT THE PLANE IS FOR.
 * A sticky framebar is a STAGE: it stays on screen because the device TRAVELS
 * across it as the sections pass. With JS off nothing travels — `data-side`,
 * `--fig-scale`, `--figtop` and `--dev-turn` are never written (§THE CONTRACT),
 * so the device is one centred box that cannot move. A plane pinned for 15,711
 * px of scroll with nothing moving on it is not a stage; it is an obstruction
 * that follows the reader down the page. So with JS off it does not stick, and
 * the device becomes what it actually is in that state: ONE phone, once, at the
 * head of the story, with the words below it.
 *
 * 🔴 AND THIS IS NOT THE VISIBILITY GATE D1 REFUSED. Nothing is hidden, nothing
 * is `display: none`, no `@media` and no `<noscript>`: the device is present,
 * centred and at full `--fig-scale`, which is the base rule's whole point
 * (defect A5's answer). Only its ATTACHMENT to the scroller changes.
 * 🔴 IT COSTS NO LAYOUT AND NO FIRST FRAME. `position` sticky→static changes
 * nothing in flow — `height` and the cancelling `margin-bottom` are untouched,
 * so the sections do not move by one pixel and there is no boot-time shift —
 * and at scrollY 0 a sticky box sits exactly where its static box does, so the
 * ONE PAINTED FRAME G1 measured (WebKit paints at 61ms, boots at 88ms) is
 * unchanged in geometry. Only paint ORDER differs in that frame, and it differs
 * in the safe direction: the words win.
 * ⚠️ MEASURED, NOT ASSERTED, BECAUSE IT IS A REAL COST: at scrollY 0, JS off,
 * old rule against this one, 177,883 px of 1,296,000 differ in Chromium and
 * 177,162 in WebKit (1440×900, CSS animations frozen) — the opening scene's
 * words drawn over the device instead of under it, for the ~27 ms before boot.
 * ⭐ And what it buys, measured the same way: the pixels INSIDE the `<h1>`'s own
 * box that change when the device's plane is removed go 13,663 → 0 (Chromium)
 * and 13,674 → 0 (WebKit) at scrollY 14050. Not an intersection of two
 * rectangles — the `<h1>` no longer depends on the device for a single pixel.
 * ⭐ The overlap had lived in one window, and it is gone at every offset now:
 * scrollY 13,925–15,710 of a 15,711 px scroll, 11.4% of the page → none.
 * ⭐ `z-index` IS INERT ON A STATIC BOX BY DEFINITION, and that is the second
 * half of the fix rather than an accident — the `3` above needs no JS-off
 * counterpart, because there is nothing for it to apply to.
 *
 * 🔴 IT FAILS CLOSED, AND `data-frame` IS ALREADY THE WITNESS FOR IT. G1 stamps
 * `<html data-frame="live">` on a successful boot and `"absent"` when there is
 * no device to drive (§THE CONTRACT item 1's neighbour; `20-frame.js:89, :98` —
 * *"`data-frame` IS THE BOOT WITNESS AND IT IS WHY THE CHECKS CAN FAIL
 * CLOSED"*). This adds a READER for an attribute that file already writes; not
 * one line of `20-frame.js` changes. If the attribute is ever missing while JS
 * IS running, then `--fig-scale` was not written either and the device is
 * mis-sized anyway — an inert, correctly-centred, non-occluding phone is the
 * better of the two failures, which is the direction `check:align`'s tombstone
 * asks for.
 * ⚠️ NAMED COST, because it is real and it is the trade: with JS off the device
 * no longer follows the story, so it is seen behind `#scene-opening`'s words
 * for the first `--avail` of scroll and then scrolls away. The words are ON TOP
 * of it there rather than behind it, which inverts §THE FRAMEBAR's *"nothing
 * crosses in front of the phone"* for the JS-off state only. That is deliberate
 * and it is `docs/design.md` rule 3 outranking a JS-on aesthetic: *"If an LLM
 * can't understand the page from the rendered HTML alone, the page is broken."*
 * ⚠️ AND THE CONTRAST OF TEXT OVER THE PHONE'S BODY IS STILL OWED, exactly as
 * §THE FRAMEBAR already records for the rail's glyphs — WCAG 1.4.11 on rendered
 * pixels, for F2/B1, in the one orientation where a scene's words cross it. */
html:not([data-frame]) .framebar { position: static; }

/* The column the device is placed within. `position: relative` is what makes it
 * the containing block for the absolutely-positioned device, so every `%` in
 * §THE ONE DEVICE resolves against THIS box and not against the viewport. */
.fb-in {
  position: relative;
  height: 100%;
  max-width: var(--content);
  margin-inline: auto;
}

/* ══ 🔴🔴 THE ONE DEVICE ══════════════════════════════════════════════════
 * LAW A, IN FULL, BECAUSE IT IS THE REASON THIS BOX IS SHAPED THE WAY IT IS:
 * EVERY PLACEMENT IS A LENGTH, NEVER AN ALIGNMENT KEYWORD. `justify-self`,
 * `justify-content`, `align-self`, `float` and `order` are DISCRETE — there is
 * no interpolation between `start` and `end`, so a transition on one flips at
 * the halfway point of the timing function and reads as a CUT. `left`, `top`,
 * `width` and `translate` all interpolate, so the device travels between any
 * two placements, including across the split/stack boundary.
 * ⚰️ The previous kit build placed the device with grid centring plus a
 * percentage margin. That is the discrete trap, and it is why that build was a
 * REWRITE rather than a rename.
 * ⚠️ AND THE HAZARD IS LIVE IN THIS DIRECTORY: `place-items: center`,
 * `justify-content: space-between` and grid centring appear throughout
 * `25-scenes.css` and `30-composition.css`. Every one of them that ends up
 * INSIDE this box is a latent cut. F2 inherits that audit.
 *
 * ⚠️ AND ONE OF THEM IS ALREADY KNOWN: `.dial`'s implicit grid track is sized by
 * `.ring`, which is wider than the dial, so `place-items: center` centres the
 * disc on the TRACK rather than on the device's centre-line. The app's whole
 * argument is ONE CONTROL, on the centre-line. The fix is F2's (an explicit
 * single track), and it is named here because this box's grid is the first
 * thing a reader will suspect and it is not the cause. */
.device-frame {
  --half: var(--half-split);
  --fig-scale: 0.86;

  /* ══ 🔴 THE ELBOW — AN ASK, NOT A LENGTH ════════════════════════════════
   * The device is the ONE element allowed out of the content column; the text
   * never leaves it [SP, terminal-c `.elbow-room`]. But an elbow is a raw
   * length against a column that may have no room for it: measured at `the-dial`
   * with `figElbow: 96` in a ~1120px view, the phone's left edge landed at
   * NEGATIVE x and the whole left third was off screen — and at `figAlign:
   * right` the same overshoot hides BEHIND a narrowing panel, so it looks
   * correct for exactly as long as the panel is up.
   *
   * ⭐ SO THE CLAMP IS WRITTEN HERE, ONCE, AND THE MEASUREMENT IS WRITTEN IN JS,
   * ONCE — and this is a deliberate improvement on the kit, which did both in
   * `frame.js`. The two terms meet in a `min()` in CSS:
   *   `--elbow-want`  the ask, from `story.json > place.figElbow`   (G1 writes)
   *   `--elbow-cap`   the room, measured off the framebar's content box (G1)
   * 🔴 IT FAILS CLOSED. If G1 never writes the cap, the fallback is `0px` and
   * the elbow is simply not granted — the device stays inside the column. The
   * alternative spelling (JS resolving the `min()` and writing one `--elbow`)
   * fails OPEN if the cap measurement is ever skipped, and this repo has a
   * tombstone about exactly that direction: `check:align`'s axis proof *"failed
   * OPEN, which is the dangerous direction: it passes, and tells you the rig is
   * alive when nothing was tested."*
   *
   * ⚰️ AND `--page-wide` IS NOT DEFINED, IS NOT NEEDED, AND IS NOT ADDED.
   * `kit/README.md` mech. 8 and `story.json`'s `the-dial` note both state the
   * cap as `(--page-wide − --page) / 2`; `--page-wide` is defined nowhere in
   * `src/` or `layouts/` (grepped, zero hits) and B1 filed that as F1's to fix.
   * The finding is right and the remedy is the other one mech. 8 itself
   * prefers: the cap is MEASURED against the framebar's CONTENT BOX, which is
   * already inside the page's gutter, and `frame.js:250-259` already implements
   * it that way rather than reading any `--page-wide`. So the missing token has
   * no consumer — defining it would be a third copy of a number nobody reads.
   * The site-side terms that DO exist and are enough: `--page` (960px since
   * 2026-10-01, via `--content`) and `--stage-edge-pad`.
   * ⚠️ One choice inside the choice, unchanged from the kit: the cap stops at
   * the framebar's CONTENT box, so the device never crosses `--stage-edge-pad`.
   * terminal-c's cap is the wide page, which would let a bleeding figure touch
   * the view's edge. Stopping at the pad is the conservative reading and it is
   * one line in `20-frame.js` to change. */
  --elbow: min(var(--elbow-want, 0px), var(--elbow-cap, 0px));

  /* ⭐ ONE EXPRESSION, TWO TERMS, AND THE HEIGHT CAP IS WHAT KEEPS BOTH
   * ARRANGEMENTS ANIMATABLE — the width term yields to it rather than a second
   * rule swapping in [SP].
   * ⚠️ THE `50%` IS `.fb-in`'s WIDTH AND IT IS DELIBERATELY NOT `50cqw`. A
   * container-query unit here would read the nearest ANCESTOR container (an
   * element cannot query its own), which would make it depend on whether
   * anything upstream happens to be a container — and this is the declaration
   * that sizes the entire device, so a plausible-but-wrong resolution is the
   * worst possible place for one. `%` on `width` resolves against the
   * containing block, which `.fb-in`'s `position: relative` fixes exactly.
   * ⚠️ `.fb-in` MUST THEREFORE STAY PADDING-FREE, or the `%` basis (its padding
   * box) and the elbow cap (its content box) stop agreeing. */
  --fw: min(calc(var(--fig-scale) * 50% + var(--elbow)),
            calc(var(--half) * var(--ar)));

  position: absolute;
  width: var(--fw);
  aspect-ratio: 1 / var(--dev-ratio);

  /* 🔴 THE BASE RULE IS THE NO-JS COMPOSITION, so it is CENTRED — a deliberate
   * departure from the kit's `left: 0`. With JavaScript off no `[data-side]` is
   * ever written, so whatever stands here is what a reader without JS sees, and
   * design.md's rule 3 (AI-first) makes that a real composition rather than a
   * fallback. A5 in `verification-one-device.md` is the defect this answers —
   * *"with JS off the phone draws on top of the headline"* — and a phone parked
   * hard left at the vertical centre of a full-height bar is the worse half of
   * it. ⚠️ CONSEQUENCE FOR G1: the first paint therefore travels from centre to
   * the lede's placement unless the first transition is suppressed. See §THE
   * CONTRACT, item 7. `translate` interpolates, so nothing is discrete either
   * way — only visible. */
  left: 50%;
  top: 50%;
  translate: -50% -50%;

  /* ⚠️ THE BLACK BODY IS PAINTED BY `.screen`, NOT HERE. It has to be: the
   * satellites are other people's phones, they belong BEHIND this one, and they
   * are CHILDREN of the device so they ride its travel and use its `cqw`. An
   * opaque background on this element would hide them at any z-index. It is
   * also more correct — `.screen`'s clip then shapes the body, rather than a
   * `border-radius` approximating the same curve a second time. */
  border-radius: calc(var(--dev-corner)   * var(--corner-entry, 1))
               / calc(var(--dev-corner-y) * var(--corner-entry, 1));
  /* a thin edge, not a rendered bezel. design.md: no faux-3D, no skeuomorph. */
  outline: 1px solid rgba(255, 255, 255, 0.16);
  outline-offset: -1px;

  /* 🔴 THE CONTAINER, AND IT IS THE STRUCTURAL FIX FOR A VERIFIED DEFECT.
   * Every app fraction downstream is a `cqw` of THIS box, which is exact by
   * construction and cannot drift from the frame. The defect it makes
   * impossible (F1 in `verification.md`): `--turn-fit` scaled `.device-edge`
   * while `.gate-layer` was its SIBLING and nothing scaled it — measured at
   * 760×1700 the frame's bbox was 336.7×155.3 while the square ALONE was
   * 366.1×366.1 and the disc sat 308px BELOW the device. Invisible at 1024×768
   * and 1440×900 because `--turn-fit` is exactly 1 there.
   * 🔴 THE THREE VIEWPORT ANCHORS GO WITH IT. `--centre`, `--gate` and `--axis`
   * existed because the props lived on a full-viewport plane and had to be
   * re-derived against it; inside a container-query device there is nothing to
   * re-base. Their measured CONTENT survives (`--ring-orbit: 0.186047` is
   * [R3]'s 80pt/430, and it agreed with [R2]'s independent `0.2072 × --gate`
   * derivation to four decimal places — that cross-validation is why the number
   * is trusted). Retiring the anchors is F2's work, not this file's.
   * ⚠️ TWO TRAPS, BOTH REAL:
   *   · `inline-size` gives `cqw`/`cqi` ONLY. There is no `cqh` without
   *     `container-type: size`, which needs a definite height and would be
   *     circular against this box's own `aspect-ratio`. Any fraction of the
   *     HEIGHT is a plain `%` of this box, which children may use directly.
   *   · An element cannot query its OWN container — which is why `--dev-corner`
   *     above is the two-value `%` form and not `12.79cqw`. */
  container: device / inline-size;

  /* ══ 🔴 THE SCALE, THE ROTATION AND THE PIVOT ═══════════════════════════
   * All three live HERE, on the box the contents are inside, so the contents
   * ride them and cannot detach.
   *
   * `--turn-fit` is measured by G1 (it needs the resolved pixel width, and
   * `--fw` carries a percentage `calc()` cannot divide by). ⭐ It still earns
   * its place under the app's turn model: a turned PORTRAIT device is
   * `--dev-h` wide in the world and the pivot is off-centre, so it reaches
   * `(1 − --sq-centre)` one way and `--sq-centre` the other. Measured across
   * five viewports × two sides it BINDS IN EIGHT OF TEN: 0.412 at 1024×768
   * side-left, 0.411 at 760×1700, 0.398 at 1024×1366, 0.700 at 1440×900.
   *
   * 🔴🔴 THE PIVOT (A1). `transform-origin` is the SQUARE's centre, not the
   * box's, and anything that takes an inverse of this rotation must share this
   * origin — AN INVERSE TRANSFORM IS ONLY AN INVERSE ABOUT THE SAME ORIGIN.
   * The retired `.screen-level` declared none, pivoted on the initial
   * `50% 50%`, and the pair composed to a net TRANSLATION: the whole
   * composition thrown 102.4px diagonally at `figTurn: 90`, 1440×900, at every
   * viewport, in both engines, agreeing to 0.05px — and 0.06–0.33px at turn 0,
   * so purely the turn. Closed form for the displacement when the origins
   * disagree: `2·sin(θ/2) · (0.5 − --sq-centre) · devH · --turn-fit`. After the
   * fix, re-derived independently twice: 0.283px [C1] against 0.281px [V1] over
   * 13 angles on BOTH hands.
   * ⚠️ DESCENDANCY FIXED THE SCALE, NOT THE PIVOT. The container above made the
   * composition ride the box, so the SCALE can no longer detach; the pivot is a
   * second, independent route to the same symptom and it survived that rework.
   * ⚠️⚠️ AND THE MOST USEFUL SENTENCE IN THE WHOLE VERIFICATION RECORD APPLIES
   * TO THIS EXACT FIX: *"the broken frame looks more complete than the fixed
   * one. An eye asked which of these is right would pick the broken one."*
   * Displaced by the pivot bug, the reel strip was pushed INTO view. NEVER sign
   * this off by looking — compute the expectation from first principles. */
  scale: var(--turn-fit, 1);
  rotate: var(--dev-turn, 0deg);
  transform-origin: 50% calc(var(--sq-centre) * 100%);

  /* 🔴🔴 `rotate` IS IN THIS LIST BECAUSE WEBKIT CUTS THE TURN WITHOUT IT (E4 /
   * fixes-r6 TASK 1), and this is the single most easily "tidied away" line in
   * the file.
   * Without it, in WebKit 26 the phone snapped to landscape in ONE FRAME and
   * the picture then rotated itself level over the following 620ms — the exact
   * opposite of the law the turn scene exists to demonstrate. Measured on the
   * SCREEN (the ratio of the picture's and the window's bounding rects, which
   * is 1 exactly when the picture is level and grown), every pinned cell, both
   * panel states: the picture's world angle 89.91–89.99° for the whole 620ms
   * and its box reaching 1.998× the window's. Chromium was 0.000° throughout.
   * ⭐ THE CAUSE IS NOT THE REGISTERED PROPERTY, and the obvious hypothesis was
   * refuted on a bare `<div>` from both sides: a custom-property→`rotate` chain
   * eases fine in WebKit UNTIL A `scale` TRANSITION CO-RUNS ON THE SAME
   * ELEMENT, which freezes the PAINTED `rotate` at its target while the
   * computed value interpolates correctly the entire time.
   * ⚠️ SO `scale` AND `rotate` MUST STAY IN THIS LIST TOGETHER. A rig that
   * drives `--dev-turn` alone walks around the fault and reports a clean page —
   * that happened, twice, in one round.
   * ⚠️ AND IT COSTS A SMALL RESIDUAL, ON PURPOSE: `rotate` now interpolates
   * toward a target that is itself interpolating, so the painted angle trails
   * `--dev-turn` slightly. Measured over 20 pinned cells: worst world angle
   * 0.000° in Chromium and 2.73° in WebKit, against 89.99° before, with 0px of
   * uncovered window either way. 2.4° for ~400ms is invisible; 90° is the
   * scene's whole argument inverted.
   * ⚰️ A SHORTER DURATION DOES NOT BUY THE RESIDUAL BACK. `rotate 0s`, `1ms`
   * and `16ms` were each measured and each STILL CUT (world 89.33–89.98°, box
   * 1.998×). Only a real duration lifts the freeze. THE CHASE IS THE PRICE, NOT
   * A CHOICE — nobody may tune it away.
   * ⚰️ NOR CAN `--dev-turn` LEAVE THE LIST to stop the chase: the picture's
   * growth is `calc()`ed from it, and a growth that jumps to its target while
   * the rotation eases is the black wedge (3f) straight back.
   * ⚠️ `aspect-ratio` IS ABSENT deliberately — this device's aspect is fixed, so
   * there is nothing to interpolate. */
  transition: left      var(--fig-dur) var(--fig-ease),
              top       var(--fig-dur) var(--fig-ease),
              width     var(--fig-dur) var(--fig-ease),
              translate var(--fig-dur) var(--fig-ease),
              scale     var(--fig-dur) var(--fig-ease),
              rotate    var(--fig-dur) var(--fig-ease),
              --dev-turn var(--fig-dur) var(--fig-ease);
}

/* SPLIT — the device takes one half of the column and may bleed outward by the
 * elbow. Lengths only; `left` and `translate` both interpolate. */
.device-frame[data-side="left"]  { left: calc(0px - var(--elbow)); top: 50%; translate: 0 -50%; }
.device-frame[data-side="right"] { left: calc(100% + var(--elbow) - var(--fw)); top: 50%; translate: 0 -50%; }

/* STACK — the device takes a band and the words take the rest. `--figtop` is
 * the GROUP's own measured top, written by G1's `placeStack()`, not a fixed
 * inset: a stacked device-plus-text group has to be centred AS A GROUP.
 * ⚠️ A PEEK ALSO LANDS HERE. `figPeek` parks the device low so that fraction of
 * its height shows above the fold — Christian: *"the initial load of the page
 * has the CTA up top with the device's top part already visible, so it makes
 * you scroll"*, and *"IT IS DELIBERATELY NOT A FULL SCREEN"*. It is written
 * through the same `--figtop`, so a peek requires a stacked side. */
.device-frame[data-side="above"],
.device-frame[data-side="below"] {
  --half: var(--half-stack);
  left: 50%;
  translate: -50% 0;
  top: var(--figtop, 5svh);
}

/* ══ iOS CHROME — it belongs to the PHONE, so it rides the rotation ═══════
 * `cqw` is legal here: these are children of the container, not the container.
 * 🔴 `corner-shape: round` IS REQUIRED, NOT COSMETIC. `base.css:348` applies
 * `superellipse(1.6)` to `*, *::before, *::after`, and base.css's own header
 * says what that costs the shapes that are not corners: *"A disc (50%) and a
 * pill (999px) come out as blobs under it — measured: a caught disc fills 0.58
 * of its corner box where a circle fills 0.31."* These two are pills. Opting
 * out where they are drawn is base.css's own documented convention, and
 * `00-props.css` already keeps the page's other opt-out list. */
.dev-island {
  position: absolute; top: 1.18%; left: 50%; translate: -50% 0;
  width: 29.07cqw;                     /* 125pt of 430 [R3] */
  aspect-ratio: 125 / 36;
  background: #000;
  border-radius: 999px; corner-shape: round;
  z-index: 3;
}
/* The green camera-in-use dot: iOS chrome, NOT app furniture, and OFF unless
 * opted in — *does the site draw the green dot and the Dynamic Island* is ONE
 * decision, not six, and it belongs to the plan ([R3] open question 6). */
.device-frame[data-ios-dot] .dev-island::after {
  content: "";
  position: absolute; left: 50%; top: calc(100% + 0.7cqw); translate: -50% 0;
  width: 1.23cqw; aspect-ratio: 1;
  border-radius: 50%; corner-shape: round;
  background: #30D158;
}
.dev-home {
  position: absolute; bottom: 1.86cqw; left: 50%; translate: -50% 0;
  width: 32.33cqw; height: 1.16cqw;    /* 139 × 5pt of 430 [R3] */
  background: #fff; opacity: 0.55;
  border-radius: 999px; corner-shape: round;
  z-index: 3;
}

/* ══ 🔴 THE GLASS — ONE `clip-path: inset(… round …)` ═════════════════════
 * [R2 §C.4(c)], quoted: *"A CLIP, NOT `overflow: hidden`, AND NOT A NEW BOX FOR
 * EACH PIECE … `clip-path` leaves the geometry completely alone and only says
 * where the paint stops — the glass, expressed exactly once."* Measured before
 * it existed: *"the reel ran 442..838 against a device of 466..814 — 24px proud
 * on BOTH sides, hanging in the dark outside the phone."*
 *
 * 🔴 AND THE GLASS DOES NOT COUNTER-ROTATE, BECAUSE THE GLASS IS PART OF THE
 * PHONE. `clip-path` is applied in the element's own coordinate system and the
 * transform maps it AFTERWARDS, so a counter-rotation here would turn the glass
 * into a rotated rounded rect inside an upright phone. The counter-rotation
 * goes onto the PICTURE (F2, `.gate > :is(img, video)`), one level further in.
 * ⚠️ THE RADIUS IS NOT INFLATED BY `--corner-entry`: `clip-path` takes no
 * `corner-shape`, and 1.4× the radius WITHOUT the curve is base.css's measured
 * 14×-worse case (3.78% rms against 0.268% for changing nothing).
 *
 * ⭐ THE GROUND IS THE STORY'S OWN, NOT A FOURTH NAME FOR BLACK. `.story`
 * already declares `--story-ground: #000` (`30-composition.css:193`), and the
 * page's house constant is stated there: *"Ground: black. Not near-black — the
 * app's ground is black and the export bakes the corners against it."* The
 * framebar is a child of `.story`, so this inherits it; the literal is the
 * fallback and is the same colour, so moving the framebar cannot change it.
 * ⚠️ THE REST OF THE APP'S PALETTE IS NOT DECLARED IN THIS FILE — not
 * `--ink-red`, not `--ink-amber`, not the eight `--maker-*`, and above all not
 * `--cam-red`: `base.css:246` has `#EB362C`, [R3] measured `#EA3B3F` off the
 * glass, port-plan §8.4 is OPEN, and a `:root` declaration here would settle it
 * for all eleven pages by accident. */
.screen {
  position: absolute;
  inset: 0;
  background: var(--story-ground, #000);
  clip-path: inset(0 round var(--dev-corner) / var(--dev-corner-y));
}

/* ══ 🔴 THE APP'S PLANE — AND IT DOES NOT ROTATE AT ALL ══════════════════
 * Three lines, and the reason is the whole of law 4.
 * ⚰️ `.screen-level` IS RETIRED and the element is renamed: a class called
 * `-level` that levels nothing is the misleading-name problem. It
 * counter-rotated the WHOLE composition so the app read upright in the world —
 * [R3 §11.3]'s suggested CHEAP construction, which contradicts [R3 §11]'s own
 * MEASURED table. Christian ruled for the app's model, 2026-09-29.
 *
 * At a quarter turn the app keeps, glass-fixed and byte-identical to portrait:
 * the square, its position and its corner · the reel strip, tile sizes, the
 * gate · the disc, its size and its centre · the ring's six positions and the
 * red tick · the corner marks' positions. It turns only: the picture inside the
 * window · every symbol, in place · the mode's name, which moves to WORLD UP.
 * That is *chrome is fixed to the glass; only pictures and symbols turn*, and
 * [R3 §11.1] says it to this site directly: *"If the site turns a phone mock,
 * the CHROME MUST STAY PUT and only the picture, the symbols and the ring's
 * word may turn."*
 *
 * ⭐ AND NOTHING IS CLIPPED, because nothing leaves the phone. Under the old
 * model a turned phone showed the square and NOTHING ELSE — the strip 8.9%
 * visible (and that 8.9% was its empty top margin), the disc, the ring and the
 * island entirely outside. Now the strip runs down one side of the world and
 * the disc sits at the world's edge, which is what makes the scene say *no
 * wrong way to hold it* rather than *here is a picture*.
 *
 * 🔴 SO NOTHING MAY PUT A `rotate` ON THIS ELEMENT. That declaration is the
 * pivot bug (A1) and it is the one way back to 102.4px of displacement. The
 * picture's counter-rotation lives on `.gate > :is(img, video)` and — the one
 * deliberate departure from the pivot law — pivots on its OWN centre, because
 * there the goal is different: the picture must be LEVEL INSIDE A WINDOW THAT
 * HAS MOVED. Rotating it by −θ about its own centre gives net rotation zero AND
 * leaves its centre on the window's centre, because a rotation fixes its own
 * origin. Using the phone's origin there would cancel the rotation and then
 * throw the picture out of the window by exactly the old displacement. That
 * rule, and the growth derived from `--dev-turn` beside it, are F2's. */
.screen-app {
  position: absolute;
  inset: 0;
}

/* ══ THE CLAMP MUST SAY SO — dev chrome, opt-in, no production path ═══════
 * 🔴 WHY THIS EXISTS AT ALL: mech. 8's ruling is that *a dial being clamped
 * should SAY SO rather than appear to do nothing*, and B1 measured that THREE
 * OF THE NINE DIALS CHRISTIAN AGREED TO TURN ARE DEAD ON HIS OWN SCREEN.
 * `--fw`'s height term binds long before the width term does: at 1440×900 with
 * `--page: 72rem` the stacked cap was ~191px against a ~550–633px width term
 * and the split cap ~349px against ~473px — so `figScale` 1.15, 1.05 and 1.00
 * drew the IDENTICAL box, as did every split scale above ~0.635.
 * ⭐ EVERY NUMBER IN THAT SENTENCE WAS MEASURED AT 1152px AND `--page` IS NOW
 * 960px (2026-10-01). The width term is a fraction of the COLUMN and the height
 * term is a fraction of the VIEWPORT, so narrowing the page lowers the width term
 * only — which RAISES the `figScale` at which the height cap takes over, that
 * threshold being `2 · cap / column`. Measured at 1440×900: split 0.606 → 0.727,
 * stacked 0.332 → 0.398, so MORE scales are live than this paragraph says.
 * ⚠️ AND NONE OF THE NINE AUTHORED VALUES CROSSED IT: measured in both engines,
 * all five splits still draw 348.8×756 and all three stacked 191×414 at
 * 1440×900. The read-out below computes it live and is the authority; these
 * figures are kept as the reason the read-out exists, not as a table. A
 * dial that
 * does nothing reads as a broken slider before it reads as a clamped one, and
 * this is a precondition of the dialling session, not a nicety.
 *
 * ⭐ WHY IT IS A `::after` AND NOT A PANEL: port-plan PART 7 item 1 refuses the
 * kit's dev dial panel outright — *"A dial panel is dev chrome, not app UI"* —
 * and that panel is also an instrument that once CONCEALED a fault. This is
 * eight lines, it is behind an attribute nothing in the build sets, and the
 * string is composed by G1 so CSS holds no arithmetic it could get wrong.
 * ⚠️ A literal colour, deliberately: it is not a theme token and must not read
 * as one. Nothing ships with `[data-dev-clamp]` set. */
html[data-dev-clamp] .device-frame::after {
  content: attr(data-fig-clamp);
  position: absolute;
  inset-inline: 0;
  top: calc(100% + 6px);
  z-index: 9;
  pointer-events: none;
  font: 500 11px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace;
  text-align: center;
  white-space: pre-wrap;
  color: #FA9912;                /* the app's amber — a read-out, not a token */
}

/* ══ 🔴 REDUCED MOTION — THE PHONE STOPS TRAVELLING ══════════════════════
 * ⭐ THE DISTINCTION THAT MAKES THIS RIGHT: PLACEMENT IS STATE; THE JOURNEY
 * BETWEEN TWO PLACEMENTS IS MOVEMENT. This kills the movement and leaves every
 * placement exactly where it was — the device still arrives at the right place,
 * at the right size, at the right angle, for every section; it simply does not
 * cross the room to get there. The precedent is one directory up: `--turn` is
 * declared OUTSIDE the reduced-motion gate because *"which name is being said
 * is STATE, not movement"* (`00-props.css` §--turn).
 * Verified [C1], nine sections × 7 viewports × 2 panels × 2 engines: travel 30
 * frames → 0, duration → 0s (0.62s when that was measured, 0.3s since), and all
 * nine placements land at 0.00px /
 * 0.000° against their settled `no-preference` boxes.
 * `design.md` names honouring `prefers-reduced-motion` as one of the four
 * things the AI-first order does NOT carry for free, so this is a LAW and
 * nobody was asked.
 *
 * ⚠️ `--fig-dur` IS DELIBERATELY LEFT ALONE, so the fades survive. A fade is not
 * travel, and zeroing it would put a frame of the outgoing scene's picture in
 * the incoming box.
 *
 * ⭐ AND ON THIS SITE THIS BLOCK IS A RESTATEMENT, NOT THE MECHANISM — which is
 * worth knowing before anyone "simplifies" it away. `base.css:194-200` already
 * forces `transition-duration: 0.01ms !important` on `*, *::before, *::after`
 * under `reduce`, so the travel is already dead. This block is here for one
 * named, likely reason: that universal rule is broad enough that somebody will
 * one day narrow it, and the largest motion on the site must not come back
 * silently when they do. It is also what the verification record measured, so
 * it is what `check:device`'s reduced-motion cells will assert against.
 *
 * ⚠️ AND THE SAME base.css RULE COSTS SOMETHING THE PROTOTYPE KEPT: the scene
 * cross-fade is `transition: opacity` too, so under `reduce` a scene change on
 * this site is a 0.01ms CUT rather than the dissolve the kit measured. That is
 * a defensible reading — a cut is not movement — but it is a real difference
 * from the prototype and it cannot be reversed from inside this file: beating a
 * `*` `!important` in a shared stylesheet is a lease-H decision. NAMED, not
 * absorbed.
 *
 * ⚰️ AND THE KIT'S THIRD SELECTOR IS DROPPED RATHER THAN PORTED. `stage.css`
 * listed `.device-frame .stack`, and there is no `.stack` in the kit's markup
 * at all — the class is `scene-stack` (`one-device/index.html:70`). It matched
 * nothing, in both the media query and the `.sim-reduce` mirror. ⭐ It is not
 * fixed here, because the paragraph two lines above it in that same comment
 * says the cross-fade MUST survive: the selector's intent contradicted its own
 * block, and being dead was accidentally the correct behaviour. */
@media (prefers-reduced-motion: reduce) {
  .framebar,
  .device-frame { transition: none !important; }
}

/* ═══ 🔴 THE CONTRACT WITH `20-frame.js` (lease G1) ═══════════════════════
 * This file draws nothing that moves on its own. Every one of the names below
 * is written by G1 and read here; a name G1 does not write falls back to a
 * value that is SAFE rather than absent, and each fallback is chosen so the
 * failure is visible-as-inert rather than plausible-and-wrong.
 *
 *  1. `data-side` = "left" | "right" | "above" | "below", on `.device-frame`.
 *     From `story.json > place.figAlign`. Absent → the centred no-JS
 *     composition above. 🔴 NEVER a `justify-*`, an `order` or a class swap:
 *     that is law A by another name, and it is the trap the previous build had
 *     to be rewritten for.
 *  2. `--fig-scale` (number), on `.device-frame`. From `place.figScale`.
 *  3. `--elbow-want` (LENGTH, px — not a bare number), on `.device-frame`.
 *     From `place.figElbow`. ⚠️ `min()` needs a unit; `96` would invalidate the
 *     whole declaration and the elbow would silently be `0px`.
 *  4. `--elbow-cap` (LENGTH, px), on `.device-frame`. MEASURED, every resize:
 *     `(framebarContentWidth − fbIn.clientWidth) / 2`, floored at 0 — the
 *     framebar's content box, which is already inside the page's gutter, so it
 *     governs `data-side="right"` identically. The `min()` is done in CSS
 *     (§THE ELBOW); do not resolve it in JS and write a single `--elbow`.
 *  5. `--dev-turn` (ANGLE, e.g. `90deg`), on `.device-frame`. From
 *     `place.figTurn`. ⚠️ IT MUST BE A REGISTERED `@property <angle>` OR IT
 *     CANNOT INTERPOLATE — see item 9.
 *  6. `--turn-fit` (number ≤ 1), on `.device-frame`. Measured from the TARGET
 *     box, never the live one: `left`, `width` and `scale` are all mid-
 *     transition one frame after a placement lands, and measuring there is
 *     exactly defect A2 (`--figband` read 800px on arrival against 458px
 *     settled). Compute the box from the same expression this file draws with
 *     — `min(figScale·0.5·fbW + elbow, half·ar)` — and assert the drawn box
 *     against it after each transition settles.
 *  7. `--figtop` (LENGTH), on `.device-frame`, read only under
 *     `[data-side="above"|"below"]`. The GROUP's measured top, or the peek's
 *     park. ⚠️ A band must NOT clear it: dropping it while a band is current
 *     snaps the device back to centre WHILE it is fading out, which reads as
 *     the stage sliding off with the section instead of dissolving in place.
 *     ⚠️ AND SUPPRESS THE FIRST TRANSITION. The base rule is centred (§THE ONE
 *     DEVICE), so the first placement after boot would otherwise be a `--fig-dur`
 *     slide from the middle of the column. One frame with `transition: none`,
 *     or set the placement before the element is first painted.
 *  8. `--nav-block` (was `--hdr` until 2026-10-01; the token names the AXIS, not
 *     the edge — see §THE RUNWAY) — DO NOT WRITE IT unless you have measured
 *     block-axis chrome. It
 *     is `0px` here for a measured reason (see §THE RUNWAY), and `--avail`, the
 *     framebar's cancelling margin and the reading line all follow from it.
 *     ⭐ DO assert it: if any element's rendered box overlaps the top of the
 *     viewport and is not `.site-rail`, `--nav-block` is wrong and nothing else will
 *     say so.
 *  9. 🔴 OWED TO `00-props.css` (lease F0), because this file may not declare
 *     `@property` — the custom-property namespace is global across the
 *     concatenated bundle and F0 is the only file allowed to register one:
 *       · `--dev-turn`  `<angle>`,  inherits: true,  initial `0deg`
 *         🔴 WITHOUT THIS THE TURN IS A STEP, NOT A TURN. An unregistered
 *         custom property interpolates DISCRETELY, so `--dev-turn` would jump
 *         to its end value at the halfway mark and the phone would flip. Same
 *         argument as `--turn`'s in that file, and it is why the kit renamed
 *         this one: `--turn` is the RING's angle here and is measured and
 *         cited, so it keeps the name.
 *       · `--half-split` `<length>`, inherits: true, initial `0px`
 *       · `--half-stack` `<length>`, inherits: true, initial `0px`
 *       · `--ar`         `<number>`, inherits: true, initial `1`
 *         These three are registered so `getComputedStyle` hands G1 RESOLVED px
 *         and a bare number, instead of the token `"84svh"` for it to parse —
 *         a third copy of the arithmetic, and on a mobile browser with
 *         retracting chrome an `svh`-versus-`innerHeight` error as well.
 *         ⚠️ `initial-value` must be computationally independent, so it cannot
 *         itself be `84svh`; the real values are on `:root` above.
 * 10. ⚠️ `--share` AND `--seam-open` ARE NOT IN THIS CONTRACT AND MUST NOT BE
 *     WRITTEN BY G1. The kit's `frame.js` sets both INLINE on `.device-frame`,
 *     which beats `home.css`'s inherited animated value and SILENTLY KILLS THE
 *     CUT on the one element that draws it — confirmed by experiment, not
 *     predicted: the halves do not move until the two `setProperty` calls are
 *     removed. Settling the one-writer question is step 6's. Until then, one
 *     writer, and it is `50-motion.css`.
 * 11. ⭐ `--figband` — WRITTEN BY G1 ON THE SECTION'S STORY ELEMENT, AND SINCE
 *     2026-10-01 IT HAS A READER. ⚠️ The element is `.scene-words`, NOT the
 *     contract's old `.stage-story`: that name is the prototype's and appears
 *     twice on this site, both times in prose, while `.scene-words` is read by
 *     seven files including the gate. The consumer is in `30-composition.css`
 *     §`--figband`, scoped `html[data-frame="live"] .scene[data-arrange="stack"]`
 *     — 🔴 the `data-frame` guard is deliberate and is item 12's attribute doing
 *     a second job: with JS off the property is never written, so the rule must
 *     not match at all or the JS-off words would lose their centring.
 *
 * ── ⭐ AND THE FRAMEBAR IS PINNED FROM THE PAGE'S FIRST PIXEL (2026-10-01) ──
 * `--figtop` is measured against `.fb-in`, and a STICKY box sits at its STATIC
 * position until the scroll reaches its pin — so `figPeek`, which parks the
 * device at `avail − peek · fh` *inside the bar*, was resolving from wherever
 * `.story` happened to begin. With `#lede` above it that was ~317px down the
 * glass and Christian's `peek: 0.9` showed ~56px of a 414px phone.
 * `25-scenes.css` §THE PEEK now pulls `.story`'s box up by one viewport and pads
 * it back down by the same (the idiom item 8 already uses at the other end), so
 * the bar is pinned from scrollY 0 and `--figtop` resolves against the glass's
 * top — which is what this file's arithmetic has always assumed.
 * 🔴 NOTHING IN THIS FILE CHANGED FOR IT, and that is the test it had to pass: a
 * peek that needs a correction term inside `placeStack()` would be a second
 * place that has to know how far down the page the bar starts.
 * 12. ⭐ `data-frame` ON `<html>` — G1 ALREADY WRITES IT, AND THIS FILE NOW
 *     READS IT. `html:not([data-frame]) .framebar { position: static }` is the
 *     JS-off arm above; nothing in `20-frame.js` changes for it. 🔴 It follows
 *     that the attribute has become LOAD-BEARING FOR LAYOUT ATTACHMENT, not
 *     only for the gates: stop stamping it and the story loses its stage on a
 *     page where JS is running. It must keep being written on EVERY boot
 *     outcome, `"absent"` included.
 *
 * ── ⚠️ CASCADE POSITION, WHICH IS LOAD-BEARING ─────────────────────────────
 * `emit.mjs > concatInto` joins `src/pages/home/*.css` in FILENAME order, flat,
 * with one `"\n"` between files — there is no import graph and no bundler, so
 * the numeric prefix is the entire cascade. `20-device.css` therefore lands
 * after `10-primitive.css` and before `25-scenes.css`, and that is checked
 * rather than assumed: nothing in this file shares a selector with either
 * neighbour, and the only rule it must lose to is `base.css`'s universal
 * `corner-shape` (a different bundle, emitted first), which is exactly what
 * `.dev-island` / `.dev-home` opt out of by hand. The one rule it must WIN is
 * its own reduced-motion arm over `base.css`'s `*` — `.framebar` at (0,1,0)
 * beats `*` at (0,0,0) at equal `!important`, in either bundle order.
 * ⚠️ The file ends with no trailing blank line, deliberately: the join adds the
 * newline, and a blank one here grows the emitted bundle and breaks any
 * byte-for-byte comparison against a previous build.
 * ═══════════════════════════════════════════════════════════════════════════ */

/* ═══ SCENE 1 — THE PROJECTOR ═════════════════════════════════════════════
 * A dark room. Light flickering out of an 8mm projector onto the wall. It
 * loops. The picture inside the gate is 8mm here; the dial in scene 2 is what
 * takes the site into rotoscope (../../PLAN.md §The scenes, the amendment).
 *
 * ⭐ THE FLICKER IS TIME, NOT SCROLL. A projector does not run faster because
 * you scrolled — it is the room's own clock. It belongs to the opening footage
 * and is switched off when scene 2 brings in the 04 looks. */

@keyframes gate-flicker {
  /* A real gate is irregular. Even steps read as a strobe, so the values are
   * uneven on purpose and the cycle length is not a round number. */
  0%   { opacity: 0.86; }
  7%   { opacity: 1;    }
  11%  { opacity: 0.91; }
  23%  { opacity: 0.99; }
  31%  { opacity: 0.83; }
  44%  { opacity: 0.97; }
  58%  { opacity: 0.90; }
  67%  { opacity: 1;    }
  79%  { opacity: 0.88; }
  100% { opacity: 0.95; }
}

/* Gate weave: a projector's frame never sits perfectly still. Sub-pixel, and on
 * the GATE rather than the picture, because the apparatus is what wanders. */
@keyframes gate-weave {
  0%   { translate: 0 0;        }
  37%  { translate: 0.35px -0.5px; }
  63%  { translate: -0.4px 0.3px;  }
  100% { translate: 0 0;        }
}

/* The light in the gate. ⚠️ THE FLICKER IS ON THIS AND NOT ON THE GATE, and
 * that is load-bearing rather than tidy: `.gate` already carries the weave and
 * (under the motion story) the emerge, and the emerge writes `opacity` — two
 * animations on one element, the later one winning a shared property, is the
 * trap this page has paid for twice. The flicker keeps its own element and
 * everything that runs through the gate flickers because it is INSIDE the
 * light, which is also the true account of a projector. */
.gate-picture {
  position: absolute; inset: 0;
  background: #000;
  animation: gate-flicker 1.7s steps(9, jump-none) infinite;
}

/* ═══ THE FLICKER IS THE PROJECTOR BEING ON ══════════════════════════════
 * ⭐ ONE SELECTOR, AND IT ANSWERS A QUESTION THE OLD ONE COULD NOT. Until
 * 2026-09-25 this was five rules: a JS-toggled `[data-flicker-off]` hung on the
 * DIAL SCENE'S GEOMETRY (`dialScene.top <= innerHeight`) plus four `:has()`
 * clauses, one per story scene. MEASURED by the audit lane: the geometric one
 * fired at y=594 while clip 03 — 8mm footage, the projector's own — was still
 * the picture, and clip 04 did not arrive until y=1485. The flicker died 890px
 * early, and the two halves of the spec disagreed about why (../../PLAN.md:866
 * "at its entrance" vs :3050 "when clip 04 takes over").
 *
 * 🔴 CHRISTIAN'S 2026-09-25 SPEC SETTLES THE INTENT AND THE SELECTOR FOLLOWS IT
 * LITERALLY: the flicker is the PROJECTOR being on, so it runs for exactly as
 * long as the projector is what is on screen — clips 01 and 02 in the opening,
 * clip 03 through beat C — and it stops the moment something else is. Nothing
 * measures a distance any more. `data-scene-mode` is written from the CURRENT
 * SCENE in home.js ▸ sync(), which is the same geometric answer the rest of the
 * page already runs on, so this cannot drift from what is actually playing.
 *
 * ⚠️ AND IT IS WHY THE FOUR `:has()` RULES WENT. They said the same thing in
 * the form "if one of these four clips is showing" — a list that had to grow
 * with every scene, and did not grow when beat C arrived. `:not(opening)`
 * cannot be forgotten.
 *
 * ⚠️ WITH JS OFF the attribute keeps its server-rendered `opening` and the gate
 * flickers over the poster for the whole page. That is the honest reading of a
 * projector nobody switched off, and a reduced-motion reader never sees it:
 * base.css:194 zeroes every `animation-duration` under `reduce`. */
.gate-picture:not([data-scene-mode="opening"]) { animation: none; opacity: 1; }

/* ═══ THE OPENING'S THREE BEATS ══════════════════════════════════════════
 * ../../PLAN.md item 40, Christian, 2026-09-25:
 *
 *   A — the landing. The square is LARGE and dead centre, no copy on screen,
 *       and it fades and flickers in like an 8mm projector being switched on.
 *       Clip 01 plays through.
 *   B — the lift. As soon as clip 02 is cued up to play, the square moves up
 *       into the top two-thirds and the bottom third opens for the words.
 *   C — its own scene (`no-wrong-way`), clip 03, and a second piece of copy.
 *
 * 🔴🔴 BEAT C IS A SCENE AND BEATS A AND B ARE NOT, AND THAT ASYMMETRY IS THE
 * DESIGN. B→C is an ordinary scroll cue, so it is an ordinary section and the
 * page's own primitive does the whole of it. A→B is a PLAYBACK cue — it
 * advances with no reader input at all — which no scroll-driven mechanism can
 * express, so it is the one thing on this page driven by a clock.
 *
 * 🔴 AND THE FINGER ALWAYS WINS OVER IT. `scroll-jacking` and
 * `animation-as-content` are on the house recoil list (design.md), and the
 * adopted primitive exists precisely so a scroll ASSEMBLES a scene and never
 * holds it hostage. So these rules never touch the scroll position, never
 * block, and are shaped so that a scroll during beat A resolves the composition
 * to beat B at once and hands the page back. The reversal is ONE WORD, and it
 * is `INTRO_CUE` in home.js — not here.
 *
 * ⭐ THE STATE LIVES ON `<body>` RATHER THAN ON `.story`, for one reason:
 * `.story` already carries two scroll-driven animations and this file's own
 * §TWO ANIMATIONS ON `.story` warns that a third writing a property one of them
 * writes makes the later one's 0% keyframe the page's opening state. `<body>`
 * has no animation of its own anywhere in this site's CSS, `--lift` inherits
 * down to `.story`, and the two mechanisms can never collide because there is
 * only ever one of them.
 *
 * ⚰️ AND IT LIVED ON `main` UNTIL 2026-09-25 (Christian: *"move the fade in of
 * the aside-rail up to the timing of the 02 B-gate projector shift"*). The rail
 * is `.site-rail`, a SIBLING of `main` — so a custom property declared on
 * `main` is invisible to it, and the only honest way to give the rail beat B's
 * timing was to put the scalar somewhere both of them can read. 🔴 The
 * alternative — a second scalar for the rail — is the bug this page has already
 * paid for twice (§--share, §--seam-open: "animate the scalar, compute the
 * length", and two numbers kept in step by hand are a promise, not a fact).
 * nav.css §THE RAIL ARRIVES WITH BEAT B is the one consumer outside `main`.
 *
 * ⚠️ `scroll()` ON `<body>` STILL MEANS THE DOCUMENT, and that is worth one
 * line because it would not if nav.css:195 said `overflow-x: hidden`. `clip`
 * does NOT make an element a scroll container, so body has none of its own and
 * `scroll()`'s implicit `nearest` walks up to the viewport exactly as it did
 * from `main`. Measured after the move: identical `--lift` at y=0, 120, 250 and
 * 450 in both engines.
 *
 * ⚠️ OUTSIDE THE `@supports (animation-timeline: view())` GATE, deliberately.
 * These are plain time-based animations; Firefox has no view-timelines and
 * should still get the projector switching on. */
/* ⭐ THE SCAFFOLDING, AND THE ONE DECLARATION EVERY FALLBACK RUNS THROUGH.
 * `--lift: 1` is the settled composition and it paints whenever no mechanism is
 * running — Firefox, reduced motion with no JS, and Chromium's one unresolved
 * frame after a deep reload (../../PLAN.md item 32). §--lift carries the full
 * table. Every mechanism below is an ANIMATION on this same element, so every
 * one of them outranks it.
 *
 * ⚠️ 720ms AND NOT design.md's "<200ms, ease-out". That budget is for a UI
 * state change; this is an apparatus warming up, and the page already runs the
 * gate's own fade at 1.4s and its weave at 5.3s. The register is the room's,
 * not the interface's. */
body[data-page="home"] {
  --lift: 1;
  animation-name: none;
  animation-duration: 720ms;
  animation-timing-function: cubic-bezier(0.22, 0.61, 0.36, 1);
  animation-fill-mode: both;
  animation-timeline: auto;
  animation-range: normal;
}

/* ── the CSS-only route: the reader's own scroll lifts it ────────────────
 * 🔴 THIS IS WHAT A PAGE WITH NO JS LANDS ON, and it is ../../PLAN.md item 22
 * part A unchanged — Christian: "We sit there and wait for the user to scroll
 * down. When they scroll the rescale happens." Beat A composes at rest, the
 * first half-viewport of scroll carries it to beat B, and every position in
 * between is a real composition because `--lift` is proportional to the scroll.
 *
 * ⚠️ NOT UNDER `reduce`: a square resizing continuously under the thumb is
 * scroll-linked movement, which is the thing being asked for less of. A
 * reduced-motion reader gets the step instead (see the intro states below), or
 * the settled composition if there is no JS to step it.
 *
 * ⚠️ `scroll()`, NOT A SCENE'S VIEW TIMELINE, because the question is "has the
 * reader scrolled at all" rather than "has this section reached this point" —
 * the one moment on this page that is about the READER rather than a scene. */
@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    body[data-page="home"] {
      animation-name: lift-on-scroll;
      animation-timing-function: linear;
      animation-timeline: scroll();
      animation-range: 0 50svh;
    }
  }
}

/* ── and when JS is there, the projector's own clock takes the property over ──
 * ⭐ ONE EXTRA `[data-intro]` OF SPECIFICITY IS WHAT MAKES THESE WIN, not
 * source order — they sit outside the `@supports` block above and still beat
 * it, which is what lets the scroll route stay where it belongs.
 *
 * 🔴 AND `done` IS A STATE RATHER THAN THE ATTRIBUTE COMING OFF, for a reason
 * worth keeping: removing it would hand `--lift` back to `lift-on-scroll`,
 * which at the top of the document says 0 — so a page that had just lifted
 * itself would drop straight back to the landing. `done` pins the arrived
 * value so that nothing moves again WHILE THE READER IS STILL AT THE TOP.
 *
 * ⭐ IT IS NOT PERMANENT, AND THAT IS ../../PLAN.md ITEM 42's OPEN QUESTION
 * ANSWERED (Christian, 2026-09-25: *"can't we time it with if scrollTop=0?"*).
 * home.js takes the attribute off the moment the scroll has passed half a
 * viewport — the point where this pin and `lift-on-scroll` agree on `--lift`
 * AND on the opening's words, measured in both engines — so from there the page
 * is purely scroll-derived and THE TOP OF THE DOCUMENT IS BEAT A for every
 * reader, however they arrived at it. home.js §THE TOP OF THE PAGE IS BEAT A
 * carries the measurements and the one consequence left for Christian. */
body[data-page="home"][data-intro="landing"] { animation-name: intro-held; }
body[data-page="home"][data-intro="lifted"]  { animation-name: intro-rise; }
body[data-page="home"][data-intro="done"]    { animation-name: intro-done; }
body[data-page="home"][data-intro] {
  animation-timing-function: cubic-bezier(0.22, 0.61, 0.36, 1);
  animation-timeline: auto;
  animation-range: normal;
}
/* ── beat A hides the opening's words; beat B brings them in on the same clock
 * ⭐ THE OPENING'S WORDS ARE UP AT REST — §BEAT B's WORDS ARE ALREADY UP WHEN
 * THE READER ARRIVES carries that argument in full. So beat A is the exception
 * that has to be put up, and these two rules are it.
 *
 * 🔴 AND THE SECOND ONE IS THE SHAPE THAT CAUSED ../../PLAN.md ITEM 39.
 * `words-arrive` runs on a clock and fills forwards, so while it is up it
 * outranks the scroll-driven `words-hold` and PINS opacity 1 — which is
 * precisely the move that made the title travel instead of fading. What makes
 * it safe is that it is TEMPORARY: home.js removes `data-intro` the moment the
 * lift's own `animationend` fires, and the words go back to a mechanic that
 * fades them. ⚠️ If you ever find yourself leaving this attribute on, read the
 * tombstone at §the words and stop.
 *
 * ⭐ THE HAND-OFF IS VALUE-CONTINUOUS BY CONSTRUCTION, NOT BY TIMING.
 * `words-hold` sits at opacity 1 across its whole range bar the last 12%, so
 * wherever the reader has scrolled to while the intro ran, the value handed
 * back is the value the intro was holding. ⚠️ The one exception: a fling past
 * contain 88% of the opening scene inside 720ms — 475px from a standing start
 * — steps the value. It is unobservable, because at that scroll the opening
 * stage has left the viewport. Stated rather than hidden: "cannot be seen" is
 * a weaker claim than "cannot happen", and this is the weaker one.
 *
 * The 200ms delay is the words following the square rather than racing it. Both
 * end at 720ms, so ONE `animationend` releases both. */
body[data-page="home"][data-intro="landing"] .scene--opening .scene-words {
  animation: words-away 1ms linear both;
  animation-timeline: auto;
  animation-range: normal;
}
body[data-page="home"][data-intro="lifted"] .scene--opening .scene-words {
  animation: words-arrive 520ms 200ms ease-out both;
  animation-timeline: auto;
  animation-range: normal;
}
/* 🔴 AND `done` HANDS THEM TO A DIFFERENT KEYFRAME SET FROM THE ONE THEY
 * STARTED ON, which is the whole reason `done` exists as a state at all.
 *
 * The scroll route's words are `words-say` — opacity 0 at the top of the
 * document, fading up as the reader scrolls in — and that is right when the
 * scroll is what lifted the square. It is WRONG after a playback cue: the
 * reader has not scrolled, so handing back to `words-say` would fade the copy
 * in and then take it straight off again. `words-hold` is the same envelope
 * with its first keyframe at 1, so the backwards fill holds beat B's copy up
 * at any scroll position the intro finished at.
 *
 * ⚠️ AND IT STILL FADES AT ITS RANGE'S END. ../../PLAN.md item 39's tombstone
 * is 200 lines down: a rule that pinned this element's opacity bought the same
 * hold by switching the mechanic OFF, and the title then travelled with the
 * scene instead of fading — item 10's bug for the third time. `words-hold`
 * buys it INSIDE the mechanic: 1 from 0% to 88%, 0 by 100%, so the words are
 * provably gone before the scene leaves. Nothing here pins anything. */
body[data-page="home"][data-intro="done"] .scene--opening .scene-words {
  animation-name: words-hold;
}

/* IDENTITY-shaped at both ends, never `none` — the Safari rule every keyframe
 * block in this file follows. ⚠️ And every word keyframe restates the
 * `-50% -50%` centring: §THE CENTRING IS RESTATED IN EVERY FRAME records what a
 * keyframe here that forgets it does to the title. */
@keyframes lift-on-scroll { from { --lift: 0; } to { --lift: 1; } }
@keyframes intro-held { from, to { --lift: 0; } }
@keyframes intro-rise { from { --lift: 0; } to { --lift: 1; } }
@keyframes intro-done { from, to { --lift: 1; } }
/* 🔴 THE RISE IS `margin-top` IN ALL THREE — PHASE C, 2026-10-01, and they had
 * to change in the same commit as `words-say` (`50-motion.css` §THE 8px
 * ENTRANCE, which carries the full argument). Short version: these keyframes
 * wrote the words' CENTRING and their rise in one `translate`, an animation
 * beats a declaration, so `30-composition.css` could not place this element at
 * all — which is why `--figband` sat written and unread for a day. One set
 * still writing `translate` would re-own the property for the intro's whole
 * 720ms and the opening's copy alone would be ~48px out.
 * ⚠️ THE CENTRING ITSELF IS NOT HERE ANY MORE. It belongs to whichever rule in
 * `30-composition.css` placed the element — `-50% -50%` on `--band-centre`,
 * `-50% 0` on `--figband` — and these four frames no longer have an opinion
 * about it, which is the whole point. The comment above this block used to read
 * "every word keyframe restates the `-50% -50%` centring"; it is kept, inverted,
 * because the next person to add a word keyframe needs to know which rule it is
 * that must NOT be restated. */
@keyframes words-away  { from, to { opacity: 0; margin-top: 0px; } }
@keyframes words-arrive {
  from { opacity: 0; margin-top: 8px; }
  to   { opacity: 1; margin-top: 0px; }
}
@keyframes words-hold {
  0%   { opacity: 1; margin-top:  0px; }
  88%  { opacity: 1; margin-top:  0px; }
  100% { opacity: 0; margin-top: -8px; }
}
.gate-picture[data-scene-mode="story"] .projector-media {
  opacity: 0;
  visibility: hidden;
}
.gate-picture[data-scene-mode="story"] .plate { visibility: hidden; }
/* ⚰️ `[data-flicker-off]` AND FOUR `:has()` RULES CAME OUT HERE, 2026-09-25 —
 * replaced by the one `:not([data-scene-mode="opening"])` selector in §THE
 * FLICKER IS THE PROJECTOR BEING ON above, which carries the measurements. */
.story-gate-clip, .story-gate-reel { position:absolute; inset:0; z-index:3; width:100%; height:100%; object-fit:cover; opacity:0; visibility:hidden; }
.story-gate-clip[data-active], .story-gate-reel[data-active] { opacity:1; visibility:visible; }
.story-gate-clip[data-active]:not([data-media-ready]) { opacity:0; visibility:hidden; }
.story-gate-reel video { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; opacity:0; }
.story-gate-reel video.is-playing, .story-gate-reel video.is-paused { opacity:1; }
/* ⚠️ UNDER `reduce` NOTHING IS FETCHED AND NOTHING PLAYS, so the reel's tiles
 * never get the package's `.is-playing` class and the gate would be EMPTY for
 * that whole scene. The first clip's own `poster` is what a reduced-motion
 * reader is owed there: the scene's picture, held still. */
@media (prefers-reduced-motion: reduce) {
  .story-gate-reel[data-active] video:first-child { opacity: 1; }
}

/* Scene 1's plate, and it opens in 8MM — scene 2's dial is what takes the site
 * into rotoscope, and from there on the films are rotoscoped (../../PLAN.md
 * §The scenes, the amendment). */
.plate { position: absolute; inset: 0; filter: var(--look-film); }

/* The projector's two modes sit over the shared still plate: clips 01 and 02 in
 * sequence through the opening, and clip 03 through beat C. Keep the first
 * poster visible without JS.
 * ⚠️ `data-cue-mode`, RENAMED FROM `data-title-mode` ON 2026-09-25. It was the
 * TITLE that used to cue clip 03 (../../PLAN.md item 36) and the attribute was
 * named after that; item 40 gave 03 its own scene and the title has nothing to
 * do with it. A name that describes a mechanism that has been replaced is how
 * the next reader gets a wrong idea for free. */
.projector-media { position: absolute; inset: 0; }
.projector-playlist,
.projector-cue { position: absolute; inset: 0; }
.projector-cue { width: 100%; height: 100%; object-fit: cover; opacity: 0; }
.projector-media[data-cue-mode] .projector-playlist { opacity: 0; }
.projector-media[data-cue-mode] .projector-cue { opacity: 1; }
.projector-playlist video {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  object-fit: cover;
  opacity: 0;
}
.projector-playlist video:first-child { opacity: 1; }
.projector-playlist[data-started] video:first-child { opacity: 0; }
.projector-playlist video.is-playing,
.projector-playlist video.is-paused,
.projector-playlist[data-started] video:first-child.is-playing,
.projector-playlist[data-started] video:first-child.is-paused { opacity: 1; }

/* ═══ SCENE 2 — THE DIAL ══════════════════════════════════════════════════
 * The live camera in the gate, and the dial turning OUT OF 8MM AND INTO
 * ROTOSCOPE. From here on the films are rotoscoped.
 *
 * 🔴 IT IS THE LIVE CAMERA, NOT A TAKE, AND THAT IS THE WHOLE POINT. The app
 * bakes the look at capture and has no undo, so a shot cannot be re-run through
 * looks afterwards. On the live camera the dial is telling the truth: it
 * changes what the NEXT press records. ../../PLAN.md §C. Do not "improve" this
 * into a take being re-graded; that is the one thing the product refuses.
 *
 * Both ends are real app modes — FILM and ROTOSCOPE (docs/modes.md) — so the
 * scene is aiming at something that exists, not at a mood. */
:root {
  /* 8mm: warm, contrasty, a little washed in the lift. Scene 1's plate only —
   * see the six below for why there is no `--look-rotoscope` beside it. */
  --look-film: sepia(0.38) saturate(1.25) contrast(1.18) brightness(1.04);
}

/* `--pin` sets scene travel length. Keep the next picture close while leaving
 * enough room for its motion and words to read. */
/* ⚠️ 0.6 WAS "shorten the 03-to-04 scroll span", and 03 is not in this scene
 * any more — item 40 gave it beat C. The number survives its reason, and this
 * is the re-derivation rather than an assumption that it still fits: the
 * opening's whole scroll job is now to carry beat B's words off and hand over
 * to beat C, because beat B ARRIVES on its own clock rather than being scrolled
 * into. `contain` here is 0.6 × 100svh = 540px at 1440×900, and the words'
 * plateau is contain 16%→88% of it, so 389px of reading before the fade begins
 * — and a reader who let the projector cue itself has already had the copy up,
 * unscrolled, for as long as they cared to look. Measured against §THE WORDS
 * GET LONG ENOUGH TO BE SEEN's budget, which is the only scene on the page
 * where that budget is not the binding constraint. */
.scene--opening { --pin: 0.6; }
/* Beat C. One sentence, no furniture, and the projector's third clip behind
 * it — so the only thing it needs from `--pin` is time to be read. A full
 * viewport of `contain` puts its plateau (words-say 16%→88%) at 720px of
 * scroll, the widest reading window on the page, which is right for the one
 * scene that makes a factual claim rather than describing a picture. */
.scene--no-wrong-way { --pin: 1; }
.scene--the-dial { --pin: 1.5; }

/* ── the six looks ───────────────────────────────────────────────────────
 * 🔴 THE LOOK CHANGE IS SIX REAL EXPORTS, AND A FILTER CHAIN IS NOT ALLOWED TO
 * STAND IN FOR IT. There used to be a `--look-rotoscope` here and a `look-dial`
 * keyframe cross-fading `filter` from one to the other. Christian ruled it out
 * on 2026-09-21 and ../../PLAN.md §*Two rulings* carries the ruling: six real
 * exports, not CSS approximations of one clip — "the scene exists to prove the
 * looks are real, so the site shows the real thing". A filter cannot
 * posterise and cannot draw a halftone, so the approximation was always going
 * to be a mood — and a scene whose whole job is "these looks are real" cannot
 * be the one place the site fakes something.
 *
 * The six layers hard-cut by the dial's own angle. Four now carry lazy-loaded
 * videos for the path FILM → DIGITAL → SYNTH → ROTOSCOPE; NOIR and COMIC retain
 * their honest picture placeholders because this scene's dial path does not
 * stop on them. Do not tint those placeholders to imitate missing footage.
 *
 * The source is a data change: name `clip` on the mode in content/story.json
 * and layouts/story.yml emits a lazy <video> in place of its placeholder div.
 *
 * ⚠️ AND THE CORNER IS ALREADY CUT INTO THE PIXELS (b130). The gate clips these
 * at the same 0.1445 fraction, so nothing here applies a corner of its own — a
 * TIGHTER one would show the baked corner against the mask. Check it on the
 * first export rather than on all six. */
.looks {
  position: absolute; inset: 0;
  /* 🔴 ZERO AT REST, AND THAT IS THE DEGRADED COMPOSITION RATHER THAN A
   * DIFFERENT ONE. On an engine with no view-timelines nothing can say which
   * scene we are in, so the gate keeps scene 1's plate the whole way down —
   * exactly what this page did before the six existed. The motion block below
   * is the only thing that raises them, and it raises them for the
   * reduced-motion reader too: the selected style is state, not movement. */
  opacity: 0;
}

/* The nearest dial detent is selected in `data-active-look`. Only that layer
 * is visible, so changing styles is a hard cut while the playhead carries. */
.look {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  object-fit: cover;               /* for the <video> this becomes */
  opacity: 0;
}
.looks [data-dial-look] { opacity: 0; }
.looks[data-active-look="noir"] [data-dial-look="noir"],
.looks[data-active-look="film"] [data-dial-look="film"],
.looks[data-active-look="digital"] [data-dial-look="digital"],
.looks[data-active-look="synth"] [data-dial-look="synth"],
.looks[data-active-look="rotoscope"] [data-dial-look="rotoscope"],
.looks[data-active-look="comic"] [data-dial-look="comic"] { opacity: 1; }

/* ═══ REST-VISIBLE — the law, stated as CSS ═══════════════════════════════
 * ⭐⭐ EVERY BEAT READS AS A FINISHED COMPOSITION AT REST. The animation does not
 * reveal a scene, it ASSEMBLES one. Everything above draws the composed still;
 * only the block below moves it, and only where the engine can. So Firefox, an
 * engine without scroll-driven animation, prefers-reduced-motion, no JS, a
 * screen reader and an agent all land on the same finished page.
 *
 * 🔴 That is why the scroll-driven half is additive and guarded rather than the
 * base state with an opt-out. Written the other way round, a reader without it
 * gets a DIFFERENT composition rather than a degraded one, and the
 * accessibility floor stops being a guarantee. */
/* ═══ ⏳ PLACEHOLDERS — DELETE ON SIGHT WHEN THE FOOTAGE LANDS ════════════
 * Nothing here is design. Each gate needs SOMETHING with enough tone and edge
 * in it that the dial's look change is visible at all, and this is the least
 * that does the job. Grep `data-placeholder`. */
[data-placeholder] {
  background:
    repeating-linear-gradient(115deg, rgba(255,255,255,0.045) 0 2px, transparent 2px 9px),
    radial-gradient(60% 45% at 32% 28%, #6E6257 0%, transparent 60%),
    radial-gradient(70% 55% at 74% 76%, #4A4036 0%, transparent 62%),
    linear-gradient(168deg, #2A2520 0%, #14110E 100%);
}

/* ═══ AND WHEN THE FOOTAGE DOES LAND — ../../PLAN.md item 33 ═════════════
 * ⭐ ONE RULE FOR EVERY PICTURE IN THE STORY, and it is deliberately the whole
 * of the CSS this feature needed. A plate that has a picture gets an <img>
 * inside it (layouts/story.yml, emitted only when story.json names one); the
 * weave above stays exactly where it is and simply ends up behind it. So the
 * two states are one element with and without a child, not two codepaths.
 *
 * 🔴 NO CORNER HERE, ON PURPOSE. `.gate` and `.satellite` already carry
 * `border-radius: var(--corner-square)` + `overflow: hidden`, so the site's own
 * curve (base.css §THE CORNER — superellipse(1.6) at 1.4× the fraction, fitted
 * against the app's own CALayer) clips these for free. ⚠️ And the app's "the
 * corner is baked into the file" law does NOT apply: that is about Camcorder's
 * own video exports, where a tighter crop would uncover a corner cut into the
 * pixels. These are plain photographs and want the ordinary treatment.
 * VERIFIED rather than assumed — the clip was read off the rendered corner, not
 * off the stylesheet.
 *
 * ⚠️ `object-fit: cover` and not `contain`: the files are already square and
 * the boxes are `aspect-ratio: 1`, so cover has nothing to crop today. It is
 * here for the day a non-square file is dropped in, where `contain` would
 * letterbox the gate and break the one shape the whole page is about.
 *
 * The 8mm look is NOT applied here — `.plate` carries `filter: var(--look-film)`
 * and the <img> is inside it, so the picture is graded by the parent exactly as
 * the placeholder weave was. One filter, one place. */
.plate-shot {
  /* ⚠️ ABSOLUTE, SO ITS HOST MUST BE POSITIONED. Item 35 paid for that: `.cut`
   * was the one host that was not, and the picture escaped to the sticky gate
   * layer and covered the story. See §the gate and the cut. */
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

/* ═══ SCENE 3 — ONE EVENT, SEVERAL PHONES ════════════════════════════════
 * A wedding from several angles. Smaller gates arrive around the main one, each
 * carrying the app's own maker disc — the dot it draws on a clip that came from
 * another phone. The radar sweeps behind them and fades once they are in.
 *
 * 🔴 THE ORDER OF THE BEATS IS THE CORRECTION, NOT DECORATION (../../PLAN.md §A).
 * The dictation had the radar first and the clips arriving by themselves. The app
 * is the other way round on both counts: nobody pairs while shooting, and nothing
 * arrives on its own — you pick people and the disc PULLS their clips in. So the
 * shot is already there, the radar runs after it, and the satellites arrive one
 * at a time. Do not "tidy" this into everything appearing at once: the arriving
 * IS the product, where four squares that were always there is just a grid. */

.scene--many-angles { --pin: 1.4; }

/* The radar: behind everything, and weather rather than a control. A sweep is a
 * cone of light going round, which is one conic-gradient and one rotation. */

/* Somebody else's clip. Placed off the gate's centre, so the composition
 * RECOMPOSES with the gate at any viewport instead of being a fixed collage. */

/* THE MAKER DISC, as the app draws it: a coloured disc on a black one, never a
 * stroke — a ring centred on the path lets the fill's antialiased edge leak a
 * second outline (Camcorder/Views/ReelView.swift ▸ makerDisc). ⚠️ In the app the
 * colour is DERIVED from the device id and is that phone's forever; here it is
 * chosen per satellite in story.json, which is a stand-in and not a claim. */
.maker {
  position: absolute; top: 6%; right: 6%;
  width: 16%; aspect-ratio: 1;
  border-radius: 50%;
  background: #000;
  display: grid; place-items: center;
  z-index: 2;
  isolation: isolate;
}
.maker::after {
  content: "";
  width: 69%; height: 69%; aspect-ratio: 1; /* 11 of 16, the app's own two discs */
  border-radius: 50%;
  display: block;
  background-color: hsl(var(--hue), 62%, 58%);
}

/* The gate has to hold the satellites, which sit outside it. */
/* …but the PICTURE still has to be cut to the corner. ⚠️ Paid for in the app:
 * hoisting a clip outward silently undoes a shape inside it. */

/* ⚰️ THE PHONE'S RECOMPOSE — BOTH RULES DELETED 2026-09-22 (../../PLAN.md item
 * 28), BECAUSE NEITHER OF THEM HAS EVER DONE ANYTHING. The intent was right and
 * survives twice over; only these two spellings were dead. What stood here:
 *
 *     @media (max-width: 720px) {
 *       .scene--many-angles .gate { width: min(56vw, 42svh); }
 *       .satellite { translate: calc(-50% + var(--x) * 0.72)
 *                               calc(-50% + var(--y) * 0.72); }
 *     }
 *   …under "the gate gives way and the satellites tuck closer, which keeps the
 *   subject whole. design.md's order — mobile is the substance."
 *
 * 🔴 `.scene--many-angles .gate` MATCHES NOTHING. There is one gate for the
 * whole story and it lived in `.gate-layer`, a SIBLING of every `.scene`, not a
 * descendant of any — which is the point of the apparatus belonging to the page
 * (§THE GATE). ⭐ Since 2026-10-01 it is further away still: inside
 * `.screen-app`, a descendant of `.framebar`. The rule is twice as dead and the
 * lesson is unchanged. Measured: the gate is 312px at 400×850, i.e. the 78vw this
 * rule believed it was overriding, never the 224px of 56vw.
 * ⚠️ And it cannot simply be re-selectored, because one shared sticky element
 * cannot be one size in scene 3 and another size in scene 1 without ANIMATING
 * between them — a scene beat nobody has asked for. The width question is
 * answered instead by the `--gate` cap at the foot of this file, which is
 * about the RAIL and applies to every scene alike.
 *
 * 🔴 THE `translate` IS INVALID AT COMPUTED-VALUE TIME — `calc(-50% + 53.28)`
 * adds a number to a percentage — so it does not fall back to the `-50% -50%`
 * above it: it computes to `none`, the initial value. Proved on a two-div page
 * in both engines (the declared element sat at its raw left/top; the control
 * sat centred). ⚠️ It is masked on the live page only because `satellite-merge`
 * animates `translate` with a fill, and an animation outranks the cascade — so
 * every satellite gets `-50% -50%` from a KEYFRAME rather than from its own
 * rule. Take that animation away and the four squares jump half their own size
 * down and right. Worth knowing before anything simplifies those keyframes.
 * ⚠️ It would also have DOUBLE-COUNTED the tuck: the phone override that
 * actually works applies the same 0.72 to `left`/`top` (§THE PHONE, at the foot
 * of this file), and translate is applied on top of left/top, not instead. */

/* ═══ SCENE 4 — THE REEL ═════════════════════════════════════════════════
 * "Those squares fly back into one. We land in the app's own shape: the big
 * rounded box with the film, and under it the reel of small ones running
 * sideways, the current one always slightly larger."
 *
 * ⭐ The four squares here are SCENE 3'S FOUR, same ids and same hues in
 * story.json, so "fly back into one" is literally the squares that arrived
 * rather than four new ones. Their motion is scene 3's run backwards: in to the
 * centre, and gone.
 *
 * 🔴 Everything below was read off the phone (site/_proto/ios-screens), not
 * imagined. Two things would have been got wrong from the dictation alone:
 * the cut marks are INSIDE the picture, and the enlarged tile is the clip
 * PLAYING rather than one that is selected. */

.scene--the-reel { --pin: 1.5; }

/* ⚠️ THE PICTURE HAS NO FURNITURE AT ALL (2026-09-21, Christian, twice).
 * The app draws marks along the bottom edge inside the film — pale dots where
 * the clips join, and an amber run for where you are — and ../../PLAN.md
 * §The reference screenshots records that as true of the APP.
 *
 * It is not true of this site, and both halves were removed on the same day for
 * the same reason. The run went first ("there should also not be a orange
 * progress bar"), and the pale join marks followed once he saw them rendered:
 * eight clips laid end to end read as one dashed BAR across the picture, which
 * is precisely the scrubber the app refuses to have. In the app they sit inside
 * a photographic frame at a glance; over a flat placeholder they are furniture.
 *
 * 🔴 Do not restore either from the PLAN. The record there describes the app. */

/* ── the strip ───────────────────────────────────────────────────────────
 * Tiles are rounded MUCH tighter than the square — the app's own are about
 * twice the square's fraction, which is allowed (rounder is fine; squarer
 * would uncover the corner cut into the pixels). It runs off both edges. */
.tile .maker { top: 8%; right: 8%; width: 30%; }

/* ⚠️ AT REST — no scroll-driven animation, reduced motion, an engine without it
 * — the strip is there, the join marks are there, and no tile is grown. That is
 * a finished composition and it is honest: nothing is playing. */

/* ⚰️ THE PHONE'S OWN `.reel` BLOCK — GONE, 2026-09-22 (§the strip, above, and
 * ../../PLAN.md §"Follow the white rabbit"). It pinned the strip to the rail's
 * edge and let it run off the right, which is what Christian reported as *"the
 * reel shifts off"*. Its real job — keeping the first tile out from under the
 * rail — is done by the base rule now, because the box is the room BESIDE the
 * rail and the strip is centred in it. Eight tiles still do not fit a phone and
 * still are not shrunk to fit; they leave at both ends, as the app's do.
 * ⚠️ Do not restore a `justify-content: flex-start` here. It is the one line
 * that made the strip read as furniture that had slid rather than a strip that
 * continues. */

/* ═══ ONE GATE, DEAD CENTRE, FOR THE WHOLE STORY ═════════════════════════
 * Christian, 2026-09-21, with a screenshot: "the rounded-video-box should be
 * dead center on screen", "always something like 50vh and 50vw to a square —
 * whatever grabs first", and "there are several background colors — theming and
 * coloring should follow the iOS app".
 *
 * ⚠️ All three were my mistakes and they are worth naming, because each one is
 * a rule that already existed:
 *   1. I read "only the sections are centered" as "left-align them". It meant
 *      the opposite. The square is the one thing on the screen; it goes in the
 *      middle, as it does on the phone.
 *   2. The square sized off `min(44vw, 58svh)`, which on a wide window is most
 *      of the screen. `min(50vw, 50svh)` — whichever runs out first — keeps it a
 *      square that always has room around it.
 *   3. THE BANDS. `.story` painted itself black inside `main`'s centred 72rem
 *      column, so the page showed three grounds at once: the theme's near-black
 *      body, main's column, and the story's true black. The app has ONE ground
 *      and it is black; the page has one too, below.
 *
 * 🔴 TWO NUMBERS, STATED ONCE, AND EVERYTHING READS THEM — the square's size and
 * the centre it sits on. The satellites, the reel, the dial and the ring all
 * position from those, so a scene's props cannot drift from the square they are
 * meant to be around. */

/* ── the ground ──────────────────────────────────────────────────────────
 * 🔴 THE ROOM IS LIT AND THE DEVICE IS NOT. Christian, 2026-10-01, looking at
 * the finished page: *the device has no separation from the background* — and
 * the cause was literal rather than a matter of taste. The room and the phone's
 * body were BOTH `#000`, so the one object on the screen had no edge but a 16%
 * white outline (`20-device.css` ▸ `.device-frame`). His fix, and his number:
 * the ROOM lifts to `#282826` and the device STAYS `#000`, which makes it the
 * darkest thing on the screen — a black object in a lit room, with the picture
 * glowing inside it. It is also what ../../PLAN.md's concept already said: *a
 * dark room, a projector*.
 *
 * ⚰️ A BEVEL, A RIM AND A GLOW WERE ALL REFUSED and must not creep back in.
 * design.md lists *faux-3D / skeuomorphic ornament* under recoil-on-sight; and
 * a COLOURED rim would break two recorded rules at once — `--site-accent` is
 * site furniture that may never appear inside the phone, and the app's red
 * CARRIES MEANING (recording), so a red edge would state something false. The
 * ground alone does the work.
 *
 * 🔴 THIS IS THE ROOM, NOT THE GLASS, AND THEY ARE DIFFERENT THINGS THAT MERELY
 * SHARED A COLOUR UNTIL NOW. The glass is `--story-ground`, declared on `.story`
 * (`30-composition.css:225`) and consumed by `.screen`
 * (`20-device.css` ▸ THE GLASS) — which, per `.device-frame`'s own header, is
 * ALSO what paints the phone's black body. So `--story-ground` must stay `#000`
 * for two reasons now, not one: the app's export bakes its corners against
 * black, AND it is the device's separation from this room. Lifting it would lift
 * the phone with it and undo the whole of this change.
 *
 * ⭐ The two-selector form is kept deliberately. ../../PLAN.md §The house
 * constants' reason for it has not changed — ONE ground for the whole page, so
 * no band can appear between the chrome, the column and the story — only the
 * colour has. Lifting `main` alone would reintroduce exactly the banding item 3
 * of §ONE GATE above was written to kill.
 *
 * ⚠️ TWO TEXT TIERS MOVE WITH IT, in `../../css/base.css` §THE LIT ROOM. Every
 * ink tier lost ~1.8–5.7 points of contrast when the ground came up, and the
 * theme's tokens are floored against `--bg` (#0C0907), which is DARKER than this
 * — so that floor stops holding here. Measured on the new ground:
 * `--text-muted` 6.14 → 4.32 (fails AA) and `--border-bright` 3.61 → 2.54
 * (fails 1.4.11's 3:1 for the focus ring). design.md's rule is to move the TIER,
 * never the ground, and that is what happens there. */
body[data-page="home"],
body[data-page="home"] main { background: #282826; }

/* ═══ ITEM 8 — THE SQUARE IS PUSHED OFF BY THE FEATURES SECTION ═══════════
 * Christian: "when the features section on the bottom is scrolled into view,
 * that is when we push off the rounded-video-box." ../../PLAN.md item 8: "the
 * gate holds the centre for the whole story; the moment that ends is when the
 * features section at the foot scrolls into view."
 *
 * 🔴 WHAT WAS WRONG, MEASURED BEFORE TOUCHING ANYTHING. `.gate-layer` is
 * `position: sticky`, so it is pinned for exactly as long as ITS PARENT'S BOX
 * is on screen — and its parent is `.story`, which ends where the last scene
 * ends. At 1440×900: `.story` ran to 14889, `#how` was 217px of content ending
 * at 15106, and `#features` began at 15203. So the square let go at scroll
 * 13989 and the features section did not arrive until 14303 — it was released
 * by nothing at all, 314px early, and the steps section had the screen to
 * itself. Christian watched exactly that and called it.
 *
 * ⭐ SO THE FIX IS TO THE CONTAINING BLOCK, NOT TO THE STICKINESS. A sticky
 * element cannot be told "stick until that other element"; it can only be given
 * a taller parent. `.story` is therefore padded by one viewport and `#how` is
 * pulled back up by the same viewport — the two cancel EXACTLY, so the page is
 * the same height it was and every section still lands where it did, while
 * `.story`'s box now reaches over the steps section and ends where the features
 * section begins.
 *
 * 🔴 AND `min-height` ON `#how` IS WHAT MAKES THE CANCELLATION EXACT rather
 * than approximate. The pad has to equal the steps section's OWN height or the
 * square leaves early (shorter) or hangs over the features cards (taller), and
 * CSS cannot read one element's height into another's padding. Giving the
 * section a viewport of its own makes both numbers `100svh` and the arithmetic
 * closes. Its content is 217px, so there is a whole screen of room: nothing is
 * squeezed, and the steps read in the band UNDER the square rather than behind
 * it (`align-content: end`).
 *
 * ⚠️ HOME ONLY, AND DELIBERATELY SO. `#how`/`#features` are shared layouts and
 * their styling lives in src/css/components.css, which every page loads. Every
 * rule here is scoped by `body[data-page="home"]` and lives in the page's own
 * stylesheet, so no other surface that uses `steps` or `feature-grid` moves. */
/* ⚠️ TWO VIEWPORTS SINCE 2026-09-22, NOT ONE — ../../PLAN.md item 17 put a
 * section between the scroll and the steps. The cancellation above is a PAIR:
 * `.story` is padded by exactly as much as the sections that have to fall
 * inside its box are tall, and each of those is pulled back by the same total.
 * Item 17 relocated `cta-final` to sit between `.story` and `#how`, so there
 * are now TWO sections in that span and the pad is 200svh.
 *
 * 🔴 IT IS `#cta-final` THAT CARRIES THE PULL, NOT `#how`, because the pull has
 * to be on the FIRST element after `.story` — that is what drags the whole
 * remaining run back up into the padded box. `#how` then follows it in normal
 * flow and needs no margin of its own, only its own viewport of height so the
 * arithmetic still closes exactly (see the `min-height` argument above: CSS
 * cannot read one element's height into another's padding, so both are given a
 * viewport and both numbers are `100svh`).
 *
 * ⭐ AND ITEM 8 SURVIVES UNCHANGED, which is the whole reason for doing it this
 * way rather than dropping the mechanism: `.story`'s box still ends exactly
 * where `#features` begins, so the square is still released by the features
 * section scrolling into view and by nothing else. Add a third section into
 * this span and the pad becomes 300svh — the rule is one viewport per section
 * between the scroll and the features band, and it is arithmetic, not taste. */
/* ═══ 🔴🔴 THE PEEK — `figPeek: 0.9` LANDS, AND IT TOOK TWO DECLARATIONS ══════
 * Christian dialled it on 2026-10-01 and it did nothing, for TWO independent
 * reasons (V1 measured both; `COORDINATION.md` 2026-10-01). Neither was in
 * `20-frame.js` and neither was a missing value:
 *
 *   1. THE LEDE WAS NEVER SELECTED. `pick()` takes the LAST place whose top has
 *      passed the reading line at `0.55 · --avail` — 495px at 1440×900 — and the
 *      lede measured 220–238px tall, so `#scene-opening`'s top was ALSO above
 *      the line and won. A peek is a placement relative to a section, and the
 *      section was never current, so `placeStack()`'s peek branch never ran.
 *   2. AND EVEN SELECTED IT WOULD HAVE PARKED THE PHONE LOWER, NOT HIGHER.
 *      `--figtop` is measured against `.fb-in`, i.e. against `.framebar` — and a
 *      STICKY box sits at its STATIC position until the scroll reaches its pin.
 *      `.story` began after the lede, so at scrollY 0 the bar's top was ~317px
 *      down the glass and `avail − peek · fh` (527px) resolved from THERE: the
 *      device's top landed at ~844 and showed ~56px of a 414px phone. The peek
 *      arithmetic was right the whole time; its ORIGIN was 317px too low.
 *
 * ⭐ SO THE FIX IS TO THE CONTAINING BLOCK, AND THAT IS THIS FILE'S OWN IDIOM —
 * the pad/pull pair directly below (item 8) does exactly the same thing at the
 * other end of the story, for the same reason: *"A sticky element cannot be told
 * 'stick until that other element'; it can only be given a taller parent."*
 * Here it is given a parent that starts EARLIER: `.story`'s box is pulled up by
 * one viewport and the FIRST SCENE is pushed back down by the same, which
 * cancels exactly — so the scenes land where they landed, and `.framebar` is
 * pinned from the page's first pixel instead of from 317px down. `--figtop` then
 * resolves against the glass's top, which is what `20-frame.js` has always
 * believed it resolved against, and 0.9 shows 0.9.
 *
 * 🔴 AND THE PUSH IS ON THE SCENE, NOT A `padding-top` ON `.story` — WHICH WAS
 * WRITTEN FIRST AND IS WRONG, so it is recorded rather than quietly replaced.
 * Padding is INSIDE the box: `margin-top: -100svh` with `padding-top: 100svh`
 * moves the border box up and leaves the CONTENT box exactly where it was, so
 * the framebar's static position does not move one pixel and the peek is
 * unchanged. It reads as a cancelling pair and cancels the wrong thing. The
 * margin on the first scene is outside the framebar, so the bar sits at the box
 * top and only what follows it moves. ⚠️ Its `margin-top` and the framebar's
 * `margin-bottom: calc(-1 * var(--avail))` are ADJOINING and collapse to
 * `max(+L) + min(−A)` = `L − A`, which is the same number they give uncollapsed
 * — checked, because a collapse that did something else here would move every
 * scene on the page.
 *
 * ⚠️ AND THE PULL — ONLY THE PULL — IS GATED ON `html[data-frame]`, WHICH IS NOT
 * DECORATION. With JS off the bar does not stick at all (`20-device.css:314`,
 * K1's one-declaration fix for 41,713 px² of occluded text), so it draws AT its
 * static position — and ungated, that position would be the top of the lede,
 * putting a 756px-tall phone straight over the lede's own copy on the first
 * screen. The attribute is already load-bearing for layout attachment (§THE
 * CONTRACT item 12), so this is the same switch answering the same question:
 * with no JS there is no travel, no peek, and no reason to move the plane.
 *
 * 🔴 BUT THE LEDE'S OWN HEIGHT IS *NOT* GATED, AND THAT IS A CORRECTION MADE BY
 * MEASUREMENT RATHER THAN BY TASTE. It was gated in the first version, for
 * symmetry — and symmetry cost **CLS 0.625 in Chromium at 1440×900**, one shift,
 * because the lede is 220px until the attribute lands and 900px after it: 680px
 * of page jumping under the reader on every load. (Google's own "poor" bar is
 * 0.25.) A height that both states can agree on has no shift to make, and the
 * cost of agreeing is only that a reader with no JS also gets a full-screen
 * opening band — which is the composition anyway, minus the phone peeking into
 * it. ⚠️ Always ask what a `[data-frame]`-gated LAYOUT property costs between
 * first paint and boot; this one was free to fix and would have shipped. 
 * 🔴 THAT IS WHY THIS AND NOT THE OTHER CANDIDATE. The alternative V1 named was
 * to re-base `figPeek` on the document top — a correction TERM added inside
 * `placeStack()`, i.e. a second place that has to know how far down the page the
 * framebar happens to start. This makes the existing definition true instead of
 * compensating for its being false, and it needs no line of JavaScript.
 *
 * ⚠️ AND IT NEEDS THE LEDE TO BE A VIEWPORT TALL, WHICH IS ALSO AUTHORED RATHER
 * THAN INVENTED. `story.json > framing[lede].place.minH` is **100** — Christian's
 * own value, inert until now because `minH` belongs to the `.scene` primitive
 * (`10-primitive.css`) and the lede deliberately is not a `.scene`
 * (`layouts/lede.yml` says why). One viewport is what makes `#scene-opening`'s
 * top fall BELOW the reading line, so the lede wins `pick()` and keeps it until
 * the reader scrolls — his *"so it makes you scroll"*.
 * ⚠️ V1'S WARNING STILL STANDS AND IS NOT CONTRADICTED HERE: *"giving the lede a
 * viewport of height ALONE makes it WORSE"* — alone it pushes the framebar's
 * static top a whole viewport further down and the peek shows nothing at all.
 * The two declarations are one fix and neither may be landed without the other.
 * ⚠️ ONE TERM TO WATCH: the pull is a CONSTANT and the lede's height is a
 * MINIMUM, so copy that overflows `--avail` would leave the bar's static top
 * that much low again. Asserted at 1440×900 and 390×844 rather than assumed —
 * the band is two short lines (`layouts/lede.yml` keeps it that way on purpose,
 * because 90% of a phone is about to stand under it). Re-measure if it grows. */
body[data-page="home"] { --lede-h: var(--avail); }
body[data-page="home"] #lede {
  min-height: var(--lede-h);
  /* 🔴 AND ITS BOTTOM MARGIN HAS TO GO, OR THE PULL IS SHORT BY EXACTLY THAT
   * MUCH. MEASURED, not reasoned: `.hero` carries `margin-bottom: 96px` from
   * `src/css/components.css`, and an adjoining pair of sibling margins collapses
   * to `max(positive) + min(negative)` — `+96` and `−900` give `−804`, so
   * `.story`'s box landed at y=96 and the sticky bar with it. The peek then read
   * 0.67 instead of 0.9, which is the first fix being 96px short rather than
   * wrong in kind, and ⚠️ it is the one number a first-principles model of this
   * would have missed: nothing in `.story`'s own rules mentions it.
   * The 96px of air is not lost, it is inside the lede's own viewport now. */
  margin-bottom: 0;
}
body[data-page="home"] .story { margin-top: calc(-1 * var(--lede-h)); }
body[data-page="home"] .story > section.scene:first-of-type {
  margin-top: var(--lede-h);
}
/* ⭐⭐ AND WITH JS OFF THE BAR GOES BACK UNDER THE LEDE — THE THIRD SHAPE OF THIS
 * FIX, AND THE FIRST TWO ARE RECORDED HERE BECAUSE ONLY A MEASUREMENT TOLD THEM
 * APART.
 * ⚰️ ATTEMPT 2 GATED THE PULL ITSELF ON `html[data-frame]`, so the plane only
 * moved once the controller had booted. The reason it is gone is a PRINCIPLE,
 * not a number: a `[data-frame]`-gated LAYOUT property is a layout shift by
 * construction, because the page has to be painted before the attribute can
 * exist — here `.story`'s box went from a 0-height rect to 900 at t≈112ms.
 * 🔴 CORRECTION TO THIS COMMENT'S OWN FIRST DRAFT, which claimed the gate cost
 * **CLS 0.625** and that ungating it bought that back. It did not. Measured 5
 * loads per arm in Chromium, this block neutralised by an injected stylesheet
 * against as-built: 1440×900 as-built 0.625×5, neutralised 0.625/0/0/0.625/0.625;
 * 390×844 as-built 1.000×5, neutralised 1.000×5. **The shift is PRE-EXISTING and
 * this block is not its cause** — and it is a real defect on this page
 * (Google's "poor" bar is 0.25) that nothing in the design record has measured.
 * ⚠️ The first draft read the number off ONE load of each arm and attributed it;
 * the arms overlap, so one load cannot separate them.
 * ⭐ THE PULL IS THEREFORE UNCONDITIONAL AND THE EXCEPTION SITS ON THE THING
 * THAT ACTUALLY NEEDS ONE. With JS off `20-device.css:314` makes the bar
 * `static` — it draws AT its box, and with the pull that box is the top of the
 * lede, which would put a 756px phone over the lede's own two lines on the first
 * screen. This puts its static box back exactly where it was: down by one lede,
 * with the cancelling margin grown by the same, so its contribution to the flow
 * is still ZERO and nothing below it moves. ⚠️ Both arms rely on the bar's
 * margin-bottom and the first scene's margin-top being ADJOINING and collapsing
 * to `max(+) + min(−)`; checked in both, and both give the scene a top of
 * exactly one lede. Measured after: peek 0.900 in both engines at both
 * viewports, and JS-off occlusion unchanged at 0 px². */
html:not([data-frame]) body[data-page="home"] .framebar {
  margin-top: var(--lede-h);
  margin-bottom: calc(-1 * var(--avail) - var(--lede-h));
}

body[data-page="home"] .story { padding-bottom: 200svh; }
body[data-page="home"] #cta-final {
  margin-top: -200svh;
  min-height: 100svh;
  /* ⭐ IN THE BAND UNDER THE SQUARE, WHICH IS THIS PAGE'S OWN ANSWER — the same
   * `align-content: end` the steps below use, for the same reason. The gate is
   * still pinned through this section, so a pitch centred in the viewport lands
   * ON the picture: measured at 1440×900 with `center`, the square held 123–477
   * and the <h1> read 385–444, inside it. `end` puts the whole pitch in the
   * 423px band beneath, where a scene's props sit.
   * ⚠️ And the phone inherits the steps' known tension rather than a new one:
   * where the pitch is taller than the band it reads over the picture, which is
   * what the `z-index: 2` below is for and what §ON A PHONE THE STEPS READ OVER
   * THE SQUARE already argues at length. */
  display: grid;
  align-content: end;
  padding-bottom: clamp(24px, 6svh, 72px);
  /* ABOVE THE GATE, NOT BEHIND IT — identical to `#how` below, and load-bearing
   * for the same reason: the composition is inside a positioned ancestor, so
   * without this the <h1> would be painted out by the picture.
   * ⚠️ IT NO LONGER WINS ON ITS OWN, AND THAT IS NOT A REGRESSION HERE. It used
   * to beat `.gate-layer`'s `z-index: 1`; the composition rides `.framebar` at
   * `z-index: 3` now (deliberately, so the scenes pass BEHIND the phone), which
   * outranks this. Two things already answer it and both are measured:
   * `story.json > framing[cta-final]` is a BAND, so `20-frame.js` fades the bar
   * to `opacity: 0` on reaching this section (J1: 10,899 -> 0 px² of <h1> overlap
   * in Chromium, 10,690 -> 0 in WebKit); and with JS off `20-device.css:314`
   * unsticks the bar entirely (K1: 41,714 -> 0 px²). 🔴 So this `2` is now a
   * belt-and-braces third answer rather than the mechanism — do not delete it,
   * and do not trust it alone. */
  position: relative;
  z-index: 2;
}
body[data-page="home"] #how {
  min-height: 100svh;
  /* The steps sit in the band beneath the square, which is where a scene's
   * props sit too — the composition does not change shape for them. Measured at
   * 1440×900 on the last pinned frame: the square holds 225–675 and the three
   * steps read at 696–855, with "How it works" at 638 and clear of it. */
  display: grid;
  align-content: end;
  padding-bottom: clamp(24px, 6svh, 72px);
  /* ⚠️ ABOVE THE GATE, NOT BEHIND IT. The gate layer is `z-index: 1` inside a
   * positioned ancestor, so without this a step's title that reached the
   * square's lower edge would be painted out by it — words vanishing behind a
   * picture, with nothing in the markup to say why. On a phone it is not an
   * edge case but the normal state; see below.
   * ⚠️ Same caveat as `#cta-final` above: this used to beat `.gate-layer`'s `1`
   * and the framebar is `3`. `#how` is outside `.story`'s pinned span by one
   * viewport of pad, so the bar has let go before it arrives — which is item 8's
   * whole mechanism, not a second fix. */
  position: relative;
  z-index: 2;
}

/* ═══ THE SPAN SHRINKS ON A DESKTOP — ../../PLAN.md item 27 ══════════════════
 * Christian: "#how section has a huge dead space above, needs to be closer to
 * the cta." Real, and not a bug — it is the felt cost of the cancellation
 * above. Each section reserves a WHOLE viewport and bottom-aligns its content,
 * so on a wide screen a 217px steps block sits at the foot of a 900px box and
 * everything over it is empty by construction.
 *
 * 🔴 THE SUM MAY SHRINK; THE PAIRING MAY NOT. `.story`'s padding-bottom and
 * `#cta-final`'s margin-top have to stay equal to the two sections' heights
 * added together, or the gate is released early or late — item 8's measured
 * writeup above is what that looked like the first time. So all THREE numbers
 * move together here, and the sum is stated in each of the three places rather
 * than derived in any of them. ⚠️ IT IS 100 + 55 = 155 SINCE ITEM 30, not the
 * 55 + 55 = 110 this paragraph shipped with — see the block below for why only
 * `#how` may shrink.
 *
 * ⚠️ IT SITS AFTER THE RULES IT OVERRIDES, and that is not tidiness. The base
 * declarations carry the same specificity, so placed EARLIER this block lost on
 * source order — measured: `.story` took the new 110svh while both sections
 * kept 100svh, leaving the pad and the pull unpaired and the gate released
 * 810px early. Item 8's bug, reintroduced by where a rule sat rather than by
 * what it said.
 *
 * ⚠️ DESKTOP ONLY, AND THE BREAKPOINT IS MEASURED. Content decides how small
 * the box may be, and it grows as the screen narrows: 217px at 1440 and 1920,
 * 335–381px from 900 down to 721, but 552px at 400 and 654px at 320 — where it
 * already exceeds a FULL viewport, so the cancellation is over budget on the
 * smallest phones before this change and stays so after (pre-existing; the gap
 * measures 312 there instead of 192). Above 720px the worst content is 381px
 * against a 385px box at the tightest tested viewport, so 55svh clears
 * everywhere this rule applies — and that arithmetic is about `#how`, which is
 * the only box item 30 left shrunk.
 * 🔴 The phone keeps 100svh untouched — the note below about the steps reading
 * over the square is arithmetic about a FULL-screen box, and shrinking it there
 * would have made that overlap worse while fixing a desktop complaint. */
/* ⭐ AND THE TWO SECTIONS DO NOT SHRINK BY THE SAME AMOUNT — ../../PLAN.md item
 * 30 CORRECTS ITEM 27 ON EXACTLY THIS POINT. Christian: "CTA scrolls on top of
 * rounded-video-box before rounded-video-box starts scrolling off — should push
 * it off with breathing room, not go on top." Measured, and it was item 27's
 * own doing:
 *
 *   MINIMUM gap, gate's bottom edge → the CTA's first line, scanned in 24px
 *   steps across the whole span, both engines:
 *       1440×900   cta 55svh: −216px    cta 100svh: +189px
 *       1280×720   cta 55svh: −189px    cta 100svh: +135px
 *        400×850   (never shrunk)       +130px  ← the phone was always right
 *   A NEGATIVE number is the pitch printed ON the square while the square is
 *   still stationary. The swing is 405px at 1440, which is exactly the 900→495
 *   the box lost: the content is bottom-aligned in its box, so shrinking the
 *   box walks the content up into the picture.
 *
 * 🔴 WHY ONLY `#how` SHRINKS, and it is the whole insight rather than a
 * compromise: the empty room above each section's content is NOT the same
 * thing in the two boxes. Above `#cta-final`'s content the gate is still
 * pinned — that space is the SQUARE, which is the point of the scene. Above
 * `#how`'s content the gate has gone, and that space is genuinely dead. That
 * is the dead scroll Christian named in item 27, and it is governed by `#how`
 * alone: measured, the room above "How it works" is 224px at 1440 and 136px at
 * 1280 in EVERY candidate tried, unchanged by the CTA's height. So item 27's
 * complaint and item 30's are not in tension at all — they were only made to
 * look that way by shrinking both boxes with one number.
 *
 * ⚠️ 100 + 55 = 155, AND ALL THREE STILL MOVE TOGETHER. The pad and the pull
 * must equal the two heights added, or item 8's cancellation unpairs and the
 * gate is released early — see the measured account of exactly that failure in
 * §THE SPAN SHRINKS above, which is also why this block sits AFTER the rules it
 * overrides. */
@media (min-width: 721px) {
  body[data-page="home"] .story { padding-bottom: 155svh; }
  body[data-page="home"] #cta-final { margin-top: -155svh; min-height: 100svh; }
  body[data-page="home"] #how { min-height: 55svh; }
}

/* 🔴 ON A PHONE THE STEPS READ OVER THE SQUARE, AND THAT IS ARITHMETIC RATHER
 * THAN AN OVERSIGHT — do not "fix" it by letting the square go early.
 * Bottom-aligned content always arrives on screen its own height before the
 * box ends, so the window where the square is still pinned and the steps are
 * visible is exactly as long as the steps are tall, at every viewport. At
 * 1440×900 they are 217px against a 225px band under the square and they clear
 * it; at 390×844 they are 509px against 245px and they cannot. Holding the
 * square until the features section (item 8) and keeping the steps off it are
 * not both available on a phone — so the text goes over the picture, which is
 * why the z-index above is load-bearing there.
 * ⏳ AND IT IS A CONTRAST RISK WORTH NAMING: over today's dark placeholder the
 * type is legible, but a bright ROTOSCOPE export behind it may not be. Check it
 * on the first footage rather than assuming (the AA floor is not optional —
 * ../../PLAN.md §Open). */

/* ⚠️ THE RELEASE LANDS 192px EARLY SINCE 2026-09-22 — it was 96, and the number
 * doubled with the section count rather than because anything changed shape:
 * `#cta-final` and `#how` each carry a 96px `margin-bottom` from
 * src/css/components.css and both now fall in this span. Still left as it is,
 * for the reason below. `#how`
 * carries a 96px `margin-bottom` of its own from src/css/components.css, so the
 * features section begins 96px after `.story`'s box ends and the square comes
 * off its pin that much before the section's first pixel — a tenth of a
 * viewport, with the square still whole on screen, and the push below then
 * carries it off. Closing it exactly would mean either restating a shared
 * component's margin here (a second copy of a number, which drifts the day
 * somebody edits the first) or zeroing that margin and tightening a rhythm this
 * page does not own. Neither is worth 96px. */

/* ⚠️ AND THE STORY'S OWN BOX NOW OVERLAPS TWO SECTIONS IT DOES NOT OWN, so it
 * must not catch their clicks. It never did catch any of its own: `.framebar`
 * (and `.gate-layer` before it) and every `.stage` are already
 * `pointer-events: none`, because nothing in the story is a control. This only
 * says the same thing one level up.
 * 🔴 AND SINCE THE PEEK FIX IT ALSO OVERLAPS `#lede`, ONE VIEWPORT ABOVE IT —
 * which is why that section needs none of this: it is a SIBLING, not a
 * descendant, so `pointer-events: none` does not reach it and the App Store
 * button `layouts/lede.yml` is still waiting for will work when it lands. */
body[data-page="home"] .story { pointer-events: none; }

.story {
  /* ═══ --centre — THE LINE THE WHOLE COMPOSITION HANGS FROM ══════════════
   * ⭐⭐ THE ONE NUMBER THAT SAYS WHERE THE SQUARE IS, and every vertical anchor
   * on this page reads it: the gate layer's own row, the satellites, the radar,
   * the words, the reel, the runtimes, the soundtrack band, and the room the
   * two rings are centred in. Move the composition again and it is THIS LINE,
   * not another audit of the file.
   *
   * ⚠️ IT REPLACES A LITERAL `50%`, WHICH IS WHY IT HAD TO BE A PROPERTY RATHER
   * THAN A HUNT-AND-REPLACE. Every prop above used to anchor to `top: 50%` or
   * `calc(50% ± …)` of a 100svh sticky box — sixteen vertical anchors, sitting
   * in the same file as a dozen HORIZONTAL `50%`s (`left: 50%`, the full-bleed
   * hatch, `border-radius: 50%`) that mean something else entirely and must not
   * move. The two axes are told apart here, once, by name.
   *
   * 🔴 A THIRD OF THE WAY DOWN IS THE MIDDLE OF THE TOP TWO-THIRDS — the app's
   * own composition (site/_proto/ios-screens/alignment.webp: the square's
   * centre sits at 0.31 of the screen, its bottom edge at 0.52, and the dial
   * fills what is left). ../../PLAN.md §"The corrections of 2026-09-22" item 9.
   * The bottom third is not leftover space any more; it is where the dial and
   * its glyphs live, and `--room` below is that band written as a number.
   *
   * ⚠️ svh, NOT `%`, AND THAT IS LOAD-BEARING. `--room` has to be a LENGTH —
   * it sizes the ring, and a percentage in a width would resolve against the
   * width. Every `.stage` is exactly `100svh` tall, so an svh and a percentage
   * of one are the same number, and one spelling serves both the `top`s and the
   * arithmetic.
   * 🔴 AND THAT EQUIVALENCE DIED FOR THE COMPOSITION ON 2026-10-01. It held
   * while every consumer sat on a `100svh` plane (`.gate-layer`, now deleted);
   * the composition's containing block is the GLASS now, ~414px tall at
   * 1440×900, where `100svh` and `100%` are 900 and 414. Only `.scene-words`
   * and `.reel`'s own box still resolve `%` against a `100svh` stage. Phase B. */
  /* ⭐ AND IT IS DERIVED FROM ONE SCALAR — `--lift`, 0 at the landing and 1
   * once the projector has warmed up. §--lift carries the whole argument. Both
   * ends are still item 22 part A's, Christian: "Before any text shows up on
   * page, we have a large rounded-video-box… in the dead center — fading in as
   * if somebody turned on the 8mm projector." Everything else on this page
   * reads `--centre` by name and needs no knowledge of either value.
   *
   * ⚰️ WHAT CHANGED: this line used to be a flat `50svh` with `centre-rise`
   * writing it directly, on `scroll()`. Item 40 gives the lift a SECOND
   * possible trigger — the projector's own playback — and two clocks that might
   * want one length is exactly what §--seam-open already has a law about. So
   * the length is computed here and the scalar is what gets animated, on
   * `main`, where all of it lives in one place (§THE OPENING'S THREE BEATS).
   * Item 22's scroll route is still there and still does the whole job with no
   * JS at all; it is now one of two ways in rather than the only one. */
  /* ⚠️⚠️ THE SHAPE OF THIS CALC IS LOAD-BEARING AND CHROMIUM IS WHY — measured
   * 2026-09-25, Chromium 153, four forms of the same arithmetic evaluated
   * against this registered property at three values of `--lift`:
   *
   *     calc(50svh - L * (50svh - 100svh/3))   0 → 450px   0.5 → 375px   1 → -300px  ❌
   *     calc(50svh + L * (100svh/3 - 50svh))   0 → 450px   0.5 → 375px   1 →  300px  ✅
   *     calc(50svh - L * 100svh/6)             0 → 450px   0.5 → 375px   1 →  300px  ✅
   *
   * 🔴 IT IS WRONG AT EXACTLY ONE VALUE, AND THAT VALUE IS THE SETTLED
   * COMPOSITION. At `L: 1` Chromium simplifies `1 * (P − Q)` to `P − Q` and
   * splices the terms in WITHOUT re-parenthesising them under the outer minus,
   * so `A − 1 * (P − Q)` evaluates as `A − P − Q`. Here that is
   * 450 − 450 − 300 = −300px, a negative centre, which makes `--gate` negative
   * and the square disappears — every scene after the opening drew a 0px gate.
   *
   * ⚠️ AND IT PASSES EVERY SPOT CHECK THAT IS NOT 1. 0 and 0.5 are both exact.
   * This was caught by measuring the composition at rest, which is the one
   * sample a mid-animation trace does not take. The general form, and it is
   * this page's own recurring lesson: a value that is only wrong at an
   * endpoint is invisible to anything that samples the middle.
   *
   * So: SUM OF A BASE AND A TRAVEL, never a difference with a product in it. */
  /* ⭐⭐ AND SINCE `settle.md` PHASE B IT IS THE **VIEWPORT** PLANE'S ANCHOR AND
   * NOTHING ELSE — see §THE DEVICE PLANE below, which re-declares this same
   * name in `cqw` of the phone. ONE element is still on this plane since Phase C
   * took the reel into the device, and it is the whole of its remaining job:
   *   · `.scene-words` — the page's own caption, which is NOT the app's
   *     furniture and is right to stay out here. With JS off it sits on
   *     `--band-centre`; with JS on a STACKED scene takes `--figband`, the
   *     device-plane answer, landed 2026-10-01 (§--figband below).
   * ⚠️ `95-narrow.css` ALSO READS IT, and that file is nobody's lease yet. Its
   * `.story { --gate: min(78vw, 42svh) }` was capping the REEL's tile and now
   * caps NOTHING; its `.satellite` block is dead too (§the other phones).
   * 🔴 NOTHING INSIDE `.screen-app` READS THIS DECLARATION. If a new prop in the
   * phone needs a vertical anchor it takes `--sq-centre`, not this. */
  --centre: calc(50svh + var(--lift) * (100svh / 3 - 50svh));
  /* 🔴 THE CAP IS WHAT THE WORDS LEAVE, NOT WHAT THE CENTRE IS — ../../PLAN.md
   * §"The corrections of 2026-09-22" item 15. Christian: *"rounded-video-box is
   * now too small, it is still the main seller!"*
   *
   * ⚠️ THE BUG WAS A RELATIONSHIP THAT SURVIVED ITS OWN REASON. This line read
   * `min(50vw, var(--centre))`, which was a faithful restatement of the old
   * `min(50vw, 50svh)` — and faithful is exactly what was wrong with it. While
   * the centre was HALF the screen, "half of --centre over the square" happened
   * to leave 225px of top band at 1440×900, far more than the words need. Item
   * 9 moved the centre to a THIRD, the cap rode along, and the square lost a
   * third of its size for a clearance nobody had re-measured. A number that is
   * derived from the wrong quantity fails quietly the moment that quantity
   * moves; this one did.
   *
   * ⭐ SO THE HEIGHT TERM IS DERIVED FROM THE THING THAT ACTUALLY BINDS. The
   * square is centred on `--centre`, so its half-height may take the top band
   * less what the words are owed:
   *
   *     gate / 2  ≤  --centre − --head − --gap
   *     gate      ≤  2 × (--centre − --head − --gap)
   *
   * `--head` is the words' measured floor (below) and `--gap` the one clearance
   * between the square and anything beside it. MEASURED, 1440×900: --centre 300,
   * --head 100, --gap 23.4 → 353px, where this line used to give 300. 1920×1080
   * → 464 (was 360). 1280×720 → 243 (was 240).
   *
   * 🔴 THERE IS DELIBERATELY NO SECOND TERM FOR THE BOTTOM BAND, and that is
   * not an omission — it is that the bottom cannot be squeezed out. `--room`
   * below is DERIVED from `--gate` (`100svh − --centre − gate/2 − --band`), so
   * the furniture does not compete with the square for space, it takes what is
   * left and sizes itself down; the ring has its own `min()` floor for when
   * that gets tight. Measured at 1440×900 after this change: the square's
   * bottom edge lands at 476.6 and leaves 423px under it for a ring that wants
   * far less. A hard bottom reserve would be an unmeasured constant competing
   * with a self-adjusting one. ⚠️ If the bottom band ever DOES grow something
   * with a fixed height, this is where its `2 × (100svh − --centre − foot)`
   * term goes — and it needs measuring, not guessing.
   *
   * ⚠️ WHAT THIS DOES NOT DO: match the app. `alignment.webp` runs its square at
   * 0.42 of the screen with its centre at 0.31, and it can because it has
   * NOTHING above the square. This page carries a title and a caption up there —
   * 97px at every width from 320 to 1920, a constant rather than a fraction —
   * and 0.42svh with those words needs `--centre` at 0.347svh, not a third.
   * That trade is Christian's to make, not this file's: it moves the
   * top-two-thirds composition item 9 just established. Flagged, not taken. */
  --gate: min(50vw, calc(2 * (var(--centre) - var(--head) - var(--gap))));
  /* The one gap between the square and whatever a scene puts under it. Stated
   * here rather than on each prop so a scene can measure with it — scene 5
   * needs it to know where its ring starts. */
  --gap: clamp(14px, 2.6svh, 34px);
  /* 🔴 THE PAGE'S ONE VERTICAL AXIS — ../../PLAN.md §"Follow the white rabbit"
   * (2026-09-22). Christian: *"Text and title or reel is offset left or right
   * under the rounded-video-box — sometimes centered sometimes not."*
   *
   * ⚠️ IT WAS TRUE, AND IT WAS THE WHOLE PAGE RATHER THAN ONE ELEMENT. THE
   * PAGE HAD TWO CENTRES, 32px APART. Measured, four widths:
   *
   *              .story / gate / reel / words   #how · #cta-final · footer
   *     390             195                            223
   *     768             384                            416
   *     1440            720                            752
   *     1920            960                            992
   *
   * The right-hand column is `main`, which every other page uses and which is
   * inset by the rail (`body { padding-inline-start: var(--rail-w) }`,
   * ../../css/nav.css §THE PUSH). The left-hand column is the story, which
   * escapes that padding to span the true glass (§FULL-BLEED below) and then
   * centred on `50%` OF THE GLASS. Both are internally consistent; they are
   * just not the same line, so scrolling out of the square and into the CTA
   * directly beneath it steps the whole composition 32px sideways. That step
   * is the "sometimes centered sometimes not", and every symptom under it —
   * the square sitting rail/2 left of the room it is in, the satellites
   * tucking under the rail, `.scene-words` needing to be narrowed by TWICE the
   * rail to clear a column on one side — is the same bug seen again.
   *
   * ⭐ SO THE GROUND STAYS FULL-BLEED AND THE COMPOSITION MOVES. Those are two
   * different jobs and item 9's "one ground" needs the first: the black has to
   * reach both edges of the glass or a band of the body's near-black shows
   * beside it. Only the AXIS the furniture hangs off moves, and it moves to
   * where the rest of the page already is — the middle of the room BESIDE the
   * rail, which is also what item 28 was asking for in Christian's own words
   * (*"that way the rounded-video-box would also be aligned in the center
   * left-right"*). The rail is opaque and on every page; the centre of the
   * canvas a reader actually has is `rail + (100vw − rail) / 2`.
   *
   * 🔴 EVERY ABSOLUTELY-POSITIONED PROP IN THE STORY READS THIS AND NOTHING
   * ELSE. A literal `left: 50%` in here is now a bug — it is the old axis. The
   * `50%`s that remain are INSIDE `.ring` (`.disc`, `.tick`, `.mark`, `.say`,
   * `.knob`), which centre on their own parent and must not be touched.
   *
   * ⚠️ THE `%` IS RESOLVED AT USE TIME, which is what makes one declaration
   * serve them all — and ⭐ ON 2026-10-01 THAT PROPERTY TURNED FROM A CONVENIENCE
   * INTO THE ONE ANCHOR THAT PORTED FOR FREE. It used to mean "every consumer's
   * containing block is 100vw wide, so `50%` is 50vw for each of them"; the
   * composition's containing block is now `.screen-app` (the glass) or `.gate`,
   * so the same declaration means "the middle of the phone" with no edit at all.
   * `.scene-words` and `.reel` are still on a `.scene > .stage` and still read it
   * as 50vw, which is correct for them. Do not use it in a property where a
   * percentage means something else. The square itself is NOT
   * in this list: it is grid-centred with no transform (see §the layer), and it
   * is moved onto the axis by that grid's own `padding-inline-start`. */
  --axis: 50%;
  /* ⚰️ `--seam-gap` MOVED TO `.screen-app` ON 2026-10-01 (`settle.md` Phase B),
   * and the reason is a trap worth the four lines it costs:
   * 🔴 A REGISTERED CUSTOM PROPERTY COMPUTES WHERE IT IS DECLARED, SO IT CANNOT
   * CARRY A `cqw` OF A CONTAINER THAT IS ONE OF ITS OWN DESCENDANTS.
   * `@property --seam-gap { syntax: "<length>" }` (00-props.css) means the value
   * is resolved to absolute px on THIS element and then inherited. `.story` is
   * an ANCESTOR of `.device-frame`, so a `100cqw` written here has no query
   * container to resolve against and falls back to the SMALL VIEWPORT's width —
   * 1440px where the phone is 348.8px, silently, with no warning anywhere.
   * ⚠️ The same is true of `--centre` (registered `<length>`), which is why the
   * device plane's copy of it is written as `--dev-ratio * 100cqw` rather than as
   * the `%` of height the prototype uses, and why BOTH live on `.screen-app`.
   * ⭐ The 0.051 itself is unchanged and is still the app's own: 34px of gap
   * against a 674px square, measured off IMG_1778. */
  /* ⭐ THE FLOOR UNDER THE WORDS, AND THE ONLY GUARANTEE THE TOP BAND MAKES:
   * the caption never leaves the screen. It is the words' own tallest box plus
   * a little air — MEASURED, not chosen: `.scene-words` stands 97px at its
   * worst on every width from 320 to 1920 (it is a constant, because the title
   * gains a line exactly as the caption loses one), and 100px is that box plus
   * 3px — the floor says "they fit", and nothing more. ⚠️ It was 104px for one
   * build and that was already too generous: at 400×850 the extra 4px pushed
   * the runtime line 1.2px THROUGH the square's top edge, because `.lengths`
   * hangs off this same floor. A floor that over-corrects moves the collision
   * rather than removing it.
   * ⚠️ RE-MEASURE IT IF THE CAPTIONS GROW. A third line of caption makes this
   * number wrong, and the symptom is a title cropped by the top of the screen
   * on a short laptop — not an obvious one to trace back to here.
   *
   * ⚠️⚠️ AND THE 97px ABOVE NO LONGER DESCRIBES THE WORDS — item 22 moved them
   * to the BOTTOM band, so what this floor actually reserves room for now is
   * `.lengths` and the top band itself. The number is unchanged and still
   * correct for that job; only its justification had gone stale, and the
   * instruction above would have sent the next reader to change the wrong
   * thing. ../../PLAN.md item 28 is the proof: narrowing the caption to clear
   * the rail took `.scene-words` from a flat 97px to 120px at 400, 141 at 360
   * and 164 at 320 — a caption growing by two thirds, with this floor rightly
   * untouched. Measured after, both engines, every scene at four phone sizes
   * down to 320×480: the words stay on screen and never meet `.lengths`. */
  --head: 6.25rem;
  --story-ground: #000;
  /* 🔴 THE HOUSE COLOURS — `--cam-red` / `--cam-amber` — MOVED OUT OF THIS FILE
   * on 2026-09-22. They now sit on `:root` in ../../css/base.css §THE HOUSE
   * COLOURS, which carries the full argument (they are app constants, not theme
   * tokens, because the disc never changes colour). They are still read here by
   * exactly the same names and nothing in this file changed but this comment.
   *
   * ⚠️ Do not re-declare them here. The rail's home mark (../../html/_nav.html)
   * is on EVERY page, and a copy scoped to `.story` would be a second source of
   * truth for the app's one red that the other ten pages could not see. */
  color: #F2EEE8;
  /* ═══ FULL-BLEED, AND IT NOW CORRECTS FOR THE RAIL ══════════════════════
   * Full-bleed: main is a centred 72rem column, and a story inside it paints a
   * black stripe down the middle of a near-black page. Same escape the docs
   * shell uses, so main's max-width still holds for every other page.
   *
   * 🔴 THE PLAIN `margin-inline: calc(50% - 50vw)` DOES NOT LAND ON THE GLASS'S
   * EDGE ON THIS SITE, and that is not a fault in the hatch — it is the rail.
   * That `50%` is half of MAIN's width, so the 100vw box centres on main's
   * centre; main is centred in body's content box; and body's content box is
   * the viewport MINUS the rail (`padding-inline-start: var(--rail-w)`,
   * ../../css/nav.css §THE PUSH). The algebra falls out to a single term, and
   * the page gutter cancels out of it, so it is the same at every width:
   *
   *     story left = (--rail-w + gutter) + M/2 − 50vw  =  --rail-w / 2
   *
   * Measured before this correction, both engines, five widths: 28px at every
   * width ≤720 (--rail-w 56) and 32px above it (--rail-w 64) — with the gate's
   * centre off the glass's centre by exactly the same number.
   *
   * ⭐ AND UNTIL ../../PLAN.md ITEM 28 THAT *WAS* THE DESIGN, not an oversight.
   * nav.css proved the identity on purpose and called the result "dead centre
   * of what a reader can actually see" — the square centred in the glass the
   * rail is not on. Christian looked at a phone and asked for the other
   * reading: "the rounded-video-box would also be aligned in the center
   * left-right." So the identity is REVERSED here rather than patched around,
   * and nav.css §THE PUSH carries the other half of that rewrite.
   *
   * ⚠️ IT COSTS THE SQUARE WIDTH ON A PHONE, AND THERE IS NO WAY AROUND IT.
   * A composition centred on the glass wears the rail's 56px on one side, so
   * the square may only be as wide as twice the room beside it — which is the
   * phone `--gate` cap at the foot of this file, and nothing else. Centred on
   * the glass at the old 78vw, the square's own left edge went 12–16px UNDER
   * the rail (measured, both engines, 400/360/320).
   *
   * ⚠️ `margin-inline-end` is stated as well, though it is over-constrained and
   * therefore ignored: the pair still sums to the hatch's own total, so nothing
   * about the box's width or the document's overflow changes. Only the start
   * edge moves, which is the whole intent. */
  width: 100vw;
  margin-inline: calc(50% - 50vw);
  position: relative;
  /* 🔴 `clip`, AND NOT `hidden` — THE TWO ARE NOT INTERCHANGEABLE HERE.
   * The radar is sized off the viewport itself since 2026-09-23 (five gates
   * wide before that, from 2026-09-22 — see §THE RADAR, where both rulings are
   * recorded), absolutely positioned and unclipped, so it pushed sideways
   * scroll onto a phone even at the smaller size — and it
   * sits at `opacity: 0` for all but one scene, so the cost was paid by readers
   * who never see it, reduced-motion readers included.
   *
   * ⚠️ `overflow: hidden` would fix the scrollbar and break the story: it makes
   * a new scroll container, which is what `position: sticky` sticks to and what
   * a view-timeline measures against — so the gate would stop pinning and every
   * scroll-driven animation on the page would be reading the wrong scroller.
   * `clip` clips without creating one. Measured by the a11y lane at 320, 375,
   * 414, 768, 1024, 1280 and 1920: overflow 0, full-bleed and sticky intact. */
  overflow-x: clip;
}

/* ══ ⚰️⚰️ `.gate-layer` — DELETED 2026-10-01, `settle.md` PHASE A ══════════
 * THE PAGE HAD TWO FULL PLANES AND ONLY ONE OF THEM WAS A STAGE. What stood
 * here was the old architecture's composition plane:
 *
 *     .gate-layer {
 *       position: sticky; top: 0; height: 100svh; margin-bottom: -100svh;
 *       z-index: 1; pointer-events: none;
 *       display: grid; grid-template-rows: calc(var(--centre) * 2);
 *       place-items: center;
 *     }
 *
 * — net-zero in flow and sticky for the length of `.story`, which is the SAME
 * construction `.framebar` carries one element up (`20-device.css` §THE
 * FRAMEBAR, expressed against `--avail`). Step 4 left both drawing on purpose,
 * the device at `z-index: 3` over this layer's `1`, so the remaining work could
 * not look done. The composition is inside `.screen-app` now
 * (`layouts/story.yml`) and this element does not exist.
 *
 * ⭐ WHAT THE DELETION TAKES WITH IT, NAMED, because all three were load-bearing
 * and their jobs are now done by the device rather than by a layer:
 *   · the STICK — `.framebar` does it, and only one plane can be the stage;
 *   · the GRID ROW (`calc(var(--centre) * 2)` + `place-items: center`), which
 *     centred `.frame` on `--centre` with NO TRANSFORM, so `gate-weave` could
 *     own `translate` outright. 🔴 That trap is still real and is now paid a
 *     different way: `.frame` is an in-flow child of `.screen-app` (`inset: 0`),
 *     and §the frame below carries the replacement. "Two transforms need two
 *     elements" — nothing may put a centring `translate` on `.gate`.
 *   · the `padding-inline-start` the satellites and the radar resolved `100%`
 *     against. Their containing block is now `.screen-app` (the glass) and
 *     `.gate` respectively, which is the point of the move.
 * 🔴 AND IT TAKES THE REASON `--centre` / `--gate` / `--axis` EXIST. Those three
 * are viewport anchors, and inside a container-query device there is nothing to
 * re-derive against the viewport. Retiring them is Phase B's work and it is
 * where every number in this file gets re-MEASURED rather than converted; until
 * it lands the composition reads lengths sized for a 900px-tall plane inside a
 * ~414px-tall phone. ⚠️ Do not "fix" that one declaration at a time — that is
 * the arithmetic-instead-of-measurement route this file's own history is a list
 * of (`port-plan.md` §10 calls the re-basing *most of the work*).
 *
 * ⭐ PHASE B HAS NOW LANDED, AND THE NEXT BLOCK IS IT.
 *
 * ⚠️ TWO RULES IN `50-motion.css` STILL NAME IT and are now dead selectors:
 * `:117` (`gate-pushed`, the square leaving on `#features`' entry) and `:488`
 * (`gate-dismissed`, its reduced-motion fade). That file is Phase C's lease, so
 * they are REPORTED rather than touched here — the push-off beat needs rebinding
 * to `.framebar`, and `.framebar` already carries a `transition: opacity` that a
 * translate keyframe has to be reconciled with. */

/* ══ 🔴🔴 THE DEVICE PLANE — EVERY APP FRACTION IS A `cqw` OF THE PHONE ══════
 * `settle.md` Phase B, 2026-10-01. ⭐ THIS BLOCK IS THE WHOLE OF THE RE-BASING.
 * Before it, the composition read lengths sized for a 900px-tall viewport plane
 * while sitting inside a 348.8×756 phone: the square drew 353.19px wide — 1.0126
 * of a device 348.8 wide, so the GLASS CLIPPED IT — and its centre sat at 0.3968
 * of the device's height where the app puts it at 0.3643. The ring's band was
 * not placed at all (`--band-centre` is declared on `.scene > .stage` and the
 * furniture is no longer a descendant of one), so the dial, the score and the
 * scissors resolved `top: auto` and stacked at the top edge of the glass.
 *
 * 🔴 THE MECHANISM IS ONE DECLARATION BLOCK, NOT A SWEEP OF THE FILE, and that
 * is deliberate: `port-plan.md` PART 10 calls the re-basing *"most of the work"*
 * and this file's own history is a list of what happens when it is done one
 * declaration at a time (§--gate: *"a number that is derived from the wrong
 * quantity fails quietly the moment that quantity moves"*). Every prop in the
 * phone already reads `--centre` / `--gate` / `--axis` by name; re-declaring
 * those three HERE, inside the container, re-bases all fourteen of them at once
 * and leaves each rule's own arithmetic to be judged on its own merits.
 *
 * ⭐ AND IT KEEPS TWO FROZEN FILES ALIVE. `50-motion.css:868-876`
 * (`satellite-merge`) reads `--centre`, `--gate` AND `--axis` inside its own
 * keyframes, and that file is Phase C's lease; `95-narrow.css` reads two of
 * them. Retiring the NAMES would have left the merge's `top` and `left`
 * undeclared — the four phones would scale up in place and never converge on
 * the square — and Phase B cannot edit the file that would fix it. So the names
 * survive, scoped, as thin aliases over the app's own fractions. ⚠️ THEY ARE NOT
 * A SECOND SOURCE OF TRUTH: each is one `calc()` on a measured fraction below,
 * so there is nothing for them to drift from.
 *
 * 🔴 NOTHING HERE MAY BE READ OUTSIDE `.screen-app`. `cqw` resolves against the
 * nearest query container, and on `.story` or on a `.scene > .stage` there is
 * none — so the same expression silently becomes a fraction of the SMALL
 * VIEWPORT. That is the one failure mode of this whole block and it is silent.
 */
.screen-app {
  /* ── the app, as fractions of the DEVICE ──────────────────────────────────
   * ⭐ COPIED AS MEASUREMENTS, NOT RE-DERIVED. Every number is verbatim from
   * `site/_proto/refactor/kit/device.css` §THE APP INSIDE, which carries [R2]'s
   * and [R3]'s independent readings and the pt counts they came from. The thing
   * Phase B exists to prevent is this file arriving at the same quantity by a
   * second route — which is exactly what happened to the ring (see below).
   * ⚠️ `--sq-centre` (0.3643) and `--dev-ratio` (2.1674) are NOT re-declared
   * here: they are already on `:root` in `20-device.css`, where the device's own
   * `transform-origin` and `aspect-ratio` read them. One pivot, one aspect. */
  --sq-side:     0.8977;    /* of dev width  — 386pt/430    [R2+R3 agree exactly] */
  --ctrl-centre: 0.8400;    /* of dev height — 2348.5/2796  [R3]                  */
  --disc:        0.172093;  /* of dev width  — 74pt/430     [R3 RecordDisc ▸ base] */
  --ring-orbit:  0.186047;  /* of dev width  — 80pt/430     [R3 ModeSwitch:304-308] */
  --app-gap:     0.029070;  /* of dev width  — 12.5pt/430   [R3 ReelView:145]      */
  /* the reel strip, added 2026-10-01 when it finally came into the phone with
   * the rest of the furniture — same provenance, copied not re-derived. */
  --reel-centre: 0.6320;    /* of dev HEIGHT — 1767/2796    [R3]; [R2] said .6314 */
  --reel-tile:   0.133721;  /* of dev width  — 57.5pt/430   [R3 ReelView:144]     */
  --reel-gap:    0.029070;  /* of dev width  — 12.5pt/430   [R3 ReelView:145]     */
  --reel-band:   0.195349;  /* of dev width  — the 84pt row [R3 ReelView:148]     */

  /* ── and the three anchors, re-based onto that ────────────────────────────
   * ⭐ `--axis` IS THE ONE THAT PORTED FOR FREE and this declaration changes
   * nothing: `50%` was already resolved at use time against each prop's own
   * containing block, which has been `.screen-app` or `.gate` since Phase A.
   * It is restated here only so the plane's vocabulary is complete in one place
   * — ASSERTED rather than assumed (frame.cx against the device's own cx).
   *
   * 🔴 `--centre` IS A REGISTERED `<length>` AND THEREFORE CANNOT BE THE `%` OF
   * HEIGHT THE PROTOTYPE USES (`top: calc(var(--sq-centre) * 100%)`). A
   * registered `<length>` rejects a percentage outright and falls back to its
   * `initial-value: 50svh` — which would have read as "the re-basing did
   * nothing" while being a type error. The device's aspect is FIXED, so the same
   * line is exactly expressible in the inline axis: `--sq-centre` of the height
   * is `--sq-centre × --dev-ratio` of the width = 78.959cqw. ⚠️ The two forms
   * must agree to the pixel and the rig asserts they do — `.frame`'s drawn cy
   * against `--sq-centre × devH`, measured from the device's own box.
   *
   * ⚠️ `--gap` IS THE APP'S GAP NOW, NOT THE PAGE'S. On the viewport plane it is
   * `clamp(14px, 2.6svh, 34px)` — 23.4px at 1440×900, which inside a 348.8px
   * phone is 6.7% of the device and reads as a page margin that wandered in.
   * The app's own one gap is the reel's 12.5pt, 10.1px on that same phone. */
  --axis:     50%;
  --centre:   calc(var(--sq-centre) * var(--dev-ratio) * 100cqw);
  --gate:     calc(var(--sq-side) * 100cqw);
  --gap:      calc(var(--app-gap) * 100cqw);
  --seam-gap: calc(var(--gate) * 0.051 * var(--seam-open));
}

/* ══ 🔴 LAW A, AUDITED — EVERY ALIGNMENT KEYWORD NOW INSIDE THE DEVICE ════════
 * `20-device.css` §THE ONE DEVICE states the law and names this directory as the
 * hazard: *"`place-items: center`, `justify-content: space-between` and grid
 * centring appear throughout `25-scenes.css` and `30-composition.css`. Every one
 * of them that ends up INSIDE this box is a latent cut."* The audit was done in
 * the same pass as the move, and the result is NOT five rewrites — it is one
 * rewrite and four acquittals, because the law is about a value that CHANGES
 * between two placements, and a keyword that never changes cannot flip at a
 * timing function's halfway point.
 *
 *   ✅ FIXED — `.gate-layer`'s `place-items: center` on a `calc(var(--centre) *
 *      2)` grid row. Deleted with the element; `.frame` is placed by
 *      `left`/`top`/`translate` now (see §the frame). This was the real one.
 *   ✅ ACQUITTED — `.frame`'s `justify-content: space-between`. Constant in every
 *      placement, so there is nothing to interpolate; and ⚠️ reverting it to the
 *      `gap: var(--seam-gap)` length form would reintroduce a MEASURED WebKit
 *      fault (§the frame: the gap takes the new seam while the halves' widths are
 *      a pass behind, 400.3px against 384). The keyword is the fix, not the
 *      hazard. Same for `60-cut.css`'s `.lengths`, by the same derivation.
 *   ✅ ACQUITTED — `.frame`'s and `.reel`'s `align-items: center`, `.reel`'s
 *      `justify-content: center`, `.maker`'s and `40-ring.css`'s `.knob` /
 *      `.cutter .disc` `place-items: center`, `70-score.css`'s `.soundtrack`.
 *      All constant, all centring a child SMALLER than its box.
 *   🔴 CORRECTION TO THE WORK ORDER — `.dial`'s "implicit grid track" is NOT A
 *      DEFECT ON THIS SITE, and the brief names it as the place to start. It is
 *      the PROTOTYPE's (`_proto/refactor/kit/device.css:621` — `display: grid;
 *      place-items: center` with no explicit track, so `.ring` sizes the track
 *      and the disc centres on the track rather than the device's centre-line,
 *      measured there at a constant 0.05344 × `--dev-w`). This site's `.dial` is
 *      never `display: grid` at all: grepped, the only rules are
 *      `30-composition.css`'s `position: absolute; left: var(--axis)` and
 *      `40-ring.css:49`'s `top: var(--band-centre); translate: -50% -50%`, with
 *      `.disc` absolutely centred inside `.ring`. The disc is on the centre-line
 *      by construction, and it was ASSERTED rather than read — see the report.
 *      ⚠️ The prototype's fix (an explicit single track) must not be ported here:
 *      it would be adding the grid in order to fix it. */

/* ── the frame: what holds the square, and the two of it ─────────────────
 * 🔴 IT IS ALWAYS EXACTLY `--gate` WIDE, at every seam position, and since
 * 2026-09-25 that is a DECLARATION rather than an arithmetic coincidence — read
 * the block below before changing any of the three widths. It is still the
 * whole trick of scene 5: the two halves and the gap between them add up to
 * the square's own side (the app's own arithmetic — measured off IMG_1778,
 * 420 + 34 + 218 = 672 against a 674 square). So the pair centres itself in the
 * grid above with no transform anywhere near it, and the "two transforms need
 * two elements" trap that this layer already documents cannot be walked into a
 * third time.
 *
 * ⚠️ AND IT MUST NEVER CLIP. The app's own hard-won note: "a container that
 * clips must be no rounder than the tightest corner inside it that has to
 * survive" — it turned the split as one piece and a window clip at the frame's
 * full radius shaved the narrowed half's corners off for a dozen builds. Here
 * each half clips its own picture at its own corner and nothing clips the pair,
 * so there is no outer radius to be wrong. */
.frame {
  /* ⭐⭐ THE CENTRING THAT REPLACED THE DELETED LAYER'S GRID — AND IT IS LAW A'S
   * OWN ANSWER, not a port of the old one. `.gate-layer` centred this box with
   * `place-items: center` on a grid row `calc(var(--centre) * 2)` tall, and
   * `20-device.css` §THE ONE DEVICE names exactly that as a LATENT CUT: an
   * alignment keyword is DISCRETE, so anything inside the travelling device
   * placed by one flips at the halfway point of the timing function instead of
   * interpolating. `left` / `top` / `translate` all interpolate.
   *
   * 🔴 AND THE "TWO TRANSFORMS NEED TWO ELEMENTS" TRAP STAYS PAID — it was the
   * whole reason the old layer used a grid. `gate-weave` writes `translate` on
   * `.gate`, and an animation beats a declaration, so a centring `translate` on
   * `.gate` would simply be erased. It is on THIS element instead: two
   * transforms, two elements, and `.gate` still owns `translate` outright.
   * ⚠️ Nothing may move this `translate` down onto `.gate` to "save an element".
   *
   * ⭐ ALL THREE ARE CORRECT BY CONSTRUCTION SINCE PHASE B, and this rule did
   * not change a byte to get there — §THE DEVICE PLANE re-declares the names it
   * reads. `left` is the phone's middle, `top` is `--sq-centre` of the phone's
   * height (0.3643, the app's own, and the same number `20-device.css` pivots
   * the turn about), and `width` is `--sq-side` of the phone's width (0.8977).
   * MEASURED after, both engines, 1440×900 and 390×844: the square is 0.8977 of
   * the device to 4 decimal places at every placement, its centre 0.3643 of the
   * device's height, and it is inside the glass at every one. Before: 1.0126 of
   * the device and 0.3968 down it — 4.4px wider than the glass it sits in. */
  position: absolute;
  left: var(--axis);
  top: var(--centre);
  translate: -50% -50%;
  display: flex;
  align-items: center;                /* both halves on the square's own centre */
  /* 🔴🔴 THE WIDTH IS DECLARED AND THE SEAM IS THE REMAINDER — and until
   * 2026-09-25 it was the other way round: no width at all, `gap:
   * var(--seam-gap)`, and the claim in the header above ("always exactly
   * `--gate` wide") was EMERGENT — true only because three separately
   * substituted `calc()`s happened to sum to one number:
   *
   *     (--gate − sg) × share   +   sg   +   (--gate − sg) × (1 − share)
   *      .gate's width            the gap     .cut's width
   *
   * ⚠️ AND WEBKIT DOES NOT RESOLVE THOSE THREE IN THE SAME LAYOUT. Measured
   * 2026-09-25, Playwright's WebKit, `prefers-reduced-motion: reduce`, at
   * 390/768/1024/1440 alike: when the scroll moves `--share`/`--seam-open`, the
   * flex container's `column-gap` takes the NEW `--seam-gap` while the flex
   * items' `width` is still resolved from the OLD one, for one style pass.
   * `.frame` then measures `--gate` ± the whole seam — 400.3px against 384 at
   * 768×1024, 364.4px one sample later — and it is drawn that way: the pair is
   * up to 20px wider or narrower than the square and up to 10px off the page's
   * axis. It settles by itself inside ~400ms (measured: wrong at 90ms, right at
   * 400ms and at 1200ms, and a forced reflow does not hurry it), which is why
   * nothing had ever caught it: it is a transient, and a transient is exactly
   * what a rig sampling at 90ms per scroll lands on.
   *
   * 🔴 IT PREDATES ../../PLAN.md ITEM 42 — `git show HEAD:…/home.css` has this
   * rule with no width and the same `reduce` keyframes, so it has been live
   * since item 7 built the split. Item 42 is not the cause; it is what finally
   * put a reduced-motion matrix in front of it.
   *
   * ⭐ THE FIX IS THE FILE'S OWN LAW, NOT A WORKAROUND. §--share: "it has to be
   * one number, because the scene's whole claim is that the sizes ARE the
   * lengths. Two numbers kept in step by hand would be a scene that says the
   * sizes MATCH the lengths, which is a weaker and more breakable thing."
   * `--seam-gap` was being resolved TWICE — once for the gap, once inside each
   * half's width — and this deletes one of the two. The box is `--gate` by
   * declaration; the halves take their share of what is left; the seam is
   * whatever is over. Three quantities become one, and a WebKit that is a pass
   * behind on the halves can only make the SEAM a little wide or a little
   * narrow for a frame — the frame stays exactly `--gate` and stays centred.
   * Re-measured after the change: 0.000px deviation at every sample, both
   * engines, both motions, four widths.
   *
   * ⚠️ `space-between` NEEDS EXACTLY TWO CHILDREN and gets them (`.gate` and
   * `.cut`, the only two in layouts/story.yml). At `--share: 1` the cut is 0
   * wide, the free space is 0, and it sits flush at the right edge — which is
   * exactly where a zero gap put it before. ⚠️ And `.lengths` below is given
   * the same treatment in the same breath, because the runtimes only sit over
   * their own boxes while the two rows compute the seam the same way. */
  width: var(--gate);
  justify-content: space-between;
}

/* Both halves. ⭐ EACH IS A SQUARE AT ITS OWN SIZE, not a tall slice — read off
 * IMG_1778/1779, where the big box is 420 and the small 218 and both are 1:1,
 * vertically centred on each other. That is also what keeps the corner honest:
 * `--corner-square` is a PERCENTAGE, exact only on a square (base.css §THE
 * CORNER), so a half that stays square keeps the app's corner at every size it
 * passes through as the seam moves. A tall slice would have drawn an ellipse. */
.gate, .cut {
  aspect-ratio: 1;
  flex: 0 0 auto;
  border-radius: var(--corner-square);
  overflow: hidden;
  /* 🔴 POSITIONED, AND `.cut` DID NOT USED TO BE — ../../PLAN.md item 35, and
   * it is the kind of bug that only a screenshot finds. `.gate` has carried
   * `position: relative` in its own block since the cut marks needed a
   * containing block; `.cut` was `static`, which nothing noticed while it held
   * only a background. Give it a picture and the absolutely-positioned <img>
   * walks up to the nearest positioned ancestor — then `.gate-layer`, which was
   * `sticky`; today it would be `.frame` — and fills the WHOLE of it: measured
   * 1440×900 inside a 148×148 box it filled the viewport, and was NOT clipped,
   * because `overflow: hidden` does not clip an absolute box whose containing
   * block is an ancestor of the clipper. ⚠️ The glass now clips the lot, which
   * makes the symptom smaller and the rule no less true.
   * ⚠️ Every host of a `.plate-shot` must establish a containing block. The
   * other four (.plate, .look, .satellite, .tile) already did, which is why
   * this surfaced exactly once and only here. */
  position: relative;
  /* The light falling on a wall: warmer and softer than the picture, and
   * outside the square rather than on it. Amber is the film's colour. One
   * projector, so both halves stand in the same light. */
  box-shadow: 0 0 clamp(40px, 9vw, 120px) rgb(from var(--cam-amber) r g b / 0.10);
}

.gate {
  /* relative, not absolute: the layer above centres it, and the cut marks
   * inside still get a containing block. No transform here — see the layer. */
  position: relative;
  /* ⭐ THE CUT, AS A DECLARATION RATHER THAN AN ANIMATION. At `--share: 1` and
   * no seam this is `var(--gate)` and nothing has changed; the split scene
   * animates the share and the width follows. Doing it this way keeps the gate
   * on the two animations it already has (weave, emerge) instead of a third,
   * and a property that no animation writes cannot lose a fill-mode argument. */
  width: calc((var(--gate) - var(--seam-gap)) * var(--share));
  animation: gate-weave 5.3s ease-in-out infinite;
}

/* The second half: zero wide until there is a cut, so it is not there at all on
 * the other five scenes. */
.cut { width: calc((var(--gate) - var(--seam-gap)) * (1 - var(--share))); }

/* ── what each scene puts around it ─────────────────────────────────────
 * The stage is the viewport, and everything in it is placed from the centre
 * rather than from its own box. */
.scene > .stage {
  position: sticky;
  top: 0;
  height: 100svh;
  z-index: 2;
  pointer-events: none;
}

/* The words sit ABOVE the square, hanging off its top edge — the square keeps
 * `--centre`, and the props below it get the bottom band.
 *
 * ⭐ `max()` IS THE ONE GUARANTEE THIS LINE MAKES: the words never leave the
 * screen. `translate: -100%` makes `top` their BOTTOM edge, so the floor is how
 * far down that edge has to be for 97px of title and caption to fit above it —
 * and the natural position wins everywhere there is room for it (measured:
 * `--centre / 2` clears it above ~672px of viewport height, which is every
 * desktop and every phone from a 375×667 up).
 *
 * ⚠️ BELOW THAT THE WORDS READ OVER THE SQUARE'S TOP EDGE, AND THAT IS THE
 * PAGE'S OWN ESTABLISHED ANSWER rather than a new concession — the `#how` steps
 * already do exactly this on a phone, which is what `.scene > .stage`'s
 * `z-index: 2` over the gate layer's `1` is FOR (see §ITEM 8 above). At
 * 320×568, the shortest screen worth drawing for, the overlap is 17px of a
 * 239px square. ⏳ Same contrast caveat the steps carry: check it against the
 * first bright ROTOSCOPE export rather than against today's dark placeholder. */
/* ═══ A SCENE'S WORDS LIVE IN THE BOTTOM BAND ═════════════════════════════
 * ../../PLAN.md item 22 part B. Christian: *"The text that currently aligns top
 * has no headroom and looks crazy there — so what if we show title and text
 * where the dial would be too — fading in and out — so we describe a section
 * (user can pause the scroll to read) and then we fade in the furniture."*
 *
 * ⚰️ WHAT WENT: `top: max(var(--head), calc(var(--centre) - var(--gate)/2 -
 * var(--gap)))` — the strip ABOVE the square, which item 15 had already
 * measured as the tightest thing on the page. That band is a constant 97px of
 * words against a headroom that SHRANK when item 9 moved the composition up,
 * and it is why the square had to be capped at all. The words were competing
 * with the square for the one scarce dimension on the page.
 *
 * ⭐ AND THE BAND THEY MOVE INTO IS NOT EMPTY SPACE — IT IS THE DIAL'S. That is
 * the design rather than a problem: the two are TIME-MULTIPLEXED, text first
 * and furniture after, where they used to compete for space or (scene 1) leave
 * the band empty altogether. `--band-centre` is the one anchor both read, so
 * they cannot land in different places.
 *
 * 🔴 ONLY THE DIAL, THE SCORE AND THE CUTTER SHARE THIS CENTRE — checked, not
 * assumed. The reel sits higher in the band (`--centre + --gate/2 + --gap`, its
 * top around 500 against the words' centre near 688 at 1440×900), the radar
 * sits behind the square and the satellites orbit it. So the sequencing below
 * had to retime three animations, not the twenty-five in this file. */
.scene-words {
  position: absolute;
  left: var(--axis);
  top: var(--band-centre);
  translate: -50% -50%;
  /* ⭐ THE RAIL IS IN THIS NUMBER SINCE ../../PLAN.md ITEM 28, and it has to be.
   * It was `min(44rem, calc(100vw - 32px))` — the page's own gutter and nothing
   * else — which was right while the story centred in the glass BESIDE the rail
   * and is wrong now that it centres on the glass itself: the box simply moved
   * half a rail left and took the words with it. MEASURED at 400px: the caption
   * box ran from x=16 with the rail's opaque column over 0–56, so 40px of every
   * wrapped line was behind it. ⚠️ CAUGHT BY EYE ON A SCREENSHOT, not by the
   * geometry pass that preceded it — that pass asked about `#how` and
   * `#cta-final`, which live in `main` and are still inset by the push, and
   * never thought to ask about the story's OWN text, which is not.
   *
   * 🔴 IT WAS ALREADY 12px UNDER THE RAIL BEFORE ANY OF THIS, at 400 and 320,
   * in all six scenes — so this is a pre-existing defect the centring made
   * three times worse and then forced a fix for, rather than one it caused.
   *
   * Same derivation as the phone `--gate` cap: twice the room beside the rail,
   * less the page's 16px of air each side. 400 → 256, 360 → 216, 320 → 176,
   * 768 → 608; at 1440 the 44rem measure still binds and nothing moves. The
   * cost is a narrower column on a phone, which is the price of a caption that
   * is centred on the same axis as the square above it.
   *
   * ⭐ AND THE `2 ×` CAME OUT WITH THE AXIS, 2026-09-22 — the cost above is
   * refunded. `2 × room` is what a box centred on the GLASS needs to clear a
   * column on ONE side; centred on `--axis` it is in the middle of that room
   * already, so the room itself is the measure. This is the same re-derivation
   * item 15 had to make after item 9 and the same lesson: a number derived
   * from a quantity that has since moved fails quietly. MEASURED after, same
   * widths: 400 → 328, 360 → 288, 320 → 248, 768 → 672. The caption is wider
   * on every phone than it was and still clears the rail, because it is now
   * clearing it on the side it is actually near. */
  width: min(44rem, calc(100vw - 2 * var(--rail-w) - 32px));
  text-align: center;
}

/* ══ 🔴 `--figband` — WRITTEN SINCE 2026-09-30, STILL READ BY NOTHING, AND NOW
 *    WE KNOW WHY ════════════════════════════════════════════════════════════
 * `20-frame.js ▸ placeStack()` measures the device and the words as ONE GROUP
 * and centres the pair: `top = (avail − (devH + gap + storyH)) / 2`, out of which
 * `--figtop` is the phone's own top and `--figband` is THE WORDS' OWN TOP — see
 * its §THE STACK, which carries both faults that have lived in that measurement.
 * It was written on this element and had no reader at all (grepped: zero
 * `var(--figband)` in `src/`), so a stacked scene centred its words in the
 * bottom band while the phone was placed from the group — which is the drift
 * `placeStack()` exists to remove: *"`align-self: end` on the words then means
 * 'the bottom of the viewport', not 'under the picture', and the pair drifts
 * apart by whatever is left over."*
 *
 * 🔴 WHY IT IS A SECOND RULE AND NOT A `var()` FALLBACK IN THE ONE ABOVE. The
 * two placements need DIFFERENT translates, and that cannot come out of a
 * fallback: `--band-centre` is the words' CENTRE (so `translate: -50% -50%`),
 * `--figband` is their TOP (so no vertical shift at all). CSS has no way to ask
 * "was this custom property written?" — a `var()` fallback answers for ONE
 * declaration, and an element cannot style-query its own properties. So the
 * switch is a selector, and the selector is chosen so the base rule above keeps
 * every byte of today's geometry in the state that matters most:
 * 🔴 `html[data-frame="live"]` MEANS THIS RULE CANNOT REACH A READER WITH JS OFF.
 * `20-frame.js` stamps that attribute on every boot outcome (`20-device.css`
 * §THE CONTRACT item 12 — it is already load-bearing for layout attachment), so
 * with no JS there is no attribute, this rule does not match, and the JS-off
 * composition is the measured one `docs/design.md` rule 3 protects. Nothing
 * here may be re-scoped to `html` alone.
 *
 * ⚠️ ONE LEAK, BOUNDED AND MEASURED RATHER THAN HAND-WAVED: `placeStack()` only
 * writes `--figband` on the scene that is CURRENT, so a stacked scene that has
 * never been current falls back to `--band-centre` with no half-shift and sits
 * half its own height low. It is not visible, and the reason is a timing one
 * worth writing down: `50-motion.css`'s `words-say` fades a scene's words in at
 * `contain 44%`, long after that scene's top crossed the reading line and made
 * it current — so the fallback is only ever worn at opacity 0. ⚠️ Which also
 * means it IS visible to any rig that reads boxes without reading opacity.
 *
 * ⏳ AND IT ONLY MATTERS ON A STACK, which is why `[data-arrange="stack"]` would
 * be in the selector rather than relying on the property being absent: in a
 * SPLIT the phone and the words sit side by side and each centres on its own,
 * and `placeStack()` clears `--figband` there.
 *
 * 🔴🔴 IT WAS WRITTEN, MEASURED, TAKEN BACK OUT, AND LANDED A PHASE LATER —
 * THE RECORD, BECAUSE THE SHAPE OF THE RULE IS THE ARGUMENT. The four lines
 * below were live for one round at the end of Phase B and were REVERTED rather
 * than shipped 44px wrong:
 *
 * The `top` half worked — measured, `top` computed to exactly `--figband`
 * (644.50px at 1440×900 on `opening`, both engines). ⚠️ THE `translate` HALF DID
 * NOT, AND AN ANIMATION WAS WHY: `50-motion.css`'s `words-say` wrote `translate`
 * in its own keyframes (`-50% calc(-50% + 8px)` → `-50% -50%`), and an animation
 * beats a normal declaration. This file already carried that trap twice over —
 * §the layer's *"two transforms need two elements"* and `.reel`'s *"an animation
 * that writes `translate` REPLACES that declaration"* — and that was the third
 * time. With the half-shift surviving, `--figband` (a TOP) was being used as a
 * CENTRE:
 *
 *     1440×900 `opening`   words centre WAS 688.25 (`--band-centre`), correct
 *                          693.0, with the rule 644.5 — a 4.75px error made 48.5
 *     390×844  `opening`   638.7 → correct ~666, with the rule 601.1 — 27 → 65
 *
 * ⭐ PHASE C FREED THE PROPERTY, 2026-10-01, and the unblock was exactly the one
 * keyframe pair this comment predicted: `words-say` (and `words-away` /
 * `words-arrive` / `words-hold` with it) carry the 8px entrance on `margin-top`
 * now, so `translate` on this element belongs to whichever rule placed it. The
 * full argument — why `margin-top` and not a custom property, and what it costs
 * — is at `50-motion.css` §THE 8px ENTRANCE.
 * ⚠️ `!important` WOULD have beaten the animation and was REFUSED: it would
 * silently delete the words' 8px entrance on three scenes from a file that does
 * not own motion. The property was freed instead of being outranked, which is
 * the difference between a fix and a mask.
 * 🔴 WHAT THE SELECTOR GUARANTEES, unchanged from the argument above it:
 * `html[data-frame="live"]` means this cannot reach a reader with JS off, so the
 * JS-off composition is still the measured `--band-centre` one;
 * `[data-arrange="stack"]` means a SPLIT is untouched, where the phone and the
 * words sit side by side and `placeStack()` clears `--figband` anyway; and the
 * `var()` fallback carries the documented leak — a stacked scene that has never
 * been current wears `--band-centre` with no half-shift, only ever at opacity 0.
 * ⚠️ THE TWO DECLARATIONS ARE ONE THOUGHT. `top` without the `translate` puts
 * the words half their own height low; `translate` without the `top` puts them
 * half a height high. Never land one of them. */
html[data-frame="live"] .scene[data-arrange="stack"] .scene-words {
  top: var(--figband, var(--band-centre));
  translate: -50% 0;
}
.scene-title { font-size: clamp(1.15rem, 2.8vw, 1.7rem); letter-spacing: -0.02em; margin: 0 0 0.4em; }
.scene-blurb {
  font-size: clamp(0.95rem, 1.9vw, 1.1rem); line-height: 1.5;
  color: #B9B2A8;                      /* 8.6:1 on black, past AA */
  margin: 0 auto; max-width: 42ch; text-wrap: pretty;
}

/* ── the other phones ────────────────────────────────────────────────────
 * ⭐ OUTSIDE the main square and each in its own size. Placed from the SQUARE'S
 * centre in percentages of its side, so the group recomposes with it at any
 * viewport instead of being a fixed collage. `--x`/`--y`/`--size` are raw
 * numbers: a percentage cannot be divided in calc(). */
/* The wrapper carries no box of its own — `display: contents` means each
 * satellite positions against whatever the nearest positioned ancestor is,
 * exactly as it did when it sat in a scene's stage and then in `.gate-layer`,
 * while giving the four of them a clean `nth-child` that cannot miscount the
 * gate as one of their number.
 * ⭐ SINCE 2026-10-01 THAT ANCESTOR IS `.screen-app` — the glass — so the four
 * phones ride the one device's travel and its turn, which is the whole reason
 * they came in off the old plane. ⚠️ `display: contents` is what makes that free:
 * had this wrapper carried a box, every `--x`/`--y` would have resolved against
 * IT instead and the group would have had to be re-derived. */
.satellites { display: contents; }

/* 🔴 THE FOUR PHONES ARE OUTSIDE THE GLASS — 2026-10-01, Phase C, AND IT IS A
 * CORRECTION TO THE WORK ORDER RATHER THAN A CHANGE OF MIND. `settle.md` §1's
 * move table sent `.satellites` into `.screen-app`; the prototype
 * (`one-device/index.html:52`) puts a `.satellite-layer` on `.device-frame`, so
 * other people's phones paint BEHIND this one. The prototype is right, for a
 * reason the scene states itself — *"One event, several phones"*: these are
 * other people's PHONES AROUND YOURS, and drawn inside your glass they are
 * pictures ON your screen, which says something the product does not mean.
 *
 * ⭐ AND IT WAS MEASURABLE, not only arguable. Inside `.screen-app` all four
 * were pinned on the square's edges and overlapping the picture, because the
 * square is 0.8977 of the device: there is 5.1% of the phone's width either
 * side of it, the authored offsets are ±70…82% OF THE SQUARE, and the clamp
 * that stopped them vanishing off the glass therefore BOUND AT EVERY VIEWPORT.
 * ⚰️ THAT CLAMP IS GONE WITH THE REASON FOR IT. `--sat-x` is the plain offset
 * again. There is now an outside to be outside of — `.device-frame` has no
 * `overflow` and the glass's `clip-path` does not reach a sibling of `.screen`
 * — so a satellite past the phone's edge is drawn past the phone's edge, which
 * is the whole point of moving it. ⚠️ `--sat-x` STAYS A PROPERTY even though it
 * no longer clamps: `satellite-merge`'s 0% keyframe reads it, and a keyframe
 * restating the arithmetic instead is the trap this file documents three times.
 *
 * ⚠️ THE ANCHORS ARE RE-DECLARED HERE AND THAT IS NOT DUPLICATION. `--gate`,
 * `--axis` and `--centre` live on `.screen-app`, which is no longer an ancestor
 * of these four. They are the same three lines, from the same three measured
 * fractions, so the satellite rule and `satellite-merge` keep reading the same
 * names and neither has to learn a second vocabulary. `--sq-centre` and
 * `--dev-ratio` are on `:root` (`20-device.css`), and `cqw` is the DEVICE here,
 * because `.satellites` is `display: contents` — it has no box, so these resolve
 * against `.device-frame` itself, which is exactly what the prototype's
 * `inset: 0` layer resolves to. */
.satellite-layer {
  --axis:   50%;
  --gate:   calc(0.8977 * 100cqw);                          /* the square's side */
  --centre: calc(var(--sq-centre) * var(--dev-ratio) * 100cqw);  /* its centre y */
}

/* 🔴 THE SPECIFICITY IS LOAD-BEARING — it was `.screen-app .satellite` for
 * Phase B and is `.satellite-layer .satellite` since Phase C; what matters is
 * that it is (0,2,0) and not (0,1,0).
 * `95-narrow.css:75-95` carries a `@media (max-width: 720px)`
 * `.satellite` block that restates `--spread`, `--w` and `top` in VIEWPORT
 * lengths (`--centre` + `--gate × y`, read from `.story`). That file is not
 * Phase B's lease and it is LATER in the bundle, so at 390×844 — one of the two
 * viewports this phase is judged at — its `top: 422px` would have been written
 * into a phone 388px tall and put all four satellites below the glass.
 * This rule is (0,2,0) against its (0,1,0) and wins wherever it lands in the
 * cascade, which is also the honest expression of the move: these belong to the
 * phone now.
 * ⭐ AND ITS THREE DECLARATIONS ARE OBSOLETE RATHER THAN OVERRIDDEN — all three,
 * since `--spread` has no reader at all any more (the clamp it tuned is gone).
 * The phone tuck (`--spread: 0.72`, size × 0.75) existed because a composition
 * on the viewport plane had less room beside a narrow screen's rail. On the
 * device there is no narrow case: the phone is the same shape at 1440×900 as at
 * 390×844, so the group's relationship to the square is viewport-independent by
 * construction. ⏳ Those three lines should be DELETED by whoever next holds
 * `95-narrow.css`; they are inert, not needed, and reading them will mislead. */
.satellite-layer .satellite {
  position: absolute;
  --w: calc(var(--gate) * var(--size) / 100);
  width: var(--w);
  aspect-ratio: 1;
  /* ⚰️ THE CLAMP — *"the group may not leave the room, and the clamp is the
   * guarantee"* — IS DELETED, and the argument for it is kept because it was a
   * good one against the room it was written for. On the viewport plane the
   * room was the viewport minus the rail, and item 28's screenshot complaint
   * (*"two satellite squares partly hidden behind the rail's icon column"*) was
   * a real, measured defect: satellites 1 and 3 started at x=34.4/31.1 at 320
   * and 21.6/17.1 at 390, under a rail 56 to 64px wide. Phase B then re-based
   * it onto the glass, where it bound at EVERY viewport and pinned all four on
   * the square's edges — correct for a room with no outside, and the signal
   * that the room was wrong rather than the clamp.
   * ⭐ OUTSIDE THE GLASS NOTHING NEEDS PINNING: the offsets are fractions of the
   * square, the square is a fraction of the device, and the device is placed by
   * `20-frame.js` — so the group recomposes with the phone at any viewport, by
   * construction, which is what the percentages were always for.
   * ⚠️ AND THE RAIL WAS CHECKED RATHER THAN ASSUMED, because that is what the
   * clamp was originally for. Swept whole-page, both viewports: a satellite
   * crosses the rail's BOX by up to 1,238 px² at 390×844 — and crosses its INK
   * by 0. The column is a transparent strip with a few glyphs in it, so the box
   * is not the rail; measuring the container would have re-introduced a clamp
   * against nothing. Text: 0 px² at both viewports. */
  --sat-x: calc(var(--axis) + var(--gate) * var(--x) / 100);
  left: var(--sat-x);
  top: calc(var(--centre) + var(--gate) * var(--y) / 100);
  translate: -50% -50%;
  border-radius: var(--corner-square);
  overflow: hidden;
  /* 🔴 A SATELLITE IS A PICTURE BOX, SO ITS GROUND IS THE PICTURE'S, NOT THE
   * ROOM'S — stated 2026-10-01, when the room was lifted to `#282826`
   * (`25-scenes.css` §the ground) and the phone kept `#000`. This square held no
   * background at all, which was free for as long as room and picture were the
   * same black: a satellite whose poster had not arrived showed black through a
   * black hole in a black page, and nobody could see it. Against a LIT room the
   * same hole is a pale rounded square with the black `.maker` disc floating in
   * it — measured with media blocked at 1440×900, four of them, 35–51 px.
   * ⭐ It is a classification rather than a patch: these are other people's
   * phones, and every picture in this app sits on black. Covered by `.plate-shot`
   * the instant the poster decodes, so it costs one paint of the colour the
   * picture is about to be. ⚠️ NOT `--story-ground`: that token is the GLASS, and
   * these four are deliberately outside it (`layouts/story.yml` ▸ the satellites
   * are outside `.screen`), so reading it here would tie them to a token whose
   * whole argument is about what the export bakes its corners against. */
  background: #000;
}
.satellite-clip { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; }
.satellite .plate-shot { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; }
.satellite-clip { z-index:0; }
.satellite-clip + .maker { z-index:1; }
.cut[data-cut-scrub] { opacity:1; }
.cut:not([data-media-ready]) { background:#171512; }

/* The radar, behind everything, on the same centre. */
/* ⚰️ THIS RULE USED TO CAP THE RADAR SO IT COULD TOUCH AN EDGE OF THE GLASS BUT
 * NEVER CROSS ONE — item 23's original bound (the shorter of the room above and
 * below `--centre`), then a five-gate ceiling once that bound was found to be
 * truncating the arm at full opacity rather than fading it (`radial-gradient`'s
 * default `farthest-corner` puts the mask's 38% stop at 0.537× the radius, so
 * anything wider than ~Ø1120 hit the top edge of the glass still fully opaque —
 * measured: 280px of 47/255 red on black in the upper-left at Ø1800, flat
 * across the top row, no falloff). Christian then ruled the radar "off page" at
 * that size and it was pulled back inside the glass entirely.
 *
 * 🔴 REVERSED 2026-09-23 (Christian): "not visible enough… increase its size so
 * it scans top to bottom of the current viewport… a huge radar arm that is
 * cropped by the viewport." The truncation the cap existed to prevent is now
 * the wanted effect, so the cap and the five-gate ceiling are both gone.
 *
 * `--sweep` sizes the circle so the arm crosses BOTH the top and bottom edge
 * of the viewport at every rotation, not just the nearer one: the pivot
 * (`--centre`) sits in the top third of the screen, so the radius has to clear
 * the FARTHER distance (centre to the bottom) for the near edge (the top) to
 * be crossed with room to spare. Doubling that farther distance is the
 * generous margin "huge" asks for, not the tight one a minimal fix would pick.
 *
 * ⚠️ THE SIDEWAYS OVERFLOW THIS CAUSES IS NOT NEW. `.story` already clips it
 * horizontally (`overflow-x: clip`, see its own comment above) because the
 * radar was already wider than the viewport on a phone before this change — a
 * circle this size reads wider than it is tall on any portrait screen
 * regardless of the exact diameter chosen here.
 *
 * ⚠️ MEASURE THIS ONE WITH `offsetWidth`, NOT `getBoundingClientRect()`.
 * `radar-sweep` rotates the element, and the rect of a rotated SQUARE is its
 * axis-aligned bounding box — 1.414× the real width at 45°. The layout box is
 * rotation-proof; the rect is not. */
.radar {
  position: absolute;
  /* 🔴 RE-BASED ONTO THE SQUARE, `settle.md` PHASE B — AND ITS `top` WAS A LIVE
   * BUG, not merely a scale one. The radar became a child of `.gate` in Phase A
   * ("correct by construction"), so `left: var(--axis)` was already the square's
   * own middle — but `top: var(--centre)` was 450px measured from the SQUARE's
   * top edge inside a square 313px tall, which put the pivot a third of a square
   * BELOW the picture and the arm off the bottom of it. Both axes are the
   * square's centre; neither needs a name.
   *
   * ⭐ THE SIZE DERIVATION IS THE OLD ONE, APPLIED TO THE NEW ROOM. It was "four
   * times the FARTHER distance from the pivot to an edge of the viewport", so
   * that the arm crosses both edges rather than only the nearer one. The pivot
   * is the square's centre now and the room is the square, so the farther
   * distance is its own half-diagonal — `√2 / 2 = 0.7072` of its side — and the
   * same ×4 keeps the same generous margin Christian's 2026-09-23 ruling asked
   * for. In `%` of `.gate` rather than `cqw` deliberately: the square is this
   * element's containing block, so the circle tracks it through the cut's
   * narrowing with no second expression to keep in step.
   * ⚠️ WHAT IT COSTS, AND IT IS THE SAME COST story.yml:174 ALREADY NAMED: the
   * arm is cropped by the SQUARE now, not by the viewport. Christian's ruling
   * was about it being cropped rather than fitted, and it still is — but by a
   * 313px window, not a 900px one. It is his call whether that reads as the
   * "huge radar arm" he asked for; nothing here can give it the viewport back
   * without taking it out of the phone. */
  --sweep: calc(0.7072 * 100%);
  width: calc(var(--sweep) * 4);
  aspect-ratio: 1;
  left: 50%; top: 50%;
  translate: -50% -50%;
  border-radius: 50%;
  opacity: 0;
  background:
    conic-gradient(from 0deg, rgb(from var(--text-bright) r g b / 0.20) 0deg, transparent 52deg),
    repeating-radial-gradient(circle, rgb(from var(--text-bright) r g b / 0.05) 0 1px, transparent 1px 12%);
  mask-image: radial-gradient(circle, #000 38%, transparent 72%);
}

/* ── everything that sits UNDER the square ───────────────────────────────
 * 🔴 ONE DECLARATION, TWO PLANES, AND THAT IS NOT A COMPROMISE — IT IS WHY
 * `--axis` COST NOTHING TO PORT. `50%` is resolved against each element's own
 * containing block: for the five props inside the phone that is `.screen-app`,
 * the glass; for `.reel`, still a child of `.scene > .stage`, it is the
 * full-bleed stage and therefore 50vw. Both are the right answer to "the middle
 * of the room I am in", and there is exactly one line saying it.
 * ⭐ AND SINCE 2026-10-01 THERE IS ONLY ONE PLANE: `.reel` came into
 * `.screen-app` with the rest (Phase C). It had been the odd one out — reading
 * `.story`'s VIEWPORT `--centre` / `--gate` / `--gap`, so a strip at viewport
 * scale stood beside furniture at phone scale; measured at 390×844 its tile was
 * 0.283 of the device where the app's own is 0.1337, more than twice too big.
 * ⚠️ `95-narrow.css`'s `.story { --gate: min(78vw, 42svh) }` was capping exactly
 * this one tile and now caps NOTHING. It is inert, like the `.satellite` block
 * beside it; both should go out with whoever next holds that file. */
.reel, .dial, .score, .soundtrack, .lengths, .cutter {
  position: absolute;
  left: var(--axis);
}

.reel {
  /* ⭐ THE APP'S OWN STRIP, IN THE APP'S OWN FRACTIONS — 2026-10-01. Every
   * length here is a `cqw` of the DEVICE (or a `%` of its height, which
   * `.screen` makes the same box), copied from `site/_proto/refactor/kit/
   * device.css` §THE REEL STRIP rather than re-derived. It replaces a block
   * written for the viewport plane: `top: --centre + --gate/2 + --gap` put the
   * strip a page-margin below a viewport-sized square, and
   * `--tile: clamp(30px, --gate/7.5, 58px)` sized it against the viewport too —
   * on a 191px-wide phone that clamp's 30px FLOOR alone was 0.157 of the device,
   * so the strip could not be right at any viewport. The app does not clamp: a
   * tile is 57.5pt of a 430pt phone, always. */
  top: calc(var(--reel-centre) * 100%);
  translate: -50% -50%;
  display: flex;
  align-items: center;
  gap: calc(var(--reel-gap) * 100cqw);
  height: calc(var(--reel-band) * 100cqw);
  /* 🔴 IT RUNS OFF BOTH EDGES AND THE GLASS CUTS IT, which is the same ruling
   * the old block reached by a harder route and the reason that route can go.
   * ⚰️ WHAT WENT WITH IT: `max-width: calc(100vw - 2 * var(--gap))`,
   * `overflow-x: clip` and a `@media (max-width: 720px)` arm before that. All
   * three existed to stop the strip being cut on ONE side by a viewport-sized
   * room — *"a strip pinned left and cut right reads as furniture that has
   * slid"*. Inside the phone the room IS the glass, `.screen`'s `clip-path`
   * cuts it symmetrically by construction, and `site/_proto/ios-screens/
   * soundtrack.png` is exactly that: leftmost tile cut by the left edge,
   * rightmost by the right, because the strip is centred on the clip playing
   * and simply continues past both.
   * ⚠️ `200cqw` IS A WIDTH, NOT AN OVERFLOW TRICK. The box is twice the phone
   * so the flex row always has room to centre in; `justify-content: center`
   * then puts the middle tile on the axis at every count. Law A acquits the
   * keyword: it never changes between two placements, so it cannot flip at a
   * timing function's halfway point. */
  width: 200cqw;
  justify-content: center;
  overflow: visible;           /* the playing tile scales to 1.22 — see §the strip */
}
.tile {
  position: relative;
  /* the HEIGHT is the measured one and the width follows the ratio — the app
   * sizes a tile by the row it sits in. ⚠️ `width: auto` is written rather than
   * left off: the rule this replaced set `width: var(--tile)`, and a stale
   * width would win over `aspect-ratio` and squash every tile. */
  height: calc(var(--reel-tile) * 100cqw);
  width: auto;
  aspect-ratio: 1;
  flex: 0 0 auto;
  /* Rounder than the square, as the app's tiles are — about twice the fraction
   * (`GradeRenderer.tileCornerCorrection`, an optical correction: at tile size
   * there are not enough pixels to draw the curve's long shallow entry, so a
   * geometrically identical corner reads as squarer). Squarer would uncover the
   * corner cut into the pixels.
   * ⚠️ --corner-entry belongs in here too. It is what turns an arc's radius
   * into the superellipse's longer one (base.css §THE CORNER), and a tile that
   * skipped it would be shaped by the curve at the arc's radius — measured at
   * 4.1% of the tile off the app's corner, where this is 0.16%.
   * ⚠️ IT IS A PERCENTAGE OF THE TILE'S OWN SIDE NOW, not of a `--tile` length
   * that no longer exists. Same shape, and it survives the 1.22 scale for free.
   * ⏳ THE KIT DISAGREES AND IS NOT FOLLOWED HERE: `kit/device.css` §.tile says
   * *"do NOT compute this as 2.03 × the square's fraction"* and measures the
   * drawn extent at a flat 28% with `corner-shape: round`, because a continuous
   * corner past ~0.25 of the side compresses toward a circular arc anyway. This
   * formula gives ~29%, so the two are within a pixel at tile size — a question
   * for Christian's eye, not a change to make inside a parentage fix. */
  border-radius: calc(var(--corner-fraction) * var(--corner-entry) * 2.03 * 100%);
  overflow: hidden;
}

/* ═══ THE RING — ONE COMPONENT, WORN TWICE ═══════════════════════════════
 * ⭐ THE APP SAYS IT FIRST: the camera's mode ring and the film's score ring
 * "keep one geometry" (`ModeSwitch.swift:303`) — same radius, same glyph size,
 * same red tick at twelve, same falloff, same name above. Only the middle
 * differs: the camera's is the RECORD DISC, the film's is the NOTE in its well.
 * So this is one set of rules and two skins, and the two scenes rhyme because
 * on the phone they are the same control twice.
 *
 * Every number below is the app's, in units of the glyph ring's own radius
 * (`ScoreRing.swift:24-28`, `ModeSwitch.swift:302-306`):
 *   glyph ring     80   1        the radius everything else is quoted against
 *   glyph           18   0.225
 *   red tick        62   0.775    2.5 × 7, between the disc and the glyphs
 *   record disc     74   0.925    diameter (RecordDisc.swift ▸ base)
 *   score's well    114  1.425    diameter (ScoreRing.inner 57)
 *   the note        50   0.625    diameter (RecordDisc.knobSize)
 *   the name       112   1.4      from the centre, upright
 *
 * 🔴 AND NO OUTLINE ROUND ANY OF IT. The version this replaces drew both rings
 * as a 1px `#3A342C` circle, which is a border — §The house constants: "Flat.
 * No borders, no glows." The app draws no ring either; the glyphs ARE the ring.
 */

/* ── where it sits ───────────────────────────────────────────────────────
 * 🔴 CENTRED BETWEEN THE SQUARE AND THE FOOT OF THE SCREEN (Christian, item 9:
 * "the large red disc sits centred between the bottom of the viewport and the
 * square — not beside it"). The stage is exactly 100svh, and `--above` is
 * written as "everything that has to fit in over the ring" so the score scene
 * can push it down past the soundtrack band with one value.
 *
 * ⭐ AND THE APP AGREES WITH IT, WHICH IS WHY THIS RULE SURVIVED ITEM 9 WORD
 * FOR WORD while everything round it moved. Measured off
 * site/_proto/ios-screens/alignment.webp: the square's bottom edge is at 0.516
 * of the screen and the disc's centre at 0.763 — the midpoint of (0.516, 1) is
 * 0.758. With `--centre` at a third of the screen this rule now puts it at
 * 0.75 rather than at the 0.83 it drew when the square held the middle, which
 * is the whole of what item 9 was asking for: the bottom third is the dial's
 * band, and this line is how it gets there. */
/* ⭐ `--above` IS ON THE STAGE NOW, NOT ON THE FURNITURE — ../../PLAN.md item
 * 22 part B. It means "everything that has to fit in over the bottom band", and
 * since that band is now shared by TWO things in turn — a scene's words, then
 * its furniture — it has to be readable by both. Declared here, it still
 * resolves per scene, because `--band` is scoped to `.scene--the-score`.
 *
 * ⚰️⚰️ AND SINCE `settle.md` PHASE B IT IS THE WORDS' BAND ALONE. The dial, the
 * score ring and the scissors are inside `.screen-app` (Phase A), which is NOT a
 * descendant of any `.scene > .stage` — so `--band-centre` never reached them
 * and `top: var(--band-centre)` resolved to `auto`. MEASURED at 1440×900 before
 * Phase B: all three drew their centre at the glass's own top edge, 707px above
 * where the app puts the one control. That is not a number to re-base; it is a
 * property that was not arriving. They take `--ctrl-centre` now (below), which
 * is the app's own measured row and is NOT this midpoint — 0.8400 of the
 * device's height against this rule's 0.7857, 41px apart on a 756px phone.
 * 🔴 `.scene-words` IS THE ONE READER LEFT and it is deliberate: `--figband` is
 * the device-plane answer for the words and it is blocked on Phase C taking
 * `translate` out of `words-say` (`30-composition.css` §--figband). Do not
 * re-point this at the phone until that lands. */
.scene > .stage {
  --above: calc(var(--centre) + var(--gate) / 2 + var(--band, 0px));
  --band-centre: calc(var(--above) + (100% - var(--above)) / 2);
}

.dial, .score, .cutter {
  /* 🔴 THE APP'S OWN ROW, MEASURED — `--ctrl-centre`, 2348.5/2796 of the
   * device's height [R3], declared in `30-composition.css` §THE DEVICE PLANE.
   * `settle.md` Phase B.
   * ⚰️ WHAT WENT: `top: var(--band-centre)` — the midpoint of the room under the
   * square, which is a derivation rather than a measurement and lands 0.7857 of
   * the way down the phone where the app puts the control at 0.8400. ⚠️ It also
   * did not RESOLVE from in here at all (see the tombstone above), so for the
   * length of Phase A the three props drew at the top edge of the glass.
   * ⭐ `%` AND NOT `cqw`, and this is the one place in the port where that is the
   * right choice: these are children of `.screen-app`, whose height IS the
   * device's, so a percentage of `top` is a fraction of the phone directly and
   * needs no `--dev-ratio` in it. It is also the prototype's own form verbatim
   * (`_proto/refactor/kit/device.css ▸ .dial`). The exception is `--centre`,
   * which is a registered `<length>` and cannot take a percentage — see that
   * block for why the two spellings exist. */
  top: calc(var(--ctrl-centre) * 100%);
  translate: -50% -50%;

  /* ⚠️ AND THE APP'S PROPORTION DOES NOT ALWAYS FIT, so it is a `min()` and
   * the room wins. It is written as "the screen, less where the square's centre
   * is, less its own half, less whatever a scene hangs under it" — which is the
   * bottom band as a number, and is why item 9's `<bottom-1-3>` never had to
   * become an element.
   *
   * ⭐ SINCE ITEM 9 THE APP'S PROPORTION WINS ON A DESKTOP TOO, which is the
   * tell that the composition is now the app's. Measured at 1440×900 before and
   * after: the room under the square was 225px and is 450, and the ring it
   * allows went from 70px — a cramped `--room / 3.2` — to the app's own
   * `--gate × 0.215`, 64.5px. The ring is a shade smaller than it was and it is
   * smaller for the right reason: it is the app's ring against a smaller
   * square, rather than the app's ring cut down to fit.
   *
   * ⚠️ 3.2 IS NOT A TASTE, IT IS THE NAME'S CLEARANCE, and it is kept for the
   * windows where the room still binds (a short laptop, a phone in landscape).
   * The ring is centred in its room, so half the room has to hold the name at
   * 1.4 radii and a little air: measured on the built page at room/2.9 the name
   * sat 1px off the square's bottom edge, and at room/2.8 it was on the
   * picture. */
  /* 🔴🔴 THE ORBIT IS A MEASUREMENT AGAIN — `settle.md` Phase B, and it is the
   * one number in this port that the re-basing alone would NOT have fixed.
   *
   * ⚰️ WHAT WENT: `min(var(--gate) * 0.215, var(--room) / 3.2)`.
   *   · `0.215 × --gate` is 0.19300 of the device's width. The app's orbit is
   *     80pt of 430 = **0.186047** — 3.8% apart, so the whole ring cluster (the
   *     disc at 0.925 of it, the tick, the glyphs, the name at 1.4) was drawn a
   *     measurable shade too big at every viewport. ⭐ AND THE RIGHT NUMBER WAS
   *     ALREADY IN THE RECORD: `20-device.css:449` names it, [R3] measured
   *     80pt/430 off `ModeSwitch.swift:304-308`, and [R2] got 0.18600 of the
   *     device's width by an independent route (`--ring = --gate × 0.2072`,
   *     derived from the disc's 17.21% and the site's ×0.925) — two methods, four
   *     decimal places. This file's 0.215 agreed with neither. ⚠️ THAT IS THE
   *     WHOLE ARGUMENT FOR *re-measure, do not convert*: converting 0.215 into a
   *     `cqw` would have ported a wrong number faithfully.
   *   · `--room / 3.2` was the NAME's clearance, kept "for the windows where the
   *     room still binds (a short laptop, a phone in landscape)". There is no
   *     such window inside the phone: the device's aspect is FIXED, so the room
   *     under the square is the same fraction of it at every viewport. Computed
   *     from first principles rather than spot-checked — in units of the device's
   *     width, the square's bottom edge is at 0.3643×2.1674 + 0.8977/2 = 1.2385
   *     and the control's centre at 0.84×2.1674 = 1.8206, so the name, 1.4 orbits
   *     up at 1.8206 − 1.4×0.186047 = 1.5601, clears the picture by 0.3216 of the
   *     device's width — 112px on a 348.8px phone, 61px on a 191px one. The
   *     clamp could not bind, and a `min()` that cannot bind is a number for the
   *     next reader to re-derive.
   *
   * ⭐ `--ring` IS STILL THE ORBIT'S RADIUS, so every ratio below it is untouched
   * and each one is the app's own (see the table at the head of this file). The
   * ring cluster hangs off ONE anchor and nothing else is a second fraction of
   * the device — two routes to one number is how they drift. */
  --ring: calc(var(--ring-orbit) * 100cqw);
  /* 18pt/80 of the orbit [R3]. ⚰️ The `max(12px, …)` floor is gone with the room
   * clamp and for the same reason: it was a guard against a cramped VIEWPORT,
   * and inside a fixed-aspect phone it only ever distorts the app's proportion
   * at the small end — 12px against the app's 8.0px on a 191px-wide device. */
  --glyph: calc(var(--ring) * 0.225);
}

.ring {
  position: relative;
  width: calc(var(--ring) * 2);
  aspect-ratio: 1;
}

/* The middle. Red is the camera and amber is the film, one accent at a time,
 * and the disc never changes colour (§The house constants). */
.disc {
  position: absolute; left: 50%; top: 50%;
  translate: -50% -50%;
  aspect-ratio: 1;
  border-radius: 50%;
}
/* ⭐ `--disc` RATHER THAN `0.925 × --ring`, AND THE TWO NOW AGREE EXACTLY — which
 * is the cross-validation that made the orbit above trustworthy: 74pt/430 =
 * 0.172093 and 0.925 × 0.186047 = 0.172093, to six places. Written as the
 * app's own fraction because it is one (`RecordDisc.swift` ▸ base), not because
 * the ratio is wrong. */
.ring--mode  .disc { width: calc(var(--disc) * 100cqw); background: var(--cam-red); }
/* ⭐ THE SAME DISC AT THE SAME SIZE, doing the film's job instead of the
 * camera's — the app's film disc and record disc are one base (74pt,
 * `RecordDisc.swift`), and scene 5 leans on their being the same object.
 * ⚠️ WHITE IS NOT THE DISC CHANGING COLOUR: red is the camera and is never
 * anything else; on the film screen the app's disc is white, for play, for
 * pause and for the scissors (IMG_1778, IMG_1779). */
.cutter      .disc { width: calc(var(--disc) * 100cqw); background: #F2EEE8; display: grid; place-items: center; }
.cutter      .disc svg { width: 42%; height: 42%; color: #000; }
/* The knob's room: the well the note can be pushed about in, drawn as the app
 * draws it — a darker field of the note's own colour, not a container. */
.ring--score .disc { width: calc(var(--ring) * 1.425); background: rgb(from var(--cam-amber) r g b / 0.17); }

/* The index, at twelve. It does NOT turn — the ring turns under it, which is
 * the whole reason a detent means anything. */
.tick {
  position: absolute; left: 50%;
  top: calc(50% - var(--ring) * 0.775);
  translate: -50% -50%;
  /* 2.5 × 7pt of the 80pt orbit [R3]: 0.03125 and 0.0875. ⚰️ The `max(2px,…)` /
   * `max(6px,…)` floors came out with the ring's room clamp — 0.031 was also a
   * rounding of 0.03125 that cost nothing to correct while the line was open.
   * ⚠️ IT IS THIN ON THE SMALLEST DEVICE AND THAT IS THE APP'S PROPORTION: 1.1 ×
   * 3.1px on a 191px-wide stacked phone, 2.0 × 5.7px on a 348.8px split one. The
   * floors would have made it 1.8× too fat on the stack, in the one arrangement
   * where the whole device is already small. */
  width: calc(var(--ring) * 0.03125);
  height: calc(var(--ring) * 0.0875);
  border-radius: 999px;
  background: rgb(from var(--cam-red) r g b / 0.92);   /* the app's own opacity */
}

/* ── the glyphs ──────────────────────────────────────────────────────────
 * ⭐ EVERY MODE'S MARK IS OUT THERE AT ONCE (Christian, item 9), and which one
 * is bright is arithmetic on the ring's angle rather than a cue of its own.
 * `--a` is the mark's place on the ring; `--turn` is how far the ring has come;
 * θ = the two added is how far that mark is from the tick.
 *
 * The falloff is the app's: `opacity 0.34 + 0.66 × near`, `scale 1 + 0.3 ×
 * near` (`ModeSwitch.swift` ▸ `opacity(at:lit:)`), where the app's `near` is
 * `max(0, 1 − d)` over a step and `2cosθ − 1` is that triangle drawn as a
 * curve: 1 at the tick, 0 at the next detent, and smooth through both. So at
 * rest one mark stands lit and the other five sit at a third — which is what
 * the phone looks like with a thumb on it, and is a finished composition.
 *
 * 🔴 TWO ELEMENTS, BECAUSE THERE ARE TWO ROTATIONS. `.mark` is a zero-sized
 * point at the ring's centre that carries the ring's angle; the glyph inside it
 * hangs at the radius and takes the SAME angle back off, so it rides round
 * upright instead of tipping over at the bottom. The app does this with
 * `RingPlace` + an upright `ModeGlyph`; one element cannot, because `rotate`
 * about a centre and `rotate` about itself are one property. */
.mark {
  position: absolute;
  left: 50%; top: 50%;
  width: 0; height: 0;
  rotate: calc(var(--turn) + var(--a));
}
.mark svg {
  position: absolute;
  left: 0; top: calc(-1 * var(--ring));
  width: var(--glyph); height: var(--glyph);
  translate: -50% -50%;
  rotate: calc(-1 * (var(--turn) + var(--a)));
  color: #F2EEE8;
  --near: clamp(0, calc(2 * cos(var(--turn) + var(--a)) - 1), 1);
  opacity: calc(0.34 + 0.66 * var(--near));
  scale: calc(1 + 0.3 * var(--near));
}

/* ── the name it says ────────────────────────────────────────────────────
 * ⭐ THE MODE SAYS ITS NAME FOR A MOMENT AND GOES (Christian, items 9 and 6:
 * "the title of that mode fades in and briefly out", "not a persistent label").
 * All six sit stacked on one spot above the ring, and each is lit only while
 * ITS mark is at the tick — so the name is a consequence of where the ring is,
 * exactly as it is on the phone, and mid-turn nothing is claimed at all.
 *
 * `(4cosθ − 3.464) × 2.5` is nothing at 30° and whole by 15°: a quick
 * arrival, a hold across the detent, a quick fade. ⚠️ 3.464 IS 2√3, AND THE
 * NUMBER IS EXACT FOR A REASON — it puts the zero exactly halfway between two
 * detents, so two names can never be half-up at once. Measured at the first
 * try, with a wider window: HARP at 0.33 and BEATS at 0.46, both printed on the
 * same spot, which is a smudge rather than a word. ⚠️ The app's own timing is 0.9s
 * held then 0.6s out with NO fade in (`ModeSwitch.swift` ▸ `ModeName.flash`) —
 * it can be instant because it fires on an event. This one is a function of a
 * scroll position a reader can drag backwards, and an instant step on a scrub
 * strobes; the short fade is the translation, not a softening.
 *
 * At rest the name at the tick is up, which is the still's honest caption:
 * the ring is on FILM, and the page says FILM. */
.say {
  position: absolute;
  left: 50%;
  top: calc(50% - var(--ring) * 1.4);
  translate: -50% -50%;
  white-space: nowrap;
  /* 🔴 11pt OF 430, NOT A VIEWPORT CLAMP — `settle.md` Phase B. It was
   * `clamp(0.66rem, 1.4vw, 0.8rem)`: 12.8px at 1440×900 against a ring that had
   * just been measured at 64.9px, and 10.6px at 390×844 against a ring of 35.5 —
   * so the name grew with the WINDOW while the control it labels grew with the
   * PHONE, and the pair read differently at the two viewports. The app's own is
   * 11pt of a 430pt device = 0.1375 of the orbit [R3 ▸ `--ring-say-sz`], which is
   * the same anchor every other measure here hangs off.
   * ⭐ AND `0.16em` IS ALREADY THE APP'S TRACKING, so it needed no change: [R3]'s
   * `--ring-say-tr` is 1.8pt/80 = 0.0225 of the orbit, and 0.0225 / 0.1375 =
   * 0.1636em. Two routes, and this time they agreed — stated because the orbit
   * above is what happens when they do not. */
  font-size: calc(var(--ring) * 0.1375);
  /* ⚠️ The line box, not the letters, is what nearly touched the square: at the
   * framework's leading this box stands 20px tall for 10px of text. */
  line-height: 1;
  font-weight: 600;
  letter-spacing: 0.16em;               /* the app's tracking 1.8 at 11pt */
  color: #F2EEE8;
  /* TWO FACTORS, AND THEY ANSWER TWO DIFFERENT QUESTIONS. The clamp is WHICH
   * name — a function of where the ring is, so only the one at the tick is up
   * (§the name it says). `--say-out` is WHETHER a name is still being said at
   * all — 1 while the ring works, faded to 0 once the scene settles, so the
   * last name does not become a permanent label (../../PLAN.md item 7; the
   * animation and the argument are in §THE NAME GOES WHEN THE RING HAS
   * STOPPED). It is 1 by default, so nothing that is not animated goes blank. */
  opacity: calc(
    clamp(0, calc((4 * cos(var(--turn) + var(--a)) - 3.464) * 2.5), 1)
    * var(--say-out)
  );
}

/* ── the note, and it STAYS PUT ──────────────────────────────────────────
 * 🔴 CHRISTIAN WAS EXPLICIT (item 6): "the orange musical-note drag handle
 * STAYS WHERE IT IS. It does not travel round to another instrument when the
 * ring turns." The version this replaces rode it outward as the ring turned,
 * which said the two were one control.
 *
 * ⭐ They are not, and the app is clear about which is which: the ring is the
 * THEME and the note is the HAND — "where the hand was left, −1…1 each way …
 * leave it wherever they let go" (`RecordDisc.swift:84`, `Score.swift` §The
 * hand). Two different things about the music, on two axes, and coupling them
 * would have taught a visitor something the app does not do. */
.knob {
  position: absolute; left: 50%; top: 50%;
  translate: -50% -50%;
  width: calc(var(--ring) * 0.625); aspect-ratio: 1;
  border-radius: 50%;
  background: var(--cam-amber);
  display: grid; place-items: center;
}
.knob svg { width: 46%; height: 46%; color: #000; }

/* ═══ THE MOTION — one block, because there is one gate ═══════════════════
 * ⭐⭐ EVERY BEAT READS AS A FINISHED COMPOSITION AT REST. Everything above draws
 * the composed still; only this block moves it, and only where the engine can.
 * Firefox, an engine without scroll-driven animation, prefers-reduced-motion,
 * no JS, a screen reader and an agent all land on the same finished page.
 *
 * 🔴 `timeline-scope` IS WHAT MAKES ONE GATE POSSIBLE. The gate lives outside
 * every scene now, so a scene's own timeline is out of its reach — a
 * scroll-driven animation can only name a timeline declared on an ancestor or a
 * sibling before it. Naming each scene's timeline on `.story` publishes them to
 * the whole subtree, and the gate then reads whichever scene it is meant to
 * answer. Without this the gate could only be animated by giving it back to a
 * section, which is the thing Christian asked us to stop doing.
 *
 * 🔴 AND EVERY KEYFRAME WRITES THE IDENTITY, NEVER `none`. Safari runs these on
 * the scrolling thread and `none` at both ends of an interval takes the whole
 * browser down (bounce/site/MECHANIC.md risk 1). This page is all rotation and
 * translation, so it is the rule that matters most here. */
@supports (animation-timeline: view()) {

  /* ⭐ THE TIMELINES THEMSELVES ARE DECLARED OUTSIDE THE MOTION GATE, and that
   * is deliberate. Naming a timeline moves nothing — it only says where in the
   * scroll a thing is. Both stories read from these: the full one below, and
   * the reduced-motion one just after, which uses them to CROSS-FADE rather
   * than to move. They used to sit inside `no-preference`, which meant a reader
   * who asks for less motion got no timelines at all, and therefore no way to
   * say "these four belong to scene 3" without moving them. */
  /* 🔴🔴 THEY ARE SCOPED ON `main`, NOT ON `.story`, AND THAT IS A SAFARI BUG
   * PAID FOR — 2026-09-22. Read this before moving the line back.
   *
   * ⚠️ NEVER LET AN ELEMENT CONSUME A TIMELINE IT SCOPED ITSELF. This block
   * used to read `.story { timeline-scope: … }`, and `.story` then consumed
   * `--s-dial` and `--s-split` itself (§--turn below). Chrome resolves that.
   * **WebKit does not: it exposes a scoped timeline only to the declaring
   * element's DESCENDANTS, never to the element itself.** So in Safari those
   * two animations got NO TIMELINE AT ALL — and an animation with no timeline
   * is `finished`, so `fill-mode: both` painted their END values forever.
   *
   * WHAT THAT LOOKED LIKE, and it is why this went unnoticed for a day: the
   * page did not break, it FROZE ARRIVED. Safari opened the dial scene with
   * ROTOSCOPE already at the tick and the ring never turned; the cut scene
   * showed one uncut square and the scissors never cut. Every scene still
   * composed, so nothing looked wrong in a screenshot — only in a scroll.
   *
   * MEASURED, 7-case minimal repro plus a 55-animation sweep over all six
   * scenes, Chromium 153 vs WebKit 26.5:
   *   scope on SELF        → Chrome ok · WebKit `timeline: null` ❌
   *   scope on PARENT      → both ok   ← what the `--s-features` line below
   *                                      has always done, and why the gate's
   *                                      own push never broke in Safari
   *   no scope, ancestor   → both ok   ← what `.ring--score` does
   * 🔴 IT IS NOT A FILL-MODE BUG. `fill: none` also never runs; it just freezes
   * at 0deg instead of −180deg. Changing the fill is a MASK, not a fix.
   * 🔴 IT IS NOT ABOUT ANGLES or registered properties: a plain <number> fails
   * identically.
   *
   * THE FIX IS THIS ONE MOVE, and the file already contained its own
   * precedent — the `--s-features` line below scopes on `main` for an
   * unrelated reason (a sibling section) and has always worked in both
   * engines. `.story` is a child of `main`, so scoping here makes it a
   * DESCENDANT and it reads them everywhere. Chromium: 18/18 samples
   * byte-identical before and after — a no-op. WebKit: animations with a null
   * timeline 2 → 0, and every value now matches Chromium to floating-point
   * noise (dial −42.1797° vs −42.1763°, split --share 0.684018 vs 0.684045).
   *
   * ⚠️ Tested on Playwright's WebKit 26.5, not on shipping Safari — same
   * engine core, not the same binary. Worth one confirmation on a device.
   * ⚠️ And the two lists are now ONE declaration. Adding a scene means adding
   * its name here, not on `.story`. */
  body[data-page="home"] main {
    timeline-scope: --s-features, --s-opening, --s-nowrong, --s-dial, --s-angles, --s-reel, --s-split, --s-score;
  }
  /* Each scene keeps `--scene` for its own props AND publishes a named one
   * for the gate. The property is a list; the axis list must match it.
   * ⚠️ `--s-nowrong` has no consumer yet and is declared anyway: beat C's words
   * ride `--scene` like every other scene's. It is here because the list above
   * and this one are the two halves of ONE declaration per scene, and a scene
   * that is in one and not the other is the shape of the next bug. */
  .scene--opening    { view-timeline-name: --scene, --s-opening; view-timeline-axis: block, block; }
  .scene--no-wrong-way { view-timeline-name: --scene, --s-nowrong; view-timeline-axis: block, block; }
  .scene--the-dial   { view-timeline-name: --scene, --s-dial;    view-timeline-axis: block, block; }
  .scene--many-angles{ view-timeline-name: --scene, --s-angles;  view-timeline-axis: block, block; }
  .scene--the-reel   { view-timeline-name: --scene, --s-reel;    view-timeline-axis: block, block; }
  .scene--the-split  { view-timeline-name: --scene, --s-split;   view-timeline-axis: block, block; }
  .scene--the-score  { view-timeline-name: --scene, --s-score;   view-timeline-axis: block, block; }

  /* ⭐ AND THE FEATURES SECTION PUBLISHES ONE TOO, from OUTSIDE the story.
   * `timeline-scope` reaches an element and its descendants, and `#features` is
   * the story's SIBLING — so the name has to be declared on the first ancestor
   * they share, which is `main`. Page-scoped, because only this page has a gate
   * to push.
   * ⭐ THIS LINE IS THE ONE THAT WAS ALWAYS RIGHT, and it is why the block
   * above now joins it: `main` scopes, a DESCENDANT consumes. `--s-features`
   * never broke in Safari because it was already shaped that way — which is
   * the whole argument for the move, recorded above. Its name moved up into
   * that one declaration; only the subject stays here. */
  body[data-page="home"] #features { view-timeline-name: --s-features; view-timeline-axis: block; }

  /* 🔴 THE PUSH — CHRISTIAN'S OWN VERB, AND IT IS NOT THE SAME THING AS THE
   * RELEASE. The pad above decides when the square STOPS BEING PINNED; this
   * decides when it LEAVES, and it is driven by the features section itself, so
   * the answer to "when does the square go?" is "when the features arrive"
   * rather than "when some length of scroll has run out".
   *
   * ⭐ Belt and braces on purpose, and the two agree by construction: the pad
   * ends the pinning exactly at the features section's first pixel, and this
   * carries the square off over the section's first half-screen of entry. An
   * engine with no view-timelines runs the first mechanism alone and the square
   * simply scrolls away there — which is a degraded version of the same beat,
   * not a different one.
   *
   * ⚠️ ON THE LAYER, NEVER ON THE GATE: `.gate` already animates `translate`
   * for the weave, and two animations writing one property is the trap this
   * page has paid for twice. The layer has no animation of its own, and the
   * four satellites are inside it — so they are carried off with the square,
   * which is right: the whole composition leaves, not the square out of it. */
  /* 🔴 THE SUBJECT IS `.framebar` — Phase C, 2026-10-01. `.gate-layer` was
   * DELETED in Phase A along with the plane it named, and these two rules were
   * the last selectors in the bundle still reaching for it, so the push simply
   * stopped happening: the composition rode the sticky bar to the end of
   * `.story` and then scrolled off with it, instead of being carried away by
   * the features section arriving. `.framebar` is the plane now — sticky,
   * net-zero in flow, the length of `.story` — which is the same box by every
   * property this animation reads. (`home.js` made the identical rebinding for
   * its IntersectionObserver in Phase A, and says so at `:478`.)
   *
   * ⚠️ AND IT HAD TO BE RECONCILED WITH A `transition` THE OLD PLANE DID NOT
   * CARRY. `20-device.css:221` puts `transition: opacity .45s ease-out` on this
   * element and `20-frame.js:679` writes `bar.style.opacity` over a BAND — a
   * section with no scene, where the phone dissolves in place. `gate-pushed`
   * writes only `translate`, which nothing else on this element writes, so
   * `both` stays safe here exactly as the note below says. ⚠️ The REDUCED-MOTION
   * variant is a different matter and is handled at its own rule. */
  .framebar {
    animation: gate-pushed linear both;
    animation-timeline: --s-features;
    animation-range: entry 0% entry 55%;
  }
  @keyframes gate-pushed {
    /* IDENTITY at the start, never `none` — Safari runs these on the scrolling
     * thread. `both` is safe here because nothing else on this element writes
     * `translate`: the backwards fill asserts the identity, which is what the
     * element had anyway. */
    from { translate: 0 0; }
    to   { translate: 0 -105svh; }
  }

  /* ⭐ AND SO IS `--turn`, FOR THE SAME REASON THE TIMELINES ARE. Where the
   * ring has got to is STATE — which mode is chosen, which picture is up, which
   * name is being said. None of that is movement, and a reader who asks for
   * less motion is asking for less movement, not to be told less. So the angle
   * animates in both stories and only its VISIBLE ROTATION is gated below.
   *
   * 🔴 SCENE 2's LIVES ON `.story` BECAUSE TWO SUBTREES NEED IT. The ring is in
   * the dial scene's stage; the six pictures are in the gate layer, which is
   * nobody's scene. `.story` is their only common ancestor, so the animation
   * has to sit here.
   * ⚠️ THIS SENTENCE USED TO END "…and it can read `--s-dial` because it is the
   * element that publishes it — `timeline-scope` covers the element itself, not
   * only its descendants (measured, Chrome 153)." EVERY WORD OF THAT WAS TRUE
   * AND THE CONCLUSION WAS STILL WRONG, because "measured, Chrome 153" was the
   * whole of the evidence: WebKit exposes a scoped timeline only to
   * DESCENDANTS, so `.story` reading a name `.story` itself scoped got no
   * timeline in Safari and both animations here froze at their end values. The
   * scope now lives on `main` (§THE TIMELINES THEMSELVES, above, carries the
   * measurements); `.story` is a descendant of it and reads them in both
   * engines. 🔴 Measuring one engine and writing the result as a property of
   * the CSS is the mistake to learn from here, not the scope itself.
   *
   * ⭐ SCENE 5's RE-DECLARES THE SAME NAME ON ITS OWN RING, which shadows the
   * inherited one for that subtree — so the score ring starts from its own
   * PIANO rather than inheriting the camera's last angle, and one set of rules
   * above serves both rings. Its `both` fill is what holds it at 0deg while
   * scene 2 is turning the other one.
   *
   * ⭐ AND SCENE 5's SEAM IS HERE FOR THE SAME REASON THE DIAL'S ANGLE IS: the
   * gate is in the layer and the two runtimes are in a scene, and `--share` has
   * to reach both or the boxes and the numbers could disagree.
   *
   * ⚠️ TWO ANIMATIONS ON `.story`, AND THE FILL-MODE TRAP IS WHY THAT SENTENCE
   * IS HERE. Two animations on one element let the LATER one win any property
   * they SHARE — which is how the satellites once sat on the projector from the
   * page's first frame. These two share NOTHING: one writes `--turn`, the other
   * writes `--share` and `--seam-gap`. So both can be `both`, and both need to
   * be: each one's backwards fill is what holds its own value before its scene
   * (0deg, and an uncut square), and its forwards fill is what holds it after
   * (rotoscope stays; the cut stays cut). 🔴 Add a third animation here and
   * check this again — the moment two of them write one property, the later
   * one's 0% keyframe silently becomes the page's opening state. */
  .story {
    /* FILM at the tick → ROTOSCOPE at the tick: three detents the short way
     * round, −60° each. The range is the one the dial has always used.
     * ⚠️ `both`, and the forwards half is the point — after this scene the
     * angle HOLDS at −180°, so the gate keeps rotoscope for the rest of the
     * story. "From here on the films are rotoscoped" (../../PLAN.md §The
     * scenes, the amendment) is that one word. */
    /* ⚰️ ═══ SCENE 1 RESTS IN THE MIDDLE — THE THIRD ANIMATION MOVED OFF
     * `.story`, 2026-09-25 (../../PLAN.md item 40) ══════════════════
     * `.story` carried `centre-rise linear both` on `scroll()` over `0 50svh`,
     * writing `--centre` — the reader's first scroll lifting the square out of
     * the middle of the glass and into the top two-thirds.
     *
     * ⭐ THE BEAT IS UNCHANGED; WHAT MOVED IS WHERE IT LIVES AND WHAT IT
     * WRITES. Item 40 gives the same lift a second trigger (the projector's
     * own playback, on a clock), so both mechanisms now sit on `main` writing
     * one scalar, `--lift`, and `--centre` is an ordinary `calc()` on this
     * element — this file's own §--seam-open law, "animate the scalar, compute
     * the length." §THE OPENING'S THREE BEATS carries all of it, including the
     * table of which engine runs which route. The square still shrinks for
     * free, because `--gate` is still derived from `--centre` (§THE CAP IS WHAT
     * THE WORDS LEAVE): 653px at the landing, 353px on the third, at 1440×900.
     *
     * ⚰️ AND THE `no-preference` RULE BELOW IT WENT WITH IT — ../../PLAN.md
     * item 32's `.story { --centre: calc(100svh / 3) }`, which painted the
     * settled composition on the one frame Chromium draws before a `scroll()`
     * animation resolves. Its replacement is one normal declaration on `main`
     * (`--lift: 1`) which does the same job and two more besides; §--lift has
     * the measurements and the table.
     *
     * ⚠️ SO THERE ARE TWO ANIMATIONS ON `.story` AGAIN, NOT THREE. They write
     * `--turn` and `--share`/`--seam-gap` — disjoint, so both keep `both`. The
     * standing instruction in the comment above still holds for whoever adds
     * the next one. */
    animation: dial-turn linear both, split-open linear both;
    animation-timeline: --s-dial, --s-split;
    animation-range: contain 54% contain 96%, contain 54% contain 96%;
  }

  /* ⚰️ ITEM 32's SETTLED-VALUE RULE AND THE OLD `centre-rise` KEYFRAMES BOTH
   * STOOD HERE UNTIL 2026-09-25 — removed by ../../PLAN.md item 40, and worth
   * a tombstone because the bug they fixed was real and expensively measured.
   *
   * Christian: "I refresh and once in a while radar and reel are misaligned."
   * Traced frame by frame from document-start with the scroll forced to 6291,
   * six trials per condition: chromium 12 of 12 painted `--centre: 450px` and a
   * 653px gate on the FIRST STYLED FRAME at a scroll where both should have
   * been 300/353; webkit 0 of 12. Chromium applies a `scroll()` animation one
   * frame after first paint, WebKit before it. 🔴 `history.scrollRestoration`
   * was NOT the cause and `'manual'` was not the fix — the trace already had
   * it set. The fix was to declare the settled `--centre` in a normal rule, so
   * an unresolved frame drew the finished composition.
   *
   * ⭐ IT IS GONE BECAUSE IT MOVED, not because it was wrong — and the thing
   * it moved to answers two more cases than it did. The `scroll()` animation
   * still exists (§THE OPENING'S THREE BEATS, `lift-on-scroll`); it just writes
   * `--lift` on `<body>` now instead of `--centre` here. Its unresolved frame
   * is covered by `body[data-page="home"] { --lift: 1 }` — a normal
   * declaration on the SAME element the animation is on, so the animation
   * outranks it whenever it has resolved and it paints the settled composition
   * whenever it has not. The same line is also what Firefox and a
   * reduced-motion reader with no JS land on, which the old rule could not do
   * from inside a `no-preference` guard.
   *
   * ⚠️ THE LESSON, IF A `scroll()`-DRIVEN VALUE IS EVER ADDED HERE AGAIN: put
   * the fallback on the element the animation is on, never on a descendant. A
   * custom property declared on a child BEATS the value it would inherit, so
   * the same rule one level down would have silently killed the animation
   * instead of backing it up. */

  @keyframes dial-turn {
    /* 🔴 IDENTITY, NEVER `none`. Safari runs scroll-driven animations on the
     * scrolling thread and `none` at both ends of an interval takes the whole
     * browser down — carried from bounce/site/MECHANIC.md risk 1. */
    from { --turn: 0deg; }
    to   { --turn: -180deg; }
  }

  /* ── THE NAME GOES WHEN THE RING HAS STOPPED ─────────────────────────────
   * ../../PLAN.md item 7, Christian: the mode title "fades out after 1s, only
   * shows per rotation".
   *
   * ⭐ "ONLY SHOWS PER ROTATION" WAS ALREADY EXACTLY TRUE, and the fix is only
   * the other half. MEASURED before writing anything, 17 scroll samples across
   * each ring: the sum of all six `.say` opacities never exceeds 1.000 at any
   * sample — never two names at once, never a blend. Each lights at its detent
   * and is gone by ±30°, which is what the 2√3 in §the name it says buys.
   *
   * 🔴 WHAT WAS WRONG IS THAT THE LAST NAME NEVER LEFT. `--turn`'s animation
   * ends at `contain 78%` (and the score's at 88%), and its forwards fill holds
   * the angle there — so the final mark sits ON the tick for the rest of the
   * scene and its name stayed lit at opacity 1 for the last ~50% of the scroll.
   * ROTOSCOPE and AIR were permanent labels. That is the "not a persistent
   * label" Christian is objecting to, not the per-detent behaviour.
   *
   * ⭐ SO IT IS A SCENE-OUT FADE, NOT A TIMER, and that matters: §the name it
   * says records the law that a wall-clock fade decoupled from scroll STROBES
   * on a scrub ("an instant step on a scrub strobes; the short fade is the
   * translation, not a softening"). This is driven by the same scroll the rest
   * of the page is, so a reader dragging backwards sees the name come back —
   * which a timer could not do. `1s` is read as "briefly", which is what it
   * buys here.
   *
   * ⚠️ NOT GATED ON `prefers-reduced-motion`, deliberately, and for the same
   * reason `--turn` is not (§--turn, above): which name is being said is STATE,
   * not movement, and a reader asking for less motion is asking to be moved
   * less, not told less. The fade is opacity on a scroll position, so it reads
   * as a caption changing rather than as something travelling.
   *
   * The range starts after each ring's own turn has finished — 82% against the
   * dial's 78%, 90% against the score's 88% — so the name is never taken away
   * while the ring is still moving under it. */
  /* ⚠️ THE SUBJECT IS THE RING, NOT THE SECTION — Phase C, 2026-10-01. These
   * read `.scene--the-dial .say` and `.scene--the-score .say` while the rings
   * lived in their scenes' stages; since Phase A they live in `.screen-app` and
   * NEITHER SELECTOR MATCHED ANYTHING. `.dial` and `.score` each appear exactly
   * once on the page (`layouts/story.yml` §THE FURNITURE checks that against
   * `content/story.json`: `dial` is on `the-dial` only, `hasScore` on
   * `the-score` only), so the bare class names one scene's ring as precisely as
   * the descendant pair used to — and the timeline comes with it. */
  .dial .say {
    animation: say-out linear both;
    animation-timeline: --s-dial;
    animation-range: contain 82% contain 96%;
  }
  .score .say {
    animation: say-out linear both;
    animation-timeline: --s-score;
    animation-range: contain 90% contain 99%;
  }
  /* IDENTITY-shaped at both ends, never `none` — the same Safari rule every
   * other keyframe block in this file follows. */
  @keyframes say-out {
    from { --say-out: 1; }
    to   { --say-out: 0; }
  }

  /* ⭐ THE CUT, THEN THE SEAM DRAGGED, THEN THE FILM AGAIN — and the two middle
   * stops are the two frames Christian sent. 0.652 and 0.375 are IMG_1778's and
   * IMG_1779's own proportions (43.0/65.9 and 24.6/65.9). The first quarter
   * makes the cut; the long middle moves the seam, which is the part that says
   * the sizes are the lengths. Its 0.051 gap is the app's too: 34px against a
   * 674px square, IMG_1778.
   *
   * 🔴 AND IT CLOSES AT THE END, WHICH IS THE CUT BEING MADE — NOT UNDONE. This
   * is the one beat the architecture forces and it is worth being plain about:
   * there is ONE gate for the whole story, so it has to be a single square
   * again before the soundtrack scene, which is composed around one. The app
   * agrees, which is the only reason this is honest: press the scissors and you
   * are back on the film screen looking at one picture — the two clips are in
   * the reel now, not on the glass. The disc PRESSES on the same frames (see
   * `.cutter .disc` below), so the closing reads as the commit it is rather
   * than as the seam springing back. ⚠️ Do NOT re-time the close without
   * re-timing the press; apart they read as an undo. */
  @keyframes split-open {
    0%   { --share: 1;     --seam-open: 0; }
    26%  { --share: 0.652; --seam-open: 1; }
    76%  { --share: 0.375; --seam-open: 1; }
    88%  { --share: 0.375; --seam-open: 1; }
    100% { --share: 1;     --seam-open: 0; }
  }

  /* The press. The disc dips as the halves come back together — the app's own
   * "fire on arriving", and the difference between a cut made and a cut
   * abandoned. 🔴 IDENTITY, NEVER `none`, at both ends. */
  .cutter .disc {
    animation: disc-press linear both;
    animation-timeline: --s-split;
    animation-range: contain 4% contain 86%;
  }
  @keyframes disc-press {
    0%   { scale: 1;    }
    92%  { scale: 1;    }
    96%  { scale: 0.88; }
    100% { scale: 1;    }
  }

  /* ⚠️ FOUR OF THE SIX LOOKS COME TO THE TICK, NOT SIX, and that is arithmetic
   * rather than a choice: the ring's order is the app's (NOIR · FILM · DIGITAL
   * · SYNTH · ROTOSCOPE · COMIC) and the scene's own sentence is "out of 8mm
   * and into ROTOSCOPE", which is three detents whichever way it turns. Showing
   * all six AND landing on ROTOSCOPE needs nine detents — a turn and a half,
   * for a scene that is one gesture on the phone. NOIR and COMIC are on the
   * ring, lit as they pass, and their clips are wired; they simply are not the
   * ones this turn stops on. Christian's to reopen. */

  /* Cut between the projector and dial footage at the scene boundary. */
  .looks {
    animation: looks-in steps(1, end) both;
    animation-timeline: --s-dial;
    animation-range: entry 10% contain 2%;
  }
  @keyframes looks-in { from { opacity: 0; } to { opacity: 1; } }

  /* ═══ THE FURNITURE FADES; IT DOES NOT TRAVEL ═════════════════════════════
   * ../../PLAN.md item 10: "NOTHING SCROLLS OVER THE GATE; FURNITURE FADES, IT
   * DOESN'T TRAVEL." Christian names the dials and their furniture — rings,
   * ticks, the knob — and NOT the words, which he says already read fine.
   *
   * 🔴 MEASURED BEFORE BUILDING, because the keyframes made this look like it
   * was already done: every furniture animation in this file is opacity or
   * scale, nothing writes a travelling `translate`. The travel is not in an
   * animation at all — IT IS THE STAGE. `.stage` is sticky and holds at the top
   * of the screen for the middle of its scene, then unsticks and scrolls away
   * with the page, and the ring goes with it. Traced at 1440×900 across the
   * dial scene, ring centre in viewport coordinates:
   *     f 0.1–0.6   688   (stuck; the gate sits at 300)
   *     f 0.7       490
   *     f 0.8       256   ← ON the square, still at FULL OPACITY
   *     f 0.9        22
   *     f 1.0      −212
   * 667px of travel for the dial, 721 for the score, at opacity 1 the whole
   * way. So the ring does not merely move — it scrolls UP ACROSS the stationary
   * square and off the top of the screen, which is the exact words of the
   * item's title.
   *
   * ⭐ AND THE FIX ALREADY EXISTS IN THIS FILE, one scene over. `.reel` had the
   * same defect and `reel-go` is the answer: a SHORT exit fade, with its own
   * comment recording that the range was measured rather than guessed — "at
   * `exit 55%` the strip was still 42% opaque as it passed the square's centre
   * line… It has to be GONE before it gets there, not going." This is that
   * treatment applied to the other three scenes' furniture, which is what makes
   * item 10 a consistency pass rather than a new mechanic.
   *
   * ⚠️ IT IS THE CONTAINER, NOT THE RING. `.dial` / `.score` / `.cutter` carry
   * everything the scene hangs under the square — ring, tick, marks, the name,
   * the knob, the scissors disc — so one fade takes the whole assembly and the
   * parts cannot fade out of step with each other.
   *
   * ⚠️ NOT GATED ON `prefers-reduced-motion`, and it is the one place in this
   * file where that cuts the other way: a reader who asked for less motion
   * still gets the page scrolling, so they still get the traverse. Fading it is
   * what REMOVES movement for them. Gating this would withhold the fix from the
   * people it helps most. */
  /* ⚠️ `furniture-go` IS DECLARED HERE AND APPLIED WITH `furniture-in` in the
   * no-preference block below — item 22 part B paired them, because two
   * animations writing one element's opacity have to be declared together or
   * the later `animation` shorthand silently drops the earlier one. The
   * keyframes stay here, outside the motion gate, so a reduced-motion reader
   * still gets the exit fade item 10 gave them: that fade REMOVES movement for
   * them (it is what stops the furniture traversing the square), which is why
   * it was never gated in the first place. */
  @keyframes furniture-go { from { opacity: 1; } to { opacity: 0; } }
  @media (prefers-reduced-motion: reduce) {
    .dial, .score, .cutter, .lengths, .soundtrack {
      animation: furniture-go linear both;
      animation-range: exit 0% exit 10%;
    }

    /* ══ 🔴 THE SCOPED TIMELINES — WHY THESE FIVE READ THREE NAMES AND NOT
     *    `--scene` ══════════════════════════════════════════════════════════
     * Phase A moved the furniture out of `.scene > .stage` and into
     * `.screen-app`, inside the ONE device. It is the right place — these are
     * the app's own controls — and it cost them `--scene`, which is a
     * `view-timeline-name` declared on each `.scene--*` section and resolvable
     * ONLY by that section's DESCENDANTS. They are no longer descendants.
     *
     * 🔴 AND THE FAILURE WAS SILENT, WHICH IS THE PART WORTH REMEMBERING. An
     * animation whose `animation-timeline` names nothing is INACTIVE, not
     * broken: it reports `finished`, and `furniture-go`'s fill then held all
     * five at `opacity: 0`. Measured before this fix, every 150px of the whole
     * page, both engines: peak opacity 0 for `.dial`, `.lengths`, `.cutter`,
     * `.score` and `.soundtrack`. Four scenes of the story drew nothing at all
     * and nothing anywhere said so.
     *
     * ⚠️ `--scene` CANNOT BE HOISTED INTO `timeline-scope` to fix this. SEVEN
     * elements publish that one name (every `.scene--*` above), and a scoped
     * name with more than one declaring element is ambiguous and resolves to
     * nothing — the same silent inactivity, page-wide instead of per-scene.
     *
     * ⭐ SO EACH PIECE READS THE SCOPED NAME ITS OWN SCENE PUBLISHES, and those
     * are already in `main`'s `timeline-scope` list at the top of this file:
     *     .dial                → --s-dial    (scene `the-dial`)
     *     .lengths, .cutter    → --s-split   (scene `the-split`)
     *     .score, .soundtrack  → --s-score   (scene `the-score`)
     * 🔴 THE PAIRING IS NOT COSMETIC — `.lengths` and `.cutter` are the SAME
     * scene's furniture (`{{#hasSplit}}` emits both), as are `.score` and
     * `.soundtrack` (`{{#hasScore}}`). A sixth piece belongs to whichever scene
     * emits it, and adding it to the shared selector above without adding it
     * here is exactly the bug this block exists to close.
     *
     * ⚠️ ORDER IS LOAD-BEARING: these come AFTER the `animation` shorthand that
     * resets `animation-timeline` to `auto`. Same specificity, later wins. */
    .dial              { animation-timeline: --s-dial;  }
    .lengths, .cutter  { animation-timeline: --s-split; }
    .score, .soundtrack{ animation-timeline: --s-score; }
    /* ⭐ AND THE WORDS LEAVE THE SAME WAY, ADDED 2026-09-25 (../../PLAN.md item
     * 40). `words-say` lives in the `no-preference` block below, so under
     * `reduce` a scene's caption had NO animation at all and defaulted to
     * opacity 1 — for every scene at once. The stages are sticky inside their
     * own sections and two of them overlap for about a viewport at each seam,
     * so two captions crossed each other there, on all six seams.
     *
     * 🔴 IT IS NOT A FADE-IN. `prefers-reduced-motion` asks for less movement,
     * not less meaning: the words stay up from the page's first frame, which is
     * the whole of §REST-VISIBLE. What this removes is the OTHER scene's words
     * being up at the same time — and it removes movement rather than adding
     * it, exactly as `furniture-go` does one rule up, which is why it shares
     * that rule's keyframes and its range. */
    .scene-words {
      animation: furniture-go linear both;
      animation-timeline: --scene;
      animation-range: exit 0% exit 10%;
    }
  }

  /* ═══ REDUCED MOTION — STATE, NOT MOVEMENT ═══════════════════════════════
   * 🔴 `prefers-reduced-motion` asks for less MOVEMENT, not less MEANING. A
   * cross-fade carries no vestibular load, so a reader who asks for it can
   * still be told which scene the four other phones belong to — they simply
   * are not flown in and not grown into the square.
   *
   * ⚠️ WITHOUT THIS THEY SIT ON THE PROJECTOR FOR THE WHOLE STORY. The
   * satellites live in the gate layer (so the merge can be the same four that
   * arrived), and the layer is pinned for the length of the page. With every
   * animation gated off they simply have no opacity rule and default to 1 —
   * visible over scenes 1, 2 and 5, which is the bug Christian caught in the
   * full story and would otherwise have shipped here unseen.
   * Ruled 2026-09-21: an opacity-only fade on scene 3.
   *
   * ⚠️ AND THE FILL MODES ARE SPLIT HERE FOR THE SAME REASON THEY ARE ABOVE:
   * two animations on one element, the later one wins a shared property, so
   * `both` on the fade-out would assert opacity 1 from the page's first frame
   * and undo the fade-in's backwards fill. `forwards` makes it assert nothing
   * until its own range opens. */
  @media (prefers-reduced-motion: reduce) {
    .satellite {
      animation: satellite-appear linear both, satellite-vanish linear forwards;
      animation-timeline: --s-angles, --s-reel;
    }
    @keyframes satellite-appear { from { opacity: 0; } to { opacity: 1; } }
    @keyframes satellite-vanish { from { opacity: 1; } to { opacity: 0; } }
    /* The same slices as the full story, so the two readings agree about WHEN
     * each phone belongs — only about HOW it arrives. */
    .satellites .satellite:nth-child(1) { animation-range: contain 10% contain 32%, entry 18% entry 100%; }
    .satellites .satellite:nth-child(2) { animation-range: contain 22% contain 44%, entry 24% entry 100%; }
    .satellites .satellite:nth-child(3) { animation-range: contain 34% contain 56%, entry 30% entry 100%; }
    .satellites .satellite:nth-child(4) { animation-range: contain 46% contain 68%, entry 36% entry 100%; }

    /* ── the two rings: the state without the turning ───────────────────
     * ⭐ The angle still runs (it is declared outside this gate, with the
     * timelines and for the same reason), so a reader who asks for less motion
     * still gets every bit of the meaning: all six marks out there at once, the
     * chosen one lit and grown, its name said and gone, and the picture
     * cross-fading from one look to the next. What they do NOT get is the ring
     * SWINGING round under the tick, which is the only part of it that moves.
     *
     * ⚠️ So the two rotations are pinned to the mark's own place. `--a` alone,
     * not `--turn + --a` — one declaration each, and everything else above goes
     * on reading the angle. The cost is honest and worth naming: the lit mark
     * no longer travels to twelve, it lights where it stands. A cross-fade
     * round a ring, which is exactly what the satellites do a scene later. */
    .mark     { rotate: var(--a); }
    .mark svg { rotate: calc(-1 * var(--a)); }

    /* ── the square leaves without travelling ───────────────────────────
     * A viewport-long translate is the largest movement on the page, so under
     * `reduce` the square goes the way the satellites arrive: it fades where it
     * stands. The features section still decides the moment — which is the
     * meaning — and nothing crosses the screen to deliver it. */
    /* 🔴 `forwards`, AND IT IS NOT TIDYING — Phase C, 2026-10-01. This rule
     * changes only the NAME, so it inherits `both` from the shorthand above;
     * on the old `.gate-layer` that was harmless, because nothing else wrote
     * that element's opacity. On `.framebar` two things do: its own
     * `transition: opacity` and `20-frame.js`'s `bar.style.opacity = "0"` over
     * a band. An animation beats an inline declaration, so a BACKWARDS fill
     * would assert `opacity: 1` from the page's first frame and pin the phone
     * visible through every band — for a reduced-motion reader only, which is
     * the hardest arm to notice. `forwards` asserts nothing until the range
     * opens; it is the same fix `furniture-go`, `satellite-merge` and `reel-go`
     * all carry, for the same reason, and this is the fourth time. */
    .framebar {
      animation-name: gate-dismissed;
      animation-fill-mode: forwards;
    }
    @keyframes gate-dismissed {
      from { opacity: 1; }
      to   { opacity: 0; }
    }

    /* ── the cut: the state, without the scrub ──────────────────────────
     * 🔴 THIS IS THE ONE PLACE ON THE PAGE WHERE THE MOVEMENT AND THE MEANING
     * CANNOT BE SEPARATED, and it is worth saying why rather than quietly
     * doing less. Everywhere else a movement has a still equivalent — the
     * satellites cross-fade instead of flying, the ring's mark lights where it
     * stands instead of travelling to the tick. Here the SIZES ARE THE
     * INFORMATION: "the two boxes' sizes are the two lengths" is the scene's
     * whole sentence, so there is no version of it in which nothing resizes.
     *
     * ⭐ So the split is delivered as ONE change of state rather than withheld,
     * and what is dropped is the part that is only movement: the seam being
     * dragged back and forth for the length of the scene. The reader gets the
     * cut, the two boxes, the two runtimes and the scissors — the whole of the
     * meaning — with a single resize as the scene arrives instead of a
     * continuous one under the thumb.
     *
     * ⚠️ `animation-name` and `animation-range` are restated as PAIRS because
     * `.story` carries two animations and these properties are lists: naming
     * one here would drop the dial's angle, which is state a reduced-motion
     * reader is owed (see above). */
    .story {
      animation-name: dial-turn, split-made;
      animation-range: contain 8% contain 78%, entry 55% contain 10%;
    }
    @keyframes split-made {
      0%   { --share: 1;     --seam-open: 0; }
      34%  { --share: 0.652; --seam-open: 1; }
      88%  { --share: 0.652; --seam-open: 1; }
      /* ⚠️ It has to close here too, and for the structural reason rather than
       * the expressive one: one gate, and the soundtrack scene is composed
       * around a single square. So a reduced-motion reader gets exactly two
       * changes of size — the cut, and the film again — where the full story
       * has a seam moving between them. */
      100% { --share: 1;     --seam-open: 0; }
    }
  }

  @media (prefers-reduced-motion: no-preference) {

    /* ═══ THE WORDS SPEAK, THEN THE FURNITURE ARRIVES ════════════════════
     * Item 22 part B's sequencing, and it replaces a whole-stage fade that used
     * to bring the words and the furniture up TOGETHER (`words-arrive` on
     * `.scene > .stage` — one animation over everything, which is "beside each
     * other in space" written as motion).
     *
     * ⭐ BOTH WINDOWS ARE INSIDE `contain`, AND THAT IS THE LOAD-BEARING PART.
     * `.stage` is `position: sticky`, so it TRAVELS during `entry` and is
     * pinned for `contain` — measured in item 10's trace, stuck from f≈0.1 to
     * f≈0.6 of the scene box. Christian's "user can pause the scroll to read"
     * is only true where nothing is moving, so the words' whole life happens
     * in the pinned stretch. Putting them in `entry` would have handed a reader
     * a paragraph sliding up the screen.
     *
     * The sequence, per scene:
     *    contain  0→10%   the words fade up in the band
     *    contain 10→22%   they hold — this is the pause-and-read window
     *    contain 22→32%   they fade out
     *    contain 26→36%   the furniture fades in behind their tail
     * The 6% overlap is deliberate: a hard cut between two things in one place
     * reads as a swap, and a short crossfade reads as a hand-off. */
    /* ═══ THE WORDS GET LONG ENOUGH TO BE SEEN ════════════════════════════
     * ../../PLAN.md item 24 part B. Christian: "When we scroll we stay a little
     * longer on the text — if the user scrolls too fast, they never see there
     * even was text."
     *
     * ⚠️ DERIVED AGAINST SCROLL SPEED, not reshuffled as percentages, because
     * the unit that matters is PIXELS OF SCROLL and a percentage hides it. A
     * scene's `contain` phase is `100svh × --pin` of scroll, so the same 12
     * percentage points bought wildly different amounts of reading in different
     * scenes. Measured before: the readable window was 225px in the opening and
     * the score (the two scenes at `--pin: 1`) — at a trackpad fling of roughly
     * 3000px/s that is 75 MILLISECONDS, which is Christian's complaint exactly,
     * and it was never going to be visible at any percentage.
     *
     * ⭐ SO THE ROOM IS BOUGHT WITH `--pin`, NOT TAKEN FROM THE MECHANICS. The
     * short scenes grow (see each scene's own `--pin`), which lengthens
     * `contain` in pixels, and the words take a wider SHARE of a longer phase —
     * so the dial still turns over about the same absolute distance it always
     * did rather than being compressed into what the words leave. Squeezing the
     * mechanic would have traded one complaint for another. */
    /* ═══ 🔴 THE BAND IS NEVER EMPTY — 2026-09-25, ../../PLAN.md item 49 ════
     * Christian, watching it: *"the duration for title/text per section should
     * be as long as it can, with minimizing the durations of just-black bottom
     * 1/3."*
     *
     * ⚰️ IT WAS `contain 0% contain 50%`, AND THAT HALVED EVERY SCENE'S WORDS
     * FOR NO REASON ANYONE HAD WRITTEN DOWN. Mapped onto a scene's own contain
     * phase, `words-say` then ran: fade up 0→8%, readable 8→44%, fade out
     * 44→50% — and NOTHING for the remaining half. In the four scenes with no
     * furniture that half was a black bottom third, which is the thing he is
     * pointing at. MEASURED at 1440×900, readable pixels of scroll per scene:
     *
     *            pin  contain   readable before   readable after
     *   opening  0.6     540         389   (72%)      389   (72%)   already right
     *   nowrong  1.0     900         324   (36%)      648   (72%)
     *   dial     1.5    1350         486   (36%)      486   (36%)   hands over
     *   angles   1.4    1260         454   (36%)      907   (72%)
     *   reel     1.5    1350         486   (36%)      972   (72%)
     *   split    1.4    1260         454   (36%)      454   (36%)   hands over
     *   score    1.4    1260         454   (36%)      907   (72%)
     *
     * ⭐⭐ AND "AS LONG AS IT CAN" IS A DIFFERENT LENGTH PER SCENE, BECAUSE THE
     * WORDS DO NOT OWN THE BAND — THEY SHARE IT. §`--above` says so in as many
     * words: *"that band is now shared by TWO things in turn — a scene's words,
     * then its furniture."* `.dial, .score, .cutter` sit on `--band-centre`,
     * which is the SAME anchor `.scene-words` sits on. MEASURED at 1440×900,
     * contain 70%, the two boxes in the scenes that have both:
     *
     *   the-dial    words [633,728]   .dial  [612,764]   ← full overlap
     *   the-score   words [633,728]   .score [612,764]   ← full overlap
     *   the-split   words [633,728]   .cutter's disc [653,723]  ← full overlap
     *       (⚠️ `.cutter` ITSELF measures 0×0 at [688,688] — it is an absolute
     *        zero-box anchor and its INK is the `.disc` child. A check that
     *        asked the container would have said "no collision" and been
     *        wrong. Ask the ink.)
     *
     * So the rule is one sentence and it is the answer to both halves of what
     * he asked: THE WORDS HOLD THE BAND UNTIL THE FURNITURE TAKES IT, AND TO
     * THE END OF THE SCENE WHERE NOTHING DOES. The band is never black while a
     * scene is on screen, and no scene shows two things in one place.
     *
     * 🔴 WHICH SCENES THOSE ARE IS READ FROM THE DOM, NEVER LISTED. `:has()` on
     * the furniture's OWN POSITIONING SELECTOR (§the band, `.dial, .score,
     * .cutter { top: var(--band-centre) }`) means "this scene puts something on
     * the band's anchor" — so a scene that grows furniture is handled the day
     * it grows it, and a scene that loses it gets its words back. A list of
     * scene classes here would be the second list this file has already been
     * bitten by twice (§--share, §--seam-open).
     * ⚠️ THE TWO SELECTORS MUST STAY IDENTICAL. If something new is given
     * `top: var(--band-centre)` up there, it belongs in the `:has()` below in
     * the same commit — otherwise that scene's words will be drawn straight
     * through it and nothing will fail.
     * ⚠️ AND IT IS NOT THE SAME LIST AS THE ANIMATION SELECTOR further down
     * (`.dial, .score, .cutter, .lengths, .soundtrack`), which is "everything
     * that fades with the scene". `.lengths` measures [109,124] and
     * `.soundtrack` [405,413] — neither is anywhere near the band. Using the
     * longer list would hold the split's and the score's words back for the
     * right reason by accident, and would hold back the first scene that grows
     * a runtime counter and nothing else for no reason at all.
     *
     * ⭐ NOTHING OVERLAPS ON `.scene-words` ITSELF, which is the seam worth
     * checking before extending any range: the element's other animation,
     * `furniture-go` at `exit 0% exit 10%`, lives in the `reduce` block and
     * this one lives in `no-preference`, so the two can never both apply.
     * ⭐ AND NEIGHBOURING SCENES STILL CANNOT COLLIDE. Scene N's `contain 100%`
     * is one whole viewport of scroll BEFORE scene N+1's `contain 0%` — scene
     * N's contain ends at `top(N+1) − 100svh` and N+1's begins at `top(N+1)`.
     * The old 50% end had a viewport and a half of margin; 100% still has a
     * viewport. There was room for this and nobody had taken it. */
    .scene-words {
      animation: words-say linear both;
      animation-timeline: --scene;
      animation-range: contain 0% contain 100%;
    }
    /* The scenes that hand the band over. The mechanics decide the number, not
     * taste: `dial-turn` and `split-open` both run `contain 54% → 96%`, so the
     * furniture has to be up by 54% and the words have to be gone by then —
     * `furniture-in` fades it in over `contain 44% → 54%` and this range's own
     * tail (keyframes 88% → 100% of `contain 0% → 50%`) fades them out over
     * `contain 44% → 50%`. That 6-point overlap is the hand-off, and it is the
     * number this rule exists to preserve. ⚠️ Do not "simplify" this away by
     * giving every scene 100%: it would draw the caption through the dial. */
    /* ⚠️ IT WAS `.scene:has(.dial, .score, .cutter)` AND IT STOPPED MATCHING —
     * Phase C, 2026-10-01. That selector asked the scene whether it CONTAINS
     * furniture, which was the honest question while the furniture was in the
     * scene's stage; since Phase A every piece is in `.screen-app`, no `.scene`
     * contains any of them, and all six scenes silently fell back to the 100%
     * range above — drawing the caption straight through the dial, which is the
     * exact thing the paragraph above says not to do.
     * 🔴 THE COUPLING IS NOW NAMED RATHER THAN QUERIED, so it has to be kept by
     * hand: these are the three scenes that hand the band over — `the-dial`
     * (`.dial`), `the-split` (`.lengths` + `.cutter`), `the-score` (`.score` +
     * `.soundtrack`). ⭐ It is the SAME list as the furniture selector and the
     * scoped-timeline block below; a scene that grows furniture needs a row in
     * all three or its words will draw over it. */
    :is(.scene--the-dial, .scene--the-split, .scene--the-score) .scene-words {
      animation-range: contain 0% contain 50%;
    }
    /* ═══ THE OPENING'S WORDS ARE BEAT B's, AND BEAT B HAS TWO ROUTES IN ════
     * ../../PLAN.md item 40. This is the SCROLL route and it is unchanged: the
     * copy is not on screen at the top of the document (beat A is wordless —
     * Christian's amendment: "nothing else is on screen with it") and it fades
     * up as the reader scrolls, which is coherent with the square rising under
     * the same scroll. The range is the whole of the scene because the opening
     * is short.
     *
     * ⚠️ THE OTHER ROUTE IS THE PLAYBACK CUE AND ITS RULES ARE NOT HERE — they
     * are up at §THE OPENING'S THREE BEATS, outside this `@supports` gate,
     * because they are plain time animations and Firefox should get the
     * projector switching on too. They out-specify this rule (a `[data-intro]`
     * and a `body[data-page]` more), so cascade order is not what makes them
     * win, and `words-hold` is the envelope they hand back to. */
    /* ⚰️ `.scene--opening .scene-words { animation-range: contain 0% contain
     * 100%; }` — DELETED 2026-09-25 AS REDUNDANT, not as a change of mind. The
     * opening was the ONE scene that already ran the full phase, and item 49
     * made that the base for every scene that has no band furniture. The
     * opening has none, so it inherits exactly the range it used to state, and
     * a rule restating the default is a rule that will one day disagree with
     * it. ⚠️ THE PARAGRAPH BELOW IS STILL LIVE — the opening's second route in
     * is unchanged and is the reason this scene is commented at all. */
    /* ⚰️ WHAT WENT, 2026-09-25: a `[data-projector-title-active]` override that
     * set `animation: none; opacity: 1`. It bought the same hold a second time,
     * by SWITCHING THE MECHANIC OFF — and that is the move §NOTHING SCROLLS OVER
     * THE GATE (item 10) and item 24 were both written to stop. With the
     * animation dead the title no longer fades at its range's end, so it
     * TRAVELLED with the scene instead of fading in place: Christian's report,
     * 2026-09-25, "'the projector…' is scrolling again, instead of hiding on
     * scroll, the way that 'one event, se…' works." The range on the line above
     * already holds it — `words-say` sits at opacity 1 from 16% to 88% — so the
     * override was redundant in the direction it was wanted and harmful in the
     * direction nobody checked.
     *
     * ⚠️ AND IT SILENTLY BRICKED home.js's EXIT CONDITION. `syncTitleMode`
     * (home.js:82) reads this element's COMPUTED opacity and leaves cue mode at
     * `opacity <= 0.05`; a rule pinning that same opacity to 1 made the test
     * unreachable, so clip 03 looped forever and the 01/02 opening pair never
     * came back after one scroll-and-back. One declaration, two bugs, and the
     * second one was invisible from the CSS alone. If a rule pins a value, ask
     * who READS that value. */
    /* 🔴 THE CENTRING IS RESTATED IN EVERY FRAME, and this file already warned
     * about it once — §THE CUT's `.reel`: "an animation that writes `translate`
     * REPLACES that declaration rather than adding to it — the same trap that
     * put the square's top-left in the middle of the screen." It caught this
     * change too. `.scene-words` is centred with `translate: -50% -50%`, and
     * keyframes writing a bare `0 8px` threw that away: measured, the title
     * rendered against the right-hand edge instead of over the square's centre.
     * ⚠️ The old `words-arrive` was immune only by accident — it lived on
     * `.stage`, which carries no translate of its own. Moving the animation to
     * the element that IS translate-centred is what exposed it. */
    /* 🔴🔴 THE 8px ENTRANCE IS `margin-top`, NOT `translate` — PHASE C,
     *      2026-10-01, AND IT IS THE UNBLOCK `--figband` WAS WAITING ON ═══════
     * These four keyframes used to write `translate: -50% calc(-50% ± 8px)`:
     * the words' CENTRING plus the rise, in one property. An animation beats a
     * normal declaration, so those keyframes OWNED `translate` on this element
     * and `30-composition.css` could not change it — which is precisely what
     * blocked `--figband`'s consumer. `--figband` is the words' TOP and needs
     * `translate: -50% 0`; `--band-centre` is their CENTRE and needs
     * `translate: -50% -50%`. Written as a declaration, either one was
     * overwritten here, and the rule shipped for one round using a top as a
     * centre: 4.75px of error became 48.5px at 1440×900, 27 became 65 at
     * 390×844. ⚠️ `!important` would have beaten the animation and was REFUSED
     * — it would silently delete this rise from a file that does not own
     * motion.
     *
     * ⭐ SO THE RISE MOVED TO A PROPERTY NOTHING ELSE WANTS. `margin-top` on an
     * absolutely positioned box shifts it and nothing else: it cannot reflow a
     * sibling (there are none in flow around it), it is a plain interpolable
     * length so there is no discrete flip to audit under Law A, and it is
     * untouched by both placements' `translate`. The alternative — a registered
     * `@property` holding the offset — needs `00-props.css`, which is not this
     * phase's lease, and an UNregistered custom property animates DISCRETELY,
     * which would turn an 8px rise into an 8px jump at the halfway mark.
     * ⚠️ The honest cost, stated: `margin-top` is not a compositable property,
     * so these four fades now touch layout on one out-of-flow element per frame
     * where they used to be pure transform. Measured as part of this phase's
     * sweep; if it ever shows, the next move is the rise on its own child, not
     * `translate` back here.
     * 🔴 ALL FOUR WORD KEYFRAME SETS CHANGED TOGETHER — `words-say` here and
     * `words-away` / `words-arrive` / `words-hold` in `25-scenes.css`. One of
     * them still writing `translate` would re-own the property for the intro's
     * whole 720ms and put the opening's copy back 48px out.
     * ⚠️ IDENTITY AT BOTH ENDS STILL HOLDS, the Safari rule this file opens
     * with: `margin-top: 0` is written, never omitted. */
    @keyframes words-say {
      0%   { opacity: 0; margin-top:  8px; }
      16%  { opacity: 1; margin-top:  0px; }
      88%  { opacity: 1; margin-top:  0px; }
      100% { opacity: 0; margin-top: -8px; }
    }

    /* The furniture's ARRIVAL. Its exit is item 10's `furniture-go`, untouched:
     * two animations on one element writing one property, so they must not
     * overlap — this one ends at `contain 36%` and that one begins at `exit 0%`,
     * which are the same instant only for a scene with no `contain` phase at
     * all, i.e. never here. */
    /* 🔴 `forwards` ON THE EXIT, AND `both` WOULD HAVE BROKEN IT SILENTLY —
     * this file's own §TWO ANIMATIONS ON `.story` warns about exactly this and
     * it still caught me: "the moment two of them write one property, the later
     * one's 0% keyframe silently becomes the page's opening state." Both of
     * these write `opacity`, `furniture-go` is the later one, and with `both`
     * its BACKWARDS fill asserted `opacity: 1` from the page's first frame —
     * so the furniture was fully visible through the whole words window and
     * `furniture-in` appeared to do nothing. Measured: opacity 1 at contain 8%,
     * where it should have been 0.
     * ⭐ `forwards` is the same fix `satellite-merge` already carries a few
     * rules down, for the identical reason: it asserts nothing until its own
     * range opens, which lets `furniture-in`'s backwards fill hold the
     * furniture at zero until the words have finished speaking. */
    /* 🔴 EVERY SIBLING IN THE SHARED SELECTOR EXITS THE SAME WAY — ../../PLAN.md
     * item 24 part A. Christian: "there are still furnishings that scroll
     * instead of fade — the cut, the time indicators, the soundtrack grid — all
     * are to stay in place as fade-in-outs."
     *
     * ⚠️ MEASURED FIRST, and it found two of the three plus a false positive.
     * Travel-while-visible across each scene, 1440×900, opacity > 0.05:
     *     .lengths     749px   visible in 24 of 24 samples   ← no fade AT ALL
     *     .soundtrack  504px   visible in 20 of 24           ← wrong-SHAPED fade
     *     .cutter       43px   visible in 13 of 24           ← already fine
     *     .reel          0px · .dial 11px · .score 72px      ← already fine
     * So "the cut" was not a regression: `.cutter` has been in this group since
     * item 10 and its 43px is the tail of its own fade. What Christian is seeing
     * in that scene is `.lengths`, the runtime numbers, which had no animation
     * of any kind.
     *
     * ⚠️ AND `.soundtrack` IS WHY "HAS A FADE" AND "HAS THE RIGHT-SHAPED FADE"
     * ARE DIFFERENT THINGS. Its `band-fade` ran `entry 35% → exit 65%`, keyed to
     * the phases where `.stage` TRAVELS, so it was at full opacity for 504px of
     * movement. `contain` is the pinned stretch; that is the whole reason the
     * dial/score/cutter fix was shaped this way, and the band simply predated
     * it. `band-fade` is deleted rather than re-ranged — one pair of keyframes
     * for all six is the point.
     *
     * 🔴 THE RULE, so a seventh addition does not repeat the gap: ANYTHING that
     * sits in the band and is not words belongs in this selector. It is the same
     * list as the positioning selector two hundred lines up, and the two must
     * stay identical — if you add a sibling there, add it here. */
    .dial, .score, .cutter, .lengths, .soundtrack {
      animation: furniture-in linear both, furniture-go linear forwards;
      animation-range: contain 44% contain 54%, exit 0% exit 10%;
    }
    /* The scoped names, as in the `reduce` block above — ONE list, read twice.
     * ⚠️ TWO ANIMATIONS, SO TWO TIMELINES: the property is a list and it must be
     * as long as `animation-name`, or the shorter one is repeated and the pair
     * silently runs on whatever `animation-timeline`'s own repetition gives. */
    .dial              { animation-timeline: --s-dial,  --s-dial;  }
    .lengths, .cutter  { animation-timeline: --s-split, --s-split; }
    .score, .soundtrack{ animation-timeline: --s-score, --s-score; }
    @keyframes furniture-in { from { opacity: 0; } to { opacity: 1; } }

    /* ── scene 1 · the gate comes out of the dark ─────────────────────── */
    /* ⭐ THE PROJECTOR TURNS ON BY ITSELF — item 22 part A: "fading in as if
     * somebody turned on the 8mm projector. We sit there and wait for the user
     * to scroll down." So the fade is a CLOCK, not a scroll position, and it is
     * the one place on this page where that is right: it is a page-load reveal
     * that happens once, not a scene beat a reader can scrub back and forth.
     * The no-strobe-on-scrub law (§the name it says) is about the second kind.
     *
     * ⚠️ IT WAS SCROLL-DRIVEN AND THAT WAS A BUG, not just the wrong feel:
     * measured at scrollY=0 the gate sat at opacity 0.76 and STAYED there for
     * as long as the reader did not scroll — the page's resting first frame was
     * a three-quarters-lit projector that never finished. Christian's "we sit
     * there and wait" is impossible against a fade that needs scrolling to
     * complete.
     * ⚠️ `prefers-reduced-motion` is handled globally in base.css, which forces
     * every animation-duration to 0.01ms — so a reader who asked for less
     * motion gets the lit gate immediately rather than no gate at all. */
    .gate {
      animation: gate-emerge 1.4s ease-out both, gate-weave 5.3s ease-in-out infinite;
      animation-timeline: auto, auto;
    }
    @keyframes gate-emerge {
      from { opacity: 0.05; filter: brightness(0.35); }
      to   { opacity: 1;    filter: brightness(1); }
    }

    /* ── scene 2 · the dial turns, and the look follows it ────────────────
     * 🔴 THE LIVE CAMERA, NOT A TAKE. The app bakes the look at capture and has
     * no undo, so a shot cannot be re-run through looks; on the live camera the
     * dial is telling the truth about what the NEXT press records. ../../PLAN.md
     * §C. Do not "improve" this into a take being re-graded.
     *
     * ⭐ THERE IS NOTHING TO WRITE HERE ANY MORE, and that is the design. The
     * turn, the glyph that lights, the name that is said and the picture that
     * comes up are ONE animated angle, declared with the timelines above
     * because they are state; every one of them reads it as plain arithmetic.
     * What used to sit here — a `filter` cross-fade on the gate and a separate
     * rotation on the dial's pointer, on two ranges that had to be kept in
     * step by hand — is gone with the thing it was approximating. */

    /* ── scene 3 · the radar, then the other phones ───────────────────────
     * ⭐ ONE AT A TIME, each on its own slice — which is what a PULL looks like.
     * Do not tidy this into everything appearing at once: the arriving IS the
     * product, where four squares that were always there is just a grid. */
    .radar {
      animation: radar-sweep 2.6s linear infinite, radar-fade linear both;
      animation-timeline: auto, --s-angles;
      animation-range: normal, contain 4% contain 62%;
    }
    @keyframes radar-sweep { from { rotate: 0deg; } to { rotate: 360deg; } }
    @keyframes radar-fade  { 0% { opacity: 0; } 18% { opacity: 1; } 76% { opacity: 1; } 100% { opacity: 0; } }

    /* ── the four phones · ONE SET, TWO CLOCKS ────────────────────────────
     * ⭐ They belong to the DEVICE (a `.satellite-layer` on `.device-frame`
     * since 2026-10-01; it was the deleted gate layer before that), so neither
     * scene owns them: scene 3's
     * timeline brings them in and scene 4's merges them, and because it is the
     * same four elements throughout, "fly back into one" is literally the
     * squares you watched arrive. They used to be minted twice, once per scene,
     * while the comment here claimed they were the same four
     * (../../PLAN.md, the corrections of 2026-09-21, item 3).
     *
     * ⚠️ EVERY KEYFRAME RESTATES `translate: -50% -50%`. The static rule centres
     * each satellite on its own point with it, and an animation that writes
     * `translate` REPLACES that declaration rather than adding to it — the trap
     * the gate layer and the reel have both already paid for. */
    .satellite {
      /* 🔴 THE FILL MODES ARE NOT INTERCHANGEABLE, AND `both` ON BOTH IS A BUG.
       * Two animations on one element, and the LATER one wins any property they
       * share. With `both` on the merge, its backwards fill asserted the 0%
       * keyframe's `opacity: 1` through scenes 1 and 2 — so the four phones sat
       * on the projector from the page's first frame, before the radar had
       * found anybody (Christian, 2026-09-21). `forwards` makes the merge
       * assert nothing until its own range opens, which lets the arrive
       * animation's backwards fill hold them at zero until scene 3 calls them. */
      animation: satellite-arrive linear both, satellite-merge linear forwards;
      animation-timeline: --s-angles, --s-reel;
    }
    @keyframes satellite-arrive {
      from { opacity: 0; scale: 0.72; translate: -50% -50%; }
      to   { opacity: 1; scale: 1;    translate: -50% -50%; }
    }
    /* ⭐ IT GROWS INTO THE SQUARE, it does not shrink away — Christian, item 3:
     * "scale up and merge into the rounded-video-box middle (matching size and
     * position)". `--size` is the satellite's width as a percentage of the
     * gate's side, so `100 / --size` is exactly the scale at which it IS the
     * gate. The corner comes free: --corner-square is a percentage of the box's
     * own side, so a scaled box keeps the same shape at the same fraction.
     * It holds full opacity most of the way and fades only as it lands, so what
     * remains is the gate's own picture rather than four ghosts over it. */
    @keyframes satellite-merge {
      /* ⚠️ `left` READS `--sat-x`, IT DOES NOT RESTATE IT, and that survives the
       * clamp it was written for. `--sat-x` used to pin the four phones into the
       * room beside the rail; a keyframe carrying the raw arithmetic would have
       * handed back the UNclamped position the instant this animation took over
       * and the group would have jumped sideways as the merge began. The clamp
       * is gone (§the other phones — outside the glass there is a room), but the
       * discipline is not: one property, two readers, the arithmetic written
       * once. `top` never had a bound and is stated as it always was.
       * ⚠️ ALL FOUR NAMES RESOLVE ON THE DEVICE NOW — `--axis`, `--gate` and
       * `--centre` are re-declared on `.satellite-layer`, which is where these
       * four live since the markup move. */
      0%   { opacity: 1; scale: 1; translate: -50% -50%;
             left: var(--sat-x);
             top:  calc(var(--centre) + var(--gate) * var(--y) / 100); }
      72%  { opacity: 1; }
      100% { opacity: 0; scale: calc(100 / var(--size)); translate: -50% -50%;
             /* `left` is the screen's middle and `top` is the composition's —
              * the two axes are different numbers since item 9, and landing on
              * `50%` in both would have flown the four phones to a point half a
              * band below the square they are merging into. */
             left: var(--axis); top: var(--centre); }
    }
    /* Arrival is staggered — one at a time is what a PULL looks like. The merge
     * is staggered at its start too, but every one of them ENDS at `entry 100%`:
     * the moment the reel scene has finished entering is the moment we land on
     * it, and Christian asked for the merge to finish exactly there. */
    .satellites .satellite:nth-child(1) { animation-range: contain 10% contain 32%, entry 18% entry 100%; }
    .satellites .satellite:nth-child(2) { animation-range: contain 22% contain 44%, entry 24% entry 100%; }
    .satellites .satellite:nth-child(3) { animation-range: contain 34% contain 56%, entry 30% entry 100%; }
    .satellites .satellite:nth-child(4) { animation-range: contain 46% contain 68%, entry 36% entry 100%; }

    /* ⚠️ IT FADES WHERE IT IS; IT DOES NOT TRAVEL (Christian, item 4). The
     * stage sticks for the scene's length and then scrolls away with it, which
     * carried the strip UP ACROSS the picture — the gate is stuck in its own
     * layer and does not follow. Fading it out early in `exit` lets the scene
     * leave without the strip ever crossing the square.
     * ⚠️ THE RANGE IS SHORT ON PURPOSE AND WAS MEASURED, NOT GUESSED. At
     * `exit 55%` the strip was still 42% opaque as it passed the square's centre
     * line — fading, but plainly visible over the picture, which is the very
     * thing this fixes. It has to be GONE before it gets there, not going. */
    /* ⚠️ THE SUBJECT IS `.reel`, ON `--s-reel` — Phase C, 2026-10-01. The strip
     * came into the phone with the rest of the furniture (`settle.md` §1's move
     * list simply omitted it), and MOVING IT AND REBINDING ITS CLOCK WERE ONE
     * CHANGE, NOT TWO: `--scene` is declared on each `.scene--*` and resolvable
     * only by that section's descendants, so the markup could not leave the
     * scene until these read the scoped name `main` publishes. `.reel` and
     * `.tile` appear exactly once each (the `reel` key is on `the-reel` alone),
     * so the bare classes name the same elements the descendant pair did. */
    .reel {
      /* ⭐ `forwards` ON THE EXIT, not `both` — the fourth time this file needs
       * the fix it already carries at `satellite-vanish`, `furniture-go` and
       * `satellite-merge`, and the one place it was missed. `reel-go` is LATER
       * in the list, so its backwards fill asserted `opacity: 1` from the
       * page's start and won over `reel-in`, whose whole job is the rise from
       * 0. The strip was therefore fully opaque before its own range began and
       * ⚠️ THE RISE NEVER RENDERED IN EITHER ENGINE — exactly what the note at
       * `furniture-go` predicted `both` would do silently. Found 2026-09-30 by
       * the rebuilt `check:scenes`; the old check called this scene green. */
      animation: reel-in linear both, reel-go linear forwards;
      animation-timeline: --s-reel, --s-reel;
      animation-range: contain 14% contain 30%, exit 0% exit 8%;
    }
    @keyframes reel-go {
      from { opacity: 1; translate: -50% 0; }
      to   { opacity: 0; translate: -50% 0; }
    }
    /* ⚠️ THE CENTRING IS RESTATED IN BOTH FRAMES ON PURPOSE. `.reel` is centred
     * with `translate: -50% 0`, and an animation that writes `translate` REPLACES
     * that declaration rather than adding to it — the same trap that put the
     * square's top-left in the middle of the screen. Either the keyframes carry
     * the offset, as here, or the rise needs its own element. */
    @keyframes reel-in {
      from { opacity: 0; translate: -50% 14px; }
      to   { opacity: 1; translate: -50% 0; }
    }

    /* The enlargement TRAVELS: the clip playing moving along, not a selection.
     * Nothing here is selectable, which is right — the app refuses a hand
     * order, because the capture-time sort IS the blend. */
    .tile {
      animation: tile-playing linear both;
      animation-timeline: --s-reel;
    }
    @keyframes tile-playing {
      0%   { scale: 1;    }
      45%  { scale: 1.22; }
      55%  { scale: 1.22; }
      100% { scale: 1;    }
    }
    .tile:nth-of-type(1) { animation-range: contain 28% contain 38%; }
    .tile:nth-of-type(2) { animation-range: contain 36% contain 46%; }
    .tile:nth-of-type(3) { animation-range: contain 44% contain 54%; }
    .tile:nth-of-type(4) { animation-range: contain 52% contain 62%; }
    .tile:nth-of-type(5) { animation-range: contain 60% contain 70%; }
    .tile:nth-of-type(6) { animation-range: contain 68% contain 78%; }
    .tile:nth-of-type(7) { animation-range: contain 76% contain 86%; }
    .tile:nth-of-type(8) { animation-range: contain 84% contain 94%; }
  }
}

/* ═══ SCENE 5 — THE CUT ══════════════════════════════════════════════════
 * "We are missing a scene where we show the splitting of a clip (makes two from
 * one)" — Christian, item 7. Read off IMG_1778 and IMG_1779, not imagined.
 *
 * ⭐ THE PICTURE IS THE SCRUBBER, SO THERE IS NO SCRUBBER. The two boxes' SIZES
 * are the two lengths: drag the seam and the big half shrinks as the small one
 * grows. Everything else in the scene follows from that one fact, which is why
 * everything below is arithmetic on `--share` and there is no second number
 * anywhere that could disagree with it.
 *
 * 🔴 AND IT IS CONTINUITY, NOT AN EXCEPTION. The one control does not grow a
 * second control: the disc BECOMES the scissors for the length of the gesture
 * and goes back to being the disc, which is the app's grammar everywhere.
 * ⚠️ Law 4 is about the LOOK, not the film: the grade is baked at capture and a
 * take cannot be re-graded, and that is untouched here. The film's LENGTH is
 * another matter — it can be cut, and a half deleted, with no undo
 * (`RecordDisc.swift:44-46`, since b177). Nothing on this page may say the film
 * is immutable; what is immutable is what the square was pointed at.
 *
 * ⚠️ WHAT A READER WITHOUT THE SCROLL GETS is the moment before the cut: one
 * square, one runtime, the scissors under it and the X beside it. That is this
 * page's own grammar for a rest state rather than a concession — scene 2 rests
 * on FILM and not ROTOSCOPE, scene 6 rests on PIANO and not AIR. Every scene's
 * still is its FIRST frame, and the scroll runs it forward. */

.scene--the-split { --pin: 1.4; }

/* ── the two runtimes ────────────────────────────────────────────────────
 * ⚠️ NOT A LAW-2 BREACH, and the clearance is already on the record:
 * §The house constants allows a runtime because it is a fact about the WORK —
 * "a spec sheet is not" — and this is one of them becoming two. Do not remove
 * them believing you are enforcing law 2.
 *
 * ⭐ ONE PER HALF, AND EACH IS EXACTLY AS WIDE AS ITS OWN BOX. The app puts both
 * at the top of the screen because that is where its chrome's runtime lives;
 * the site has no chrome bar, and Christian's own words are "one per half", so
 * each number sits centred over the box it measures and travels with the seam.
 * It costs nothing — the row is the frame's arithmetic a second time — and it
 * means the label cannot be read against the wrong box.
 *
 * 🔴 THEY ARE COUNTERS, NOT WORDS, so they are TRUE at every seam position
 * rather than at the two the screenshots happen to show. `counter-reset` takes
 * an <integer>, a calc() in that position is rounded to one, and `--share` is a
 * registered number the scroll can tween — so `counter(len)` prints
 * `66 × share` and its complement, live, with no JS. The pair always sums to
 * the film's length, because that is what a cut does to one. */
.lengths {
  /* ⚰️ THE `--head` FLOOR IS GONE — `settle.md` Phase B, and it is OBSOLETE
   * rather than overridden. It read
   *
   *     top: max(calc(var(--head) + 1.5rem), calc(--centre - --gate/2 - --gap))
   *
   * and its job was "the runtime line never leaves the SCREEN, and never climbs
   * through the caption": `--head` is 6.25rem = 100px of viewport measured from
   * the top of a 100svh plane. ⚠️ INSIDE THE PHONE THAT FLOOR INVERTS. The natural
   * position in the device is 108.7px down a 756px glass at 1440×900, and the
   * floor would have asked for 100 + 24 = 124px — so a guard written to keep the
   * digits OFF the square would have pushed them 15px ONTO it, and harder the
   * smaller the device. ⭐ The guarantee it made is now made by construction
   * instead: the square's top edge is a FIXED fraction of the phone (0.3643 −
   * 0.8977/2/2.1674 = 0.1337 of its height), so the band above it is 101px on a
   * 348.8px split device and 55px on a 191px stacked one, against a row ~12px
   * tall. It cannot run out, because the room and the row are both fractions of
   * the same box. The caption is on the viewport plane and no longer shares a
   * dimension with this at all (../../PLAN.md item 22 part B).
   *
   * ⚠️ THE `translate: -50% -100%` STAYS, so `top` is still this row's BOTTOM
   * edge, one app gap above the square. `--gap` is the app's own 12.5pt now and
   * not the page's 23.4px (`30-composition.css` §THE DEVICE PLANE). */
  top: calc(var(--centre) - var(--gate) / 2 - var(--gap));
  translate: -50% -100%;
  width: var(--gate);
  display: flex;
  /* ⭐ THE FRAME'S SEAM, THE SAME WAY THE FRAME NOW COMPUTES IT — the remainder
   * of a declared `--gate`, never a second resolution of `--seam-gap`. It used
   * to be `gap: var(--seam-gap)` with the comment "the frame's gap, so the
   * numbers sit over their boxes", and that is still the whole point: the two
   * rows only agree while they derive the seam by the same route. §the frame
   * above carries the WebKit measurement that made the route matter. */
  justify-content: space-between;
  color: var(--cam-amber);              /* amber is the film's; red stays the camera's */
  /* 🔴 A FRACTION OF THE PHONE, NOT OF THE WINDOW — `settle.md` Phase B. It was
   * `clamp(0.78rem, 1.6vw, 0.95rem)`, 15.2px at 1440×900 inside a square 313px
   * wide and 12.5px at 390×844 inside one 171px wide: the digits got RELATIVELY
   * BIGGER as the phone got smaller, which is the opposite of what a label on a
   * device does. 2.5581cqw is 11pt of 430 — the one type size [R3] measured off
   * the app (the ring's name, `40-ring.css` §the name it says), borrowed here
   * rather than a second size invented for a second piece of text. ⏳ If the
   * app's own runtime is ever measured off a screenshot, this is the one line. */
  font-size: 2.5581cqw;
  font-weight: 600;
  font-variant-numeric: tabular-nums;   /* the app's `.monospacedDigit` */
  letter-spacing: 0.06em;
  line-height: 1;
}
.length { flex: 0 0 auto; text-align: center; }
.length::after { content: counter(len); }
.length--a {
  width: calc((var(--gate) - var(--seam-gap)) * var(--share));
  counter-reset: len calc(var(--total) * var(--share));
}
.length--b {
  width: calc((var(--gate) - var(--seam-gap)) * (1 - var(--share)));
  counter-reset: len calc(var(--total) - var(--total) * var(--share));
  /* Nothing to say until there are two of them: at `--share: 1` this is a zero
   * of zero seconds, and a second runtime on an uncut film is a lie the still
   * would tell.
   * ⚠️ This expression used to be shared with the X beside it (`.calloff`,
   * deleted 2026-09-22 — see the tombstone below), so it now stands alone.
   * It is still the right shape: it is the cut's own arrival, read off
   * `--share`, and nothing else on this line needs to agree with it. */
  opacity: clamp(0, calc((1 - var(--share)) * 12), 1);
}

/* ⚰️ `.calloff` — THE CUT'S X — GONE, 2026-09-22. ../../PLAN.md §"The
 * corrections of 2026-09-22" item 8: "THE CUT LOSES ITS X."
 *
 * ⚠️ IT WAS ASKED FOR, AND THE SAME PERSON HAS NOW ASKED FOR IT BACK OUT, so
 * this tombstone is here rather than a silent deletion. It arrived as the
 * ORIGINAL item 7 ("an X appears top-right to call it off") and it did what it
 * was asked to: an X on the runtime's own line at the picture's right edge,
 * fading in with the cut on the same `(1 - --share) * 12` expression the second
 * runtime still uses. Nothing failed. The scene simply reads better without a
 * second affordance competing with the disc, which is the app's own argument
 * everywhere else — the one control does not grow a second control.
 *
 * 🔴 THE CUT DOES NOT DEPEND ON IT. `--share` drives the split, the two
 * runtimes and the disc's press; the X only ever read that value, never wrote
 * it. Deleting it removes a picture, not a mechanism.
 *
 * Its `<span class="calloff">` went from layouts/story.yml in the same change.
 * `#icon-ui-close` STAYS in src/html/_sprite.html and is now uncalled — which
 * is not an oversight: `icon-ui-share`, `-play`, `-pause`, `-check` and
 * `-arrow-down` are uncalled too. That sprite is a curated table of the app's
 * own marks, not a used-symbol list, and its header says so. */

/* ═══ SCENE 6 — THE SOUNDTRACK ═══════════════════════════════════════════
 * "How the music works. Not in detail — what is shown is that there IS a
 * soundtrack and that it is PLAYED, not chosen."
 *
 * 🔴 THE ONE THING THIS SCENE MUST NOT SAY is that you pick a song. A music
 * library is refused on the record (docs/music.md: every route leads back to
 * Spark's dead end, and "choose music" is named in the app's own list of what
 * built the pile), and the refusal is what makes the feature possible at all —
 * a composition the app renders itself has no licensing problem to have.
 *
 * ⭐ So the shape is the app's: the RING is the instrument and the DISC is the
 * note (docs/variance.md). Six themes in the app's own order. Scrolling turns
 * the ring; it never opens a list.
 *
 * 🔴 THE GLYPHS ARE ROUND THE DISC, NOT IN A ROW BESIDE IT (Christian, item 6:
 * "the theme icons are centred above the dial, not off to the side"). What
 * stood here was a flex row — a ring with six dots on it, and the six WORDS
 * laid out beside it in a second element — which is a picture of a list, the
 * one thing this scene must not be. The ring above is the component; scene 5
 * differs from scene 2 in three lines of skin and in nothing else. */
/* 🔴 THE BAND AND THE RING SHARE ONE BUDGET, so they are sized together rather
 * than each on its own. `--band` is the room the soundtrack takes and the
 * ring's `--room` subtracts it, so the ring shrinks by exactly what the band
 * spends and the two cannot end up overlapping at some window nobody tried.
 * Measured at 1440×900: the app's proportion asks for a 97px ring, the room
 * allows 70 with no band under the square and 45 with one — which is why the
 * score's ring is visibly smaller than the dial's on a desktop, and why neither
 * is on a phone. The phone has the room the app assumes; the desktop does not.
 *
 * `--band` is its TRUE extent, written the way the band is actually drawn: the
 * gap it hangs by, four steps of score above the axis, two of the film's own
 * sound below, and the axis box itself. ⚠️ The first version left the gap out
 * and the ring's NAME landed 4px inside the band's bottom row — a budget that
 * is short is worse than no budget, because it looks deliberate. The 1.2 is the
 * air between the band and the name. */
.scene--the-score {
  /* 🔴 THE BAND'S UNIT IS A FRACTION OF THE SQUARE, NOT OF THE VIEWPORT —
   * ../../PLAN.md §"Follow the white rabbit". Christian: *"the soundtrack-grid
   * expands its grid columns too wide."*
   *
   * ⚰️ WHAT WENT: `--box: clamp(3px, 0.55vw, 5px)` with the band laid out by
   * `justify-content: space-between` across a container as wide as the square.
   * Both halves of that are viewport-driven, and in opposite directions: the
   * box hits its 5px ceiling at 910px of viewport and then never grows again,
   * while the container keeps growing with `--gate`. So the PITCH — which is
   * what the eye reads as the grid — is whatever is left over, and it gets
   * looser the wider the window. Measured, twenty columns: pitch 16.6px at
   * 1440 and 23.4px at 1920, against a 5px box. At 1920 the gaps are 3.7× the
   * ink and the band reads as scattered speckle rather than a soundtrack.
   *
   * ⭐ THE APP'S OWN NUMBERS, MEASURED OFF site/_proto/ios-screens/soundtrack.png
   * rather than chosen — the same discipline as the corner and the 0.09 axis
   * below. Autocorrelation of the axis row over the square (which spans
   * 310…1140 there, 830px): pitch 26.0px, box ≈19.5px, so
   *
   *     box   = 0.0235 × the square      gap = 0.0078 × the square
   *     pitch = 0.0313 × the square      (and the grid is SQUARE: --step = pitch)
   *
   * ⭐ AND THE COLUMN COUNT FALLS OUT OF IT, WHICH IS WHY content/story.json
   * GREW FROM 20 COLUMNS TO 29. At the app's density a band of the app's own
   * proportion is 29 columns wide: 29 boxes + 28 gaps = 0.90 of the square —
   * so the two boxes of air each side that ../../PLAN.md item 29 asked for are
   * now the ARITHMETIC's, not a separate rule. (0.10 / 2 = 0.05 of the square =
   * 2.13 boxes.) The levels are composition, exactly as that file says — a
   * drawing of a soundtrack, not a measurement of one — so extending the
   * drawing to the count the density implies is a content edit, and it is
   * flagged as one.
   *
   * ⚠️ THE FLOOR IS A FLOOR, NOT A CLAMP. 3px keeps the ink visible if the
   * square ever gets very small; there is deliberately no ceiling, because a
   * ceiling is precisely what made the pitch a leftover. */
  /* ⚰️⚰️ `--box` / `--col-gap` / `--step` MOVED ONTO `.soundtrack` ITSELF —
   * `settle.md` Phase B, and this was a SILENT BREAK rather than a scale error.
   * The band is inside `.screen-app` since Phase A, and `.screen-app` is NOT a
   * descendant of `.scene--the-score` — so these three never reached the element
   * that reads them. MEASURED at 1440×900 before Phase B: `.soundtrack`'s
   * `height: var(--box)` resolved to `auto` and every `.bar` drew at 0 × 0 with
   * all six box-shadows collapsed onto one point. The band was not mis-sized; it
   * was not drawn. ⚠️ A custom property declared on a SCENE cannot reach the
   * phone, in either direction. Anything the app draws belongs on the app's own
   * element or on `.screen-app`, and that is the general form of this port.
   * ⭐ The app's measured numbers (0.0235 box, 0.0078 gap, square grid) are
   * unchanged and the derivation is in §THE BAND'S UNIT, below. */
  /* ⚰️ `--band` IS GONE, 2026-09-22 — ../../PLAN.md item 6. It was the room the
   * soundtrack took UNDER the square, subtracted from the ring's `--room` so
   * the two could not overlap. The band is drawn ON the square now, so it
   * spends nothing: `var(--band, 0px)` falls back to zero at both call sites
   * (`--above` and `--room`) and the ring gets the whole bottom band back.
   * 🔴 THIS IS THE BUDGET ITEM 6 SAID IT WOULD FREE, and it is the whole of it
   * — see §THE SOUNDTRACK for what did NOT need equalising as a result. */
}

/* ═══ THE SOUNDTRACK — AND IT READS BOTH WAYS ════════════════════════════
 * ⭐ Christian, item 6: "upward from the axis is the added score; downward is
 * the video's own existing audio. That is a real two-sided reading, not
 * decoration." So it is drawn as two-sided, and the scene's whole claim is in
 * the asymmetry: the film came with its own sound, and the score is the thing
 * that is added to it.
 *
 * ⭐ IT IS ON THE SQUARE AGAIN — ../../PLAN.md item 6, and this is the THIRD
 * time this exact move has been made, so the history is here rather than in a
 * commit nobody will find. Marks along the picture's bottom edge came off this
 * page twice in one day: the orange run first ("there should also not be a
 * orange progress bar"), then the pale join marks once they were rendered —
 * both times because over a flat placeholder a row of marks reads as the
 * SCRUBBER the app refuses to have (§SCENE 4 carries that ruling in full).
 *
 * 🔴 THE RULING SURVIVES THIS MOVE, AND ITEM 6 SAYS WHY: it is about not
 * LOOKING like a scrubber, not about where the band sits relative to the gate.
 * A scrubber is one row, edge to edge, reading as a position in a duration.
 * This is two-sided — four steps of added score above an axis, two of the
 * film's own sound below — and a two-sided thing cannot be read as a playhead,
 * because there is nothing for the second side to mean. That asymmetry is the
 * scene's whole claim, and it is also what keeps this legal.
 * ⚠️ If it ever reads as a scrubber over REAL footage (it is a placeholder
 * today, which is exactly the surface that made the last two attempts fail),
 * this block's `top` is the one line to move — and §SCENE 4 is the thing to
 * read first, again.
 *
 * ⚠️ AND IT IS A DRAWING OF A SOUNDTRACK, NOT A MEASUREMENT OF ONE. The levels
 * are composition (content/story.json), like the satellites' x and y. */
.soundtrack {
  /* ── the band's unit, now declared where it can be read (see the tombstone in
   * §.scene--the-score). ⭐ A FRACTION OF THE SQUARE, which is what it always
   * claimed to be — `--gate` is the square's own side on the device plane
   * (`30-composition.css` §THE DEVICE PLANE), so these three expressions did not
   * change and their derivation from site/_proto/ios-screens/soundtrack.png
   * stands verbatim: box 0.0235 × the square, gap 0.0078 × the square, and the
   * grid is SQUARE so `--step` is the pitch in both axes.
   * ⚰️⚰️ THE 3px FLOOR IS GONE, AND A GATE CAUGHT IT — `settle.md` Phase B.
   * It was kept on the first pass with the note *"does not bind in either tested
   * arrangement"*, which was true and was the wrong question: `check:align`
   * sweeps the viewport from 62% to 140% of itself mid-scene, and at a 103px
   * square the floor DID bind — box 3px where the app's proportion asks for
   * 2.4px, and the measured pitch came out at **0.0348 of the square against the
   * app's 0.0313**. ⚠️ A PIXEL FLOOR INSIDE A CONTAINER-SCALED COMPOSITION IS
   * THE SAME FAULT AS THE CEILING THIS BLOCK'S OWN TOMBSTONE IS ABOUT: the box
   * stops tracking the square and the pitch becomes whatever is left over. The
   * old `clamp(3px, 0.55vw, 5px)` failed in one direction; this failed in the
   * other, and the honest answer to both is that the app's density is the only
   * thing here that may decide the pitch.
   * ⭐ WHAT THE FLOOR WAS FOR — "keep the ink visible if the square ever gets
   * very small" — is not lost in any case that exists: it could only bind below
   * about 128px of device width, and the smallest device this page draws is 191.
   * MEASURED after: `check:align`'s pitch assertion goes 4 FAIL → 0 at every
   * width it sweeps, both engines. */
  --box: calc(var(--gate) * 0.0235);
  --col-gap: calc(var(--gate) * 0.0078);
  --step: calc(var(--box) + var(--col-gap));
  /* ⭐ THE AXIS SITS 0.09 OF THE SQUARE UP FROM ITS BOTTOM EDGE — measured off
   * site/_proto/ios-screens/soundtrack.png, which is the app drawing this: the
   * square spans 310…1140 of that shot and the band's axis row lands at 1063,
   * which is 0.907 of the way down it. Written as a FRACTION OF `--gate` rather
   * than a pixel inset, for the same reason the corner is a fraction: it has to
   * be the same placement at every size.
   * ⚠️ The band draws four steps up and two down from this axis, so at 1440×900
   * (gate 353, step 8) its bottom row lands 16px inside the square's bottom
   * edge and its top row 64px inside. Both stay on the picture at every width
   * the `--box` clamp allows. */
  /* ⭐ TWO CONSTRAINTS, AND THEY AGREE — ../../PLAN.md item 26. Christian: the
   * band "should sit inside with at least one grid size padding left and right
   * and at least 3 on bottom."
   *   · the FRACTION says where the band's centre sits: 0.09 of the square up
   *     from its bottom edge, measured off soundtrack.png (item 6).
   *   · the FLOOR says how close its own drawn edge may come: never nearer the
   *     square's bottom than three grid units.
   * Different questions, so both are stated and `min()` takes whichever sits
   * higher. ⚠️ MEASURED before assuming they could coexist, because item 6 took
   * that 0.09 straight from the app: at 1440×900 the fraction asks for 444.7
   * and the floor allows 443.0 — 1.7px apart, so the floor binds by a hair and
   * the app's proportion survives. At 1920 and at 320 the fraction binds
   * outright. No viewport in the tested range makes them fight.
   *
   * `--band-below` is the band's own drawn extent under its axis: two steps of
   * the film's own sound plus half the axis box. Stated rather than guessed,
   * because those two shadow rows are what actually reaches furthest down. */
  --band-below: calc(var(--step) * 2 + var(--box) / 2);
  /* ⭐ THE FLOOR IS FIVE BOXES SINCE ../../PLAN.md ITEM 29, not three.
   * Christian, looking at item 26 rendered: "The grid still needs more
   * breathing room to the edge of rounded-video-box." A feel call, confirmed
   * the way item 26's own numbers were — on a screenshot clipped to the band,
   * with 3×/4×/5× rendered side by side. At 3 the shadow row sits in the
   * square's bottom CORNER CURVE, which is what reads as cramped and which no
   * straight-edge measurement shows.
   *
   * ⚠️ AND IT CHANGES WHICH CONSTRAINT GOVERNS, which item 26 was careful about
   * and this has to be honest about in turn. At 3 boxes the app's own 0.09
   * fraction still bound at two of five viewports (1920 and 320) and the floor
   * only just took 1440. At 5 the floor binds at ALL FIVE: the element's own
   * box sits a flat 40.9px clear from 1280 to 1920 and 24.5px on both phones —
   * 8.2 boxes either way, which is this floor's 5 plus `--band-below`'s own
   * 3.2 — so it is the floor's value everywhere, not the proportion's.
   * ⚠️ 8.2 BOXES IS THE ELEMENT, 5 IS THE INK. `--band-below` is the shadow
   * rows drawn beneath the axis and they are in no child's rect, so a DOM
   * measurement of this band reads 40.9 where a reader sees 24. Confirmed on
   * the rendered pixels at 1440×900: lowest lit row 24px above the square's
   * bottom edge, first column 9px in from its left. Quote the ink.
   *
   * The fraction is KEPT rather than deleted, because it is the app's own
   * measurement and it should win on a square bigger than anything here can
   * draw — but it is a ceiling now rather than the governing number, and
   * nobody should read this `min()` as a live tie. */
  top: min(
    calc(var(--centre) + var(--gate) / 2 - var(--gate) * 0.09),
    calc(var(--centre) + var(--gate) / 2 - var(--band-below) - var(--box) * 5)
  );
  translate: -50% -50%;
  /* ⭐ TWO GRID UNITS OF AIR EACH SIDE — ../../PLAN.md item 29, and the second
   * answer to one complaint. `--box` is the same unit the columns themselves
   * are drawn from, so the inset is one column of the thing it insets and
   * scales with the band rather than being a pixel someone picked; only the
   * multiplier has moved.
   *   · it was `min(var(--gate), …)` — EXACTLY the square's width, padL 0 /
   *     padR 0 at every viewport, so the band ran edge to edge and read as
   *     wider than the picture instead of sitting inside it;
   *   · item 26 made it one unit a side, which measured correctly and still
   *     read as a margin that had been asked for rather than one the eye could
   *     see — particularly against the square's rounded corners, where the
   *     outer columns of a band sitting this low are alongside the CURVE and
   *     not the straight edge;
   *   · item 29 is two. Measured after, both engines: padL/padR 10.0/10.0 at
   *     1440 and 5.9/6.1 at 400 — two boxes a side at every width, the split
   *     being sub-pixel centring on an odd content width.
   *
   * ⭐ AND SINCE §THE BAND'S UNIT (above) IT IS NO LONGER A RULE AT ALL — the
   * band is `fit-content` at the app's own pitch, so its width is 0.90 of the
   * square by arithmetic and the 2.13 boxes of air each side are what is left.
   * A stated width here would be a second source of truth for a number the
   * pitch already decides, and it is what let the pitch become leftover space
   * in the first place. */
  width: fit-content;
  /* Christian's own guard, and the shape he asked for: *"a fixed grid super
   * wide and just overflow:hidden on it so it keeps the design."* The grid is
   * fixed now, so this never binds at any width in the tested range — it is
   * here so that a square small enough to make it bind loses columns off the
   * ends rather than re-spacing the ones it has.
   * ⚠️ `overflow-y` MUST STAY VISIBLE. The band's whole picture is box-shadows
   * on `.bar`, four steps above the axis and two below; the element itself is
   * one box tall. `overflow: hidden` — or plain `overflow: clip` — would clip
   * the soundtrack off the soundtrack and leave a single grey row. */
  /* ⭐ THE SQUARE, NOT THE WINDOW — `settle.md` Phase B. It was `100vw − --rail-w
   * − 32px`: a bound against the page's own glass and its rail, neither of which
   * can reach inside the phone. The band is drawn ON the picture, so the picture
   * is the only thing it may not exceed, and that is what Christian's guard
   * actually asked for — *"a fixed grid super wide and just overflow:hidden on it
   * so it keeps the design."* It still never binds (the grid is `fit-content` at
   * 0.90 of the square); it is here so a square small enough to make it bind
   * loses columns off the ends rather than re-spacing the ones it has. */
  max-width: var(--gate);
  overflow-x: clip;
  overflow-y: visible;
  height: var(--box);
  display: flex;
  gap: var(--col-gap);
  justify-content: center;
  align-items: center;
}

/* ⭐ ONE ELEMENT PER COLUMN, AND THE BOXES ABOVE AND BELOW IT ARE ITS SHADOWS.
 * A box-shadow takes the element's own border-radius, so six offset copies are
 * six tiny rounded boxes for one element and no markup — and the count is data
 * rather than DOM: each shadow's alpha is `(--up − k)` clamped, so a column
 * with `up: 2` draws two and the other two are transparent. The alternative was
 * six <i>s per column and 120 elements on the page for a band 53px high.
 *
 * The element itself is the axis box: the film, which both sides are about. */
.bar {
  width: var(--box); height: var(--box);
  border-radius: calc(var(--box) * 0.3);
  background: #3A342C;
  --own: #6E6257;                         /* the video's own sound: no accent */
  /* Amber is the film's colour and this is the film's sound; red stays the
   * camera's. One accent at a time (§The house constants). */
  --score: var(--cam-amber);
  box-shadow:
    /* upward — the added score, and `--score-in` is how much of it has been
     * laid down by the time the scroll reaches this column */
    0 calc(var(--step) * -1) 0 rgb(from var(--score) r g b / calc(clamp(0, calc(var(--up) - 0), 1) * var(--score-in))),
    0 calc(var(--step) * -2) 0 rgb(from var(--score) r g b / calc(clamp(0, calc(var(--up) - 1), 1) * var(--score-in))),
    0 calc(var(--step) * -3) 0 rgb(from var(--score) r g b / calc(clamp(0, calc(var(--up) - 2), 1) * var(--score-in))),
    0 calc(var(--step) * -4) 0 rgb(from var(--score) r g b / calc(clamp(0, calc(var(--up) - 3), 1) * var(--score-in))),
    /* downward — the video's own existing audio. It was there before anything
     * was added, so it is not waiting on `--score-in`. */
    0 var(--step) 0 rgb(from var(--own) r g b / clamp(0, calc(var(--down) - 0), 1)),
    0 calc(var(--step) * 2) 0 rgb(from var(--own) r g b / clamp(0, calc(var(--down) - 1), 1));
}

@supports (animation-timeline: view()) {
  /* ⭐ THE SCORE IS LAID DOWN AS THE FILM PLAYS, column by column — which is
   * the one thing this scene has to say that a still cannot: it is PLAYED, not
   * chosen. Nothing moves; each column's amber simply arrives, so this sits
   * outside the motion gate with the ring's angle and for the same reason.
   * ⚠️ At rest `--score-in` is 1 (see @property), so the band is whole on an
   * engine that never runs this — the composition is finished, and the
   * animation only assembles it. */
  /* 🔴 `--s-score`, NOT `--scene` — Phase C, 2026-10-01, and the full argument
   * is at `50-motion.css` §THE SCOPED TIMELINES. One sentence of it: Phase A
   * moved this scene's furniture into `.screen-app`, `--scene` is declared on
   * `.scene--the-score` and resolvable only by that section's DESCENDANTS, and
   * an animation naming a timeline that does not resolve is INACTIVE rather
   * than broken — so `score-added` and `score-turn` were two of the 40
   * timeline-less animations `check:scenes` was red on, and the score scene
   * drew a flat band and a ring that never turned.
   * ⚠️ THIS FILE WAS OUTSIDE PHASE C'S WRITTEN LEASE and is edited anyway, said
   * out loud rather than folded in: Phase A's hand-off assigns these two lines
   * to Phase C by name and line number, the phase's own bar is `check:scenes`
   * 1 → 0, and the alternative was shipping Christian a score scene with its
   * two moving parts frozen. Nothing else in this file is touched. */
  .bar {
    animation: score-added linear both;
    animation-timeline: --s-score;
    animation-range: contain calc(56% + var(--i) * 2.4%) contain calc(66% + var(--i) * 2.4%);
  }
  @keyframes score-added { from { --score-in: 0; } to { --score-in: 1; } }

  /* ⭐ AND THE BAND AS A WHOLE ARRIVES AND LEAVES — ../../PLAN.md item 6: "Add
   * a scene-in/scene-out fade for the band as a whole… there's no matching fade
   * on exit today."
   *
   * `score-added` builds the COLUMNS in on entry, one after another, and is
   * about the score being laid down. This is a different claim: whether the
   * band is on the picture at all. Without it the band appeared with the scene
   * and then sat on the square for the rest of the page — the same "permanent
   * label" defect item 7 fixed on the mode name, one element over.
   *
   * ⚠️ NOW THAT IT IS ON THE PICTURE THE FADE IS LOAD-BEARING, not polish. The
   * gate is ONE square for the whole story (layouts/story.yml), so anything
   * drawn on it that does not leave is drawn on every scene after this one —
   * the reel, the cut, whatever comes next — and a soundtrack band over the cut
   * scene would be exactly the scrubber §SCENE 4 refuses. Under the square that
   * was impossible; on it, this animation is what makes the move safe.
   *
   * `entry`→`exit` rather than `contain`, because the claim is about the scene
   * being on screen at all, not about progress through it. The plateau is wide
   * so the band is simply THERE for the middle of the scene — it is a finished
   * composition at rest, not something mid-transition. */
  /* ⚰️ `band-fade` — GONE, 2026-09-22 (item 24 part A). It ran `entry 35% →
   * exit 65%`, which kept the band at full opacity through 504px of measured
   * TRAVEL, because `entry` and `exit` are exactly the phases where `.stage` is
   * not pinned. The band now takes the same `furniture-in`/`furniture-go` pair
   * as every other thing in the band. ⭐ Item 6's reason for having a fade at
   * all is unchanged and still load-bearing — the gate is ONE square for the
   * whole story, so a soundtrack band that never left would be drawn over every
   * later scene — it just has the right-shaped one now. */
  /* IDENTITY at both ends — never `none`, the same Safari rule as every other
   * keyframe block in this file. */


  /* The ring turns through the instruments. PIANO is at the tick at rest, as
   * the screenshots show, and four detents take it round to AIR — NONE stays
   * on the ring, because turning the score off is on the ring, but a scene
   * about there BEING a soundtrack does not stop on it. */
  /* `--s-score`, for the reason at `.bar` above. */
  .ring--score {
    animation: score-turn linear both;
    animation-timeline: --s-score;
    animation-range: contain 56% contain 97%;
  }
  /* IDENTITY-shaped, never `none` — Safari runs these on the scrolling
   * thread. And the name is `--turn` on purpose: declared on this element it
   * shadows the camera's for this subtree, so one set of ring rules serves
   * both and the score ring cannot inherit the dial's last angle. */
  @keyframes score-turn {
    from { --turn: 0deg; }
    to   { --turn: -240deg; }
  }
}

/* ⚠️ THE PHONE. `min(50vw, var(--centre))` already keeps the square inside a
 * narrow screen, so the composition does not need rebuilding — only the room
 * around it does: the words get closer and the satellites tuck in, so the
 * subject stays whole rather than the group being cropped. design.md's order —
 * mobile is the substance, desktop just gets more room. */
/* The words step up to leave the runtime line its own air — the only scene
 * with anything between the caption and the square. ⚠️ THE FLOOR DOES NOT STEP
 * UP WITH THEM, and that is deliberate: `--head` is "the caption still fits on
 * the screen", which does not become a smaller number because this scene has an
 * extra line under it. `.lengths` carries the matching `--head + 1.5rem` floor,
 * so when the band runs out the pair moves down together and keeps its 9px of
 * air rather than the runtimes climbing through the caption. */
/* ⚰️ THE SPLIT'S OWN `top` — GONE, 2026-09-22 (../../PLAN.md item 22 part B),
 * and it is OBSOLETE rather than overridden. It stepped this scene's caption up
 * by 1.5rem because the split is the one scene with something between the words
 * and the square — the two runtime numbers — and the pair needed its own air.
 * The words are in the BOTTOM BAND now, so they do not share a dimension with
 * `.lengths` at all and there is nothing left to step up from. `.lengths` keeps
 * its own `--head + 1.5rem` floor, which was always about the runtimes fitting
 * on screen rather than about the caption. */

@media (max-width: 720px) {
  /* ⚠️ THE PHONE KEEPS ITS OWN FRACTION RATHER THAN READING `--centre`, and
   * the two disagree on purpose. 42svh is the APP's proportion, read off
   * site/_proto/ios-screens/alignment.webp (0.41 of the screen) — the phone is
   * the case the app was drawn for, and `78vw` is what actually binds on a
   * modern handset anyway (min(312, 357) = 312 at 400×850). Taking `--centre`
   * here would shrink the one screen that has the room the app assumes.
   * The words' `max()` floor above is what covers the short phones this leaves
   * tight, and it covers them by letting the text read over the square — the
   * same answer the `#how` steps already give. */
  /* ⭐ AND SINCE ../../PLAN.md ITEM 28 A THIRD TERM: THE RAIL. The story is
   * centred on the GLASS now (§FULL-BLEED, at the head of this file), not in
   * the space beside the rail, so the rail's own column sits over one side of
   * the composition — and a square centred on the glass may therefore be no
   * wider than twice the room next to the rail. Same shape of derivation as
   * `--gate`'s desktop cap: bounded by the SHORTER half of the room its centre
   * sits in, because a thing reads as centred only when both of its sides are
   * there. ⚠️ `.radar` used to be the third example of this and is NOT any
   * more — Christian ruled on 2026-09-22 that it may bleed off the screen, and
   * on 2026-09-23 that it should (§THE RADAR), so it is sized off the
   * viewport's own height rather than the room beside the rail. The derivation
   * still holds for anything that must read as CENTRED; the radar stopped
   * being such a thing.
   *
   *     100vw − --rail-w − 2 × --gap
   *
   * ⭐ IT WAS `2 × --rail-w` UNTIL THE AXIS MOVED (2026-09-22, §THE PAGE'S ONE
   * VERTICAL AXIS). The doubling was the price of centring on the GLASS: a
   * square whose centre sits half a rail into the rail's own half of the
   * screen can only be as wide as twice the SHORTER half. On `--axis` both
   * halves are the same, so the cap is simply the room. MEASURED after: 390 →
   * 254 becomes 290, 360 → 224 becomes 280, 320 → 184 becomes 240 — the main
   * seller gets 36–56px back on every phone, which is item 15's complaint
   * answered by geometry rather than by a trade.
   *
   * `--gap` is the air, and it is the story's own — the same 12px the square
   * already keeps from its caption, so the square is no closer to the rail
   * than to the words. MEASURED, both engines: 400 → 312 becomes 264, 360 →
   * 281 becomes 224, 320 → 239 becomes 184. Without it, centring put the
   * square's left edge 11.9 / 16.3 / 15.2px UNDER the rail at those widths.
   *
   * ⚠️ IT IS STATED ONLY HERE, ON THE PHONE, BECAUSE ONLY HERE CAN IT BIND —
   * checked rather than assumed: at 721px (the narrowest width the desktop rule
   * covers) the cap would allow 569px against a `--gate` that is already 353,
   * and the margin only widens from there. A dead term in the desktop `min()`
   * would be one more number for the next reader to re-derive.
   * 🔴 It shrinks the main seller, which item 15 is the standing warning about
   * — so it is the arithmetic of a request rather than a tuning knob. Raise
   * the square back and it goes under the rail; that is the whole trade. */
  .story {
    --gate: min(78vw, 42svh);
    --gap: 12px;
  }
  .satellite {
    /* The group tucks in so the subject stays whole rather than being cropped
     * — see the head of this block. ⭐ IT IS `--spread` NOW RATHER THAN A
     * SECOND `left`: the base rule's clamp is stated once and this changes only
     * the number it clamps, so the phone cannot quietly opt out of the
     * containment the way a whole `left` declaration did. */
    --spread: 0.72;
    /* ⭐ AND THEY GET SMALLER, WHICH IS THE OTHER HALF OF "TUCKING IN". The
     * offsets are percentages of the square and the square grew by a rail on
     * this breakpoint (§the phone), so at 320 a 239px square in a 264px room
     * leaves 12px of outside — and the clamp above, doing its job, parked four
     * full-size tiles ON the picture. Three quarters of the size is what lets
     * them read as other phones AROUND the film rather than thumbnails over
     * it: measured at 320, the deepest intrusion onto the square goes from
     * 71px to 39px, and at 390 from 88px to 53px.
     * ⏳ IT IS STILL AN OVERLAP, and on the two narrowest phones it always will
     * be — there is no outside to be outside of. If that reads wrong to
     * Christian the lever is the square's own 78vw, not this number. */
    --w: calc(var(--gate) * var(--size) * 0.75 / 100);
    top:  calc(var(--centre) + var(--gate) * var(--y) * 0.72 / 100);
  }
  /* ⚰️ `.scene-words`'s phone `top` — GONE, 2026-09-22 (item 9). It restated
   * the phone's 12px gap as a literal beside `--gap`'s own declaration above,
   * which is the second copy of a number this file warns about elsewhere; the
   * base rule reads `var(--gap)` since item 9 and computes the same value here.
   * Left in place it would also have overridden the `max()` floor. */
  /* ⭐ The two rings need no phone rule at all, which is the tell that the
   * sizing is right: the square is 42svh here, so the room under it is the half
   * the app's own layout assumes and the app's proportion wins the `min()`
   * outright. It is the DESKTOP that is the cramped case. */
}
