/* ==================================================================
   THE DEVICE'S SCREENS — layout and type.
   Spec: docs/superpowers/specs/2026-07-31-site-device-screens-design.md

   SURFACES COME FROM kithe-web.css, which is generated from the app's
   own tokens. If a value has a token it is taken from there and never
   overridden — that is the one rule the app lane's handoff asks of us,
   and a guard in scripts/site-screens.test.mjs enforces it.

   This file carries what the generated skin does NOT: layout, type, and
   values with no token behind them.

   NAMES ARE ROLES, so identical rule bodies are expected and not sediment.
   .scr-value, .scr-rowtitle and .scr-li-name are byte-identical today
   (caption type, ink) and stay three classes on purpose: a value in a data
   row, a title in a settings row and a name in a list are three different
   things that happen to agree on one voice right now. Folding them into one
   shared class would make the next divergence a rename across the markup
   instead of an edit to one rule. Same principle one step weaker for
   .scr-hint / .scr-ghost / .scr-note: not identical bodies, but three roles
   agreeing on --k-color-faded because each is "settled, not up for choosing".
   ================================================================== */

/* 332x738. The WIDTH is the handoff board's own canvas, not a handset's: the
   board builds every screen 332 wide and displays it at scale(0.66), so
   building here at 332 means its composition and copy transfer with no
   re-scaling of type or shadow. The HEIGHT is not the board's. It was 700 —
   the board's own 462/0.66 — until 2026-08-07, which made the canvas 2.108
   where the captures this site replicates (docs/ui-kit/0.5.0/*.jpg, 1440x3200)
   are exactly 9:20. 738 is 332 * 20/9: the board's composition in the phone's
   proportion. Every board measurement quoted further down this file is still
   quoted against the BOARD's 700px canvas, which is correct — it is the
   mapping into this file's own body that had to be re-derived.

   Each screen is built at that size and scaled to the frame. The frame is
   235–306px wide on desktop and at least 100vw+46 on narrow, and the generated
   recipes carry absolute pixel shadows tuned for a real phone — so building at
   the frame's literal width would scale everything except the shadows.

   transform-origin is top left so the scale keeps .screen's own top:0; left:0
   anchor fixed inside .screen-stack, whatever the viewport — scaling from the
   centre would shift it down and right instead. What that anchor decides is
   where .device's own `overflow: hidden` cuts (the .device rule in index.html):
   the scaled screen is 332*scale wide by 738*scale tall, and .device clips
   whatever falls outside its own box. On desktop nothing does — --device-h
   derives from --device-w at 738/332, so the scaled screen and the frame are
   the same rectangle, to within offsetWidth's integer rounding. On narrow the
   frame is --device-beat-h, computed in index.html, and it is shorter than
   the canvas only where the viewport leaves no room — then the foot IS cut,
   and top left is what puts that cut at the foot rather than across the
   header. See "What a phone's frame actually crops" below for the measured
   bands. (--device-crop-h is a different, outer clip: it trims the whole
   .device-col against the viewport inside the narrow query. No
   transform-origin here changes it.)

   The two literals below do NOT read --screen-canvas-w / -h, despite naming
   the same numbers: tokens.css declares that pair (tokens.css, "The device")
   only so --device-h can derive the FRAME's height from the canvas ratio,
   and .screen's own box is written in px here. So 332 exists in three files
   that cannot see each other — this rule, tokens.css' --screen-canvas-w, and
   scaleScreens()'s `offsetWidth / 332` in index.html — and a guard in
   scripts/site-screens.test.mjs ("carries the canvas constant in three
   files") is what keeps them one number instead of three. */
.screen-stack { position: absolute; inset: 0; }

.screen {
  position: absolute; top: 0; left: 0;
  width: 332px; height: 738px;   /* the canvas itself, unscaled — 332 * 20/9,
                                    the captures' own 9:20; see tokens.css */
  transform: scale(var(--screen-scale, 1));
  transform-origin: top left;
  opacity: 0;
  transition: opacity var(--swap-in) var(--swap-ease);
  /* A screen that is off must not catch a tap meant for the rail behind it. */
  pointer-events: none;
}
.screen.is-on { opacity: 1; }

/* ---- Two faces, one screen --------------------------------------------
   Beat 0's loop switches from your card to Elena's "what they see" panel.
   That is a second COMPOSITION, not a second screen: one [data-screen]
   node, so the mechanism's "every screen a beat names, and no orphans"
   guard still describes the page, and setScreen() still has exactly one
   node to turn on.

   Every state the loop changes is TWO NODES crossfaded, never one node
   re-tinted. The toggles are the reason: their on/off recipes come from
   the generated skin (k-toggle-track2--on/--off) and differ in background,
   radius AND box-shadow layer count — three inset layers off, two on —
   which cannot interpolate anyway. Writing the colours into a keyframe
   would fork the skin this site copies byte-for-byte from the app.

   Everything the loop introduces rests HIDDEN. That is what makes reduced
   motion correct by construction FOR NODES INSIDE .screen: `animation:
   none` leaves today's card, not a half-played frame, and beat 2's
   near-copy of this card needs no matching edit to stop the address
   vanishing between beats 0 and 2.

   That construction argument stops at .screen's own boundary. Beat 2's
   .note and .arrival (below) live OUTSIDE it — siblings of .device-col
   under .stage, connected to the armed screen only through a :has() — so
   nothing here covers them "by construction"; the reduced-motion kill in
   index.html has to name them directly, and does
   (`.screen, .screen *, .note, .arrival, .arrival * { animation: none !important; }`).
   Shipped once without them: reduced motion left .note fading and
   .arrival sliding for the full six seconds while every .screen-scoped
   keyframe correctly went still. scripts/site-screens.test.mjs derives
   the kill's actual reach from every `animation:` shorthand in both
   stylesheets now, rather than asserting only what it already covers, so
   a future beat animating a node outside .screen fails the suite instead
   of shipping quietly. */
.scr-face { position: absolute; inset: 0; }
.scr-face--seen { opacity: 0; }

/* The typed values. --typed is the one that does not exist on the resting
   card (Home address rests as its "+ add" ghost); the Instagram value is
   already there and is hidden by the loop's first keyframe instead, so it
   reads normally with motion off. */
.scr-type { display: inline-block; }
.scr-type--typed { position: absolute; right: var(--sp-16); clip-path: inset(0 100% 0 0); }
.scr-row { position: relative; }

/* The number as it was, present only so the change has something to cross
   FROM. The new one is what rests visible, which is what keeps reduced motion
   showing the beat's own claim rather than its starting position. Positioned
   out of flow so the row's width is decided by the value that stays. */
.scr-was { position: absolute; right: var(--sp-16); opacity: 0; }

/* Two sub-lines and two toggles per changing row, stacked. */
.scr-sub { position: relative; }
.scr-sub--b,
.scr-sub--c { position: absolute; inset: 0; opacity: 0; }
.scr-toggles { position: relative; width: 40px; height: 24px; flex: none; }
.scr-toggles .scr-toggle { position: absolute; inset: 0; }
.scr-toggle--b { opacity: 0; }

/* The collapsible well. grid-template-rows 0fr → 1fr is the open/closed pair:
   no height to guess and nothing to retune if a row is ever added. min-height: 0
   on the child is not optional — a grid item refuses to shrink below its
   content's min-content height by default, so without it the track is 0fr and
   the well still stands at full height. */
.scr-open { display: grid; grid-template-rows: 0fr; overflow: hidden; }
.scr-open > * { min-height: 0; }
/* The field rows carry a toggle rather than an "Always" label, and a 24px
   control on a baseline-aligned row hangs below the text it belongs to. */
.scr-well--fields .scr-row { align-items: center; }

/* ---- Screen type is FIXED, because the canvas is ----------------------
   A screen is authored at a literal 332x738 — .screen's own width and height
   just above — and scaled to the frame as ONE unit by --screen-scale, which
   scaleScreens() in index.html computes as el.device.offsetWidth / 332 and
   sets on documentElement for .screen's transform to read. Scaling the whole
   canvas at once is what keeps the generated neu shadows in proportion
   instead of needing a second, hand-tuned set of them.

   Viewport-relative type breaks that, and not subtly. Take the page's lead
   role, --text-lead: its size is a clamp with a vw term in it, so on a screen
   rule the type inside the canvas resizes on its own, independently of
   --screen-scale. The canvas stops being a fixed thing being scaled and
   starts reflowing, and every measurement taken of it holds at one viewport
   width only. Beat 0 hit exactly this: two lead-sized lines in its header
   pushed the card 14px past .scr-body at any viewport 667px or wider, and
   .scr-body's overflow: hidden clipped the foot of the Sharing sheet.

   So type inside .screen takes FIXED pixel sizes, and takes them from the
   generated scale — kithe-web.css' --k-font-size-* and --k-font-line-height-*,
   which are the app's own numbers. The page's fluid roles outside .screen stay
   fluid; that is what they are for. scripts/site-screens.test.mjs enforces the
   rule so the next six screens cannot quietly reintroduce it.

   --scr-text-head is the head-face voice for screen content: 20px on a 24px
   line, at the board's own weight. Board 28 sets both the screen title and
   the name beside the avatar with one component at one size (NameTitle at 22,
   weight 800), and 20/24 is the nearest step the app's scale actually has. */
:root {
  --scr-text-head:
    var(--k-font-weight-black) var(--k-font-size-lg)/var(--k-font-line-height-md) var(--ff-head);

  /* The app's second voice, and the reason these two roles exist at all.
     Counted across its screen components, the app uses font('mono') 99 times,
     font('head') 13 and font('body') never — so nearly everything in this file
     being --text-caption is faithful, not lazy. The thirteen are not decoration
     though: container names (detail-parts.tsx:266) and every button label
     (Btn.tsx:19) are font('head', 'bold'). They are the things you act on, and
     they are where the app changes voice.

     This file used to uppercase both into the mono label voice. For container
     names that was argued ("uppercase like every other label in this file");
     for buttons it was never argued at all, which is how a decision becomes a
     habit. Owner ruled: match the app on both.

     SIZES COME FROM THE GENERATED SCALE, not the app's bespoke 13.5 and 12.5 —
     the same rule --scr-text-head already follows, and 14 and 12 are the
     nearest steps the scale actually has. The letter-spacings ARE the app's own
     numbers: 0.3 and 0.6 are far tighter than --tracking-label, which is a
     label's tracking and belongs to the voice these two just left. */
  --scr-text-cont:
    var(--k-font-weight-bold) var(--k-font-size-sm)/var(--k-font-line-height-sm) var(--ff-head);
  --scr-text-btn:
    var(--k-font-weight-bold) var(--k-font-size-xs)/var(--k-font-line-height-xs) var(--ff-head);

  /* The wide cap's heading voice — see .scr-tab2--wide below for why it left
     the label voice and why 16 rather than the app's 17. One step down from
     --scr-text-head, because a cap heading sits under the contact's name and
     must not compete with it: on the contact detail .scr-name is already
     --scr-text-head at 20, and two 20s stacked read as one heading broken in
     half rather than as a name with a page under it. */
  --scr-text-cap:
    var(--k-font-weight-black) var(--k-font-size-md)/var(--k-font-line-height-md) var(--ff-head);
}

/* ---- Screen furniture -------------------------------------------------
   Names are scr-* so nothing here can be mistaken for a generated .k-* class.
   .scr carries NO padding of its own: .scr-appbar/.scr-body/.scr-tabbar
   below are each absolutely positioned against .scr's own edges (top:0,
   top:36px, bottom:0 — the shared-shell block further down), so any padding
   here would shift all three by the same amount and put the appbar's own
   36px off true. Each block owns its own inset instead. */
.scr {
  position: absolute; inset: 0;
  display: flex; flex-direction: column;
  font: var(--text-caption);
  color: var(--k-color-ink);
}

/* ---- What a phone's frame actually crops -------------------------------
   RE-MEASURED 2026-08-07, after the canvas moved 332x700 -> 332x738. Every
   cell in this table was wrong at that point, and the phone rows were wrong
   twice over: the crop numbers were the old canvas's, and the frame that
   produces them had been rewritten underneath them. What follows is a fresh
   run in Chromium, not the old numbers adjusted by 38.

   This file used to carry --scr-window-top: 232px / --scr-window-bottom:
   520px, measured against the 390x867 canvas this plan's Task 1 replaced
   with 332x700 (and 2026-08-07 replaced again with 332x738, the captures'
   own 9:20). Checked what actually reads them before re-measuring onto the
   new canvas, rather than assuming the pair still mattered — and neither
   did:
   --scr-window-bottom had NO consumer anywhere in this file. Its one-time
   consumer was a fixed height on .scr-payload, which the comment on that
   rule (below) says was removed once .scr-body took over the clipping job.
   --scr-window-top's only consumer was a rule called .scr-head, which sized
   its own height to it — but .scr-head had no consumer either: `scr-head`
   matches nowhere in site/index.html (grepped, not assumed). git confirms
   why — commit 2bdb5f1 ("beat 0 is the real card builder") swapped beat 0's
   header markup to .scr-idhead's plain flex layout and left the old rule
   behind rather than deleting it. So both variables were dead — a variable
   whose only consumer is itself unused is exactly as dead as one with no
   consumer at all — and re-measuring either would have produced a fresh
   number nothing reads. Deleted instead, along with .scr-head, and replaced
   with what the frame's crop still really does, now that it has eight
   screens to describe instead of one.

   The crop itself is real: .device clips (overflow: hidden, index.html) and
   the frame's height is not always the canvas's, so when it is short the
   scaled screen runs taller than the frame and the FOOT is what goes missing
   — transform-origin: top left (top of this file) keeps the head anchored,
   never the reverse. Re-measured in Chromium (2026-08-07, Playwright) the
   same way the 2026-08-02 run did it: every one of the eight screens forced
   .is-on in turn, at each viewport, reading .device's and .screen's own
   rects back into unscaled canvas pixels. Forty measurements, and every
   screen agreed with every other screen at a given viewport — expected,
   since neither .device's box nor .screen's own box depends on which screen
   is loaded, but checked rather than assumed:

     viewport      visible band, canvas px      same for all 8 screens?
     1024x768      0–738  (full canvas)         yes
     1440x900      0–738  (full canvas)         yes
     1920x1080     0–738  (full canvas)         yes
     390x844       0–738  (full canvas)         yes
     360x640       0–663.3                      yes

   Two rows are "full" to within a pixel rather than exactly. 1440x900 reads
   739.1: --device-w is fractional there and the frame's derived height rounds
   a pixel OVER the canvas, which crops nothing. 390x844 reads 737.0 or 738.0
   depending on which scale the rect is divided by — --screen-scale comes from
   .device's integer offsetWidth, the frame's own box does not — so it is one
   pixel either side of the canvas, not a crop. Neither is worth a rule; both
   are recorded so a future re-run does not read them as drift.

   The two phone rows are the ones that moved most, and not only by 38px.
   Below 768px .device-col is `display: none` unless the beat is device-led
   (index.html's narrow band), so --device-h-narrow — which the old version
   of this comment named as the cause of the phone crop — no longer governs
   any frame a reader can see. What does is --device-beat-h, computed by
   sizeDeviceBeat() in index.html: it caps the frame at the canvas and, when
   there is not room for the whole canvas, cuts at the tab bar's TOP edge
   rather than through it. 390x844 is the first case (the whole canvas fits)
   and 360x640 the second (663.3 is the tab bar's top, 667, minus the
   rounding of a 0.75 scale) — the approved trade: crop the foot, never the
   type.

   Where each screen's own last real content sits (fixed canvas px, so
   viewport-independent — the same number at every row above):

     .scr-tabbar (card, edited, people, sharing,
                  two-copies, pause)                     667–738
     .scr-sharing (card, edited)                         651–759
     .scr-actions (card, sharing)                        619–659
     .scr-actions--foot (doors)                          647–687
     .scr-edit (two-copies)                              576–616
     .scr-actions--col (backup)                          400–488
     .scr-veil's own content (pause)                     255.2–482.8

   backup's pair moved 462.5–550.5 → 400–488 on 2026-08-17: its card now
   centres on the canvas rather than on the room under the title, to land on
   the same axis as the pause block the reader sees next. Re-measured, not
   derived. It clears the frame by more than it did, so nothing in the table
   below changes because of it.

   two-copies joined the tabbed list when its route was checked rather than
   assumed — it is people/[id].tsx, the same screen as sharing, and the two
   cannot wear different shells. Its .scr-edit moved 647–687 → 576–616 with
   no hand-editing: the button is pinned to the BODY's bottom, and the body
   is 71px shorter under .scr--tabbed. Re-measured, not derived.

   So the frame no longer cuts anything at four of the five viewports —
   including 390x844, where the tab bar used to be invisible on every
   tab-root screen. At 360x640 the tab bar (667–738) is gone whole — for
   two-copies now as well — and the last 23px of the one remaining
   bottom-pinned action row (doors, 647–687) goes with it: the frame shows
   the canvas to y=664 there, measured, which is where that 23 comes from.
   two-copies' own
   button no longer sits in that band, so the crop that used to take its
   foot now leaves it whole. The veiled screen still lands inside every
   crop, centred by its own flex rule (.scr-veil below).

   One row does not describe the FRAME at all, and is left here rather than
   dropped: .scr-sharing's own box runs to 759, past the canvas itself, on
   both card and edited. Both are tabbed, so it sits inside a .scr-body that
   ends at 667 and clips there — what the frame would do to it never comes
   up. Measured, recorded, not chased.

   None of this is fixed here — the shared shell's body budget (669 on a
   pushed route, 598 under .scr--tabbed) is set in the shared-shell block
   below — only recorded, so the next task that touches the shell inherits
   the fact instead of re-discovering it. (The 40 raw measurements this table
   collapses from live only in this comment: every one of the eight screens
   agreed with every other at a given viewport, so the other 35 rows would
   have repeated these 5 verbatim.) */

/* EVERY avatar is a ROUNDED SQUARE, at 0.22 of its own side.

   This rule said circle, and the reasoning was sound on the evidence it had:
   the handoff's hand-written skin says so for all of them at once
   (docs/kithe-v4-handoff/app/v4/skin-neu.css:91,
   `[data-k-skin="neu"] .k-avatar{ ...border-radius:50% }`), and since the
   generated .k-avatar carries only surface and shadow, the shape had to come
   from here. The first half of that is now the tell rather than the argument:
   the generated skin is built from the app's CURRENT tokens and deliberately
   carries no radius, because the app's component sets its own —
   src/components/avatar-metrics.ts gives 9/11/14 for dims 38/50/64 and states
   the family ratio for any size outside that set as 0.22 of the dimension.
   skin-neu.css is the 2026-07-16 snapshot, and it is what moved on.

   22% rather than three hand-copied numbers: a percentage radius on a square
   box resolves to 0.22 of each side, so --sm, --lg and the base all take the
   ratio without a per-variant value to keep in step, and a changed dimension
   carries its corner with it. The named 'sm' triple wants 9 where 22% of 38
   gives 8.36 — 0.64px, well under what --screen-scale can render.

   Still divergent and deliberately left: the DIMENSIONS. This file uses
   44/38/56 where the app's named set is 50/38/64. Only 'sm' agrees. Changing
   them moves layout on six screens, which is a different job from a corner. */
.scr-avatar {
  --scr-avatar-dim: 44px;
  width: var(--scr-avatar-dim); height: var(--scr-avatar-dim); flex: none;
  border-radius: 22%;
  display: grid; place-items: center;
  font: var(--text-caption); color: var(--k-color-faint);
  /* AFTER the shorthand, which resets font-size. 0.28 of the frame is the
     corner's sibling in avatar-metrics.ts, and the app applies it to every
     avatar: font('mono','regular') at that size, tracked 0.8
     (src/components/indicators.tsx:50). This file had a flat 12px for all three
     frames and, on --lg, the head face at 20px — Bricolage black where the app
     has mono regular, on the largest avatar the reel shows. */
  font-size: calc(var(--scr-avatar-dim) * 0.28); letter-spacing: 0.8px;
}
/* Uppercase because the board's NameTitle is (docs/kithe-v4-handoff/app/v4/
   k-kit.jsx: textTransform: 'uppercase'), and every name on the board — the
   card's own, and the ones in the people list still to be built — is that one
   component. Cased here rather than retyped in the markup, so the copy in
   index.html still reads "Sam Rivera" as a person's name is written. */
/* SENTENCE CASE, and the reason is worth keeping because this rule was right
   when it was written. The handoff kit's NameTitle carries
   textTransform: 'uppercase' (k-kit.jsx:390) and every rule here taking
   --scr-text-head copied it. The app's live component has none
   (src/components/typography.tsx:4-10) — it dropped the transform and the kit
   did not follow, because the kit is a 2026-07-16 snapshot that the
   device-screens spec already described as "205 commits behind src/".
   Reported from looking at the site: the name is all caps and the app's is not.
   src/ is ground truth. .scr-title and .scr-veil-head lose the same line for
   the same reason — it is one component, and half-fixing it would put a name in
   sentence case beside a title in caps. */
.scr-name {
  font: var(--scr-text-head); color: var(--k-color-ink);
}

.scr-payload {
  /* No fixed height here. Before Task 2's shell existed, beat 0 WAS the whole
     screen and a pair of window constants (since deleted — see "What a
     phone's frame actually crops" above) doubled as the sheet's own height.
     Now .scr-body is the one clip — 669px on a pushed route like beat 0,
     598px under .scr--tabbed, overflow: hidden in
     both cases; all of that is in the shared-shell block below. A second
     fixed-height clip here made it a window inside a window — 288px inside
     whichever body budget applied — and silently cut beat 0's Important
     container to nothing. Let this sheet size to its content and leave the
     clipping to .scr-body.
     overflow is deliberately not set to hidden either: with no fixed height
     it would never trigger, and leaving it in reads as "this box still
     clips," which is exactly the false impression that caused the bug. */
  margin: 0 var(--sp-16);
}
/* Flex, not the plain block Task 2 shipped: beat 7 (below) puts a toggle or
   a count on the SAME bar as the container name, and a block lays those out
   stacked underneath it rather than beside it. All eight screens carry
   .scr-bar now, and six of them only ever give it a single text child —
   beat 0's and beat 2's "Basics"/"Social"/etc., beat 4's and beat 11's letter
   dividers ("E", "J", "M"...), beat 8's container names, beat 10's "Your own
   cloud". A single flex child with no siblings is unaffected by
   justify-content (there is nothing to put space between), so this is
   additive for them, confirmed in Chrome after the change rather than
   assumed. The two that use the second slot are beat 5 ("They'll see /
   BASICS ONLY") and beat 7 ("Editing your card / 2 of 4 open"); the pair of
   .scr-bar-right rules further down is where they differ. */
/* The bar takes NO role, and its numbers are the app's own anyway.

   .k-kicker is the tempting mapping — "a short all-caps label above a block of
   content" is the kicker's own description and describes this exactly. It is
   still wrong: the app's Bar (src/components/Sheet.tsx:64) was never migrated
   to a role and hand-writes mono/bold 10 at ls 1.6. Only three files in the app
   consume roles at all. Taking .k-kicker here would be adopting a value the app
   does not use in this place and calling it fidelity.

   So these are hand-written, from Sheet.tsx, and that is a debt: when the app
   migrates Bar to a role, this should follow and stop being a second copy.
   Reported to the app lane rather than left as a silent divergence.

   Corrected 2026-08-06 from --text-caption (mono/REGULAR 12) at 0.14em, which
   was 1.68px of tracking on a size the app has never used here. It went
   unnoticed while the row labels beneath were also 12 — the whole bar/label
   relationship was uniformly too big, so nothing looked out of proportion.
   Adopting rowLabel at 8.5 shrank the labels and left the bar standing over
   them, which is what made it visible. Against 04-detail-you-share.jpg the app
   separates BASICS from PHONE by WEIGHT at nearly equal size; the site had been
   separating them by size at equal weight. */
.scr-bar {
  display: flex; align-items: center; justify-content: space-between; gap: var(--sp-8);
  font-family: var(--k-font-family-mono); font-weight: var(--k-font-weight-bold);
  font-size: 10px; line-height: var(--k-font-line-height-xs);
  letter-spacing: 1.6px;
  text-transform: uppercase; color: var(--k-color-muted);
  /* 7px, the app's own (Sheet.tsx, bar.paddingVertical) — the site had 8. */
  padding: 7px var(--sp-16);
}
.scr-row {
  display: flex; justify-content: space-between; align-items: baseline;
  gap: var(--sp-12);
  padding: var(--sp-8) var(--sp-16);
}
/* .scr-label and .scr-value have no rule here any more: the markup wears
   .k-row-label and .k-row-value, and the generated skin supplies the whole of
   their type and colour. What they used to say was --text-caption (mono 12) at
   --tracking-label (0.14em, which is 1.68px at that size) for BOTH — a label
   the same size as its own value, which the app has never done. The roles say
   8.5/0.8 for the label against 12 for the value, and that difference is the
   row reading as a row. detail-parts.tsx:283-284 is where the app applies
   them. */

/* ---- The shared shell -------------------------------------------------
   Two variants, both absolute bands filling the 738px canvas, per the
   2026-08-01 amendment to docs/superpowers/plans/2026-08-01-site-seven-screens.md
   (measured against the 31-screen board, not assumed): most of the app's
   screens are routes PUSHED above the tabs (src/app/(tabs)/_layout.tsx
   pushes /add the same way; you/card sits above its tab) and never had a tab
   bar to begin with. Only a tab ROOT shows one.

     root-level route (default, no modifier)  status 33 + appbar 36 + body 669
     inside (tabs)/ (.scr--tabbed on .scr)    status 33 + appbar 36 + body 598 + tabbar 71

   (Both bodies grew by the 38px the canvas gained on 2026-08-07: 631 -> 669
   and 560 -> 598. The chrome around them — 33, 36, 71 — did not move, so
   every number below that maps a board measurement into "our body" had to be
   re-derived against the new figure, not left as it was.)

   The board spends its first 33px on an iOS status bar. The site still does
   NOT draw one — a frozen 9:41 is a signal with no job here, and every screen
   already carries a real app bar with its own centred KITHE wordmark, which
   is the chrome that says "this is a phone". But it now RESERVES the band
   rather than handing it to the body.

   This reverses the previous note, which called the reserve "a strip of bare
   frame under nothing". It isn't under nothing — it is under the frame's own
   corner. Measured at 1440x900: --device-radius is 38px and the back
   chevron's corner landed 37px from the corner circle's centre, i.e. ONE pixel
   inside the curve. The arrow and the phone's roundover were touching. The
   band is what holds them apart, and it is the same space the real app spends
   on its status bar, so anything the OS puts up there lands where the site
   already shows nothing. Not drawing the clock and not reserving the room were
   always two separate decisions; only the first one still holds. */
.scr-appbar {
  position: absolute; top: 33px; left: 0; right: 0; height: 36px;
  /* Three slots, KITHE centred — the board's kit states this outright: back
     far left when qualified, logo centred, info-dot far right ("screen
     titles live as headlines in the body, not here" — the half this file
     already had right, .scr-title below). 1fr auto 1fr keeps the outer two
     columns equal width whether the left one holds a back chevron (pushed
     routes, this file) or, later, an equal-width empty spacer (tab roots) —
     either way the centre column, and so KITHE, never shifts. */
  display: grid; grid-template-columns: 1fr auto 1fr; align-items: center;
  padding: 0 var(--sp-16);
  font: var(--text-caption); letter-spacing: var(--tracking-label);
  text-transform: uppercase; color: var(--k-color-faint);
}
.scr-back { justify-self: start; }
.scr-logo { justify-self: center; }
/* There is no third child. The `i` came off every app bar on 2026-08-17: tips
   are switched off for this version (overlays.tsx:165, `TIPS_ENABLED = false`)
   and the dot was their only trigger, so chrome.tsx:22 reduced TipDot to a bare
   `<View style={styles.slot} />` — "no icon, no press — keeping the wordmark
   centred". No <Header> in the app passes a `tip`, so the slot is empty on
   every screen, and the site was drawing a control v0.5.0 does not have.

   Nothing replaces it. The grid is 1fr auto 1fr and the two remaining children
   auto-place into columns 1 and 2, so the auto column stays centred between
   two equal thirds whether or not anything occupies the third — measured after
   removal, KITHE's box is unmoved on all nine bars. An empty span on the right
   would be the debris this file's own spacer comment warns about; the LEFT
   spacer stays, because there the missing child would pull the logo into
   column 1.

   If tips come back — the comment in chrome.tsx says how — this returns with
   them. */
/* A tab root has no back chevron, but it still needs something in column 1:
   with only two children the logo is auto-placed into column 1 itself and
   KITHE goes hard left — the regression the 1fr auto 1fr grid above exists to
   end. So both tab roots (beats 4 and 11) carry an empty span in that slot.
   It is named, and named here, because an unclassed <span></span> in the
   markup reads as debris a tidy-up would delete; `grid-column: 1` states the
   one thing the element is for, rather than leaving its job to source order.
   scripts/site-screens.test.mjs asserts a tab root carries it and a pushed
   route does not. */
.scr-appbar-spacer { grid-column: 1; }
/* 33 + 36 + 669 = 738, the canvas. Both heights below grew by 38 on 2026-08-07
   when the canvas did — they are the canvas minus fixed chrome, so they are not
   free numbers and must move with it. */
.scr-body {
  position: absolute; top: 69px; left: 0; right: 0; height: 669px;
  overflow: hidden;
}
/* Inside (tabs)/: 33 + 36 + 598 + 71 = 738, the tab bar's own 71px taken back
   out of the root-level default above. */
.scr--tabbed .scr-body { height: 598px; }

/* The anchor — UI kit 0.5.0 §9, whose reference capture is the pause-sharing
   confirm. A screen whose entire content is ONE block centres it in the room
   under the title instead of stacking it against the heading.

   `margin-block: auto` on the CARD, not `justify-content: center` on the body.
   The first thing written here was the second, and it was wrong: a column flex
   with `justify-content: center` centres ALL its children as one group, so the
   title came down with the card. Measured in Chromium at 1440x900 before the
   fix — the title sat 167.5 canvas px from the top of the 631px body of the
   day (669 since 2026-08-07; the measurement is history, not a current
   reading) with zero gap under it, when the app has it at the top and the
   room split above and
   below the card. An auto margin on one child absorbs the free space around
   that child only, which is the actual shape: title fixed, card floating.

   Caught by looking, not by the guard below — which passed while asserting
   the mechanism that produced the bug. Worth remembering: a static test can
   see that a centring declaration EXISTS, never that it centred the right
   thing.

   A modifier rather than `[data-screen="backup"]`, because this is a shape the
   app now applies to a class of screens and beats 5, 8 and 9 are unbuilt —
   the next screen to need it should reach for the name, not copy a selector
   keyed to somebody else's data attribute.

   This is also the first real answer to the "40% bare screen below the card"
   question the device-screens spec left open: for a single-block screen, the
   empty room goes ABOVE and BELOW the block rather than all of it underneath.
   It answers that question for this one screen only — the multi-block screens
   still have it. */
/* The block centres on the CANVAS, not on the room left under the title.

   Owner, 2026-08-17: the card should sit "the same location with Sharing
   Pause the next screen". Beat 11's veil is `position: absolute; inset: 0`
   over the whole 738px canvas with `justify-content: center`, so its block
   centres on 369 — the canvas midpoint. Backup's card was centring in the
   room between the title's foot and the body's, which measured 425.5: 56px
   lower, and the two beats run back to back, so the block visibly dropped
   between them.

   Two things had to change for the two to agree. The body is inset 69px from
   the canvas top (status 33 + app bar 36) and runs to the canvas foot, so its
   own midpoint is 34.5px below the canvas'. Reserving 69px at the FOOT makes
   the body's content box 69–669, whose centre is exactly 369. And the title
   has to leave the flow, or it pushes the block down by its own height again.

   The same idea the app reached for on 2026-08-05, in the other direction:
   Btn.tsx exports ACTION_FOOTER_H so a footer-LESS screen can reserve the
   footer's room and land its content where a footered screen lands it —
   "which is why the backup card and the pause block did not agree, and the
   owner could feel it across the two screens". They did not agree here
   either, for the mirror-image reason. */
.scr-body--anchored {
  display: flex; flex-direction: column;
  padding-bottom: 69px;
}
/* The title keeps the top and is not part of what floats — out of flow, so
   the block below it centres on the body rather than on what it leaves over.
   Scoped to --anchored, which backup is the only screen to wear. */
.scr-body--anchored .scr-title {
  position: absolute; left: 0; right: 0; top: 0;
}
.scr-body--anchored .scr-payload { margin-block: auto; }
/* 1fr auto 1fr, the same three-slot grid .scr-appbar uses above — and for the
   same reason. space-around distributes equal space AROUND each item, so with
   three children of different widths (a PEOPLE pill, a round + disc, a YOU
   pill) the middle one lands wherever the outer two's widths leave it, not in
   the centre. Reported from a device: "the + is too far on the right" —
   measured 13px right of the midpoint between its neighbours. The add disc is
   the one control on this bar that must be dead centre: it is the only one
   that is not a destination, and the eye reads it as the bar's axis. */
.scr-tabbar {
  position: absolute; left: 0; right: 0; bottom: 0; height: 71px;
  display: grid; grid-template-columns: 1fr auto 1fr;
  align-items: center; justify-items: center;
  padding: 0 var(--sp-16);
}
.scr-tab {
  display: flex; flex-direction: column; align-items: center; gap: var(--sp-2);
  padding: var(--sp-8) var(--sp-16);
  font: var(--text-caption); letter-spacing: var(--tracking-label);
  text-transform: uppercase; color: var(--k-color-idle);
}
.scr-tab--on { color: var(--k-color-moss-text); }
/* The centre button is a disc, not a tab: the generated .k-tab-add paints it. */
.scr-tab-add { width: 46px; height: 46px; flex: none; display: grid; place-items: center;
  font: var(--scr-text-head); color: var(--k-color-moss-ink); }

/* ---- Beat 0 · your card (board 28) ------------------------------------
   Four containers, each a Bar over a sunk well of fields. The sheet carries
   the tint and the containers paint nothing — one box painting one colour,
   because two boxes painting the same colour disagree by a pixel and leave a
   seam (the app learned this the expensive way; see you/card.tsx). */
/* The screen title is a HEADLINE, not a caption. Board 28 sets it with the
   very same component, at the very same size, as the name beside the avatar
   (k-screens.jsx: <NameTitle size={22}>Your card</NameTitle>, then
   <NameTitle size={22}>{MY_CARD.name}</NameTitle>) — head face, ink,
   uppercase. So it takes --scr-text-head, the same voice .scr-name above
   takes, because one component on the board must not become two voices here.
   No letter-spacing: --tracking-label is the mono label voice (0.14em, wide
   enough to read as a kicker) and went with the caption it belonged to; the
   board's NameTitle tracks 0.01em, which is normal to the eye. */
/* Sentence case — see .scr-name for why the uppercase left. */
.scr-title {
  text-align: center; padding: var(--sp-8) 0 var(--sp-12);
  font: var(--scr-text-head); color: var(--k-color-ink);
}
.scr-idhead {
  display: flex; align-items: center; gap: var(--sp-12);
  padding: 0 var(--sp-16) var(--sp-16);
}
.scr-avatar--lg { --scr-avatar-dim: 56px; }
/* Type and colour from .k-hint in the markup; only the spacing is ours. */
.scr-you { margin-top: var(--sp-4); }
/* Board 28's third line under the name, a Hint below "this is you". Quieter
   than it (--k-color-faded, the same step down .scr-ghost's "+ add" takes)
   because it is a note about the avatar, not a fact about Sam. */
/* .k-hint supplies the type. The COLOUR stays overridden, deliberately: the
   role is --k-color-faint and this is a step quieter, for the reason just
   above. Kept rather than tidied away as a redundancy, and guarded, because a
   later sweep would read it as one. */
.scr-hint { color: var(--k-color-faded); margin-top: var(--sp-4); }
/* The fields well IS the input recipe — the app says so outright
   (src/app/(tabs)/you/card.tsx: fieldsWell takes input.rest, because every row
   in it opens the field editor and a fill-in surface wears the fill-in fill).
   So the well carries class="k-input" and this rule declares NO surface: it
   only reshapes corners.

   Every well's TOP is square. A Bar sits directly on each one, and a rounded
   top would leave two notches at its corners with the sheet's mine tint
   showing through — the third colour at the seam this block opens by warning
   about. The app squares them the same way, and only implicitly: fieldsWell
   sets borderBottomLeft/RightRadius and nothing else, so the top pair stay at
   React Native's default 0.

   The FOOT is a deliberate divergence. The app squares the LAST well's bottom
   too (fieldsWellLast) and lets the sheet's own corner draw the single curve
   there — it can, because its sheet clips (overflow: hidden). Ours does not:
   .scr-payload's overflow is left visible on purpose (see its rule above — a
   fixed-height clip there once ate a whole container), so a squared-off last
   well would poke out past the sheet's rounded corner instead of being cut by
   it. So every well including the last keeps a bottom radius of
   --k-radius-card, which IS the sheet's radius (kithe-web.css gives .k-sheet2
   and .k-sheet2--mine 20px, and --k-radius-card is 20px), and the two curves
   coincide and read as one — as long as the last well is the sheet's last
   child and runs edge to edge, which is board 28's shape and the shape any
   screen reusing .scr-well should keep. */
.scr-well { border-radius: 0 0 var(--k-radius-card) var(--k-radius-card); }
.scr-ghost { color: var(--k-color-faded); }
.scr-add {
  margin: var(--sp-16) var(--sp-16) 0; padding: var(--sp-12) var(--sp-16);
  font: var(--text-caption); letter-spacing: var(--tracking-label);
  text-transform: uppercase; color: var(--k-color-muted);
}
/* SHARING sits below the fold on your own card, because it does in the app.
   src/app/(tabs)/you/card.tsx puts this block LAST inside a single ScrollView
   with no pinned footer, so it is something you scroll to; 01-your-card.jpg,
   the 9:20 reference capture, does not show it at all — the tab bar cuts
   through ADD A FIELD, and everything after that is off screen.

   The site cannot scroll, so "below the fold" has to be drawn. Left in flow it
   started 651 into a body that ends 667 and rendered as 16px of a sliced green
   bar hanging above the tab bar — a rendering fault, not a screen that
   continues. It was invisible until 2026-08-07: the canvas moved from 700 to
   738, the body grew 629 -> 667, and the extra 38px pulled the top of this
   block into frame. A defect introduced by a fix.

   Why not make it fit: it ends 802 against a body ending 667, 135px over. Why
   not draw the app's own line that sits between the adder and this block ("You
   still choose who sees each field, person by person.", card.tsx): measured, it
   needs 398px on one line inside a 332px canvas, so it is always two lines, and
   two lines from the adder's foot overflow by 11px. Fitting it would mean
   shaving the app's stated marginTop of 12, and a screen edited to fit is the
   one thing this replica must not be.

   So it stays in the markup, where it belongs as a faithful part of the screen,
   and is not painted. Owner-ruled: crop where the app crops. */
.screen[data-screen="card"] .scr-sharing,
.screen[data-screen="edited"] .scr-sharing { display: none; }
.scr-sharing { margin: var(--sp-16) var(--sp-16) 0; }
.scr-rowtitle { font: var(--text-caption); color: var(--k-color-ink); }
.scr-rowsub { font: var(--text-caption); color: var(--k-color-faint); margin-top: var(--sp-2); }
.scr-toggle { width: 40px; height: 24px; flex: none; position: relative; }
.scr-toggle-knob { position: absolute; top: 3px; left: 3px; width: 18px; height: 18px;
  border-radius: var(--k-radius-full); }
/* The knob slides to the right when its toggle is ON — the app's own
   component does exactly this (docs/kithe-v4-handoff/components/
   kithe-components.css:25, .k-toggle[data-state="on"] .k-knob sets
   left: calc(100% - knob - 3px)). The neu skin restyles the knob's colour
   and shadow (skin-neu.css:74) but never touches that position rule, so it
   still applies. kithe-web.css only ever generates colour/shadow/radius —
   position is layout, this file's job — and had no on/off rule at all until
   now because every toggle built before this task (beat 0's "Pause sharing")
   was OFF; beat 7's Social toggle is the first ON one, and without this the
   knob would sit at the OFF position while the track alone read as green,
   undercutting the one thing this screen has to say at a glance. 19px is
   this rule's own arithmetic, not a token: 40px track − 18px knob − 3px
   inset, the same inset the OFF position already uses on the left. */
.k-toggle-track2--on .scr-toggle-knob { left: 19px; }

/* ---- Beat 4 · the people list (board 07) ------------------------------
   Board bands, unscaled: banner 81/49, search 142/42, INFO-NOTES 198/40,
   sheet 232 running off the foot. Minus the 33px status bar the site drops,
   those become 48, 109, 165 and 199 inside .scr-body. The sheet is NOT given
   a height: it overflows and .scr-body clips it, which is what makes the list
   read as continuing rather than ending. */
/* OVERRIDES .k-input's 20px corner (kithe-web.css) with the full radius.
   The board's own banner is not an input at all — it is .k-alertbtn, a raised
   panel at 16px under the neu skin (skin-neu.css:46) over a sunk 20px box
   unskinned (kithe-components.css:97) — and the generator emits no
   .k-alertbtn recipe (36 .k-* classes, none of them that one). So this
   borrows .k-input for the sunk-well surface it does emit, and takes the pill
   corner of the search field directly beneath it: the two sit 12px apart at
   the top of the same screen, and one pill above one rounded rectangle reads
   as a mistake at that distance. The divergence is deliberate — a raised,
   tappable alert button would promise a tap this static screen cannot honour. */
.scr-banner {
  display: flex; align-items: center; gap: var(--sp-8);
  margin: var(--sp-12) var(--sp-16) 0; padding: var(--sp-8) var(--sp-15);
  border-radius: var(--k-radius-full);
}
.scr-banner-txt { flex: 1; }
.scr-banner-head {
  font: var(--text-caption); letter-spacing: var(--tracking-label);
  text-transform: uppercase; color: var(--k-color-ink);
}
/* OVERRIDES .k-input's 20px corner (kithe-web.css) with the full radius, and
   this one is restoring a source value rather than choosing one: the board's
   search field is .k-search, which the real neu skin gives the same sunk well
   AND a pill — `border-radius:999px` (skin-neu.css:101). The generator emits
   no .k-search recipe, so the site wears .k-input for the surface and puts
   the pill back by hand. */
.scr-search {
  display: flex; align-items: center; gap: var(--sp-8);
  margin: var(--sp-12) var(--sp-16) 0; padding: 7px var(--sp-15);
  border-radius: var(--k-radius-full);
  font: var(--text-caption); color: var(--k-color-faint);
}
.scr-tabs2 { display: flex; gap: var(--sp-4); margin: var(--sp-12) var(--sp-16) 0; }
/* OVERRIDES .k-tab2--idle/--active's 20px corner (kithe-web.css) with
   `14px 14px 0 0` — and the override is right, because the generator and the
   real neu skin disagree here. skin-neu.css:105 is
   `[data-k-skin="neu"] .k-tab2{ ...border-radius:14px 14px 0 0 }`: a
   connected tab is a cap that FUSES down into the sheet below it, so its foot
   must be square. The generator flattens that four-value radius to the single
   20px it can express, which would round all four corners and float the cap
   free of its sheet. This is the one place in this file where a generated
   value is refused on the strength of the source it was generated from —
   everywhere else the generated number wins. */
/* Side padding is --sp-4, not the --sp-8 the other three edges get, because
   "WHAT THEY SHARE" wrapped to two lines by ONE POINT TWO PIXELS. Measured:
   the tab row is 332 - 2*--sp-16 = 300 wide, two flex:1 cells either side of a
   --sp-4 gap = 148 each, so --sp-8 both sides left 132 usable against 133.2 of
   text. Its sibling "WHAT THEY SEE" needs 115.5 and never wrapped, which is why
   only one of the pair looked broken.
   Halving the SIDE padding gives 140 — 6.8px of margin, enough to survive a
   font fallback, and it buys the same headroom for every label in this row
   rather than rescuing one string. The vertical --sp-8 is untouched: the cap's
   height is what fuses it to the sheet below.
   Not fixable by scaling the phone. .screen is authored at a literal 332px and
   transform:scale()d as one unit, so line breaks are decided at canvas width
   before the transform — a wider frame renders this same wrap larger. */
/* Flex-centred, not `text-align` alone, and the pair is why.
   .scr-tabs2 is a flex row, so align-items defaults to stretch and both caps
   already share a box — 40px tall, same top, measured. But stretching a BOX
   does not move the TEXT in it: each label kept its own line position under
   the top padding, so the 12px link and the 16px heading sat 4px apart (ink
   centres 276.0 and 280.0 at 1440x900). Equal boxes, misaligned labels, which
   reads as the pair wobbling against each other.
   Centring each cap's own content puts both labels on one line whatever sizes
   they are — which matters here precisely BECAUSE the two are deliberately
   different sizes now. */
.scr-tab2 {
  flex: 1; display: flex; align-items: center; justify-content: center;
  text-align: center; padding: var(--sp-8) var(--sp-4);
  font: var(--text-caption); letter-spacing: var(--tracking-label);
  text-transform: uppercase; color: var(--k-color-idle);
  border-radius: var(--k-radius-control) var(--k-radius-control) 0 0;
}
/* UI kit 0.5.0 §1a: the pair stopped being two equal caps.

   The proportion IS the message, and the reason is the pager. The two pages
   have swiped since 2026-08-01, so tapping is no longer the only way across —
   which is what freed one cap to become a heading and the other a link. Equal
   caps asserted the two were the same kind of thing, and they are not: one is
   where you are, the other is a door.

   The wide cap therefore leaves the label voice entirely. It is a heading, so
   it drops the uppercase and the label tracking that `.scr-tab2` above sets,
   and takes the head face in ink.

   SIZE COMES FROM THE GENERATED SCALE, not the app's own 17. The app's
   .k-sheet-title is 17px; --k-font-size-md is 16 and is the nearest step the
   scale actually has. That is the same rule --scr-text-head and --scr-text-cont
   already follow, and it is deliberate: the app's ten new type roles carry
   17, 10.5, 9.5 and 8.5, none of which are on the 12-40 token scale (the kit
   says so itself, §6's caveat). Adopting those roles wholesale is its own
   round — it needs design/generated/kithe-web.css to arrive first, which it
   has not: this worktree's copy is still the 36-recipe, 58-property version
   with no role classes in it at all.

   The small cap keeps the label voice unchanged — mono, uppercase, idle — and
   only stops growing. In the app it is 9.5px against the heading's 17; here it
   is 12 against 16, which is less contrast than the app has. That is the cost
   of staying on the scale, and it is the honest place to pay it: the ratio is
   a type-role question, and the type roles are the next round. */
.scr-tab2--wide {
  flex: 1;
  font: var(--scr-text-cap); letter-spacing: normal;
  text-transform: none; color: var(--k-color-ink);
}
.scr-tab2--small {
  flex: none; padding-left: var(--sp-12); padding-right: var(--sp-12);
}
.scr-list { margin: var(--sp-12) var(--sp-16) 0; }
.scr-listitem {
  display: flex; align-items: center; gap: var(--sp-12);
  padding: var(--sp-8) var(--sp-15);
}
.scr-li-txt { flex: 1; min-width: 0; }
.scr-li-name { font: var(--text-caption); color: var(--k-color-ink); }
.scr-li-meta {
  font: var(--text-caption); color: var(--k-color-faint);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.scr-pips { display: flex; gap: var(--sp-4); flex: none; }
/* OVERRIDES .k-chip--on/--off's 14px corner (kithe-web.css) with the full
   radius — and renders identically either way, on purpose. A 9px box cannot
   show a 14px corner: CSS scales the radii of an overflowing side down by a
   common factor (9/28 here), which lands both at 4.5px, exactly half the box,
   exactly a circle. Measured rather than reasoned (Playwright, Chromium, 4x
   device pixels, 2026-08-02): a screenshot of one pip with this declaration
   and with the chip's own 14px is byte-identical, while the same shot at 2px
   — the value the unskinned .k-pip carries, kithe-components.css:151 — is
   not, so the comparison can tell radii apart and simply finds these two the
   same.
   So this states the shape rather than inheriting a keycap's corner and
   trusting the clamp — the chip recipe is borrowed for its moss/panel COLOUR
   pair only. The board itself draws these as 17px outline glyphs
   (k-kit.jsx:342, Pips → ContainerGlyph); at a third of that size a glyph is
   a smudge, so the site says the same thing with a dot. */
.scr-pip { width: 9px; height: 9px; border-radius: var(--k-radius-full); }
/* Size only — the corner is .scr-avatar's own 22%, above, and being a ratio it
   follows this width without being restated. */
.scr-avatar--sm { --scr-avatar-dim: 38px; }

/* ---- Beat 7 · sharing, one contact (board 13) --------------------------
   The site's single most important build rule, made visible: only the
   fields the owner has opened travel to this one person. So the point of
   this screen is that OPEN and HIDDEN containers must both read clearly —
   Basics always on, Social partly on, Personal and Important fully hidden —
   not that the fields shown happen to look tidy.

   Rebuilt against the actual board source, not the design brief's own
   markup sketch: docs/kithe-v4-handoff/app/v4/k-screens.jsx, ScreenDetail's
   edit-mode branch (~line 468) and its DetailRow (~line 311). Two things
   the brief's markup got wrong, both because it collapsed a real row
   component down to a re-used .scr-bar:
   1. Each container (Basics/Social/Personal/Important) is its own ROW —
      name + a sentence-case SUBTITLE beneath it + a control on the right —
      not a single-line bar. Collapsing it to one line is where most of
      this screen's missing height went (measured: 376px of content in a
      body of the day — 664px, 669 since 2026-08-07 — against the board's
      own ~646).
   2. The subtitle is sentence case in the source ("always shared · 2 +
      photo", "2 of 3 fields on") — .k-cname is uppercase, .k-csub is not
      (kithe-components.css:75-76). Forcing it through .scr-bar's inherited
      uppercase is what truncated "hidden" to "hidd…" in the first build:
      an all-caps "4 FIELDS · HIDDEN" is measurably wider than sentence-case
      "4 fields · hidden" at the same point size, which is what pushed the
      row past its available width in the first place.
   "EDITING YOUR CARD · N of 4 open" is unchanged as .scr-bar — the source
   confirms it as a real Bar (k-screens.jsx:483), the sheet's own first
   child, not a separate pill floating above it. No .scr-toggle-knob or
   .scr-payload .scr-li-meta override survives from the first build:
   real rows have no ellipsis-truncated meta text left to guard against
   (that fix's job is gone with the class it was scoped to), but the
   ON-toggle knob-slide rule two sections up is still load-bearing — Social
   is still a real ON toggle. */
.scr-conthead {
  display: flex; align-items: center; gap: var(--sp-12);
  padding: var(--sp-8) var(--sp-16) var(--sp-12);
}
/* One row per container. 11px vertical, from the RUNNING APP —
   src/features/contacts/detail-parts.tsx, `groupRow.paddingVertical`.

   It was 15, and that was not careless: the v4 board's own arithmetic
   (kithe-components.css:72, `.k-row{ padding:var(--k-row-pad) }`, with
   --k-space-row-pad at 15px), carried over as --sp-15, the nearest token this
   file already had. The board was the best source available when this sheet
   was built. It is not the best source now — the app ships 11.

   The 4px is not a nicety. Four container rows on a sheet is 32px of the
   560px tabbed body of the day (598 since 2026-08-07), and beat 0's loop
   OPENS a container inside a sheet that clips, so the excess came off the
   bottom while the reader watched: the Birthday row sliced
   in half, the whole Important container never drawn. Measured at 1440x900
   with Personal open, the crop closed in four steps — 105 -> 39 (the
   always-shared well, which 05-detail-editing.jpg does not show), 39 -> 5
   (this line), 5 -> 3 (the bar's own 7px), 3 -> 0 (the tail's margins).

   A literal, not a token: site/tokens.css has no --sp-11, and inventing one
   would imply the site's scale has a step there. It does not — this is one
   borrowed measurement, like the 7px two rules up.

   Horizontal padding stays this file's own --sp-16, matching every other
   edge in this sheet (.scr-payload, .scr-well, .scr-row), rather than the
   source's 15px — a 1px difference not worth a second horizontal rhythm.
   No divider between rows: the neu skin actually used
   (docs/kithe-v4-handoff/app/v4/skin-neu.css:16) sets its hairline token
   to rgba(...,0) — fully transparent — so the real screen has no visible
   line here either; rhythm comes from padding alone, same as every other
   block in this file. */
/* gap is --sp-4 rather than --sp-12 because this row is space-between: the two
   children already sit at opposite ends, so the gap is only a MINIMUM
   separation and only bites on the one crowded row. Basics is that row — it
   carries an "ALWAYS ON" badge (~80px) where its three siblings carry a
   toggle, leaving its text column 300 - 2*--sp-16 - 12 - 80 = 176 against the
   180 "always shared · 2 + photo" needs. Short by 3.9px, so it wrapped, and the
   row stood at 80px against 64 for the other three — a matched set with one
   member visibly taller. --sp-4 gives it 184. */
.scr-cont {
  display: flex; align-items: center; justify-content: space-between; gap: var(--sp-4);
  padding: 11px var(--sp-16);
}
/* Container name: the app's head face, bold, sentence case — see
   --scr-text-cont for the full argument.

   This rule used to read the other way, and its reasoning is worth keeping
   because it was sound and still lost: "uppercase like every other label in
   this file, rather than adding a third size the rest of the reel does not
   otherwise use." What it did not weigh is that the app's own change of voice
   here is not typographic decoration — head-bold is how the app marks the rows
   you can act on, and flattening them into the label voice quietly deleted that
   distinction from every screen the site shows. Owner ruled to match the app.

   Sentence case falls out of dropping text-transform: the markup already says
   "Basics", not "BASICS". */
.scr-cont-name {
  font: var(--scr-text-cont); letter-spacing: 0.3px; color: var(--k-color-ink);
}
/* Sentence case, no tracking — the fix that keeps "hidden" from being
   measured as if it were "HIDDEN" (see the section comment above). */
.scr-cont-sub {
  font: var(--text-caption); color: var(--k-color-faint); margin-top: var(--sp-2);
}
/* "Always on" / "Always" — the one label that must survive at a glance even
   though it says the least (a field that is always shared has no decision
   left to show). Set in --k-color-faded, the same quiet step .scr-hint and
   .scr-ghost above already use for "this is settled, not up for choosing". */
.scr-always {
  font: var(--text-caption); letter-spacing: var(--tracking-label);
  text-transform: uppercase; color: var(--k-color-faded);
}
/* The hint between the sheet and the buttons — board 13's own copy
   (k-screens.jsx:494), not this file's beat-0 .scr-hint (different margin
   context: that one sits mid-block under an avatar, this one is its own
   full-width line under the sheet). */
/* Centred, like the app's. Every capture that carries one of these puts it on
   the centre line — 02's "This is an example to explore", 04's "This is
   exactly Sam's view", 05's "Toggle containers and fields", 06's whole body.
   This file ranged them left, and the giveaway was that .scr-listnote (added
   in the same pass, from the same capture) centred while .scr-note beside it
   did not: two captions on one screen disagreeing about their own voice.

   Deliberately NOT .scr-para. 07-backup.jpg keeps the paragraph inside the
   card ranged left, and the distinction is real rather than an oversight —
   .scr-para is reading copy that happens to be short, these are captions
   under a picture. Centring a paragraph would be a different mistake. */
/* .k-hint supplies type and colour — this IS the app's Hint, so it takes the
   role's --k-color-faint rather than the faded step .scr-hint keeps. Only
   placement is ours. */
/* --sp-8 above, not --sp-12: the sheet needs those 4px more than the gap does,
   and --sp-8 is already the rhythm the wells use inside it, so the tail still
   reads as separated. */
.scr-note {
  margin: var(--sp-8) var(--sp-16) 0;
  text-align: center;
}
/* Full-width, distinct from the Cancel/Preview pair below it — the one
   place to pause everything shared with this one contact
   (k-screens.jsx:495-498, "PAUSE SHARING" as its own Btn danger, the
   footer's ActionPair rendered separately below). k-btn-danger
   (kithe-web.css) is the raised surface; --k-color-danger-text is the
   generated text colour that recipe is meant to carry (kithe-web.css's
   --k-color-danger/-text pair), not a hand-picked red. */
/* --sp-12 above, not --sp-16, so the whole tail below the sheet steps at one
   rhythm: .scr-note 12, .scr-pause 12, .scr-actions 12. It used to run 12/16/16
   for no stated reason, and those two extra 4s were the last 8px keeping beat
   7's PREVIEW button — the screen's primary action, and the one its own hint
   line names — clipped by the frame. Side and bottom margins unchanged. */
.scr-pause {
  margin: var(--sp-8) var(--sp-16) 0; padding: var(--sp-12);
  text-align: center;
  font: var(--scr-text-btn); letter-spacing: 0.6px;
  color: var(--k-color-danger-text);
}
/* --sp-12 top, matching .scr-pause above — see the note on that rule. */
.scr-actions {
  display: flex; gap: var(--sp-8); margin: var(--sp-12) var(--sp-16) 0;
}
.scr-actions > * {
  flex: 1; text-align: center; padding: var(--sp-12);
  font: var(--scr-text-btn); letter-spacing: 0.6px;
}
.scr-btn      { color: var(--k-color-muted); }
.scr-btn--go  { color: var(--k-color-moss-ink); }

/* ---- Beat 8 · their copy of your card (board 12) ------------------------
   The spec's one deliberate divergence from board 12: the static state
   shows Elena Rivera's copy, not Mara's (beat 8 is the family/friend
   contrast and Elena is the family half), and hers is fuller than the
   board's own two containers — Basics, Social AND Personal — because the
   contrast with a casual contact (beat 7's Mara: Basics, and only PART of
   Social) is what the beat is making visible.

   Nothing new is needed for either the tab pair or the sheet: the caps are
   beat 4's own .scr-tabs2/.scr-tab2 with its two cells swapped idle/active,
   and the sheet is beat 0/7's .scr-payload + .scr-bar + .scr-well +
   .scr-row.

   CORRECTED 2026-08-06. This paragraph used to end "carrying the neutral
   k-sheet2/k-sbar because this is a RECEIVED card — k-sheet2--mine means
   'your own card', and painting a copy someone else holds as 'mine' would
   say the opposite of what this beat is for." That reads the beat's
   NARRATIVE correctly and the SCREEN wrongly, and 8338b7d reversed it on
   the owner's ruling against 04-detail-you-share.jpg: the app's own page
   here is `You share…` and paints it --mine, because the data is your
   outgoing card and filtering it to what Elena sees does not make it hers.
   The screen's own copy agrees — "Tap EDIT to change" is only true of a
   card you own. The markup has said --mine since that commit; this comment
   went on arguing the other way, which is how a comment starts lying.

   The old labels are recorded here too because they moved in the same
   round: "WHAT THEY SHARE / WHAT THEY SEE" both began "WHAT THEY", so the
   direction rested entirely on share-vs-see. UI kit 0.5.0 replaced them
   with a pair that flips the subject instead.

   The avatar/name row inside the sheet — SR, "Sam Rivera", "(their note,
   you can't see it)" — is the card OWNER's own identity, shown inside the
   "what they see" tab regardless of whose copy is open (the board's source,
   docs/kithe-v4-handoff/app/v4/k-screens.jsx:513, renders it once above the
   CONTAINERS loop, not keyed to the contact) — so it reuses .scr-conthead a
   second time on this one screen rather than a second class for the same
   avatar+name+meta shape.

   EDIT is a different piece of the source than everything above it: board
   12's own screen passes it as a FOOTER, not scrolling body content
   (k-screens.jsx:443, <Btn primary go>EDIT</Btn> given to <Screen
   footer={...}>), and the board measures it at 641–682 in the 700px canvas
   — the screen's own last 59px, a real gap below the hint (which ends
   around 409 once the 700px canvas is read the same way the rest of this
   screen's numbers are: minus the 69px the board spends on a status bar +
   app bar this site never draws, which lands EDIT's own bottom edge at
   682 − 69 = 613 into the body). That gap is the point, not filler: EDIT
   stays pinned near the foot whether the sheet above it holds two
   containers or three. A margin-top big enough to fake that from normal
   flow would only be right for THIS sheet's height and drift the moment a
   future edit changed it, so .scr-edit is positioned off .scr-body instead
   (already `position: absolute`, per the shared shell above) at a fixed
   distance from ITS bottom edge: 51px.

   Where 51 came from, and what the 2026-08-07 canvas move did to it: the
   body was 664 then, so 664 − 613 = 51 put EDIT at the board's own 613. The
   body is 669 now. `bottom: 51px` holds the distance from the FOOT, not from
   the top, so EDIT now lands 618 into the body — measured 647–687 in canvas
   px, 5px below where the board has it. Left as it is rather than re-derived
   to 56px: 5px is inside what this replica already trades away, and moving a
   pinned control is a composition decision, not a comment fix. Recorded so
   the next reader knows 51 is now a distance, not a derivation. */
/* Sentence case in the app's button voice, not this file's uppercase label
   habit. The owner already ruled on exactly this once — container names and
   button labels take font('head','bold') and keep their case, because they are
   the things you act on and are where the app changes voice. .scr-edit was
   written before that ruling and kept the label voice; 04-detail-you-share.jpg
   shows the button reading "Edit". */
.scr-edit {
  position: absolute; left: var(--sp-16); right: var(--sp-16); bottom: 51px;
  padding: var(--sp-12); text-align: center;
  font: var(--scr-text-btn); color: var(--k-color-moss-ink);
}

/* ---- Beat 5 · three doors (board 18: docs/kithe-v4-handoff/app/v4/
   k-screens2.jsx, ScreenAdd, the addMode==='show' branch) ----------------
   Two corrections against the task plan's own markup sketch, caught before
   any of this was built (2026-08-02 dispatch): board 18 carries no tab bar
   at all — /add is a route PUSHED above the tabs
   (src/app/(tabs)/_layout.tsx routes onAdd to /add with router.push), the
   same variant beats 0/7/8 already use, not a third tab-bar screen — and
   the code plate measures 220px on the board (top 125, ends 345 in the
   700px canvas), not the plan's first-draft 148.

   A third difference, found reading ScreenAdd itself rather than the plan's
   sketch: "THEY'LL SEE / BASICS ONLY" is not a standalone pill floating
   above the QR. It is the sheet's own Bar (k-screens2.jsx:37,
   `<Bar right="BASICS ONLY">THEY'LL SEE</Bar>` inside `<Sheet mine>`) — the
   exact component beat 0's "Basics"/"Social" bars already render here as
   .scr-bar. So this screen reuses .scr-bar rather than adding a new pill
   class, plus the one thing .scr-bar didn't yet need: Bar's own quieter
   right-hand label (kithe-components.css:246, .k-sbar-right sets --k-faint
   against .k-sbar's own --k-muted; the difference this rule carries over is
   only the colour step, matching how .scr-cont-sub/.scr-always already
   reuse a shared voice at a quieter colour rather than adding a new size). */
/* Sheet.tsx:66 — mono/medium 9.5 at ls 0.8, faint. Its own line, because the
   bar's right slot is a different voice from the bar's label and inheriting the
   label's bold 10 made them one. Same debt as .scr-bar above. */
.scr-bar-right {
  font-weight: var(--k-font-weight-medium); font-size: 9.5px; letter-spacing: 0.8px;
  color: var(--k-color-faint);
}

/* Beat 7's right-hand label is the one exception to both halves of the rule
   above, and the source is why. Board 18 passes Bar a plain STRING
   (k-screens2.jsx:37, `right="BASICS ONLY"`), so it renders through
   .k-sbar-right as written: uppercase, faint. Board 13 passes a styled NODE
   instead (k-screens.jsx:483, `right={<span style={{ ...color:K.muted }}>
   {openCount} of 4 open</span>}`) — sentence case, and one step up to muted,
   because it is a live count of what is open rather than a label naming a
   fixed thing. Both take .scr-bar-right for the shape; this modifier carries
   the two differences the source actually has.
   The uppercase being undone is .scr-bar's, inherited by every child: the
   same defect .scr-cont-sub was fixed for five lines down this same screen
   ("hidden" measured as if it were "HIDDEN"), which survived up here because
   this label sits in the bar rather than in a row. */
.scr-bar-right--count { text-transform: none; color: var(--k-color-muted); }

/* The code is DECORATIVE — a fixed 11x11 pattern checked into index.html
   (121 <i> elements, 68 on / 53 off, matched against the plan's own
   per-row counts before this was committed), never a real encoder and
   never a URL. A scannable code on a marketing page is a link nobody
   vetted, pointing at a relay that does not know this person. .k-qr
   (kithe-web.css) paints the plate's own tinted, sunk-adjacent surface and
   20px corner; this rule only sizes and centres it, the split every .k-*
   pairing in this file already keeps. */
.scr-qr {
  width: 220px; height: 220px; margin: var(--sp-16) auto;
  display: grid; place-items: center;
}
/* 176px, not the full 220: flush-to-the-edge cells would leave no plate
   visible at the rim, which the board's own code does not do. 3px gaps,
   not the plan's first-draft 2px — the plate grew from 148 to 220 and a
   flat 2px would have stayed the same absolute width while the cells
   around it grew ~60%, reading thinner relative to them than the
   original recipe intended. */
.scr-qr-cells {
  width: 176px; height: 176px;
  display: grid; grid-template-columns: repeat(11, 1fr); gap: 3px;
}
.scr-qr-cells i { background: var(--k-color-moss-ink); border-radius: 1px; }
.scr-qr-cells i.off { background: transparent; }

/* SHOW MINE / SCAN THEIRS: board 18 measures this pair at 641–682 in its
   700px canvas — 682 − 69 = 613 into the body (69 is the status bar +
   app bar the board spends before its body starts and this site never
   draws, the same figure .scr-edit's own comment above uses; against the
   664px body of the day, 664 − 613 = 51). That is not copied from .scr-edit
   — it is board 18's own measurement, independently landing on the same 51px
   because board 12's EDIT footer and board 18's action pair both end at
   canvas y=682. The 2026-08-07 canvas move took the body to 669 and left
   `bottom: 51px` alone, so this pair moved with EDIT and for the same reason
   — measured 647–687 in canvas px, 618 into the body. See .scr-edit's own
   comment above for why it was not re-derived to 56. Pinned
   to .scr-body's own bottom edge for the reason .scr-edit gives: the gap
   above the pair is real, not filler, and a margin sized to fit today's
   sheet height would stop fitting the day that sheet's content changes.
   .scr-actions already supplies the flex row, the gap, and (via
   `.scr-actions > *`) each button's padding and type — this modifier only
   swaps its normal-flow margin for a fixed position off .scr-body, zeroing
   the margin so it doesn't add to the left/right inset already set here. */
.scr-actions--foot {
  position: absolute; left: var(--sp-16); right: var(--sp-16); bottom: 51px;
  margin: 0;
}

/* ---- Beat 10 · backup & recovery (board 29: docs/kithe-v4-handoff/app/v4/
   k-screens2.jsx, ScreenBackup, the idle-mode branch at lines 482-495) -----
   Three corrections against the task plan's own markup sketch, caught
   before any of this was built (2026-08-02 dispatch):
   1. The plan's "Consumes" line names .scr-pill and .scr-foot. Neither
      class exists — no earlier task built them, they were only ever
      sketched. "YOUR OWN CLOUD" is not a floating pill either: it is the
      sheet's own Bar (k-screens2.jsx:485, `<Bar>YOUR OWN CLOUD</Bar>`, no
      `right=` prop), the exact component beat 0/5/7's containers already
      render as .scr-bar — so this screen reuses .scr-bar (and needs no
      .scr-bar-right, since board 29's Bar carries no right-hand label)
      rather than adding a pill class the source never has.
   2. The Sheet wrapping that Bar carries NO `mine` prop (k-screens2.jsx:484
      is a bare `<Sheet>`; Sheet's own definition, k-kit.jsx:293, only
      paints k-sheet--mine when `mine` is passed). So despite this screen
      being entirely about the owner's own card and contacts, it takes the
      NEUTRAL k-sheet2/k-sbar beat 8's two-copies screen already
      established — ground truth over the assumption that "the owner's own
      data" implies the mine tint. Task 8's doors screen, by contrast, DOES
      pass `mine` on its Sheet (k-screens2.jsx:36): the tint is a
      per-screen source fact, not a rule inferable from subject matter.
   3. There is no tab bar. Board 29 opens with a TopBar (onBack) — a route
      pushed above the tabs, the same variant as beats 0/5/7/8
      (src/app/(tabs)/_layout.tsx pushes /backup the way it pushes /add and
      /card) — not a third tab-bar screen. */

/* The paragraph under the Bar (k-screens2.jsx:487, `padding:'12px 14px'`,
   K.muted) is prose, not a label/value row — the one shape .scr-well
   already covers — so no existing class fit it; this is the one new class
   this screen needed. Padding takes --sp-12 (an exact match to the
   source's 12px) vertically and this file's own --sp-16 horizontally, the
   same substitution .scr-cont already makes for the source's 15px against
   this file's 16px rhythm (see .scr-cont's own comment above). */
.scr-para {
  padding: var(--sp-12) var(--sp-16);
  font: var(--text-caption); color: var(--k-color-muted);
}

/* Back up my data / Restore from backup stack vertically
   (k-screens2.jsx:492, `flexDirection:'column'`) — the first vertical pair
   this file has built; every ActionPair so far (Cancel/Preview,
   Show mine/Scan theirs) sits side by side, which is why .scr-actions
   needed a modifier here rather than gaining a new default that would
   silently turn those existing horizontal pairs into columns too. Gap
   stays .scr-actions' own --sp-8 rather than the source's 10px — a 2px
   difference not worth a second gap value in a file that already treats
   --sp-8 as its standard step between stacked controls. */
.scr-actions--col { flex-direction: column; }

/* A pair INSIDE a sheet has to close the sheet, and .scr-actions only carries
   a top margin — every other use of it sits loose in .scr-body, where the
   sheet's edge is not its problem. Measured at 1440x900: RESTORE FROM BACKUP's
   bottom edge and the sheet's own were the same 550.5, 0px apart, so the card
   read as padded at the head and cut off at the foot (owner, 2026-08-17).
   --sp-12 to match the margin above it, so the block sits centred in its own
   sheet rather than hung from the top of it.

   Scoped by `.scr-payload >` on purpose. The obvious place for this is
   .scr-actions--col, and that would have been wrong twice over: beat 11's
   pause block uses --col loose in the veil (which sets `margin: 0`), and
   beat 5's doors 1 and 2 use --col with --foot, whose `margin: 0` is
   declared EARLIER in this file and would have lost to it. */
.scr-payload > .scr-actions { margin-bottom: var(--sp-12); }

/* The space below the buttons is real, not unfinished. Board 29's own
   content ends at canvas y=373 of 700 (RESTORE FROM BACKUP measures
   332/41, so 332+41=373) — 373 − 69 = 304 into the body, using the same 69px
   status-bar-plus-header constant .scr-edit's and .scr-actions--foot's own
   comments above establish.

   RE-MEASURED 2026-08-07 (Playwright, 1440x900, this file's own built
   markup), because both halves of what used to be here had gone stale: the
   body is 669 now, not 664, and this sheet gained `margin-block: auto` (see
   the .scr-title comment above) after the 2026-08-02 run. It is centred now
   rather than sitting where the board's arithmetic put it, so the "~300px of
   664, ~364px of empty body" recorded here is no longer what the browser
   draws. The board arithmetic and the built screen no longer agree, and the
   centring is why; recorded rather than reconciled, because the point below is
   about the gap EXISTING, not about its exact size.

   RE-MEASURED AGAIN 2026-08-17, same rig. The 2026-08-07 figures (sheet
   300.5–550.5, room split 187.5/187.5) were true of a card that centred in
   the room under the title and had no padding under its last button. Both
   changed today — see .scr-body--anchored and `.scr-payload > .scr-actions`
   above — so what the browser draws now is: sheet 238–500, midpoint 369,
   which is the canvas midpoint and the same axis beat 11's veil centres on.
   The gap below is 238px and still deliberate.

   The app's real backup screen fills only ~44% of its canvas and
   does not bottom-anchor these buttons the way .scr-edit and
   .scr-actions--foot pin theirs. So
   .scr-actions--col is deliberately NOT given a bottom-pinned position —
   doing that here would draw a screen the app does not have. Do not add
   copy or stretch spacing to fill this gap if you find it again later;
   verified against k-screens2.jsx and measured in Chrome before this was
   committed (2026-08-02 dispatch). */

/* ---- Beat 11 · sharing paused (board 31: docs/kithe-v4-handoff/app/v4/
   k-screens2.jsx, HiddenOverlay, the `hidden && !peek` branch at
   lines 287-299) -----------------------------------------------------------
   The overlay is the WHOLE canvas, tab bar included — a state the app is
   in, not a sheet over one band. A pause that leaves the tab bar lit says
   "paused, but carry on browsing", which is not a full stop.

   .scr-veil is a SIBLING of .scr-appbar/.scr-body/.scr-tabbar inside .scr,
   not a child of .scr-body: .scr-body clips (overflow: hidden, the shared
   shell above) and sits ABOVE the tab bar only, so a veil nested inside it
   could never reach past its own 598px band to cover the 71px tab bar
   below. .scr itself is the right containing block for `inset: 0` to reach
   the full 738px canvas — it is already `position: absolute; inset: 0`
   (the shared shell's own .scr rule above), a positioned ancestor with no
   padding of its own for `inset: 0` to be offset against.

   What actually puts it ON TOP of the tab bar is its own `z-index: 2`
   below, not DOM order and not .scr's `display: flex`. `.scr-appbar`,
   `.scr-body` and `.scr-tabbar` (the shared shell above) are each
   `position: absolute`, same as `.scr-veil` here — none of the four is
   `position: static`. Absolutely positioned children are taken out of
   normal flow, and the spec excludes out-of-flow children from flex
   layout entirely: they are not flex items, so the flex-item z-index
   carve-out (which exists to give `position: static` flex items stacking
   power they would not otherwise have) never engages for any of them.
   What's actually in play is ordinary CSS2.1 positioned stacking: `.scr`
   is a positioned containing block; its three absolutely positioned bands
   carry no `z-index` (auto), so they paint at the same, unlayered level as
   normal content; `.scr-veil` carries `z-index: 2`, which lifts it into
   its own stacking context above all three, regardless of source order —
   and this has nothing to do with `.scr` being flex; it would behave
   identically as `display: block`.

   Verified in Chrome (2026-08-02): switching `.scr`'s own `display` from
   `flex` to `block` at runtime left `.scr-veil`'s and `.scr-tabbar`'s
   `getBoundingClientRect()`s pixel-identical (both unions unchanged) and
   the veil still visibly on top — the outcome the positioned-stacking
   explanation predicts and the flex-carve-out explanation does not (losing
   flex would have to matter to the carve-out; it provably didn't). An
   earlier pass of this comment moved `.scr-veil` to be `.scr`'s first
   child and called the result proof of a flex mechanism; that experiment
   only showed z-index beats DOM order, which is also true under ordinary
   positioned stacking, so it could not tell the two explanations apart.

   The markup still places .scr-veil after .scr-tabbar in the DOM (and
   scripts/site-screens.test.mjs still asserts that ordering) as a second,
   redundant line of defence: with z-index stripped in the same Chrome
   session, DOM order alone (`.scr-veil` last) was enough to put it back on
   top, and DOM order alone (`.scr-veil` first) was enough to put it fully
   behind. So DOM order is not idle, but it is not the reason this works
   today; the z-index above is.

   The task brief's own sketch put .scr-veil-head on --text-caption +
   --tracking-label — checked against the source instead: "Sharing paused"
   is `<NameTitle size={20}>`, the exact component .scr-title and .scr-name
   already carry as --scr-text-head (k-kit.jsx:389-391 — head-face, weight
   800, no letter-spacing beyond NameTitle's own flat 0.01em, which this file
   already treats as untracked). Board 31 measuring the line at 22px tall (a
   20px face on NameTitle's own 1.08 line-height, ≈21.6px) confirms it, not
   the 24px-line-height caption the brief guessed.

   This paragraph used to end by arguing that text-transform had to be written
   out beside the font — `font:` is a shorthand and cannot carry it — so that
   this head-face moment would not render in sentence case "against what the
   component does". The kit line it cited does say uppercase; the app's own
   component no longer does, and sentence case is now exactly what the
   component does. See .scr-name. */
.scr-veil {
  position: absolute; inset: 0; z-index: 2;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: var(--sp-16); padding: 0 var(--sp-24);
  text-align: center;
  background: color-mix(in srgb, var(--k-color-stone) 92%, transparent);
}
/* Sentence case — see .scr-name for why the uppercase left. */
.scr-veil-head {
  font: var(--scr-text-head); color: var(--k-color-ink);
}

/* The paragraph under the head (k-screens2.jsx:293): a plain div, K.muted —
   not the Hint component, so it does NOT take --k-color-faded the way the
   line below it does. The task brief's own markup names this .scr-foot
   twice, once here and once for the trailing note — the THIRD time this
   plan has named a class that was never built (task-9-brief.md named the
   same one; progress.md's "PLAN DEFECT" entry for Task 9 traces the first
   two). This is the first genuine use of the name: .scr-para (Task 9) does
   not fit — its own sp-12/sp-16 padding is sized for a paragraph sitting
   directly under a Bar inside a sheet, and stacking that padding on top of
   .scr-veil's own sp-24 side inset and sp-16 flex gap would pinch the text
   narrower and space it looser than the board shows. */
.scr-foot { font: var(--text-caption); color: var(--k-color-muted); }

/* The trailing note (k-screens2.jsx:296) IS the Hint component, so it takes
   .scr-hint as built for beat 0 — same component, same --k-color-faded,
   same bare --text-caption. Its own margin-top: --sp-4 stacks on top of
   .scr-veil's sp-16 gap for this one pair; a few extra px between the
   button and the note reads as intentional (the source itself gives that
   pair a wider gap than any other in the overlay) rather than a bug worth a
   third gap value in a file that otherwise leaves the flex gap alone. */

/* The button pair (k-screens2.jsx:294-295, two full-width Btns) reuses
   .scr-actions/.scr-actions--col/.scr-btn/.scr-btn--go verbatim (Task 9
   built the same column pair for "Back up my data" / "Restore from
   backup"). Both of .scr-actions' own defaults fight this context, though:
   its `margin` assumes normal document flow, and .scr-veil's own
   `align-items: center` (needed so the head/foot/hint text can be
   centre-narrower than the canvas) shrinks any flex child to its own
   content width by default, which would leave two half-width buttons
   floating side by side instead of one stacked full-width pair. Scoped to
   this one ancestor rather than a third .scr-actions modifier, since no
   other screen nests the action pair inside a centred flex column. */
.scr-veil .scr-actions { margin: 0; align-self: stretch; }

/* ==================================================================
   BEAT 0's LOOP
   Spec: docs/superpowers/specs/2026-08-04-site-beat0-loop-design.md

   ONE duration and percentage keyframes, all infinite, all on the same
   clock — no JS sequencing, so no two acts can drift apart. Declared
   only under .is-playing: an inactive screen has no `animation` property
   at all, so the browser has nothing to run, and replay is free.

   The act boundaries, and why each is where it is. Task 3 lengthened the
   loop from 10s to 12.5s and rewrote every percentage below so that not one
   act's milliseconds moved — the ms column is what is load-bearing, the %
   column is just where that ms lands on the new, longer clock:

        0 →  7.2%      0 →   900   Instagram types in
      7.2 →  9.6%    900 →  1200   hold
      9.6 → 11.2%   1200 →  1400   the Home address "+ add" ghost leaves
     11.2 → 19.2%   1400 →  2400   Home address types in
     19.2 → 23.2%   2400 →  2900   hold — THE CARD IS COMPLETE
     23.2 → 30.4%   2900 →  3800   the switch, over --swap-in
     30.4 → 36.8%   3800 →  4600   hold
     36.8 → 41.6%   4600 →  5200   Social ruled off
     41.6 → 47.2%   5200 →  5900   Personal ruled on
     47.2 → 51.2%   5900 →  6400   the well opens
     51.2 → 56%     6400 →  7000   HOME's own toggle turns on
       56 → 90.4%   7000 → 11300   hold — 4.3s, the frame to read
     90.4 → 100%   11300 → 12500   back to the resting card

   23.2% (2900ms) is where the switch starts, and that number is measured,
   not reasoned. On narrow the loop is armed at 1150ms and the phone leaves
   at HERO_HOLD_MS 4000ms: a visible budget of 2850ms, and the last frame
   with the phone actually on screen was measured in Chromium at loop
   2833ms. The card finishes typing at 2400ms, so a phone visitor now
   watches it sit COMPLETE for the ~430ms it has left, and the switch
   begins about 50ms after the phone has gone. Lengthening the loop to
   12.5s did not change either number, because every act kept its
   milliseconds — that is the whole point of doing it this way instead of
   picking new percentages by eye, and it is what
   scripts/site-screens.test.mjs's "keeps every act's milliseconds when the
   loop's length changes" guards.

   Two earlier drafts got this wrong by reasoning about it. The first ran
   the two typed fields to 3200ms, which would have taken the phone off
   screen mid-word. The second finished them at 2400 and started the switch
   at 2600, on the sentence "2600ms leaves 250ms of settled card before the
   exit" — but 2600 is where the switch BEGINS, so those 250ms were the
   crossfade, not a settled card. Measured at the last visible frame: card
   face 0.31, Elena's face 0.69, two app screens superimposed, illegible.
   Two numbers reasoned to and one measured; only the measured one held.

   THE CONSEQUENCE, so it is not rediscovered: a phone never sees Elena's
   panel. The switch and both rulings are wide-viewport acts. What narrow
   gets is the card filling itself in and then holding, complete — which is
   the honest half of the story to show in 2.85 seconds. The rejected
   alternative was lengthening HERO_HOLD_MS to ~4700ms so phones saw the
   whole switch; it costs 700ms before the site's thesis and was ruled
   against. Arming narrow earlier was also rejected: it would type the
   fields while the device was still rising, which is the motion-on-motion
   the arming seam exists to prevent.

   And the loop is slow on purpose. Over a third of it (4.3s of 12.5s) is
   the reading hold. On wide viewports this runs beside the site's opening
   sentence, and a cycle competing with a sentence is what the page's
   motion law exists to prevent. */
:root { --scr-loop: 12.5s; }

/* steps(), so the reveal reads as typing rather than as a wipe. clip-path
   percentages are font-independent — a width animation would need the
   rendered text width, which changes with --screen-scale. No caret: at
   scale 0.7 a 1px bar renders sub-pixel and reads as dirt.

   RECORDED, OWNER-RULED, LEFT AS IS (final review of the beat-2 handoff,
   2026-08-04): the RESTING state of .scr-type / .scr-type--typed /
   .scr-ghost--replaced — no .is-playing, so no `animation` property at all —
   is unclipped: the card draws @sam.makes and every other typed field
   complete, deliberately, so reduced motion (which never arms .is-playing)
   reads as a normal, fully-drawn card rather than one frozen mid-type. That
   resting frame is NOT this keyframe's 0% (`inset(0 100% 0 0)`, fully
   clipped) — so every arrival at beat 0 shows the same two-step reveal
   whether or not the reader has seen it before: the crossfade lands a
   complete card, ARM_CROSS (900ms) later .is-playing arms and the animation
   restarts from its own 0%, and the card blanks the same fields it just
   showed before retyping them. Measured identical on all three routes into
   beat 0 — from a stanza, from beat 4, from a veiled chapter opening —
   +860ms clip:none/opacity ~1 (a fully-drawn card), +940ms
   inset(0 100% 0 0) (blanked, retyping). The stanza route only makes the
   hold more conspicuous, because .is-on is retained across a stanza so the
   card pops in already at full opacity instead of fading in during the
   wait. Do not "fix" this by moving the 0% frame or shortening ARM_CROSS —
   both were deliberate, for reasons stated above and at ARM_CROSS's own
   declaration in index.html, and the owner ruled: leave the behaviour,
   correct the record. (The ledger that once called this latent and
   unreachable by ordinary scrolling was wrong — it is live on every
   arrival, and was already deployed when that was written.) */
@keyframes scr-type-in {
  0%     { clip-path: inset(0 100% 0 0); }
  7.2%   { clip-path: inset(0); }
  90.4%  { clip-path: inset(0); }
  /* Reset while the card face is still at opacity 0 (it fades back in from
     90.4%), so the empty card is what arrives, not a card being emptied. */
  90.41%, 100% { clip-path: inset(0 100% 0 0); }
}
@keyframes scr-type-in-late {
  0%, 11.2%     { clip-path: inset(0 100% 0 0); }
  19.2%, 90.4%  { clip-path: inset(0); }
  90.41%, 100%  { clip-path: inset(0 100% 0 0); }
}
@keyframes scr-ghost-out {
  0%, 9.6%      { opacity: 1; }
  11.2%, 90.4%  { opacity: 0; }
  90.41%, 100%  { opacity: 1; }
}

.screen.is-playing .scr-face--card .scr-type {
  /* @sam.makes is 10 characters — the convention is character count, same as
     scr-type-in-late's steps(16) below for "14 Ashgrove Road" (16 chars).
     Cosmetically invisible either way: clip-path steps are proportional to
     the element's WIDTH, not to glyphs, so an off-by-one here never showed. */
  animation: scr-type-in var(--scr-loop) steps(10) infinite;
}
.screen.is-playing .scr-face--card .scr-type--typed {
  animation: scr-type-in-late var(--scr-loop) steps(16) infinite;
}
/* --replaced, not .scr-ghost: the card face carries TWO "+ add" ghosts, Home
   address and Emergency contact, and the bare class took both. Measured in
   Chromium at 390x844, 360x640, 834x1194 and 1440x900 — from 1400ms to 8800ms
   of every loop, 7.4 seconds out of 10 (the loop was 10s then; the same
   1400→11300ms window is now 9.9s of the current 12.5s), the Emergency
   contact row rendered its label with nothing beside it, because nothing
   types in there to take the ghost's place. The act table moves the ghost a
   typed value replaces, and that is what the class now says. */
.screen.is-playing .scr-face--card .scr-ghost--replaced {
  animation: scr-ghost-out var(--scr-loop) linear infinite;
}

/* The switch. --swap-in is the page's own crossfade duration, and
   23.2→30.4% of the 12.5s loop is exactly that 900ms — unchanged from the
   10s loop's 29→38%, because Task 3 retimed every percentage so the
   milliseconds would not move. It starts at 2900ms (23.2%), not 2600ms,
   so that on narrow the phone leaves DURING the hold rather than during
   this crossfade; see the act table above. */
@keyframes scr-face-out {
  0%, 23.2%     { opacity: 1; }
  30.4%, 90.4%  { opacity: 0; }
  100%          { opacity: 1; }
}
@keyframes scr-face-in {
  0%, 23.2%     { opacity: 0; }
  30.4%, 90.4%  { opacity: 1; }
  100%          { opacity: 0; }
}
.screen.is-playing .scr-face--card { animation: scr-face-out var(--scr-loop) var(--swap-ease) infinite; }
.screen.is-playing .scr-face--seen { animation: scr-face-in  var(--scr-loop) var(--swap-ease) infinite; }

/* The two rulings. Both are a crossfade between the two nodes the markup
   already carries — never a re-tint, because the on/off recipes come from
   the generated skin and differ in shadow layer count as well as colour.
   36.8→41.6% and 41.6→47.2% (600ms and 700ms of the 12.5s loop, unchanged
   from the 10s loop's 46→52% and 52→59%): sequential, not simultaneous, so
   the eye follows one decision at a time rather than watching a row of
   switches flick. Both moved 300ms later with the switch; they are still
   600ms and 700ms wide. */
@keyframes scr-rule-off {          /* --b (the off node) comes up */
  0%, 36.8%     { opacity: 0; }
  41.6%, 90.4%  { opacity: 1; }
  100%          { opacity: 0; }
}
@keyframes scr-rule-on {           /* --b (the on node) comes up, later */
  0%, 41.6%     { opacity: 0; }
  47.2%, 90.4%  { opacity: 1; }
  100%          { opacity: 0; }
}
/* Social and Personal are named by POSITION here, not a new class. Inside
   .scr-face--seen's .scr-payload every direct child is a <div>, and
   :nth-of-type counts siblings of the same TAG, not the same class — so an
   index here is a count of DIVS, and any div that is not a .scr-cont still
   takes a number.

   Re-derived in the browser on 2026-08-17 and CORRECTED. This comment used to
   describe a seven-div sequence with an always-shared Name/Phone well sitting
   between Basics and Social, and concluded "Social is the 4th div, Personal
   the 5th". That well was deleted when Basics was collapsed to its summary
   line (see the markup's own note: 66px the app does not spend here, and the
   61px that pushed this face past its body). The selectors were corrected with
   it; the prose was not, and has been describing a layout the page has not had
   since. The live sequence, read off the rendered DOM:

     div 1  .scr-bar
     div 2  .scr-cont   Basics
     div 3  .scr-cont   Social      ← :nth-of-type(3)
     div 4  .scr-cont   Personal    ← :nth-of-type(4)
     div 5  .scr-open               the well Personal opens
     div 6  .scr-cont   Important

   The trap the old note was pointing at is still real and has just moved:
   `.scr-cont:nth-of-type(5)` reads like "the fifth container" and matches
   NOTHING, because div 5 is the well and is not a .scr-cont. Nothing below
   addresses Important; prefer a class over a position for any new row. */
.screen.is-playing .scr-face--seen .scr-cont:nth-of-type(3) .scr-toggle--b,
.screen.is-playing .scr-face--seen .scr-cont:nth-of-type(3) .scr-sub--b {
  animation: scr-rule-off var(--scr-loop) var(--swap-ease) infinite;
}
.screen.is-playing .scr-face--seen .scr-cont:nth-of-type(4) .scr-toggle--b {
  animation: scr-rule-on var(--scr-loop) var(--swap-ease) infinite;
}
/* The middle state: the container is on and nothing is shared yet. It is the
   only frame in the loop that says the container gate and the field switch are
   two decisions — which is exactly what the app's onCount does
   (src/features/contacts/detail-parts.tsx:77), and what a single "1 of 2" the
   moment the container flips would have quietly denied. */
.screen.is-playing .scr-face--seen .scr-cont:nth-of-type(4) .scr-sub--b {
  animation: scr-sub-interim var(--scr-loop) var(--swap-ease) infinite;
}
/* The --a nodes fade out as their --b partners arrive. Same keyframes, so
   the pair cannot drift; opacity is the only thing that differs. */
.screen.is-playing .scr-face--seen .scr-cont:nth-of-type(3) .scr-toggle--a,
.screen.is-playing .scr-face--seen .scr-cont:nth-of-type(3) .scr-sub--a {
  animation: scr-rule-on-out var(--scr-loop) var(--swap-ease) infinite;
}
.screen.is-playing .scr-face--seen .scr-cont:nth-of-type(4) .scr-toggle--a,
.screen.is-playing .scr-face--seen .scr-cont:nth-of-type(4) .scr-sub--a {
  animation: scr-rule-off-out var(--scr-loop) var(--swap-ease) infinite;
}
@keyframes scr-rule-on-out {       /* partner of scr-rule-off (36.8→41.6%) */
  0%, 36.8%     { opacity: 1; }
  41.6%, 90.4%  { opacity: 0; }
  100%          { opacity: 1; }
}
@keyframes scr-rule-off-out {      /* partner of scr-rule-on (41.6→47.2%) */
  0%, 41.6%     { opacity: 1; }
  47.2%, 90.4%  { opacity: 0; }
  100%          { opacity: 1; }
}

/* ---- The well opens, and one field is switched --------------------------
   grid-template-rows is the only thing in this loop that animates LAYOUT.
   Everything else is opacity and clip-path, deliberately. The cost, stated
   rather than discovered: layout on one 332x738 canvas subtree for 500ms
   opening and 1200ms closing out of every 12500ms — a 14% duty cycle over
   about eleven boxes. Taken because the alternative is a max-height magic
   number, and because a subtree this small on a cycle this slow is not what
   a phone's battery notices. */
@keyframes scr-open {
  0%, 47.2%    { grid-template-rows: 0fr; }
  51.2%, 90.4% { grid-template-rows: 1fr; }
  100%         { grid-template-rows: 0fr; }
}
/* The caret turns with the well it opens. detail-parts.tsx:100 —
   `transform: [{ rotate: expanded ? '90deg' : '0deg' }]` — and #k-i-chevron
   points RIGHT at rest, so a quarter turn lands it pointing DOWN, which is the
   only thing on the row that says which container's fields are the ones on
   screen. Beat 0 opened Personal's well for 43% of every loop with its caret
   still lying on its side; the reader was being shown an open drawer under a
   closed handle (owner, 2026-08-17).

   Identical stops to scr-open, not approximate ones: the handle and the drawer
   are one gesture in the app, where the same `expanded` drives both. */
@keyframes scr-caret-open {
  0%, 47.2%    { transform: rotate(0deg); }
  51.2%, 90.4% { transform: rotate(90deg); }
  100%         { transform: rotate(0deg); }
}
@keyframes scr-field-on {          /* the --b (on) node comes up */
  0%, 51.2%   { opacity: 0; }
  56%, 90.4%  { opacity: 1; }
  100%        { opacity: 0; }
}
@keyframes scr-field-on-out {      /* its --a (off) partner goes down */
  0%, 51.2%   { opacity: 1; }
  56%, 90.4%  { opacity: 0; }
  100%        { opacity: 1; }
}
/* Up when the container is ruled on, down again when HOME is switched on. */
@keyframes scr-sub-interim {
  0%, 41.6%    { opacity: 0; }
  47.2%, 51.2% { opacity: 1; }
  56%, 100%    { opacity: 0; }
}

.screen.is-playing .scr-face--seen .scr-open {
  animation: scr-open var(--scr-loop) var(--swap-ease) infinite;
}
/* Personal's caret — the same nth-of-type(4) every other Personal rule uses.
   Scoped to the svg, not the .scr-caret key, because the app rotates the icon
   inside the button and leaves the button itself square to the world. */
.screen.is-playing .scr-face--seen .scr-cont:nth-of-type(4) .scr-caret svg {
  animation: scr-caret-open var(--scr-loop) var(--swap-ease) infinite;
}
.screen.is-playing .scr-face--seen .scr-well--fields .scr-toggle--b,
.screen.is-playing .scr-face--seen .scr-cont:nth-of-type(4) .scr-sub--c {
  animation: scr-field-on var(--scr-loop) var(--swap-ease) infinite;
}
.screen.is-playing .scr-face--seen .scr-well--fields .scr-toggle--a {
  animation: scr-field-on-out var(--scr-loop) var(--swap-ease) infinite;
}

/* ==================================================================
   BEAT 2 · THE CHANGE ARRIVES ON SOMEONE ELSE'S PHONE
   Spec: docs/superpowers/specs/2026-08-04-site-beat2-handoff-design.md

   6.1s per cycle, looping, after a 2s read delay. Beat 0's loop repeats
   because it is a demonstration you can join at any point; this loop repeats
   for a different reason — the event it shows is one-way, and phone B's own
   exit (below) is what makes replaying it possible without un-happening it.

      0 →  8.2%     0 →   500   B slides in
    8.2 → 80.3%   500 →  4898   the cycle proper — both phones present; A types, B follows
   80.3 → 88.5%  4898 →  5399   B slides out
   88.5 → 100%   5399 →  6100   A's rows dip, reset unseen, and come back on the old values

   --scr-read is the 2s the reader spends with phone A alone and the note
   still up, before the first cycle starts. Every cycle after the first runs
   the table above back to back, with no gap between one 100% and the next 0%.

   The arrival is DESKTOP's. The room it needs is the note's column, and
   the tablet band has no third column at all. Both band queries hide it. */
/* One cycle. 6.1s carrying twice the content the 6s one-shot carried, bought by
   overlapping phone B's flips against phone A's next edit (see the act table in
   the spec) rather than by rushing either.

   --scr-read is the delay before the FIRST cycle. .arrival occupies the note's
   column, so a permanently-present phone B means a permanently-hidden note —
   and beat 2's note is "No re-sending. No 'hey, new number'.", the claim the
   beat demonstrates. Two seconds is the reader arriving and reading it with
   phone A alone, exactly as on every other beat; then it yields, B slides in,
   and the loop runs for as long as they stay. Letting the note return during
   each rest was rejected: 700ms per cycle is a blink. */
:root { --scr-handoff: 6.1s; --scr-read: 2s; }

/* ---- Phone A types -----------------------------------------------------
   clip-path with steps(), the same mechanism as beat 0's scr-type-in: the
   percentages are font-independent, where a width animation would need the
   rendered text width and that changes with --screen-scale.

   DELETING is the same keyframe backwards. Values are right-aligned
   (.scr-was and .scr-value both sit at right: --sp-16), and inset(0 X% 0 0)
   clips from the right — which is the direction backspacing shortens text.

   Every keyframe below ends at 100% exactly where it began at 0%, so the cycle
   has no seam. The state each row is left in after its change is held until
   91.8%, then reset at 93% — under cover of the row dip in scr-a-reset, which
   runs while phone B is off screen. */
@keyframes scr-a-phone-del {
  0%, 12.3%    { clip-path: inset(0); }
  17.2%, 91.8% { clip-path: inset(0 100% 0 0); }
  93%, 100%    { clip-path: inset(0); }
}
@keyframes scr-a-phone-type {
  0%, 17.2%    { clip-path: inset(0 100% 0 0); }
  30.7%, 91.8% { clip-path: inset(0); }
  93%, 100%    { clip-path: inset(0 100% 0 0); }
}
@keyframes scr-a-addr-del {
  0%, 35.2%    { clip-path: inset(0); }
  40.2%, 91.8% { clip-path: inset(0 100% 0 0); }
  93%, 100%    { clip-path: inset(0); }
}
@keyframes scr-a-addr-type {
  0%, 40.2%    { clip-path: inset(0 100% 0 0); }
  50.1%, 91.8% { clip-path: inset(0); }
  93%, 100%    { clip-path: inset(0 100% 0 0); }
}
/* The reset, and the only thing that hides it. Two rows out of eleven dip —
   the same restraint beat 8 uses, where only the differing nodes cross and
   everything else holds still — while phone B is sliding out and the reader's
   eye is following it. */
@keyframes scr-a-reset {
  0%, 88.5%    { opacity: 1; }
  91.8%, 95.1% { opacity: 0; }
  100%         { opacity: 1; }
}
/* ---- Phone B receives ---------------------------------------------------
   B crossfades where A types, and the difference is the point: A is being
   edited by a person, B is being updated by the relay. A typing animation on
   B would claim Elena retyped Sam's number herself.

   Each flip starts AFTER A has finished the matching field — 32.8% against A's
   30.7%, 54.5% against A's 50.1%. Cause, then effect. The points between them
   are the beat's whole argument.

   The address lag was 1.5 points until 2026-08-17 and is 4.4 now. That is a
   consequence of the handover below being longer, not a second decision: the
   number's own change does not settle until 47.5%, and starting the address at
   51.6% would have overlapped the two. Wider is not worse here — cause before
   effect is the claim, and more air between them states it more plainly.

   The reset sits at 90%, which is inside the window where B is off screen
   (out at 88.5%), so B needs no dip of its own. */
/* HANDOVER, NOT CROSSFADE — changed 2026-08-17, owner: "the old info on the
   small phone should actually disappear before it's replaced with the new one.
   Right now it's too fast, we hardly see the changes."

   Both values sit in the same cell (.scr-was and .scr-value are both pinned
   right: --sp-16), so a crossfade puts two strings of digits on top of each
   other for the whole of it. At the old 451ms that overlap was most of what
   the reader saw: not a number changing, a number smudging. Nothing was
   mistimed — the two keyframes were exact mirrors, which is precisely what
   made them wrong.

   So each field now runs OUT · EMPTY · IN, 300ms apiece, and the empty middle
   is the point: for 300ms the row shows its label and no value at all, which
   is the frame that says the old one is gone. 900ms a field against 451ms.

   ms are into the 6.1s handoff, which itself starts --scr-read (2s) after the
   beat arms — so add 2000 to compare against anything measured off the page.

     %          ms      phone            address
     32.8       2001    old fades out
     37.7       2300    (row empty)
     42.6       2599    new fades in
     47.5       2898    settled
     54.5       3325                     old fades out
     59.4       3623                     (row empty)
     64.3       3922                     new fades in
     69.2       4221                     settled

   The addr flip moved 51.6% → 54.5% to keep a beat between the two fields:
   with both changes twice as long, back-to-back they read as one continuous
   event rather than two things arriving. 427ms between them now, against the
   old 695ms — shorter, but the reader is no longer being asked to catch a
   change that is already half over. B still holds the finished card for 677ms
   before it starts leaving at 80.3%. */
@keyframes scr-b-phone-was {
  0%, 32.8%    { opacity: 1; }
  37.7%, 88.5% { opacity: 0; }
  90%, 100%    { opacity: 1; }
}
@keyframes scr-b-phone-now {
  0%, 42.6%    { opacity: 0; }
  47.5%, 88.5% { opacity: 1; }
  90%, 100%    { opacity: 0; }
}
@keyframes scr-b-addr-was {
  0%, 54.5%    { opacity: 1; }
  59.4%, 88.5% { opacity: 0; }
  90%, 100%    { opacity: 1; }
}
@keyframes scr-b-addr-now {
  0%, 64.3%    { opacity: 0; }
  69.2%, 88.5% { opacity: 1; }
  90%, 100%    { opacity: 0; }
}
/* The note steps aside once and stays aside — see --scr-read. It returns when
   the reader leaves the beat, not between cycles. */
@keyframes scr-note-yield {
  0%   { opacity: 1; }
  100% { opacity: 0; }
}
/* B arrives from the right and leaves the same way, once per cycle. translate
   rather than a width or a margin: it composites, and it cannot disturb the
   argument column's layout on its way in.

   Its exit is load-bearing. A one-way event cannot simply repeat — replaying
   "the number changed" means un-changing it — so B leaving at 88.5% is both the
   end of a cycle and the cover under which phone A's rows reset at 93%. */
@keyframes scr-arrive {
  0%           { opacity: 0; transform: translateX(var(--sp-24)); }
  8.2%, 80.3%  { opacity: 1; transform: translateX(0); }
  88.5%, 100%  { opacity: 0; transform: translateX(var(--sp-24)); }
}

/* Phone A only. The arrival carries the same two marker classes and must NOT
   type — it receives, it does not edit — so every selector here is scoped
   through .screen, which the arrival is not inside.

   `backwards` on every one of these is load-bearing, not decoration. These
   animations are delayed by --scr-read, and .scr-was is statically
   `opacity: 0` (its resting state, so reduced motion shows the new value, not
   the old one). Without `backwards`, that static opacity is what renders
   through the whole delay: the OLD value would stay invisible and only the
   new one would show, so the beat would open already changed, its argument
   gone before the reader has read the note. `backwards` holds each node at
   its own 0% instead, which is the card exactly as it was. */
.screen.is-playing[data-screen="edited"] .scr-chg--phone .scr-was {
  animation: scr-a-phone-del var(--scr-handoff) steps(15) var(--scr-read) infinite backwards,
             scr-a-reset     var(--scr-handoff) var(--swap-ease) var(--scr-read) infinite backwards;
}
.screen.is-playing[data-screen="edited"] .scr-chg--phone .scr-value:not(.scr-was) {
  animation: scr-a-phone-type var(--scr-handoff) steps(15) var(--scr-read) infinite backwards,
             scr-a-reset      var(--scr-handoff) var(--swap-ease) var(--scr-read) infinite backwards;
}
.screen.is-playing[data-screen="edited"] .scr-chg--addr .scr-was {
  animation: scr-a-addr-del var(--scr-handoff) steps(16) var(--scr-read) infinite backwards,
             scr-a-reset    var(--scr-handoff) var(--swap-ease) var(--scr-read) infinite backwards;
}
.screen.is-playing[data-screen="edited"] .scr-chg--addr .scr-value:not(.scr-was) {
  animation: scr-a-addr-type var(--scr-handoff) steps(11) var(--scr-read) infinite backwards,
             scr-a-reset     var(--scr-handoff) var(--swap-ease) var(--scr-read) infinite backwards;
}
/* The note and the arrival are OUTSIDE the device, so the armed screen cannot
   be an ancestor of either. :has() is what connects them, and it keeps the
   state in the DOM rather than adding a body class that would be a second
   copy of something already true. */
.stage:has(.screen[data-screen="edited"].is-playing) .note {
  animation: scr-note-yield var(--swap-in) var(--swap-ease) var(--scr-read) 1 both;
}
/* `backwards` for the same reason as phone A's: through the --scr-read delay
   these hold their own 0%, which is B standing off-screen with the OLD values
   underneath. Without it B renders its static state during the delay — visible,
   on the new number — and the first frame gives away the ending. */
.stage:has(.screen[data-screen="edited"].is-playing) .arrival {
  animation: scr-arrive var(--scr-handoff) var(--swap-ease) var(--scr-read) infinite backwards;
}
/* Phone B rests VISIBLE, and this hook is .is-on rather than .is-playing on
   purpose. Reduced motion never arms .is-playing (see the head of this file),
   and even where the class does land, this file's animations are killed by
   `animation: none !important` — so a reader with motion off would otherwise
   get phone A alone, which was the old behaviour and is now wrong, because the
   second phone IS the beat.

   What they see instead is a coherent still: two people, one card, the same new
   number and address on both. The argument without the motion.

   MEDIA-SCOPED, and it was not at first. .is-on lands at the beat change and
   .is-playing 900ms later (setScreen's handover, index.html ~2123), so with
   motion ON this rule owned phone B for that whole gap: it stood at full
   brightness in its resting place, then blinked out when `backwards` handed the
   animation its 0% frame, then slid in properly two seconds later. Sampled per
   frame at 1440x900, arriving the way a reader does — 0ms opacity 0, 125ms
   opacity 1.00 with the veil still at 1.00, 1025ms opacity 0, entrance from
   3040ms. The reader saw the ending of the beat before its beginning, while the
   chapter's own text was still travelling to its column.

   Reduced motion is the only state that needs a resting opacity, because it is
   the only one where no animation will ever supply one. With motion, the base
   `opacity: 0` at index.html:149 holds until scr-arrive brings B in — which is
   what the `backwards` fill above is for. */
@media (prefers-reduced-motion: reduce) {
  .stage:has(.screen[data-screen="edited"].is-on) .arrival { opacity: 1; }
}
.stage:has(.screen[data-screen="edited"].is-playing) .arrival .scr-chg--phone .scr-was {
  animation: scr-b-phone-was var(--scr-handoff) var(--swap-ease) var(--scr-read) infinite backwards;
}
.stage:has(.screen[data-screen="edited"].is-playing) .arrival .scr-chg--phone .scr-value:not(.scr-was) {
  animation: scr-b-phone-now var(--scr-handoff) var(--swap-ease) var(--scr-read) infinite backwards;
}
.stage:has(.screen[data-screen="edited"].is-playing) .arrival .scr-chg--addr .scr-was {
  animation: scr-b-addr-was var(--scr-handoff) var(--swap-ease) var(--scr-read) infinite backwards;
}
.stage:has(.screen[data-screen="edited"].is-playing) .arrival .scr-chg--addr .scr-value:not(.scr-was) {
  animation: scr-b-addr-now var(--scr-handoff) var(--swap-ease) var(--scr-read) infinite backwards;
}

/* ---- The 0.5.0 fidelity pass ------------------------------------------
   Everything below came from putting each screen next to its device capture
   in docs/ui-kit/0.5.0/ one to one. The kit's diff covered what CHANGED in
   the app since these screens were built; this covers where the replica was
   simply wrong, most of it since the day it was written.

   Sizes stay on the generated scale and colours stay on --k-color-*, so
   nothing here can fork the skin. The glyphs are the one exception by
   necessity: the app's are a native icon set, so these are drawn from the
   captures, inherit currentColor, and claim to be nothing more than the same
   subjects at the same weight. */

/* The sprite itself never paints — only its <use>s do. */
.k-sprite { position: absolute; width: 0; height: 0; overflow: hidden; }

/* Nav tab glyphs. The bar was labels alone on all eight screens; every
   capture has a glyph above the word. Stacked, because that is what the app
   does and because a 332px canvas has no room for them side by side. */
.scr-tab { display: flex; flex-direction: column; align-items: center; gap: 3px; }
.scr-tab-i { width: 18px; height: 18px; flex: none; }

/* Container-bar glyphs, on your own card. They say what KIND of thing the
   container holds, which is the distinction the site was making with nothing
   at all. Basics has none — it is the container you cannot turn off. */
.scr-bar-i { width: 15px; height: 15px; flex: none; color: var(--k-color-muted); }

/* The same three glyphs at the head of a container ROW, in edit mode. */
.scr-cont-i { width: 17px; height: 17px; flex: none; color: var(--k-color-ink); }

/* The caret. In the app this is a raised round key beside the toggle, and it
   is the ONLY thing that says a container can be opened — without it the row
   reads as a switch and nothing more. --open turns it a quarter, which is how
   the app shows the one container whose fields are on screen. */
.scr-caret {
  width: 26px; height: 26px; flex: none;
  display: flex; align-items: center; justify-content: center;
  border-radius: var(--k-radius-full); color: var(--k-color-muted);
}
.scr-caret svg { width: 13px; height: 13px; }
.scr-caret--open svg { transform: rotate(90deg); }

/* The open container's fields. Two levels of control — container, then field
   — is the whole claim of this screen, and it could not be read while the
   only open container was the one the app will not let you edit. */
/* The drawer runs the SHEET's full width and tucks under the row it opened
   from. detail-parts.tsx:269 — `fieldsWell` carries a background and two
   radii and no margin at all: "square top, rounded bottom … the top tucks
   flush under the title row it opened from; the bottom curve is what says the
   drawer ENDS here rather than running on into the row below."

   The site had it inset 12px each side with a uniform radius, which floated it
   inside the sheet as a separate object rather than reading as the Social row
   opening downward (owner, 2026-08-17). Radius from the same input recipe the
   app takes it from, so the two wells cannot drift. */
.scr-fields {
  margin: 0 0 var(--sp-8);
  border-radius: 0 0 var(--k-radius-card) var(--k-radius-card);
}
.scr-field {
  display: flex; align-items: center; gap: var(--sp-4);
  padding: var(--sp-8) var(--sp-16);
}
/* The empty glyph column. detail-parts.tsx:122 opens every field row with
   `<View style={{ width: 24 }} />` — the container glyph's own width — so a
   field label starts where the container name above it starts. Sized off
   .scr-cont-i, and the row's padding and gap match .scr-cont's for the same
   reason: three values that have to agree or the column bends. */
.scr-field-gutter { width: 17px; flex: none; }
.scr-field .scr-li-txt { display: flex; flex-direction: column; gap: 1px; }
.scr-toggle--sm { transform: scale(.82); transform-origin: right center; }

/* The photo-add badge on your own avatar. A contact's avatar never has one —
   there is nothing of theirs for you to add. */
.scr-avatar { position: relative; }
.scr-avatar-add {
  position: absolute; right: -4px; bottom: -4px;
  width: 18px; height: 18px; border-radius: var(--k-radius-full);
  display: flex; align-items: center; justify-content: center;
  background: var(--k-color-panel); color: var(--k-color-muted);
  font: var(--text-caption); line-height: 1;
}

/* The search row's magnifier. */
.scr-search-i { width: 14px; height: 14px; flex: none; color: var(--k-color-faint); }

/* Add a field is a SHEET in the app — a bar and a labelled input — not the
   lone pill this file had. The pill said "a button"; the sheet says "a form
   waiting for a name", which is what it is. */
.scr-addsheet { margin: var(--sp-12) var(--sp-16) 0; border-radius: var(--k-radius-card); }
.scr-addrow {
  display: flex; align-items: center; gap: var(--sp-12);
  padding: var(--sp-8) var(--sp-15) var(--sp-12);
}
.scr-addinput {
  flex: 1; padding: 7px var(--sp-12); border-radius: var(--k-radius-full);
  font: var(--text-caption); color: var(--k-color-faint);
}

/* ---- The people row ---------------------------------------------------
   The app's row is a card, not a line in a list, and it carries two things
   this one did not: WHICH containers are shared (the glyphs) and the note
   that makes the person recognisable. Three anonymous pips said "how much"
   and never "what", which is the distinction the whole product is about. */
/* NOTES and the note itself hang off the NAME, not off the card's edge — the
   strip under the avatar stays empty. They are siblings of .scr-li-head rather
   than children of .scr-li-txt (the head is a flex row and they are full-width
   blocks under all three of its columns), so the indent has to be restated
   here instead of inherited: .scr-avatar--sm's 38px frame plus the gap
   .scr-li-head puts after it. The two literals are pinned against their
   sources in site-screens.test.mjs — a changed avatar must move this too. */
.scr-listitem {
  display: block; padding: var(--sp-8) var(--sp-12);
  --li-indent: calc(38px + var(--sp-12));
}
.scr-li-head { display: flex; align-items: center; gap: var(--sp-12); }
.scr-li-icons { display: flex; gap: 5px; flex: none; color: var(--k-color-moss); }
.scr-li-icons svg { width: 14px; height: 14px; }
/* The example row wears a moss edge and says so in words. Kithe ships one
   sample contact so an empty install is not an empty screen; the app marks it
   rather than letting it pass for someone the reader added. */
.scr-listitem--sample { border-left: 3px solid var(--k-color-moss); }
.scr-sample {
  margin-left: var(--sp-8); padding: 1px 6px;
  border: 1px solid var(--k-color-moss); border-radius: var(--k-radius-full);
  font: var(--text-caption); font-size: 9px; letter-spacing: var(--tracking-label);
  text-transform: uppercase; color: var(--k-color-moss);
}
/* NOTES, then a rule to the edge — the app's own divider. */
.scr-li-notes {
  display: flex; align-items: center; gap: var(--sp-8);
  margin: 6px 0 2px var(--li-indent);
  font: var(--text-caption); font-size: 9px; letter-spacing: var(--tracking-label);
  text-transform: uppercase; color: var(--k-color-muted);
}
.scr-li-notes i { flex: 1; height: 1px; background: currentColor; opacity: .45; }
.scr-li-note {
  margin-left: var(--li-indent);
  font: var(--text-caption); font-style: italic; color: var(--k-color-faded);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.scr-listnote {
  margin: var(--sp-8) var(--sp-16) 0;
  font: var(--text-caption); color: var(--k-color-faint); text-align: center;
}

/* The tabbed body has to keep room for a footer.
   Measured before this line existed: on `sharing` and on the card's second
   face the actions ended 576px into the 560px tabbed body of the day (598
   since 2026-08-07) — 16px of overflow, so Cancel and Preview were sliced in
   half by the tab bar.

   The FIRST fix pinned .scr-actions to the body's bottom edge, and looking at
   it showed why that was wrong: the note and the Pause sharing button still
   sat in normal flow, so the pinned pair landed on top of both. Absolute
   position takes an element out of the flow — it does not make room, it just
   moves the collision.

   The SECOND fix made the sheet a flex:1 box that clipped its own overflow,
   and it is the one this rule has just stopped doing. It was described here as
   "the app's own shape: a sheet that crops" — and that was a misreading of the
   app. src/app/(tabs)/people/[id].tsx:136-233 puts the identity head, the
   Sheet, the hint AND Pause sharing inside one ScrollView; only the footer
   (:235) sits outside it. The app never crops that sheet. It SCROLLS, and when
   a container opens, everything below it moves down.

   A clip cannot do that. What it does instead is delete whatever the growth
   pushes past the edge, which is how beat 0's loop came to slice the Birthday
   row mid-animation (8d8b939) and how `sharing` came to cut the Important
   container's sub-line in half — the same defect twice, from the same rule.
   Owner ruled 2026-08-07: pushing the elements down is what the app does, and
   cropping content off is not.

   So the sheet takes its own content's height and never shrinks (flex: none —
   a flex item's default is `0 1 auto`, which still shrinks, and shrinking with
   the clip gone spills content instead of hiding it). Note, Pause sharing and
   the action pair keep their place in the flow beneath it.

   That leaves the footer, which the app really does pin: margin-top: auto on
   .scr-actions absorbs the body's leftover room, so Cancel/Preview sit above
   the tab bar whatever height the sheet currently is, and the gap over them
   breathes as the sheet grows. Measured in Chromium after the change, in
   canvas px against the 560px tabbed body of the day (598 since 2026-08-07):
   sharing 505 · beat 0 closed 399 · beat 0 open 479, and 0px of overflow in
   all three.

   None of this fits without the in-screen caption, which is why it went — see
   the two comments in site/index.html where it used to sit. */
.scr-body--stacked { display: flex; flex-direction: column; }
.scr-body--stacked > .scr-payload { flex: none; }
.scr-body--stacked > .scr-actions { margin-top: auto; }
.scr--tabbed .scr-body { padding-bottom: var(--sp-8); }

/* "Default", not "DEFAULT". .scr-always is the ALWAYS voice — a label shouting
   that a field cannot be turned off — and the app does not use it here: this
   is a plain word for the state a container is in. Uppercasing it also made
   the Basics row wide enough to wrap its own sub-line onto two, which is how
   a type decision turned into a layout one. */
.scr-always--default {
  text-transform: none; letter-spacing: normal; color: var(--k-color-faint);
}

/* The Add-a-field placeholder is one line. Left to wrap it took two, and the
   second was sliced by the tab bar — the app's own row keeps it on one and
   lets the text run out. */
.scr-addinput { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }


/* ---- Beat 8 · the same card, two people --------------------------------
   Spec: docs/superpowers/specs/2026-08-06-site-beat8-copies-design.md

   Beats 0 and 2 animate EVENTS — a card being built, a change arriving.
   This one animates a COMPARISON. Nothing happens to Sam's card; two people
   hold it and see different amounts, so the subject is the difference
   between two still frames, and the motion exists only to put both frames
   in the same place so the eye can subtract them.

   That is the whole reason this is not beat 0's mechanism. The obvious
   build is two .scr-face nodes crossfaded whole, and it is wrong here:
   roughly two thirds of the two copies are byte-identical, and crossfading
   a node against a copy of itself makes it dip and recover for nothing.
   The reader then has to work out which parts actually changed. For a beat
   captioned "the same card", the stillness of the shared rows IS the
   argument — so five nodes cross and everything else holds still.

   What holds still, deliberately: Sam's own identity strip inside the
   sheet, the Basics bar and both its rows, the Social bar, the Instagram
   row, the wide "You share…" cap, and the Edit button.

     ms             %              act
     0    →  4400   0    → 41.5%   hold Elena — the frame to read
     4400 →  5300   41.5 → 50%     the five cross to Mara, over --swap-in
     5300 →  9700   50   → 91.5%   hold Mara
     9700 → 10600   91.5 → 100%    the five cross back

   As with beat 0 the MS COLUMN is load-bearing and the percentages are
   derived from it; retune the loop and every percentage is recomputed so
   that not one act's milliseconds move. 41.5→50% of 10.6s is 901ms, which
   is --swap-in (900ms, --t-card-cross) to within the rounding cost of
   quoting a percentage to one decimal place.

   The two holds are equal at 4400ms because neither copy is the subject.
   Beat 2 is a one-shot with an "after" to rest on; this beat has none, and
   it loops for the same reason: a comparison a reader can only witness once
   is not a comparison, and resting on Mara would leave the beat showing a
   single copy — the exact thing it argues against. There is no tail. The
   100% frame IS the 0% frame, both Elena.

   Every --b rests hidden and every --a rests visible, so `animation: none`
   leaves Elena's complete card rather than a half-played frame: reduced
   motion is correct BY CONSTRUCTION, the way the note at the head of this
   file describes. All of it is scoped under .screen.is-playing, so nothing
   here joins the reduced-motion kill list in index.html — and .arrival,
   beat 2's second phone, which carries a cap pair of its own and is not a
   [data-screen] node, is untouched for the same reason. */
:root { --scr-copies: 10.6s; }

/* The house pair, identical in shape to .scr-sub/.scr-sub--b above: the --a
   node stays in flow and decides the wrapper's height, the --b node lies on
   top of it and rests invisible. */
.scr-copy { position: relative; }
.scr-copy--b { position: absolute; inset: 0; opacity: 0; }
/* .scr-copy is a <span>, and `inset: 0` against an INLINE containing block is
   resolved from its line boxes rather than from a full-width box. Everywhere
   else the pair holds one short line and that is invisible; in the note it
   wraps, and Mara's --b node shrank to 189px against Elena's 288 — the same
   sentence set three ragged lines instead of two, ending 16.5px lower.
   Harmless while the Edit button sat at 647. It is not harmless now: the
   button moved to 576 when this screen gained its tab bar, and the third line
   printed underneath it. Measured, active, mid-crossfade: text bottom 583 vs
   button top 576. Blocking the wrapper is what makes `inset: 0` mean the
   width it reads as meaning. Scoped to the note, because the tab caps above
   want an inline that shrink-wraps its own label. */
.scr-note .scr-copy { display: block; }

@keyframes scr-copy-out {          /* Elena's nodes, and the lone Personal block */
  0%, 41.5%  { opacity: 1 }
  50%, 91.5% { opacity: 0 }
  100%       { opacity: 1 }
}
@keyframes scr-copy-in {           /* Mara's nodes */
  0%, 41.5%  { opacity: 0 }
  50%, 91.5% { opacity: 1 }
  100%       { opacity: 0 }
}

.screen.is-playing[data-screen="two-copies"] .scr-copy--a,
.screen.is-playing[data-screen="two-copies"] .scr-copy-only {
  animation: scr-copy-out var(--scr-copies) var(--swap-ease) infinite;
}
.screen.is-playing[data-screen="two-copies"] .scr-copy--b {
  animation: scr-copy-in var(--scr-copies) var(--swap-ease) infinite;
}


/* ---- Beat 5 · three doors ----------------------------------------------
   Spec: docs/superpowers/specs/2026-08-06-site-beat5-doors-design.md

   The last of the four screen animations. Same ruling as beat 8 — what holds
   still is what the caption is claiming.

   THE CAPTION CHANGED ON 2026-08-17, and the reason is worth keeping. It read
   "Three doors, one gate", and the claim underneath it was that `PeekCard
   heading="THEY'LL SEE" headingRight="BASICS ONLY"` is the same component on
   all three doors. It is not. It is the same call twice — ShowMinePage
   (exchange-parts.tsx:152) and send-link.tsx:119, identical props — and on the
   third door introduce.tsx renders a DIFFERENT PeekCard: heading "Basics", no
   right label, the SUBJECT's card rather than the owner's, and above the list
   rather than below it. The old comment quietly named only two files and never
   noticed it was arguing against itself.

   So the site drew a gate on door 3 that the app never draws, naming Sam as
   the person shared on a screen about introducing someone else — and left out
   the subject card, its caption, and the whole first half of the sentence the
   app's own comment insists on ("without it both halves are just names and a
   list"). Door 1 was drawing SHARE A LINK as a prose hint where the app has a
   Btn, and its segmented control as two free-standing buttons. Door 2 had its
   two footer buttons side by side where the app stacks them. Reported by the
   owner from the running app, 2026-08-17.

   The caption now claims what is actually true of all three: Basics only
   moves, and both sides have to agree. That is on the screen in three places —
   `BASICS ONLY` on doors 1 and 2, "Basics only — and they both have to
   approve" on door 3, "You both still confirm afterwards" on door 2.

   What holds still: the app bar, and the peek across doors 1 and 2 — the
   `They'll see · Basics only` bar, Sam Rivera's head, and the Phone row.

   What crosses, in three: the title, the mid, the note, the actions. The gate
   crosses too, but only once per loop — see .scr-mid and scr-gate-hold.

     ms              %               act
     0     →  3600   0     → 26.67%  hold door 1 — in person
     3600  →  4500   26.67 → 33.33%  cross to door 2, over --swap-in
     4500  →  8100   33.33 → 60%     hold door 2 — a link
     8100  →  9000   60    → 66.67%  cross to door 3
     9000  → 12600   66.67 → 93.33%  hold door 3 — an introduction
     12600 → 13500   93.33 → 100%    cross back

   THREE EXACT THIRDS — 4500ms per door, a 3600ms hold plus a 900ms cross. That
   is the caption made literal, and it is also why there is one keyframe per
   door, each the same shape offset by a third, instead of six hand-placed
   stops. It marches in step with the beat's own note, which is already a
   three-part sentence in door order: the code lives a few minutes, a link works
   once, an introduction needs a yes from both sides.

   The ms column is load-bearing, as always. Percentages are quoted to two
   decimals rather than beat 8's one, because a third does not land on one:
   26.67% of 13500 is 3600.45ms. The crosses come out at 891–905ms against
   --swap-in's 900ms; the guard's tolerance is ±10ms and says so, rather than
   hiding the residue inside a rounder loop length.

   3600ms per door against beat 8's 4400ms: a door is a title, one mechanism
   and a footer. Beat 8 asks the reader to subtract two cards from each other,
   which is slower work.

   Door 1's nodes are the --a set and rest visible; doors 2 and 3 rest hidden.
   So `animation: none` leaves precisely the screen that shipped before this
   commit — the built, verified door — and reduced motion is correct by
   construction. Everything is scoped under .screen.is-playing, so nothing
   joins the reduced-motion kill list.

   Beat 5 folds (index.html, `screen: 'doors'` carries fold: true), so like
   beat 8 it needs no --compact re-arm: below the boundary it is not on the
   page at all. It escapes c6a42c3's desync trap structurally, not luckily. */
:root { --scr-doors: 13.5s; }

/* Stacked in one grid cell rather than absolutely positioned on a relative
   wrapper, which is what .scr-sub and .scr-copy do.

   The difference matters here and it was found by LOOKING, not by measuring:
   an `inset: 0` child is out of flow, so the wrapper is only ever as tall as
   the one child left in it. Door 1's note is one line and door 2's is two
   ("Expires in 3 days or after the first use." / "You both still confirm
   afterwards."), so door 2's second line hung out of the wrapper and printed
   over the buttons underneath. Every opacity reading was still correct — the
   crossfade was doing exactly what the guards said — which is why the browser
   pass shoots the frames as well as sampling them.

   `grid-area: 1 / 1` on every child stacks them in one cell and sizes the cell
   to the TALLEST, so the layout is decided by the whole set instead of by
   whichever member happens to rest visible. The pairs on beats 0 and 8 are the
   same shape and get away with `inset: 0` only because their two states happen
   to be the same height; this is the version to copy from now. */
.scr-door { display: grid; }
.scr-door > * { grid-area: 1 / 1; }
.scr-door--b,
.scr-door--c { opacity: 0; }

/* The mid. Doors 1 and 2 stand a mechanism over a shared gate; door 3 is a
   different composition that reuses neither, so it lies OVER both of them.

   Why the gate is one node and not a member of the door set. It is genuinely
   the same call twice — `PeekCard heading="THEY'LL SEE" headingRight="BASICS
   ONLY" name={ownerName} view={myView} mine` on ShowMinePage
   (exchange-parts.tsx:152) and on send-link.tsx:119 — and drawing it once per
   door would cross-fade it against a copy of itself, dipping to ~50% through
   the 900ms swap. One node crossing out only on door 3 is the honest version:
   it holds still exactly where the app holds it still.

   Door 3 overlays rather than joining .scr-slot because it does not stand in
   the slot at all. introduce.tsx puts its card ABOVE the list, where the QR
   and the link well stand, and then keeps going — subject, caption, TO, list,
   terms — past where the gate ends. An absolute overlay on the whole mid is
   the only shape that lets one door be taller than the thing the other two
   share without pushing the footer around. */
.scr-mid { position: relative; }

/* The fixed slot. 220px is the QR's own box — QR_BOX in the app's
   indicators.tsx, and .scr-qr's own width here — so the slot is sized to the
   door that already existed rather than the other way round. */
.scr-slot {
  position: relative; height: 220px; margin: var(--sp-16) auto;
}

/* Door 3's whole composition, hung from the top of the mid so it starts where
   the other two doors' mechanisms start. Top-aligned with slack beneath is the
   app's own shape: introduce.tsx scrolls a short body and leaves the room
   between the terms and the footer empty.

   A plain block, not a flex column: every child it holds already carries its
   own horizontal rule — .scr-payload's `margin: 0 var(--sp-16)`, .scr-note's
   and .scr-kicker's centred text, .scr-cands' own width — and a flex context
   would have overridden all three at once. */
.scr-intro { position: absolute; inset: 0; }
/* No rows under the head — the NO_VIEW branch — so the sheet would otherwise
   end hard against the avatar's own bottom padding. */
.scr-subject { padding-bottom: var(--sp-8); }
/* Hung from the TOP of the slot, not centred in it. Centring was the first
   draft and it made door 2 read as broken: a 33px link well floating in the
   middle of the slot with equal air above and below looks like a rendering
   failure rather than a short element. Hanging it from the top is also what
   the app does — send-link.tsx puts LinkWell directly under the title at
   marginTop: 16 — so the more faithful version is the better-looking one.
   The QR is unaffected either way, since it fills the slot exactly. */
.scr-slot > * { position: absolute; inset: 0; display: grid; place-items: start center; }
.scr-slot .scr-qr { margin: 0; }

/* The link, as LinkWell draws it: a pressed well with the url in it, allowed
   two lines and truncated after. */
.scr-linkwell {
  width: calc(100% - var(--sp-32)); padding: var(--sp-12) var(--sp-16);
  border-radius: var(--k-radius-card);
  font: var(--text-caption); color: var(--k-color-muted);
  overflow-wrap: anywhere;
}

/* "To" — the label over the candidate list. Centred, because it labels the
   whole list beneath it and not the list's left edge (introduce.tsx:151). */
/* .k-kicker supplies the type — mono/medium 10.5 at ls 2.1, muted. This class
   was invented on 2026-08-06 for beat 5's "To" label and every number in it was
   a guess; the role is typography.tsx:63's own. Only placement is ours. */
.scr-kicker {
  text-align: center; margin-bottom: var(--sp-8);
}

/* Centred by its own auto margins now. It used to sit in .scr-slot, which
   centred it with `place-items: start center`; door 3 left the slot, so the
   centring has to come with it. */
.scr-cands { width: calc(100% - var(--sp-32)); margin: 0 auto; border-radius: var(--k-radius-card); }
.scr-cand {
  display: flex; align-items: center; gap: var(--sp-12);
  padding: var(--sp-8) var(--sp-12);
}
.scr-cand-name { font: var(--text-body); color: var(--k-color-ink); }
.scr-cand-met  { font: var(--text-caption); color: var(--k-color-faint); }
/* `candidateRowActive: { backgroundColor: theme.color.panelSunk }` — a flat
   fill, not a neu recipe, so this takes the generated colour and no skin
   class. The radio says which row is picked; this says it a second time at
   row scale, which is what makes the pick readable at a glance on the reel. */
.scr-cand--on { background: var(--k-color-panel-sunk); }

/* 18px well, 8px moss dot when on — indicators.tsx:92-93. The well itself is
   .k-radio from the generated skin, so the recipe is the app's and not a
   redrawing of it; only the box and the dot live here. */
.scr-radio-w {
  width: 18px; height: 18px; flex: none;
  display: grid; place-items: center;
}
.scr-radio-w i { width: 8px; height: 8px; border-radius: 4px; background: var(--k-color-moss); }

/* The segmented control — `ActionPair seg` (Btn.tsx:76). ONE raised track with
   two flex cells in it, the live one sunk in moss, which is a different object
   from the two free-standing buttons the site drew here until now. The track is
   .k-action-pair and the live cell .k-ap-cell--seg-fill, both already in the
   generated skin and both unused before this: the recipes are the app's, only
   the flex row and the cell padding are ours.

   Both labels stay ink. ApCell says so outright — "a segmented-selected cell is
   a GREEN pressed well and keeps ink, same as an unselected one" — so the
   selected half is NOT the moss-ink of a go button. */
/* padding: 0 is load-bearing, and it is an OVERRIDE. .scr-seg is a child of
   .scr-actions, whose `> *` rule pads every child by --sp-12 — right for a
   button, wrong for a track whose children do their own padding. Measured
   before this line at 1440x900: the track stood 53px tall beside a 33.1px
   SHARE A LINK, and the moss half floated 10px inside the track on every edge
   with the raised surface showing around it. The app has neither — Btn and
   ApCell are both paddingVertical 13, and ApCell carries no margin at all, so
   the fill runs to the track's edge and is clipped by its radius. */
/* Loose in the body, the track carries its own gutter — the app's footer is
   `paddingHorizontal: theme.space.gutter` and nothing here supplies that for
   it. Beat 7's pair inherited its margins from .scr-actions and lost them when
   it became a track; it ran edge to edge of the phone, past .scr-pause and the
   sheet above it (owner, 2026-08-17). Same 12/16 the .scr-actions row it
   replaced used, so it sits exactly where the two buttons sat.

   Zeroed again inside .scr-actions: beat 5's doors put the track in
   .scr-actions--foot, which is absolutely positioned with its own left/right
   16 already, and a margin there would inset it twice. */
.scr-seg { display: flex; overflow: hidden; padding: 0; margin: var(--sp-12) var(--sp-16) 0; }
.scr-actions .scr-seg { margin: 0; }
.scr-seg > * {
  flex: 1; text-align: center; padding: var(--sp-12);
  font: var(--scr-text-btn); letter-spacing: 0.6px; color: var(--k-color-ink);
}
/* The ONE cell that does not keep ink. ApCell: `color: goFill ? mossInk : ink`
   — a seg-selected half stays ink because it is a pressed green well, but a
   go-filled half is solid moss and needs the label that sits on moss. Beat 7's
   Cancel/Preview is the only go-filled pair in the reel; beat 5's is seg. */
.scr-seg-cell--go { color: var(--k-color-moss-ink); }

@keyframes scr-door-1 {
  0%, 26.67%     { opacity: 1 }
  33.33%, 93.33% { opacity: 0 }
  100%           { opacity: 1 }
}
/* The gate: doors 1 AND 2, so it crosses once per loop instead of twice. Same
   two stops as scr-door-1's and scr-door-2's outer edges — visible from the
   top of door 1 to the end of door 2's hold, out across door 3's cross-in,
   back across door 3's cross-out.

   Named --gate rather than --ab on purpose: `scr-door--ab` CONTAINS
   `scr-door--a` as a substring, so every regex guard counting door 1's nodes
   would have swept the gate in and reported a door that changes four things
   when it changes three. CSS class selectors match whole tokens and would
   never have noticed; the tests are what the name protects. */
@keyframes scr-gate-hold {
  0%, 60%        { opacity: 1 }
  66.67%, 93.33% { opacity: 0 }
  100%           { opacity: 1 }
}
@keyframes scr-door-2 {
  0%, 26.67%     { opacity: 0 }
  33.33%, 60%    { opacity: 1 }
  66.67%, 100%   { opacity: 0 }
}
@keyframes scr-door-3 {
  0%, 60%        { opacity: 0 }
  66.67%, 93.33% { opacity: 1 }
  100%           { opacity: 0 }
}

.screen.is-playing[data-screen="doors"] .scr-door--a {
  animation: scr-door-1 var(--scr-doors) var(--swap-ease) infinite;
}
.screen.is-playing[data-screen="doors"] .scr-door--b {
  animation: scr-door-2 var(--scr-doors) var(--swap-ease) infinite;
}
.screen.is-playing[data-screen="doors"] .scr-door--c {
  animation: scr-door-3 var(--scr-doors) var(--swap-ease) infinite;
}
.screen.is-playing[data-screen="doors"] .scr-door--gate {
  animation: scr-gate-hold var(--scr-doors) var(--swap-ease) infinite;
}
