/*
 * Design system — see DESIGN.md for the reasoning behind every value here. It is the only
 * reference; the design-preview.html companion was retired 2026-09-07 [L480].
 *
 * Two rules that must not erode:
 *   1. Separation is a 1px hairline rule. No cards, no boxes, no shadows, no radius.
 *   2. Three faces, three jobs, never trading. Baskervville SC display only,
 *      Libre Baskerville for everything read, EB Garamond for every number and label.
 */

/* ----------------------------------------------------------------- fonts -- */

/*
 * SELF-HOSTED, deliberately. Two reasons, and the second one is the real one:
 *
 * 1. Google Fonts + a full page load re-swaps the face on every navigation, so
 *    tapping through the ballot flashed Georgia -> Libre Baskerville each time.
 * 2. A venue in a wooded canyon on wifi sized for 30 users cannot be assumed to
 *    reach fonts.googleapis.com at 4pm. Self-hosted means the typography either
 *    ships with the app or nothing does.
 *
 * 144KB for all six faces, latin subset, fingerprinted and cached by Propshaft.
 *
 * The display face is Baskervville SC and not a Garamond because the invitation's
 * face — Mrs Eaves Roman All Small Caps, which is what the Zola site actually
 * serves — is a Baskerville revival. Matching the genus gets closer than matching
 * nothing. Only 400 exists, and only 400 was ever used.
 */
@font-face {
  font-family: "Baskervville SC"; font-style: normal; font-weight: 400; font-display: swap;
  src: url("/assets/baskervville-sc-400-4df0cfc9.woff2") format("woff2");
}
@font-face {
  font-family: "Libre Baskerville"; font-style: normal; font-weight: 400; font-display: swap;
  src: url("/assets/libre-baskerville-400-ab604eea.woff2") format("woff2");
}
@font-face {
  font-family: "Libre Baskerville"; font-style: italic; font-weight: 400; font-display: swap;
  src: url("/assets/libre-baskerville-400-italic-faee9a18.woff2") format("woff2");
}
@font-face {
  font-family: "Libre Baskerville"; font-style: normal; font-weight: 700; font-display: swap;
  src: url("/assets/libre-baskerville-700-ab604eea.woff2") format("woff2");
}
/*
 * EB Garamond carries the labels and every figure. It replaced DM Mono, which
 * was a category error: monospace exists to make columns of characters align,
 * which words never need, so on the eyebrow we paid the whole terminal/receipt
 * semantic and got nothing — including a SLASHED ZERO, a glyph that exists only
 * so programmers don't confuse 0 with O, rendering above the couple's names.
 *
 * EB Garamond is the only free serif tested that keeps uniform digit widths, so
 * the leaderboard still aligns. Do NOT swap it for a face that lacks them: at
 * 375px a rank column with proportional figures visibly jitters, and
 * font-variant-numeric CANNOT rescue it — the Google Fonts subsets ship no
 * `tnum` table, so the request fails silently (measured, not assumed).
 *
 * One variable file spans 400-500; both weights are used.
 */
@font-face {
  font-family: "EB Garamond"; font-style: normal; font-weight: 400 500; font-display: swap;
  src: url("/assets/eb-garamond-var-1e782b72.woff2") format("woff2");
}

/* ---------------------------------------------------------------- tokens -- */

/*
 * The ground is the invitation's green, product-wide. Sampled off the printed
 * card, not off the Zola site — when the two disagree the printed piece wins,
 * because that is the object every guest has already held.
 *
 * This REVERSES the earlier cream-paper default. Cream survives only as ink.
 * Everything that used to be an exception for the second, darker ground is now
 * simply the rule — and since L460 removed that ground entirely, this block is
 * the whole colour system with no counterpart anywhere below it.
 */
:root {
  --paper:     #2A3A31;  /* sampled off the printed invitation */
  --surface:   #31463A;
  --ink:       #FCF8F5;  /* 11.4:1 on paper */
  --pine:      #FCF8F5;  /* the brand green is the GROUND now, so its token
                            resolves to ink. Kept as a token rather than swept:
                            it names a role the markup still asks for. */
  --muted:     #A9BCA6;  /* 5.97:1 — the NOTE tier (tier 4). See DESIGN.md § Text roles */
  /*
   * TIER 3, the SAY tier. Ink dimmed, not tinted — same warm cream family, so it
   * reads as "the same voice, quieter" rather than as a second colour.
   *
   * It exists because the system had exactly TWO text colours doing four jobs:
   * --ink carried 14 rules across 16/17/19/24px and --muted carried 25 across
   * 13/14/15/16px. Supporting prose either borrowed full --ink (and competed with
   * the thing it was supporting) or dropped to --muted (and became chrome). There
   * was no tier for "prose the guest reads but is not the answer", which is most
   * of the words in a text-heavy app.
   *
   * 8.47:1 — a real step down from ink's 11.38 and a real step up from muted's
   * 5.97, so the three tiers are distinguishable at a glance and all three clear
   * AA for body text with room.
   */
  --ink-soft:  #DCD8D3;  /* 8.47:1 */
  --gold:      #E8C45A;  /* the venue's festoon bulbs (~2400K). FILL/MARKS ONLY */
  --gold-ink:  #E8C45A;  /* 7.1:1 on this ground: gold finally carries text.
                            Kept as a separate token so callers need not change */
  --vine:      #AFC4AD;  /* 6.5:1 — the invitation's botanical line */
  --miss-ink:  #8A9179;
  --miss-rule: #4A5A4E;  /* strike-through rule. NO TEXT */

  --hair:        rgba(252, 248, 245, .20);
  --hair-strong: rgba(252, 248, 245, .38);

  --display: "Baskervville SC", Georgia, serif;
  --body:    "Libre Baskerville", Georgia, serif;
  /* Named --label, not --mono: it is not a monospace and the name should not lie.
     Its job is every figure and every micro-label. */
  --label:   "EB Garamond", Georgia, serif;

  --gutter: 20px;
  --row-h:  76px;   /* answer rows. 4 of these fit a 375x667 SE; 5 do not (L30) */
  --row-h-resolver: 88px;
}

/*
 * THERE IS ONE PALETTE, ALL EVENING [L460]. The block above is the whole colour
 * system: nothing in this file branches on the time of day, and no rule below has
 * or needs a second-ground counterpart.
 */

/* ----------------------------------------------------------------- base -- */

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

/*
 * Load-bearing. Any element given an explicit `display` in author CSS beats the
 * UA stylesheet's `[hidden] { display: none }`, so `hidden` silently stops working
 * — which is exactly how the roster typeahead shipped broken the first time:
 * `.namerow { display: flex }` made every row unhideable and the filter looked dead.
 */
[hidden] { display: none !important; }

/* button_to wraps its button in a form; collapse the wrapper when the button hides. */
.button_to:has(> [hidden]) { display: none; }

/*
 * THE GROUND LIVES ON <html>, NOT ON <body>.
 *
 * NO TRANSITION. There was a 900ms cross-fade here — DESIGN.md's "Invert" — and its
 * only trigger was the palette changing under a phone across a poll tick. With one
 * palette [L460] the ground never changes at runtime, and a transition with no
 * trigger is a rule a future session has to work out the purpose of.
 *
 * It stays on <html> anyway. `Turbo.visit(action: "replace")` replaces the body
 * element on every poll tick, and <html> survives every visit — so the ground and
 * the inherited `color` are declared on the element that outlives a swap. Body keeps
 * no background of its own: html's then paints the canvas, which is the same result
 * the propagated body background used to give.
 */
html {
  -webkit-text-size-adjust: 100%;
  background: var(--paper);
  color: var(--ink);
}

/*
 * Caught by rendering, not by inspecting. `.micro` was being set on the <p> and
 * the <a> inside it inherited nothing, so "Not you? Find your name →" drew in the
 * UA's link blue with an underline through it — on the invitation's green, on the
 * screen whose whole job is to look like the printed card. Every computed style
 * was correct; nobody had looked at it.
 *
 * Safe to drop the underline here and only here: there is no link inside a
 * sentence anywhere in this app. Every one of them is a row, a door, or a
 * standalone nav line ending in an arrow, so the affordance is the shape and the
 * arrow rather than the decoration.
 */
a { color: inherit; text-decoration: none; }

body {
  font-family: var(--body);
  font-size: 17px;      /* floor. Grandparents are playing this. */
  line-height: 1.55;
  -webkit-font-smoothing: antialiased;
  padding: 22px var(--gutter) 64px;
  max-width: 640px;
  margin: 0 auto;
}

/* ------------------------------------------------------------ typography -- */

/*
 * The ballot masthead is ONE line and must never wrap: a second line pushes an
 * answer row below the fold on a 375x667 SE, which is what the four-option cap
 * (L30) exists to prevent. It used to carry the guest's name, and a measured
 * `Guest::MASTHEAD_CHARS` kept that name inside the width — both are gone [L501].
 * The line now reads `QUESTION 8 OF 15`, which is 210.7px at its longest against
 * 335px of usable width, so the budget is no longer tight enough to need a
 * constant. Change the face, the size or the tracking and re-measure anyway.
 *
 * The 14px is not arbitrary either. EB Garamond is a Garamond and runs small:
 * at a nominal 12px its caps are 7.84px against Libre Baskerville's 9.24px, so
 * carrying DM Mono's old 12px over would have quietly shrunk every label by
 * ~15% on a screen read outdoors at 4pm by guests in their eighties. Every
 * --label size is bumped ~1.18x for the same reason.
 */
/*
 * THE MASTHEAD, AND NOTHING ELSE. [L74]
 *
 * This class used to serve four altitudes with no rank between any of them: the page
 * masthead (12 files), an in-page section band (14 instances, each with an inline
 * `margin-top` in one of FOUR arbitrary values — 26, 34, 40, 44), a form label on the
 * admin roster, and a transient scroll hint. One 14px letterspaced muted line meant
 * "this is the screen you are on" and "this is a band of the screen you are on",
 * which is why every screen read hand-rolled: there was no header component, only a
 * class reused at two heights.
 *
 * `.sect` below is the section band and had been written for exactly this job before
 * reaching three lines of one screen. The distinction was designed and then not
 * applied. Applying it is the fix; nothing here needed to change but its scope.
 *
 * Do not use this for a band, a label or a hint. `.sect` is the band, `.fieldlabel`
 * is the label. A masthead is rendered through `shared/_masthead` or
 * `shared/_flow_masthead`, never by hand — hand-rolling a thirteenth one is how the
 * five-grammar defect got here in the first place.
 *
 * PROMOTED OFF NOTE TIER [L320]: 19px `--ink`, not 14px `--muted`. The way-home
 * control sits directly above this line (`.wayhome` on a browse screen, `.stepback`
 * on a ballot card since L502), and both were NOTE tier — 14–16px
 * `--muted` — so per § Text roles the size step between them was EMPHASIS, not
 * hierarchy, and the emphasis landed on the chrome. The screen's own name was the
 * quietest treatment in the app, on a screen CLAUDE.md says must state what it is
 * in its first 100px. Rendered at 22px first and rejected: it rivalled /travel's
 * 26px `.voice-display` card names.
 *
 * `.12em`, NOT `.22em`. § Typography's sentence-case Factual setting. At 19px with
 * .22em the longest screen name runs past the 335px measure.
 *
 * Applied on ALL NINE masthead callers, crew screens included, so this class keeps
 * one job — `chrome_test.rb` asserts nothing outside the two masthead partials
 * renders it, and a guest-only modifier would have made it mean two things.
 */
.eyebrow {
  font-family: var(--label);
  font-size: 19px;
  letter-spacing: .12em;
  text-transform: uppercase;
  color: var(--ink);
}

/*
 * THE WAY HOME IN THE MASTHEAD [L320]. Additive — the footer's `.door--home` is
 * untouched. That door is the REACHABLE one (Hoober: bottom-center is where a
 * one-handed thumb sits, the top corners are not); this is the LEARNABLE one, and
 * it exists because the door is below the fold on five of seven guest screens.
 *
 * PLAIN VOICE, NOT FACTUAL, and this is the whole reason it works. Shipped first in
 * the masthead's own 14px letterspaced caps and it read as a FOURTH sibling caps
 * line above the screen's name — on /standings the first 200px became `HOMEPAGE` /
 * `STANDINGS` / `YOUR SCORECARD` / `PLACE`, four identical muted caps lines with no
 * rank between any of them, which is the § Text roles defect this app has already
 * fixed twice. A control is not a name; setting it in the guest's reading voice is
 * what stops it competing with one.
 *
 * A CHEVRON DRAWN AS A BORDER BOX, NOT AN ARROW GLYPH. `←` at 15px in the label
 * face was the heaviest mark in the masthead and outweighed the screen's name.
 * 6×6px with two 1px borders rotated 225deg inks a fraction of that. It is also the
 * technique every design system with this component uses, for the same reason.
 *
 * ON THE GUTTER, NOT HANGING OUTSIDE IT. `left: 0` with 13px of left padding puts
 * the chevron ON the 20px gutter every other element in the app respects and indents
 * the words behind it. The first draft hung the chevron at -13px so the WORDS shared
 * the h1's left edge — GOV.UK's hanging-chevron trick — and it made this the only
 * mark in the app outside the gutter, which read as the control floating loose.
 *
 * PINNED TOWARD THE TOP EDGE. The negative top margin is what stops it floating:
 * every design system that ships this component has a site header above it to belong
 * to, and this app has no site chrome at all, so with 22px of open ground above and
 * ~12px below it hung in the middle of nothing. VA.gov specs the crumb closer to the
 * page top than to the heading (20 above / 32 below on mobile); ours was the inverse.
 *
 * 44px MINIMUM, held tight visually. CLAUDE.md's tap floor is hard, and the negative
 * margin is the standard way to keep the target legal without the spacing it implies.
 * The box is taller than the ink; that is the point.
 *
 * No underline: there is no tracking here to drag one through, but `a.backlink` set
 * the house precedent for a masthead-position control and matching it costs nothing.
 */
.wayhome {
  display: inline-flex;
  align-items: center;
  position: relative;
  min-height: 44px;
  padding-left: 13px;
  margin-top: -14px;
  margin-bottom: 10px;
  font-family: var(--body);
  font-size: 15px;
  color: var(--muted);
  text-decoration: none;
}
.wayhome::before,
.stepback::before {
  content: "";
  position: absolute;
  left: 0;
  top: 50%;
  width: 6px;
  height: 6px;
  border: solid currentColor;
  border-width: 1px 1px 0 0;
  transform: translateY(-50%) rotate(225deg);
}

/*
 * THE BALLOT'S WAY BACK [L502] — the same object as `.wayhome`, pointing somewhere
 * else. `← Question 7`, or `← Your guesses` on a card reached from the sheet.
 *
 * WHY A SECOND CLASS RATHER THAN REUSING `.wayhome`. That class names ONE
 * destination and is locked to it: chrome_test asserts every `.wayhome` points at
 * root_path, that its label matches the footer door's word for word, and that only
 * `shared/_masthead` renders one. All three are load-bearing and all three would
 * have to be loosened to let this control borrow the name. Cheaper to duplicate
 * seven declarations than to weaken a lock — and `stepback_test` asserts the two
 * blocks stay identical on the properties that matter, so the duplication cannot
 * drift silently.
 *
 * Every value here is `.wayhome`'s and the reasoning above it applies unchanged:
 * the chevron ON the gutter rather than hanging outside it, the negative top margin
 * that stops the control floating in open ground, and 44px held tight so the box is
 * taller than the ink.
 */
.stepback {
  display: inline-flex;
  align-items: center;
  position: relative;
  min-height: 44px;
  padding-left: 13px;
  margin-top: -14px;
  margin-bottom: 10px;
  font-family: var(--body);
  font-size: 15px;
  color: var(--muted);
  text-decoration: none;
}

/*
 * A form's label on the admin roster. Same type as a section band, and it is NOT one:
 * it names a control rather than a band of the screen, and it sits 6px above its
 * field rather than opening 22px of air. Its own class so the band's margins cannot
 * arrive with it.
 */
.fieldlabel {
  font-family: var(--label);
  /* 14px, not `.sect`'s 13px. This class replaces what was an `.eyebrow` on the admin
     roster label, which is 14px — and 13px is below the NOTE floor, which
     text_roles_test freezes as a backlog that must not grow. Letterspaced caps get
     14px; see DESIGN.md § Text roles rule 3. */
  font-size: 14px;
  letter-spacing: .22em;
  text-transform: uppercase;
  color: var(--muted);
}

/*
 * TIER 1 — ANSWER — and the VOICES that are allowed to speak it.
 *
 * DESIGN.md § Text roles calls tier (how loud) and voice (who is talking) two
 * INDEPENDENT axes, and says conflating them is what produced the drift that
 * section exists to fix. This rule did exactly that: `.display` was the app's
 * only tier-1 declaration AND it hardcoded the Display voice, so a screen that
 * needed tier-1 mass in any other voice had no class to reach for and rolled
 * its own hierarchy instead.
 *
 * /address did, and got it backwards. `Wildwood Acres Resort` took 34px small
 * caps and the street it names took 19px — the same size, colour and face as
 * the footer's back-link — so the screen ranked chrome level with the one thing
 * it exists to hand over. That is L77 (a count at ANSWER tier) and L78 (the
 * guest's own name the faintest thing on her screen) a third time, from one
 * cause. The `.display--big` character gate further down is the same welding
 * showing through from the other side: a face picked by TIER rather than by JOB
 * breaks on long strings, so the seat door had to bound it at nine characters.
 *
 * So the axes are separate rules now. `.display` survives as the composite of
 * the two, because tier 1 in the Display voice is genuinely what the landing and
 * the seat door are, and their call sites should not have to spell out a pairing
 * that never varies. [L82]
 *
 * `/ballot/done` was the third call site named here and is gone [L85]. The
 * composite is unaffected — two sites still want it — but the list had to be
 * corrected rather than left standing: a comment naming a screen that no longer
 * exists is the kind of thing a future session reads as ground truth.
 */
.answer, .display {
  font-size: 34px;
  font-weight: 400;
  line-height: 1.15;
  color: var(--pine);
}

/* VOICE — Display. Baskervville SC, uppercase.
   .09em ~= the flat 3px the Zola site tracks its headings at, at this size.
   The old .18em was Cormorant compensation — Baskervville sets much wider. */
.voice-display, .display {
  font-family: var(--display);
  letter-spacing: .09em;
  text-transform: uppercase;
  /* Baskervville SC ships SHORT FIGURES by default: measured at 200px, a cap T
     inks 142.6px and every digit inks ~93px, so the numbers set at 65% of the
     caps beside them. On the seat screen that inverted the hierarchy of the one
     line that matters — `TABLE 12` rendered the word every guest already knows
     at full height and the answer they came for two-thirds size.

     Unlike the `tnum` case on EB Garamond above, `lnum` IS in this file's
     subset: measured, not assumed — with lining-nums the digit matches cap
     height. It belongs on the VOICE rather than on any one screen, because
     Baskervville SC is what ships the short figures — so every future line in
     this voice with a digit in it inherits the fix regardless of its tier. */
  font-variant-numeric: lining-nums;
}

/* VOICE — Plain. Libre Baskerville roman: what the guest reads and says out
   loud. The case that forced this voice off `.display` was /travel's address
   block (then `/address`): a sentence spoken to a rideshare driver rather than a
   fact put on display, so it needed tier-1 mass without the small caps [L82].
   It is no longer set at tier 1 — 20px, inside a card [L207] — and the voice is
   what survives that, which is the whole point of the two axes being separate. */
.voice-plain {
  font-family: var(--body);
  letter-spacing: 0;
  text-transform: none;
}

/* THERE IS NO `.voice-host`. It existed for one day [L202], for a /travel that
   opened with "Please take the shuttle" — a request from the couple, which is
   what the invitation's italic is for. That screen is two cards now and its
   names are labels rather than requests, so the class had no caller and went
   [L205]. `.greet` still hand-rolls the Host voice at a fixed 22px, which is the
   tier/voice weld L82 unpicked for Plain; it is the place to start if a second
   screen ever needs this voice. */

.question { font-size: 26px; line-height: 1.30; }

/* The line under a ballot question saying her first tap is final [L860]. Muted
   italic, the same Factual voice as the review sheet's deadline line. */
.card__final { margin-top: 10px; color: var(--muted); }

/* THE NAME BEING CHECKED, ON /join/confirm [L663]. Tier 1 — it is the one thing
   that screen exists to say — in the Plain voice, because a guest's own name in
   Baskervville SC small caps is a fact on display rather than a name being matched
   against herself.

   `.answer` sets the tier and no face; this rule sets neither, only the leading and
   the space under it. `text_roles_test` fails a `font-family` on a tier rule, which
   is why the voice is a second class in the markup rather than a line here.

   IT WRAPS, AND THERE IS NO LENGTH VARIANT. A `.greet--long`-style gate shipped
   here for one revision and rendering killed it: `Konstantin Petrov` sets on one
   line at 34px and `Wilhelmina Mwangi` does not, both 17 characters. A character
   count is a proxy for width and EB Garamond's glyphs are not uniform, so the
   boundary falls on both sides of any threshold. Two lines at tier 1 beat one line
   a tier down — DESIGN.md § Text roles, "where a line cannot be large, mass carries
   the tier" — and this screen has ~350px of slack under its buttons.

   `overflow-wrap` is the one guard that matters: a name must break at a SPACE, never
   inside a word, because matching a string against yourself is the whole job here.
   Every real shape does that on its own; this only bites a single token wider than
   the measure, where breaking still beats overflowing the viewport. */
.claimname { line-height: 1.15; margin-bottom: 2px; overflow-wrap: break-word; }

/* NOTE tier, and it disambiguates the answer rather than being it. Carries its own
   word (`Table 8`, never `8` under a caption), so the figure is labelled on its own
   line — CLAUDE.md's first rule about naked numbers. */
/* THE GAP BELONGS TO THE PAIR, NOT TO THE TABLE LINE. It sat on `.claimtable` for
   one revision, and the seating gate [L622] exposed it on the first screenshot: with
   the table withheld the name had 2px under it and the filled primary butted into
   its descenders. A margin on a conditionally-rendered element disappears with it. */
.claimwho { margin-bottom: 30px; }
.claimtable {
  font-family: var(--label);
  font-size: 15px;
  letter-spacing: .12em;
  color: var(--muted);
}

/* Only ever rendered for a name already on another phone, where the consequence is
   real and cannot be inferred. The free branch carries no line at all [L663]. */
.claimwarn { color: var(--muted); margin-bottom: 26px; }
.micro    { font-family: var(--label); font-size: 15px; letter-spacing: .12em; color: var(--muted); }

.rule { height: 1px; background: var(--hair); border: 0; margin: 12px 0 26px; }

/*
 * THE FLOW REGISTER'S RULE — ballot cards only, and the one thing that tells a guest
 * she is inside something rather than reading something. [L74]
 *
 * Two-tone, not a bar: `--hair-strong` for the questions behind her, `--hair` for the
 * ones ahead. Both weights are already in the app and already mean adjacent things.
 * DESIGN.md § Banned was AMENDED to permit this rather than deviated from — the ban's
 * neighbours are confetti, streak flames, badges and countdown timers, which are
 * reward-loop tells; position in a form is wayfinding.
 *
 * 2px, not the masthead's 1px. The two tokens are .20 and .38 alpha, and at one pixel
 * that difference does not survive a phone held outdoors under a canopy. Verified by
 * screenshot at 375x667 in both palettes.
 *
 * A CHILD SPAN, NOT A GRADIENT AND NOT A PSEUDO-ELEMENT. `linear-gradient` with hard
 * stops renders identically and is the usual way to do this, but DESIGN.md bans
 * gradients outright and a reviewer greps for the word, not for the rendering. A
 * pseudo-element was the other option and this file already carries the scar: the
 * selected row computed correctly and was invisible because
 * `isolation: isolate` changes pseudo-element paint order. A real element cannot be
 * repainted out from under itself.
 *
 * The margin matches `.rule` exactly so swapping registers moves nothing below it.
 */
.rule--flow {
  height: 2px;
  background: var(--hair);
  border: 0;
  margin: 12px 0 25px;
}
.rule__behind {
  display: block;
  height: 100%;
  background: var(--hair-strong);
}

/* --------------------------------------------------------- answer rows -- */

.opt {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 14px;
  min-height: var(--row-h);
  padding: 0 2px;
  /* border: 0 FIRST. A <button> carries a UA border on all four sides, so setting
     only border-top leaves three stray edges and every row draws as a box — which
     is precisely the thing DESIGN.md forbids. Separation is a hairline, never a box. */
  border: 0;
  border-top: 1px solid var(--hair);
  border-radius: 0;
  -webkit-appearance: none;
  font-size: 19px;
  color: var(--ink);
  background: none;
  width: 100%;
  text-align: left;
  font-family: var(--body);
  cursor: pointer;
  /* The Press: option compresses on tap, releasing slow. */
  transition: transform 160ms cubic-bezier(.2, .8, .3, 1);
}
/* The closing hairline lives on the wrapper, not on :last-child. Each .opt sits in
   its own button_to <form> with a hidden CSRF input after it, so the button is never
   actually the last child and :last-child silently matches nothing. */
.opts { border-bottom: 1px solid var(--hair); }
.opt:active { transform: scale(.985); transition-duration: 90ms; }

/*
 * THE MARK: a 16px ring at the head of every answer, hollow at rest and carrying
 * a gold dot when it is her pick. A radio button. [L83]
 *
 * WHAT IT FIXES. Measured on the running app at 375x667, not argued: an answer
 * option and the question above it were the SAME OBJECT in every property but
 * one. Both Libre Baskerville 400, both `--ink` rgb(252,248,245), the hairline
 * above the first option identical in weight to the ones between options, and
 * `.opt` carried no child but `.opt__text` — no arrow, no icon, no mark. The
 * only thing separating a tap target from prose was 7px of font size.
 *
 * Worse, the screen contradicted itself. The footer's way-home door on this very
 * card carries a `←`, and one tap back all three landing doors carry a `→` — the
 * same full-bleed 76px hairline row, PLUS a mark. So a guest learns "row with a
 * mark = control" on the landing and then meets four rows without one, which are
 * the only four things on the screen she is meant to touch. L78 rejected a
 * tablemate list for being "inert rows that look tappable"; this is that defect
 * inverted, and it had shipped.
 *
 * The row height was never the problem — 76px is ~2x Apple's 44pt floor (L30).
 * Affordance was.
 *
 * WHY A RING, AFTER THREE OTHER SHAPES WERE RENDERED AND REJECTED. A single-select
 * set gets one of three treatments in the category: a selection control per option
 * (a radio), a bounded object per option (card or pill), or a trailing disclosure
 * mark (the iOS chevron). DESIGN.md bans cards, boxes and pill buttons outright,
 * so the middle one is out. The trailing arrow went first: four right arrows say
 * "four destinations", forward in this flow is the tap itself, and L79 had just
 * cut this screen's only `→` for pointing sideways.
 *
 * That left a mark at the head of the line, and the first two attempts at one both
 * failed BY LOOKING AT THEM, which is the only way either could have been caught:
 *
 *   - A 7px SQUARE, HOLLOW. This is the shape the app already owns — the running
 *     order's rail mark, the standings' called-answer mark. At 7px with a 1px
 *     edge it does not read as a control at all. It reads as tofu: the empty
 *     rectangle a browser draws for a glyph the font cannot supply. Mike, on the
 *     rendered screen: "the [] square looks broken. It looks like that broken
 *     unicode symbol." An affordance that reads as a font failure is worse than
 *     no affordance, because now the screen looks broken as well as inert.
 *   - A 7px SQUARE, FILLED. Kills the tofu read and costs no rule change at all.
 *     It reads as a bulleted list — four content items, not four controls. Solves
 *     the glyph problem and not the job.
 *   - AN 18px SQUARE, HOLLOW. Unmistakably a control, and stays radius 0. It is
 *     also literally a box, which is § Layout's hardest ban; it is a CHECKBOX,
 *     whose semantics are multi-select on a pick-one question; and at the 34px
 *     indent it needs, "Someone in the wedding party" wraps to two lines at 375.
 *
 * Size is what separates a control glyph from a character. Below ~12px an
 * outlined shape reads as broken type, and a filled one reads as a bullet; a ring
 * at 16px reads as the radio button every guest has used since paper forms. That
 * is the whole reason this one works where the square did not, and it is why the
 * fix was a shape change rather than a colour or weight change.
 *
 * BORDER-RADIUS: 50% AND THE RULE IT SPLIT. § Layout says "radius: 0. Everywhere",
 * and L80 had closed its exception list at three THAT SAME DAY, in bold: "A FOURTH
 * RADIUS IS A BUG, not a precedent." This ring would have been the fourth, so it
 * was put back to Mike rather than taken quietly, and the rule now counts two
 * classes separately (Mike's call, on the collision):
 *
 *   - CORNER RADII on bounded objects — the game block, the rules card, the stamp
 *     ground. Still three, still CLOSED, still a bug if a fourth appears.
 *   - CIRCLES at 50% — the stamp, and this ring. Two, named.
 *
 * The split is not a dodge, and the test for it is what each radius DOES: a corner
 * radius softens a bounded object, which is what § Layout's ban is actually about
 * ("no cards, no boxes"). A radio is a mark that contains nothing, and the stamp —
 * already exception one — is the same 50% at a larger size. A THIRD CIRCLE needs
 * the argument this one had: a rendered comparison against the radius-0 shape, and
 * a reason the curve is doing semantic work the square cannot. Here it is: a 16px
 * square is a checkbox, and a checkbox on a pick-one question is the wrong promise.
 *
 * COSTS NOTHING VERTICAL. Measured at 375x667 and 360x640: page height 663 with
 * the ring and 663 without it, and no option wraps at 375 that did not already
 * wrap without it. The fold guarantees at L30/L73/L79 are untouched.
 *
 * It also carries the chosen state a second way. `.opt--chosen` marked a pick with
 * a lifted `--surface` ground, a gold inset rule at the screen edge and the YOUR
 * PICK label; the gold dot now says it at the head of the line too, where the eye
 * starts. Colour is not carrying that alone — the ring FILLS as well as changing
 * hue, which is what keeps it legible to a colourblind guest and on a sun-washed
 * screen, the same standard `.opt--miss` is held to.
 *
 * `:not(.opt--read)` IS LOAD-BEARING, NOT TIDINESS. `shared/_call.html.erb`
 * reuses `.opt` and `.opt__text` for the RESULT rows on the standings, which are
 * inert. Without the guard those four rows get rings and three of them read as
 * tappable — the exact bug this rule exists to remove, relocated one screen over.
 * Rendered before the guard was written, so this is a fixed regression rather
 * than a hypothetical. The padding is scoped too: unguarded it indents the result
 * rows 30px for a mark they never draw.
 *
 * Centred with top/translateY rather than an em offset so it holds when the
 * option wraps to two lines, which "Someone in the wedding party" does below
 * 375px.
 */
.opt:not(.opt--read) .opt__text {
  position: relative;
  padding-left: 30px;
}
.opt:not(.opt--read) .opt__text::before {
  content: "";
  position: absolute;
  left: 0;
  top: 50%;
  transform: translateY(-50%);
  width: 16px;
  height: 16px;
  border-radius: 50%;
  border: 1px solid var(--hair-strong);
  background: none;
}
/* The dot is an inset shadow rather than a second pseudo-element: `.opt__text`
   already spends its ::after on `.opt--miss`'s struck rule, and a ring that
   needed two children could not sit on a span that is sometimes struck through.
 *
 * `.opt--pressing` FILLS THE RING TOO, and that is the whole point of listing it
 * here rather than under the Press block below [L87]. The press swept the surface
 * in, brightened the text and drew the gold rule, and left the one mark on the row
 * that MEANS selected sitting hollow for the entire 220ms — so for the length of
 * the hold the screen said "this row is being touched" and never "this is your
 * answer". The radio only filled after the round trip, which on venue wifi is the
 * moment a guest is no longer looking.
 *
 * No transition on it. A control's state is not a thing that eases; the surface
 * sweep is the motion and the mark is the fact. */
.opt--chosen:not(.opt--read) .opt__text::before,
.opt--pressing:not(.opt--read) .opt__text::before {
  border-color: var(--gold);
  box-shadow: inset 0 0 0 4px var(--gold);
}

/*
 * "The Press" — DESIGN.md § Motion.
 *
 * The ink arrives: a fill sweeps the row from the left over 220ms, then the page
 * moves. Without this the tap navigates instantly and the choice never visibly
 * registers — you press a row and are simply somewhere else.
 *
 * The fill bleeds past the gutters via negative offsets rather than animating
 * margin, which would reflow the whole column mid-sweep.
 */
.opt { position: relative; isolation: isolate; }
.opt::before {
  content: "";
  position: absolute;
  left: calc(var(--gutter) * -1);
  right: calc(var(--gutter) * -1);
  top: 0;
  bottom: 0;
  z-index: -1;
  background: var(--surface);
  transform: scaleX(0);
  transform-origin: left;
  transition: transform 220ms cubic-bezier(.16, 1, .3, 1);
}

.opt--pressing { color: var(--ink); }
.opt--pressing::before { transform: scaleX(1); }
.opt--pressing .opt__share { color: var(--gold); }

/*
 * The row keeps cream text and marks with gold, same as .opt--chosen — a bright
 * fill in a dark canyon is the flashlight problem, and on a dark ground there is
 * no lighter fill available that is not one.
 *
 * The gold rule lives on ::before, NOT on the button's box-shadow. `isolation:
 * isolate` makes each row its own stacking context, and inside one a
 * z-index:-1 pseudo-element paints AFTER the element's own background and
 * box-shadow — so an inset shadow here is drawn and then immediately covered by
 * the ink. Putting the rule on the ink itself also lands it at the screen edge,
 * matching .opt--chosen, which is full-bleed via negative margins.
 */
.opt--pressing::before { border-left: 3px solid var(--gold); }

.opt__share {
  font-family: var(--label);
  font-size: 15px;
  letter-spacing: .06em;
  color: var(--muted);
  font-variant-numeric: tabular-nums;
}

/*
 * Chosen / correct: a slightly lifted surface, cream text, and a 3px gold inset
 * rule doing the marking.
 *
 * The whole product sits on the invitation's green, so there is no cream ground
 * left for a pine fill to sit on. Worth keeping the original warning attached to
 * it: the
 * first draft carried a `background: var(--pine); color: var(--paper)` rule
 * across from the cream ground, and since --pine resolves to cream on a dark
 * ground that produced dark-on-dark and the selected row vanished, on the most
 * important screen in the app. It was only caught by rendering it.
 *
 * A cream fill would solve contrast and reintroduce the flashlight problem, so
 * the row lifts slightly instead and the gold rule marks it at no brightness cost.
 *
 * The old rule here said to check any new selected state in BOTH palettes. There
 * is only one now [L460], so the check is against this ground alone — but the
 * failure it guarded against is still live: any token whose value assumes a cream
 * ground goes invisible on green.
 *
 * NO BALLOT CARD RENDERS IT since L860: a guessed question has no card, so the
 * press is the only moment a pick is drawn. Kept because `.playrow`, `.lb__row--you`
 * and the seat row cite it as the pattern for "this row is yours".
 */
.opt--chosen {
  background: var(--surface);
  color: var(--ink);
  margin: 0 calc(var(--gutter) * -1);
  padding: 0 var(--gutter);
  /*
   * The width has to be restated, and this was shipped wrong. `.opt` sets
   * `width: 100%`, and a percentage resolves against the containing block — the
   * column INSIDE the gutters. With the negative margins the chosen row therefore
   * started at the screen edge and stopped 40px short of the other one: a fill
   * that bled left and not right, on the screen a guest taps fifteen times.
   *
   * Every declaration read correctly on its own and no test could see it, because
   * a computed style is not a painted box. Measured with getBoundingClientRect on
   * the real page. `.opt::before` — the press ink — was always right, because it
   * bleeds with absolute left/right offsets rather than a width.
   */
  width: calc(100% + var(--gutter) * 2);
  border-color: transparent;
  box-shadow: inset 3px 0 0 var(--gold);
}
.opt--chosen .opt__share { color: var(--gold); }

/* Missed. Never signalled by colour alone — a struck rule survives a colourblind
   guest, a sun-washed screen, and a phone at 40% brightness. */
/* Result rows on the Call block. Nothing here is tappable, and inheriting .opt
   gave them the ballot's 76px row and its pointer cursor — the exact appearance of
   the control she just spent fifteen taps learning. The stamp and the percentages
   already say "this is a result", so this only has to break the rhythm. */
.opt--read {
  cursor: default;
  min-height: 64px;
  transition: none;
}

.opt--miss { color: var(--miss-ink); }
.opt--miss .opt__text { position: relative; }
.opt--miss .opt__text::after {
  content: "";
  position: absolute;
  left: -2px; right: -2px; top: 52%;
  height: 1px;
  background: var(--miss-rule);
}

/* ----------------------------------------------------------- arrival -- */

/*
 * The first screen after the QR scan. It reproduces the invitation rather than
 * introducing a masthead: the couple's names at display size, the date spelled
 * out beneath them, a short rule, then the ask.
 *
 * The 12px mono masthead used everywhere else is deliberately absent here. It
 * was the inversion this screen suffered from — the one piece of type on the
 * page belonging to nobody else was the smallest and faintest thing on it.
 */
/*
 * padding, NOT a margin on the h1. `.arrival` is the vine's positioning context,
 * and a top margin on its first child collapses THROUGH it — which drags the
 * container's top edge down and takes the absolutely-positioned vine with it,
 * about 112px in practice, straight into the lede. Caught by rendering the real
 * app; the standalone prototype had a spacer div and never showed it.
 */
.arrival { position: relative; padding-top: 40px; }

/*
 * The masthead must never run into the vine's column. This is a real constraint,
 * not a preference: it broke once already when the display face changed from
 * Cormorant SC at .18em to Baskervville SC at .09em, which is narrow enough that
 * "LIAN & MICHAEL" stopped wrapping and ran straight through the drawing —
 * 9 overlaps, 38px deep.
 *
 * 260px is the loosest width that still forces the wrap, so a longer event name
 * is not over-constrained. Anything from 200 to 280 measures identically
 * (0 overlaps, 11px clear); do not tighten it without re-measuring.
 */
.arrival .display { max-width: 260px; }
.arrival__date {
  font-family: var(--body);
  font-style: italic;
  font-size: 16px;
  color: var(--muted);
  margin-top: 8px;
}
/* Short, not full-bleed: a printer's rule under a name, not a section divider. */
.arrival__rule { height: 1px; background: var(--hair); border: 0; width: 72px; margin: 14px 0; }

/*
 * Rule, the motion — DESIGN.md's only loading, locking and section-change device,
 * and until now it fired nowhere. This stylesheet carried no @keyframes at all,
 * so nothing moved on the one screen all ~170 guests see, on a product whose
 * whole delight budget is motion because there is no ornament anywhere else.
 *
 * It draws under the couple's names, left to right, once, after the type has
 * painted. 380ms on the sheet's own easing. `both` holds the from-state through
 * the 120ms delay so the rule does not flash at full width and then redraw.
 *
 * NOT keyed to scaleX(1) in a `to` block: the animation ends on the element's own
 * computed transform. If animations are disabled outright the rule is simply
 * drawn, which is the correct resting state rather than an invisible hairline.
 * The prefers-reduced-motion block already collapses the duration to .01ms.
 */
@keyframes rule-draw { from { transform: scaleX(0); } }

.arrival__rule {
  transform-origin: left center;
  animation: rule-draw 380ms cubic-bezier(.22, 1, .36, 1) 120ms both;
}

/* ------------------------------------------------------------- finder -- */

/*
 * The roster screen moved off the arrival screen to /table with Two Worlds, and
 * these four rules moved with it. They were named `.arrival__*` when the arrival
 * screen WAS the roster screen; the landing no longer carries a field or a list,
 * so a rule scoped to `.arrival` now matches nothing on the page it was written
 * for. Renamed rather than left dangling, because a selector that matches nothing
 * is indistinguishable from one that is simply not doing its job.
 */

/*
 * `.finder__ask` is GONE, and so is the caps placeholder that went with it. Both
 * were fixes aimed at the wrong defect.
 *
 * The screen used to open with "Find your name" at 24px --body in full --ink over
 * a hint reading A FEW LETTERS IS ENOUGH. Every revision of it argued about the
 * two lines' registers and sizes, and none of them asked whether the thing
 * underneath looked like a control. It did not: an input whose only mark is a
 * bottom hairline is, on this palette, the same object as a `.namerow` — a
 * left-aligned serif line on a full-width rule. So the loudest type on the screen
 * was a SENTENCE, the control was invisible, and the caps hint read as a second
 * caption rather than as text inside a field. Three muted caps lines then stacked
 * down the screen with no rank between them, which is the symptom that got
 * reported; this was the cause.
 *
 * The fix is to make the field a field — `.search` below, with a magnifier in it —
 * and then the header has nothing left to do. "Find your name" over a search box
 * ghosted "Search for your name" is the same instruction twice, and the masthead
 * already names the screen a third time. It survives as a `.visually-hidden`
 * label so the field keeps its accessible name.
 *
 * The caps placeholder falls with it. That rule came from the admin roster search
 * (`.field--search::placeholder`), where it stops a 19px --body placeholder
 * outshouting the 14px caps label above it. That is a real inversion on THAT
 * screen. Here the label was 24px serif, so there was never anything to invert —
 * the rule was carried across on the strength of its comment, and its only effect
 * was to make the one control on the screen look like a caption. The ghost text is
 * --body serif now, at the field's own size, which is what a search field looks
 * like everywhere else the guest has ever used one.
 */

/*
 * The search field. The wrapper owns the hairline and the focus colour, not the
 * input, because the magnifier has to sit ON the line with the text rather than
 * above a line the input draws under itself.
 *
 * min-height 52px, not the ~43px the input's own padding gives: CLAUDE.md's 44px
 * floor is a hard constraint and this is the only control on the screen.
 *
 * The icon is an inline SVG, never an emoji — § Banned. `currentColor` on both
 * strokes is what makes one rule flip it on focus, and it means the icon follows
 * the palette rather than carrying a literal of its own. It is
 * `aria-hidden`: the label already names the field, and a screen reader
 * announcing "magnifier" adds nothing.
 */
.search {
  display: flex;
  align-items: center;
  gap: 12px;
  min-height: 52px;
  border-bottom: 1px solid var(--hair-strong);
}
.search:focus-within { border-bottom-color: var(--gold); }

.search__icon {
  width: 20px;
  height: 20px;
  flex: 0 0 auto;
  color: var(--muted);
  transition: color 160ms cubic-bezier(.2, .8, .3, 1);
}
.search:focus-within .search__icon { color: var(--ink); }

/* The wrapper draws the line; a second one here would double it. */
.finder .field { border-bottom: 0; }

/*
 * THE RULES CARD, on the Play door only [L80].
 *
 * A guest reaching this screen is one tap from being claimed and dropped into
 * question 1, and until this shipped the screen said none of that: not what the
 * game is, not that tapping WRITES, not when guessing shuts. The deadline lived
 * on the landing and nowhere else in the flow, so a guest arriving by direct QR
 * never saw it at all.
 *
 * IT IS A BOX, AND THAT BREAKS TWO DESIGN.md RULES ON PURPOSE — both amended in
 * the same change, per L73's precedent that an unamended override is how the next
 * session reverts by reading the file:
 *
 *   * § Layout "No cards, no boxes, no group borders". Separation everywhere else
 *     is still a 1px hairline. The frame is what makes this read as an object you
 *     were handed rather than a section of the page, which is the whole metaphor:
 *     the rules leaflet in a game box.
 *   * § Layout "Border radius: 0. Everywhere." L73 took the first exception for
 *     the landing's game block and warned that a second radius would mean the
 *     exception was being read as a licence. This is that second radius, taken
 *     deliberately, and the exception is now CLOSED AT TWO. A third is a bug.
 *
 * GOLD AS A 1px FRAME, not a fill. The token says FILL/MARKS ONLY and a hairline
 * frame is a mark. The fill was rendered first and cut by Mike, 2026-09-04: a
 * gold field here made the roster look like an afterthought under an advertisement.
 * (He also said it restated the landing's block, which was gold then and is sage
 * now [L680] — that half of the reason is spent; the roster half is not, and is why
 * this stays a frame.)
 *
 * NEVER GIVE THIS A BACKGROUND. `.rulecard__label` knocks itself out of the top
 * border with `background: var(--paper)`, exactly as `.world__label` does. On a
 * filled card that paints a dark strip behind the label and reads as a bug — the
 * failure the killed list already records against the `.world` treatment.
 */
.rulecard {
  position: relative;
  margin: 22px 0;
  padding: 18px 16px 16px;
  border: 1px solid var(--gold-ink);
  border-radius: 12px;
}

/*
 * IT IS THE SCREEN'S h1 [L81], not a caption on a box — so the margin reset and
 * the weight are load-bearing rather than tidying: a UA h1 is 32px bold with
 * 21px of margin, which would push the notch off its own border.
 *
 * The Play door wears no masthead now. This line replaced it, because
 * `JOIN THE GAME` over a hairline sat 39px above `HOW TO PLAY` in this notch —
 * two letterspaced caps lines in the same register announcing the same thing.
 * It is the same 14px/.22em/uppercase the masthead sets, so the guest reads the
 * screen's name in the type she reads it in everywhere else; only its position
 * and colour changed.
 */
.rulecard__label {
  position: absolute;
  top: -9px;
  left: 14px;
  margin: 0;
  padding: 0 10px;
  background: var(--paper);
  font-family: var(--label);
  font-size: 14px;
  font-weight: 400;
  letter-spacing: .22em;
  text-transform: uppercase;
  color: var(--gold-ink);
}

/*
 * THE ALPHABET GROUPS [L81], and they exist as a SCROLL CUE, not as decoration.
 *
 * A partially visible row is the only wordless signal that a list continues, and
 * on this roster it measured 5px at 375x667, 10px on the seat door and 8px at
 * 390x844 — the remainder of a viewport divided by a 58px row, which no
 * stylesheet can pin. With the rules card above it, four clean names ending just
 * before the fold read as a complete list of four. An `A` group and a `B` group
 * both starting on the first screen say "alphabetical, and longer than this" at
 * every viewport height.
 *
 * It is also the object this screen has always been imitating: the view's own
 * comment says she reads this the way she reads the escort card board at the
 * door, and an escort board is grouped by letter. Ungrouped, it copied the board
 * without the one property that makes a board usable.
 *
 * STICKY, so the letter she is inside stays on screen while she scrolls 170
 * names. `top: 0` and not a header offset: nothing else on this screen is fixed.
 */
.alpha {
  position: sticky;
  top: 0;
  z-index: 2;
  /* Opaque, or the names scroll THROUGH the letter. --paper rather than a
     transparent ground for exactly the same reason `.world__label` carries one. */
  background: var(--paper);
  margin: 0;
  padding: 10px 0 6px;
  border-bottom: 1px solid var(--hair-strong);
  font-family: var(--label);
  font-size: 14px;
  font-weight: 400;
  letter-spacing: .22em;
  color: var(--gold-ink);
}

/*
 * `.rostergroup` deliberately has NO RULE. The wrapper is what the filter hides —
 * header and rows together, since hiding the header alone leaves a lone `A` above
 * nothing on a search for "zane" — and a div is already block, so the only thing
 * a `display` here could do is the damage this file already documents: an author
 * `display` beats the UA stylesheet's `[hidden]`, which is why line 158 carries
 * `!important`. Styling this wrapper means re-reading that comment first.
 */

.rulecard__steps {
  list-style: none;
  margin: 0;
  padding: 0;
}

.rulecard__step {
  display: flex;
  gap: 12px;
  align-items: flex-start;
  margin-bottom: 16px;
}
.rulecard__step:last-child { margin-bottom: 0; }


/*
 * The numeral is the design device, and it only works because of `lining-nums`
 * on .display: Baskervville SC ships short figures, so before that property
 * landed a 26px "1" inked at 65% of the caps beside it and read as a footnote
 * marker rather than a step number. Do not remove it from .display.
 *
 * aria-hidden, and the list is an <ol>: the marker is already announced by the
 * list semantics, so a visible numeral that is also readable text gets read out
 * twice.
 */
.rulecard__num {
  flex: none;
  width: 30px;
  font-family: var(--display);
  font-variant-numeric: lining-nums;
  font-size: 26px;
  line-height: 1;
  color: var(--gold-ink);
}

/*
 * SAY TIER, NOT ASK [L401]. 17px at full `--ink` with 1.25 leading was legal
 * while a NOTE-tier sub-text sat under each title and did the ranking; with the
 * sub-texts gone [L400] every line in the card is a step title, two of them wrap
 * to three lines, and six lines of full-contrast serif at 1.25 read as one dark
 * block rather than three steps. Mike, 2026-09-07: *"the font size makes the
 * instructions really heavy."*
 *
 * The fix is the COLOUR STEP and the LEADING, not the size — § Text roles is
 * explicit that the colour is what ranks a tier and "the size never was", and
 * this screen is designed against a 70-year-old aunt reading outdoors, so
 * shrinking her instructions is the wrong direction. 17px `--ink-soft` at 1.4 is
 * the SAY row of the tier table exactly, and matches `.greet` and `.reveal`.
 *
 * It also restores the rank the sub-texts used to carry: the gold numerals are
 * now the only full-contrast marks in the card, so the eye counts three steps
 * before it reads a word of any of them.
 */
.rulecard__what {
  display: block;
  font-size: 17px;
  line-height: 1.4;
  color: var(--ink-soft);
}

/*
 * The step's text column. The <li> is the flex row (numeral | text), so a step
 * with two lines needs them stacked inside ONE flex item — without this wrapper
 * a second span becomes a third column and sits beside the title.
 */
.rulecard__text {
  display: block;
  flex: 1;
  min-width: 0;
}

/*
 * SUB-TEXT, ON STEP 3 ONLY [L500]. NOTE tier in the Host voice — the couple
 * asking, not the app instructing, which is why it is the one italic in a card
 * of roman procedure.
 *
 * It ranks below `.rulecard__what` on all three of {face, size, colour}, which
 * matters more here than anywhere else in the card: its own step title is the
 * dated fact a guest came for, and a sub-text set any louder would take the line
 * the title is trying to hold. 15px `--muted` is the NOTE row of the tier table;
 * italic is the voice.
 *
 * 6px of top gap, not more. `.rulecard__step` already pays 16px between steps,
 * and the sub-text has to read as attached to step 3 rather than floating
 * between 3 and the roster below it.
 */
.rulecard__how {
  display: block;
  margin-top: 6px;
  font-family: var(--body);
  font-style: italic;
  font-size: 15px;
  line-height: 1.4;
  color: var(--muted);
}

/*
 * The list needs NO opener — no label, and no rule either.
 *
 * L562 put `All 170 guests, by last name` here for a day and L564 moved it to the
 * seat screen's tablemates, which is the list Mike was asking about. It is not
 * kept in both places: the masthead and the field already introduce these rows,
 * which is the same objection the three versions below ran into.
 *
 * Three versions of it shipped in one session and only the third was honest.
 * First, "Or scroll the list" as a plain `.eyebrow` 32px below the field, which
 * made it the THIRD 14px muted caps line down the screen after the masthead and
 * the field's hint; all three read as sibling captions with no rank between them.
 * Second, the same label knocked out of a `--hair-strong` rule, borrowing the
 * `.world` treatment the landing opens WEDDING DAY INFO and THE GAME with — which
 * did rank it, and was scaffolding around a defect one layer down: the input did not
 * look like a control, so the loudest thing on the screen was a sentence. Fixing
 * that (see `.search` above) left the label saying nothing the screen did not
 * already show.
 *
 * The rule went with the label rather than surviving it. Label cut, rule kept was
 * rendered: two full-width rules ~30px apart with an empty band between them,
 * which reads as a blank row. That is this comment's own former warning —
 * "without it the input reads as an empty row" — arriving from the other
 * direction, and it is why this is both-or-neither rather than a taste call.
 *
 * What ranks the list now is weight, not a line of its own. `.search` closes on
 * `--hair-strong` (.38) and every `.namerow` divides on `--hair` (.20), so the
 * heavier rule reads as the boundary between the control and the data. On the
 * Play door that boundary is still bare: `.namerow` divides on border-BOTTOM, so
 * the field's underline sits directly above the first name with nothing doubled.
 * Checked, because the obvious assumption was border-top and a 26px gap was
 * nearly added to fix a collision that does not exist. The seat door's label
 */

/*
 * A "see all N names" button lived here through three revisions and is gone. It
 * was justified as the route back for a guest who mistyped, but while she is
 * typing the only thing worth her attention is the names she is filtering
 * toward. Backspace is the way back. If it is ever reproposed, the reasons it
 * failed were placement (above the results it is an exit sign hung before the
 * room) and weight (promoted, it outranked the name she had just found).
 */

/*
 * The tablemates, on /find/:id. Body type at full --ink and directly under the
 * table number, because these names are doing two jobs at once: they are the
 * answer she came for, and they are what tells her she tapped the right Mike
 * Smith. A muted caption would have made the disambiguator the faintest thing on
 * the screen — better than any "(Table 3)" suffix on a roster row, and only if
 * it is legible at a glance.
 *
 * Full names, never first names. `name.split.first` renders "Aunt Deb" as AUNT
 * and "Plus One" as PLUS, and a wedding roster is exactly where that breaks.
 *
 * SAY tier (--ink-soft), not ASK (--ink). It shipped at full --ink and sat 2px
 * and zero colour-steps away from every other body line in the app, which is the
 * "primary and secondary are too close" failure the tier system exists to end.
 * Still the loudest prose on the screen and still a full AA pass at 8.47:1 — it
 * is supporting the table number, not competing with it.
 */
.mates {
  /* 0, because `.matehead` above it owns the gap off the drawing now [L564]. */
  margin-top: 0;
  color: var(--ink-soft);
  list-style: none;
  /*
   * 1.28, not body's 1.55, and the leading is what buys the fold on this screen
   * [L421, tightened at L564]. A list of nine short strings is not a paragraph:
   * prose leading exists to carry the eye ACROSS a measure and back, and nothing
   * here is longer than a name. Tightening body prose this far would be the drift
   * DESIGN.md § Text roles exists to stop; this is a different object.
   *
   * The last 0.07 of it paid for `.matehead`: nine rows at 1.35 are 207px and at
   * 1.28 are 196px, and the header costs 31. The rest of the header's cost comes
   * off the bottom of the screen — see `.matehead`.
   *
   * The SIZE is untouched at body's 17px. That floor is the one this app is
   * designed against and is not available to spend.
   */
  line-height: 1.28;
}

/*
 * THE TABLEMATES' HEADER [L564]. `AT YOUR TABLE` — `.eyebrow`'s register, 19px
 * caps `.12em` `--ink`, on the one screen where that register is free.
 *
 * Mike, 2026-09-07, asked for a header introducing the list of people and meant
 * THIS list, not the roster on /table. L421 had cut exactly this line, with a
 * measurement: nine rows plus an "At your table" line came to 254px against a
 * 218px budget, and the label was judged the half a guest can infer. That call is
 * reversed, and the fold cost is real rather than argued away — see the ledger.
 *
 * WHY THE MASTHEAD'S REGISTER IS AVAILABLE HERE: /find/:id wears no masthead
 * [L74 exception], so 19px letterspaced caps `--ink` collides with nothing. It is
 * unmistakably a different KIND of line from the 17px sentence-case `--ink-soft`
 * names under it — face, case and colour all step — which is what makes it read
 * as a header rather than as the list's first item. A 21px sentence-case line in
 * the body face was the alternative and is 2px and one colour-step from the names.
 *
 * The `<h2>` also gives this screen a heading below its `<h1>`, so a screen
 * reader walks table number -> the people at it. `.mates` loses its `aria-label`
 * with this change: the heading says the same words out loud, and both would
 * announce "At your table" twice in a row.
 */
.finder-result .matehead {
  font-family: var(--label);
  font-size: 19px;
  letter-spacing: .12em;
  text-transform: uppercase;
  line-height: 1.2;
  color: var(--ink);
  /* 12 off the drawing — the gap `.mates` used to own — and 6 to the first name,
     which is closer to the list than to the picture, so the two group. Both were
     4px larger for one commit; the fold spent them [L565]. */
  margin: 12px 0 6px;
}

/* ---------------------------------------------------- the two worlds -- */

/*
 * Two worlds — WEDDING DAY INFO and THE GAME — and the guest may take either,
 * both or neither. The grouping is grouping treatment C from the studies: a
 * strong rule OPENS each world with its label knocked out of that line, and
 * faint hairlines divide the doors inside it.
 *
 * The first world was YOUR SEAT while it held one door. It holds three now — the
 * table, the running order and the address — and they are one world because they
 * are one kind of thing: facts about the day, no name required, no game attached.
 *
 * The line does the ranking, which is why the label can stay quiet: weight alone
 * says outer versus inner, so nothing has to shout to be a heading. That is also
 * why there is no box here. A group border would have been the obvious way to say
 * "these three belong together" and it is the one thing DESIGN.md § Layout
 * forbids outright — separation is a hairline, never a box.
 *
 * The same grammar carries the game unit on /find/:id and on the standings, so a
 * guest learns one shape and meets it three times.
 */
.world {
  position: relative;
  margin-top: 30px;
  border-top: 1px solid var(--hair-strong);
}

/*
 * 30px above the first world as well as between them — no exception for the first.
 *
 * `.world:first-of-type { margin-top: 26px }` sat here and never applied once.
 * `:first-of-type` matches the first element of that TAG among its siblings, and
 * inside `.arrival` the first div is `.arrival__date`, not `.world`. Measured at
 * 375x667, the gap under `.arrival__rule` has always been 30px — identical to the
 * gap between the two worlds.
 *
 * Switching the 4px on now would be a NEW change to the one screen all ~170 guests
 * see, not a restoration of intent: 26px has never been on a phone, and every
 * review, screenshot and flow-preview row of this screen is the 30px version.
 *
 * It would also re-open a decision that was made with the measurement in front of
 * it. When Wedding day info grew to three doors on 2026-09-04 the landing went
 * over the fold — the standings door now ends ~9px below it at 375x667 signed out
 * — and buying that back by tightening the vertical rhythm was considered and
 * REJECTED, because 20px between the worlds leaves 1.3px of margin and reads as
 * ungrouped. The fold guarantee in fold_test.rb was narrowed instead. 4px here
 * does not restore the old guarantee and does quietly re-litigate that call.
 *
 * If a tighter opening is ever actually wanted, `.arrival__rule + .world` is the
 * selector that works. `:first-of-type` is not, on any element in this file.
 */

/*
 * The knockout. `background: var(--paper)` is load-bearing and must stay a token:
 * the label sits ON the rule and hides the span of it that it covers, so a
 * hard-coded green goes wrong the moment the press flips at 6:30pm and the ground
 * drops to #161E17 — the rule would reappear as a bright bar through the word.
 *
 * On the landing this also passes in front of the vine, which is why the ground
 * is opaque rather than a gap in the border: the drawing runs behind the label
 * and a transparent knockout would put leaves through the type.
 *
 * -9px is half the label's line box, so the rule crosses its optical middle.
 * Full --ink, not --muted: it is the only thing naming the world.
 */
/*
 * Self-contained since L74. It used to be `.eyebrow .world__label`, overriding the
 * eyebrow's colour and adding the knockout — a third altitude riding the masthead's
 * class. It is neither a masthead nor a `.sect`: it OPENS a group, sitting on the
 * group's own `--hair-strong` rule at full `--ink`, which is why it is the only one
 * of the three that is not `--muted`.
 */
.world__label {
  font-family: var(--label);
  font-size: 14px;
  letter-spacing: .22em;
  text-transform: uppercase;
  display: inline-block;
  position: relative;
  top: -9px;
  background: var(--paper);
  padding-right: 14px;
  color: var(--ink);
}

/*
 * A door. Full-bleed to the screen edge like every other row in the app, ≥76px
 * because the crowd is one-handed and drinking, and two lines: what is behind it,
 * and one caption saying what that is or why it is shut.
 *
 * The captions are LIVE DATA wherever there is any to be had ("8 of 15 called ·
 * Marcus leads by one"), so a door says what it is rather than naming a category.
 */
.door {
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 3px;
  min-height: var(--row-h);
  margin: 0 calc(var(--gutter) * -1);
  padding: 10px var(--gutter);
  color: var(--ink);
  text-decoration: none;
  transition: transform 160ms cubic-bezier(.2, .8, .3, 1);
}
.door:active { transform: scale(.985); transition-duration: 90ms; }
/* The divider goes BETWEEN doors only. The world's own rule closes the group at
   the top and the next world's rule closes it at the bottom, so a trailing
   hairline here would draw a second line 30px above the real one. */
.door + .door { border-top: 1px solid var(--hair); }

.door__name {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: 10px;
  font-family: var(--body);
  font-size: 19px;
}
/*
 * The arrow is --muted and it is not gold. Gold carries text on this ground at
 * 7.1:1 and was rendered here and rejected: on the landing it pulls against the
 * vine, which is the only mark on that screen entitled to be noticed. Gold stays
 * a fill and a mark — the inset rule, the answer bullet, the stamp.
 */
.door__arrow {
  font-family: var(--label);
  font-size: 15px;
  letter-spacing: .1em;
  color: var(--muted);
}
/* 15px, the NOTE-tier floor. This was 13px — below `--t-micro`, the system's own
   smallest documented text — and it is the caption a guest reads to decide which
   door to take, outdoors, in dappled light. "Tertiary is too hard to read" was
   this rule and the seven others like it. */
.door__cap {
  font-family: var(--label);
  font-size: 15px;
  letter-spacing: .1em;
  line-height: 1.4;
  color: var(--muted);
}

/*
 * A door that is not open YET [L620, L625]. The seat door before `tables_at`, and
 * the only shut state in the app.
 *
 * `.door--off` USED TO LIVE HERE and was deleted with the pre-open Play door —
 * the note below says so. This is not that rule coming back: that one dimmed a
 * control that still looked like a control, which is why the markup here is a
 * `<span>` rather than a disabled link.
 *
 * SHUT AND BROKEN LOOK THE SAME AT --muted, and they must not. Mike, 2026-09-12:
 * *"visuals clearly indicate it's disabled HOWEVER it's only temporarily
 * disabled."* The first cut dropped the whole row to `--muted`, which is the
 * NOTE tier — the ink this app uses for captions and asides. A row title set in
 * caption ink beside two full-ink titles does not read as "later", it reads as
 * greyed out, and greyed out at a wedding reads as a thing that stopped working.
 *
 * THREE PARTS, and each carries a different half of the message.
 *
 *   1. The title sits at `--ink-soft` (8.47:1, the SAY tier), not `--muted`.
 *      One clear step below the live doors above and below it — quiet enough to
 *      rank last in the world, bright enough to still be a title.
 *   2. The arrow's slot holds a CLOCK, not nothing and not an arrow. An arrow
 *      promises a destination this row does not have; an empty slot says only
 *      that something is missing. A clock face says the thing this row needs is
 *      time, and it sits two inches from a caption naming the time — the glyph
 *      and the words check each other, so neither needs a legend.
 *   3. The caption keeps full `--muted` and carries the moment. It is the only
 *      half of a shut door a guest actually has to read, which is also why there
 *      is NO opacity on the row: dimming the whole thing takes the caption with
 *      it.
 *
 * The press transform goes, because a row that flinches under the thumb has
 * promised something.
 */
.door--shut { color: var(--ink-soft); }
.door--shut .door__name { color: var(--ink-soft); }

/*
 * IT IS A `<button>` NOW [L626], so the browser's own button styling has to be
 * unwound onto `.door` — same resets `.namerow` already carries for the same
 * reason, and the same list: border, background, width, alignment and the font,
 * which a button otherwise takes from the OS rather than from this stylesheet.
 *
 * THE PRESS TRANSFORM COMES BACK. L625 removed it while the row was an inert
 * span, on the rule that a row which flinches under the thumb has promised
 * something. It answers a tap now, so the flinch is honest — and it is the first
 * half of the response, arriving before the sheet does.
 *
 * THE WIDTH IS THE COLUMN PLUS BOTH GUTTERS, and getting there took two wrong
 * answers and a ruler. `.door` is full-bleed by `margin: 0 calc(var(--gutter) *
 * -1)`, and an `<a>` fills its containing block for free; a `<button>` does not.
 *
 *   width: 100%  -> 335px of column, bled left by 20 — the row starts at the
 *                   screen edge and STOPS 40px short of the two below it, with
 *                   the clock landing 39px inboard of the arrows it shares a
 *                   column with.
 *   width: auto  -> 278px. A button shrink-to-fits even as a block-level flex
 *                   container, so this is WORSE, not the fix for the above.
 *
 * `calc(100% + var(--gutter) * 2)` is the column plus what the negative margins
 * take back: 375 on a 375 screen, matching both `<a>` doors to the pixel. It
 * reads off the same token the margin does, so the two cannot drift.
 *
 * NONE of this is visible in the markup, and none of it is visible in a
 * screenshot either — the row still looks aligned at 2x because its text starts
 * at the same x. It is one `getBoundingClientRect` away and nothing short of
 * that finds it. Third instance of the rule in CLAUDE.md § Verify by rendering.
 */
button.door {
  border: 0;
  background: none;
  width: calc(100% + var(--gutter) * 2);
  text-align: left;
  font: inherit;
  -webkit-appearance: none;
  appearance: none;
  cursor: pointer;
}

/*
 * The clock, in the arrow's slot. Same 1px-family stroke as the vine, the
 * festoon and the search magnifier — this app's only line art — and sized in the
 * viewBox so it scales with the box rather than with the font.
 *
 * 15px to match `.door__arrow`, which is what keeps the two live doors' arrows
 * and this glyph on one optical column down the right-hand gutter. A different
 * size here reads as a third kind of thing rather than as this row's answer to
 * the same slot.
 */
.door__clock {
  width: 15px;
  height: 15px;
  flex: 0 0 auto;
  color: var(--muted);
}

/*
 * THE SHUT DOOR'S NOTE [L626]. A bottom sheet, and the app's one kind of modal —
 * /admin's "reset the game" confirm is the same object, not a second one [L780].
 *
 * SQUARE AND FULL-BLEED, flush to three edges. DESIGN.md § Layout sets
 * `border-radius: 0` everywhere and closes the corner-radius list at three named
 * objects, so a floating rounded card would be a fourth — and that section says
 * in as many words that a fourth is a bug rather than a precedent. A sheet that
 * meets the screen edges is the page's own furniture arriving; a card would be a
 * new kind of object introduced for one sentence.
 *
 * `display` IS SET ONLY UNDER `[open]`, and that is not a style preference — it
 * is this project's oldest bug, met a third time. A closed `<dialog>` is hidden
 * by `display: none` in the UA stylesheet, and ANY author `display` rule beats
 * it, exactly as an author `display: flex` beat `[hidden]` on the roster filter
 * and shipped a filter that filtered nothing. An unqualified `.sheet { display:
 * flex }` here would paint the note over the landing at all times.
 *
 * EVERYTHING ELSE IS ON THE BASE RULE, because the exit fade plays AFTER `[open]`
 * is gone. With the layout under `[open]`, the closing 200ms painted the
 * browser's default dialog — a white bordered box in the middle of the screen,
 * fading out over the backdrop. Caught by rendering /admin's sheet closing
 * [L780]; the landing's note had done it since L626. Position and colour on a
 * `display: none` element paint nothing, so they are safe here.
 *
 * THE FADE IS THE EXISTING 200ms OPACITY MOVE, not a fourth motion. DESIGN.md
 * § Motion names three and names that fade as the reduced-motion collapse; the
 * landing's rotating question [L84] already uses it as an effect in its own
 * right, and this is the second instance of that precedent rather than a new
 * entry in the table. Nothing slides: a translate would be a fourth motion, and
 * a sheet that moves is also a sheet that moves under a reader who is slow to
 * look down. `@starting-style` is what animates the entry; where it is not
 * supported the sheet simply appears, which is the correct degradation.
 */
.sheet {
  position: fixed;
  inset: auto 0 0 0;
  width: 100%;
  max-width: 100%;
  max-height: 85vh;
  margin: 0;
  padding: 0;
  border: 0;
  border-top: 1px solid var(--hair-strong);
  border-radius: 0;
  background: var(--surface);
  color: var(--ink);
  overflow-y: auto;
  opacity: 0;
  transition: opacity 200ms cubic-bezier(.2, .8, .3, 1), overlay 200ms allow-discrete,
              display 200ms allow-discrete;
}
.sheet[open] {
  display: block;
  opacity: 1;
}
@starting-style {
  .sheet[open] { opacity: 0; }
}
/* The browser's own backdrop, darkened enough that the landing behind it stops
   competing for the eye. No blur: it is one more thing to render on 170 phones
   at once, and the sheet is opaque anyway. */
.sheet::backdrop { background: rgba(0, 0, 0, .55); }

/* `--gutter` on three sides, and the safe-area inset on the fourth — this is the
   one surface in the app that sits ON the home indicator. */
.sheet__body {
  padding: 26px var(--gutter) calc(26px + env(safe-area-inset-bottom));
}

/* ANSWER tier, and the sheet's only heading. 24px rather than the seat screen's
   34: this is a note the guest opened on purpose, not a screen she navigated to,
   and it sits ~8 inches from her eye with the page still visible behind it. */
.sheet__title {
  margin: 0;
  font-family: var(--display);
  font-size: 24px;
  font-weight: 400;
  line-height: 1.2;
  color: var(--ink);
}

/* SAY tier (--ink-soft) at 17px/1.45, the same setting as `.flash` and the rules
   card's steps. Not italic: italic is reserved for the couple's own voice
   (DESIGN.md § The card's two voices) and this sentence is the app explaining
   itself, which is a different speaker. */
.sheet__say {
  margin: 10px 0 22px;
  font-size: 17px;
  line-height: 1.45;
  color: var(--ink-soft);
}

/*
 * The way home, on /find/:id. A door rather than a footer link, because it is the
 * only exit from the one screen a guest can reach by QR with no back button and
 * no other tab.
 *
 * OPENED BY --hair-strong, like every other door group in the app. This shipped
 * under a `.rule` hairline, which is `--hair` — the app has two hairline weights
 * and they mean different things: `--hair-strong` opens a group (`.world` on the
 * landing and on the game unit), `--hair` divides items inside one. A group
 * boundary drawn at item weight reads as "another line of the same thing".
 *
 * `justify-content: flex-start` is the whole reason the name modifier exists:
 * .door__name is space-between, which is right when the arrow trails the label and
 * wrong the moment it leads — the arrow pinned to the left gutter and the words to
 * the right one, 300px apart, reading as two unrelated things rather than one
 * control.
 */
.door--home {
  border-top: 1px solid var(--hair-strong);
  /* Wins over `.door`'s `margin: 0 calc(var(--gutter) * -1)` shorthand on the top
     edge only, so the row stays full-bleed. */
  margin-top: 24px;
}
.door__name--back { justify-content: flex-start; gap: 16px; }

/*
 * The greeting on /find/:id, and the only line in the guest app addressed to a
 * person by name.
 *
 * Body italic because the card reserves italic for the host talking and
 * letterspaced caps for the facts — this is the couple saying hello, and setting
 * it in the LABEL face would have made it a third piece of chrome above a heading
 * that is already shouting.
 *
 * SAY TIER AT 22px, PROMOTED FROM NOTE [L78]. It shipped as 16px --muted, and that
 * was measured against the wrong thing: the risk priced was the greeting
 * outshouting the 46px answer, and what actually happened is that the guest's own
 * name became the QUIETEST element on the screen she reaches by tapping her own
 * name in a list. Ranked by loudness, the screen read table (46 --ink), the footer
 * way-home door (19 --ink), the tablemates (17 --ink-soft), her name (16 --muted) —
 * so the second-loudest thing was chrome and the confirmation she came for was
 * last. Two ends of one inversion, and this fixes the bottom one.
 *
 * It does NOT reopen the 20px --ink regression. That draft failed on COLOUR: two of
 * three blocks at full --ink is not a ranking. This stays --ink-soft, so the answer
 * is still the only thing on the screen at full strength.
 *
 * IT IS THE SAME TIER AS `.mates`, NOT A NEW RANK ABOVE IT. Both are SAY — prose
 * the guest reads that is not the answer — and rule 2 says sizes inside one tier
 * are emphasis, not hierarchy. So 22/17 is not a ladder that needs rule 1's two-axis
 * step; the pair only has to clear rule 4, and 5px at the same colour does.
 *
 * The margin is tight on purpose. The sentence runs INTO the table number and the
 * h1 finishes it, so a gap the size of a paragraph break would cut the sentence in
 * half and leave a name hanging over nothing. 6px rather than 4px because the line
 * is 6px taller.
 */
.greet {
  font-family: var(--body);
  font-style: italic;
  font-size: 22px;
  line-height: 1.4;
  color: var(--ink-soft);
  margin: 0 0 6px;
}

/*
 * ONE PIXEL DOWN, AND IT BUYS A WHOLE LINE [L566]. A long name wraps the greeting
 * at 22px — `Anastasia Papadopoulos, you're at` renders 62px, two lines — and the
 * second line is 31px the fold does not have: the way-home door ended 25px under
 * it on a nine-tablemate table. At 21px the same sentence sets on ONE line, 29px,
 * so the screen comes back 33px and the door lands at ~659.
 *
 * MEASURED, at 375px, not estimated: at 22px a 20-character name fits and a
 * 21-character one wraps; at 21px a 22-character name fits and a 23-character one
 * wraps. The view gates on `name.length > 20`, which is the same shape as
 * `.display--big`'s `<= 9` two rules down — a character count standing in for a
 * width, on the same screen, for the same reason.
 *
 * 21 AND NOT 19. Both fit the line, and 19 breaks a rule for nothing: `.mates` is
 * 17px in the same `--ink-soft`, so DESIGN.md § Text roles rule 4 wants more than
 * 3px between them and `text_roles_test` holds it. 21 keeps 4. Still inside SAY's
 * 17-22 band, and one pixel is invisible next to a 46px h1.
 *
 * The residual is stated rather than hidden: a name of 23 characters or more still
 * wraps, at 59px instead of 62. `fold_test` measures the 22-character case, which
 * is the longest name on its own stress-test table.
 */
.finder-result .greet--long { font-size: 21px; }

/* The seat screen opens on the greeting now that the masthead is gone, and body's
   22px of top padding alone sat it too high — the eyebrow and its rule were
   carrying ~58px more. Restored here rather than on .display, which is also the
   ballot's and the landing's. */
/* 6px, not 16 [L565]. The layout already sets 22px above this, so 38px of air sat
   over the greeting on the one screen with a fold problem — and 10 of those px are
   what bought back the way home when the tablemates gained a header and the plan
   gained its clearance. */
.finder-result { padding-top: 6px; }

/*
 * The table number is BIGGER HERE THAN ANYWHERE ELSE IN THE APP, and this is the
 * one screen that earns it: it is the whole content, the answer a majority of the
 * 170 came for, and it is being read one-handed in dappled light with a drink in
 * the other hand. At the shared 34px it sat between a greeting and a full-width
 * line of names and read as the middle of three paragraphs.
 *
 * OPT-IN BY LENGTH, and the guard is not decoration. `Guest.table_label` returns
 * "Table 12" for a numeric table and the RAW STRING for anything else, so the head
 * table can legitimately be called "Sweetheart Table". Applied unconditionally,
 * 46px ran that label off the right edge of a 375px screen with the S and the E
 * clipped — every computed style correct, and only a screenshot showed it. The
 * view sets `.display--big` at <=9 characters, which is every numeric table up to
 * three digits with room to spare.
 *
 * Scoped to .finder-result. The landing and the ballot use .display for headings
 * that share their screen with other content and must not grow.
 */
.finder-result .display--big { font-size: 46px; }

/* No seat-screen override any more. The names no longer sit against the table
   number — the drawing does [L420] — so the 24px this set is wrong twice over: it
   was the gap between an answer and its supporting line, and the list now hangs
   off a drawing with its own 16px on `.mates`. */

/* ------------------------------------------------ the seating plan -- */

/*
 * The plan on /find/:id [L160]. Monoline, `fill: none`, non-scaling stroke — the
 * vine's and the festoon's hand, so a fourth mark in the app reads as the same
 * pen rather than a second product.
 *
 * EVERY COLOUR IS A TOKEN. `--hair`, `--muted` and `--paper` are the drawing's
 * whole palette and none of them is written here as a literal, so a change to the
 * ground moves the plan with it. That is the whole reason the SVG carries no
 * colour attributes of its own.
 *
 * `height: auto` with a viewBox is what makes the drawing scale to the column.
 * The aspect is 1120x640, so at 335px of column it renders 191px tall — and note
 * that the 335px is what fixes CIRCLE size: at a fixed column the rendered radius
 * is r/viewBox-width, so the box may grow DOWNWARD for free and never sideways.
 */
.plan {
  display: block;
  width: 100%;
  height: auto;
  /* 12px. It was 10 when a caption sat above it [L420], then 14 when the drawing
     started hanging directly off the table number — 10px against a 46px h1 read as
     the number's own underline rather than as the next object. 12 still does that
     and returns 2px of the fold [L565]. */
  margin-top: 12px;
}

/*
 * NON-SCALING STROKE IS LOAD-BEARING, not a nicety. The viewBox is 1020 units
 * wide rendered into 335px, so a plain `stroke-width: 1` would come out at
 * 0.33px — below the device pixel on a 2x phone, which renders as a grey
 * smear rather than a line. With `vector-effect` the widths below are rendered
 * pixels, and the hairlines stay hairlines at any column width.
 */
.plan line,
.plan rect,
.plan circle {
  fill: none;
  stroke-width: 1;
  vector-effect: non-scaling-stroke;
}

/*
 * The two groups she is NOT in, and the walkway: present, quiet, and carrying no
 * numbers. They are what give her own group a position to be in — a single
 * highlighted rectangle on an empty field says "here" without saying "here
 * rather than where".
 *
 * ALL AT --hair-strong, not --hair. The fainter token is .20 alpha, and DESIGN.md
 * § Color has already caught one .20-ish separation that measured fine and
 * disappeared on a phone (--surface on --paper). The ballot window is 4:00-4:30pm
 * outdoors, and a rectangle whose tables have vanished reads as an empty space to
 * walk into rather than a group of six.
 */
.plan__pod rect,
.plan__pod circle,
.plan__walk line { stroke: var(--hair-strong); }

/*
 * THE TWO ENDS OF THE PATH, and the dance floor's name [L360]. Louder than the
 * walkway and the two groups she is not in, because between them the two arrows
 * are the only thing on the drawing that says which way she is FACING — a plan
 * she can hold 180 degrees wrong sends her the length of the venue in the wrong
 * direction.
 *
 * `--muted`, not `--ink`: her own group is the answer and must stay the brightest
 * thing here. NOTE tier, matching every other caption in the app. Three of these
 * words now, which is more quiet ink than the two the plan started with — they
 * hold at 38 units (10px) against her gold marker precisely because none of them
 * is --ink.
 *
 * THE DANCE FLOOR IS IN THIS RULE ENTIRE [L701], where it used to be half in it:
 * an outline at pod weight with only its name at NOTE. The outline is gone, so
 * the place and the two directions are now one register — three landmark words,
 * the same ink, and an arrow on the two that name a direction.
 */
.plan__mark line { stroke: var(--muted); }
.plan__mark text,
.plan__place text {
  fill: var(--muted);
  font-family: var(--label);
  letter-spacing: .2em;
}

/*
 * Her group. --ink at 11.4:1 against --hair-strong at .38 alpha is the whole
 * signal at pod level, and it is a WEIGHT difference before it is a colour one —
 * the same grammar `.world` uses to say outer versus inner.
 */
.plan__pod--mine > rect:not(.plan__sweet) { stroke: var(--ink); stroke-width: 2; }
.plan__pod--mine circle,
.plan__pod--mine rect.plan__sweet          { stroke: var(--muted); }

/*
 * Her table. Gold is a FILL and a MARK here, never a meaning on its own
 * (DESIGN.md § Color): the circle is also the largest on the plan and the only
 * one carrying a number, so it survives a colourblind guest and a sun-washed
 * screen at 4pm.
 *
 * Full `--paper` on `--gold` — 7.14:1. This is the app's LAST gold fill carrying ink;
 * the landing's game block was the other and went sage [L680]. Never an alpha of
 * --paper: the first draft of that block shipped at rgba(42,58,49,.82), measured
 * 4.36:1, and failed.
 */
.plan__pod--mine circle.plan__mine,
.plan__pod--mine rect.plan__sweet.plan__mine { fill: var(--gold); stroke: none; }
.plan__num { fill: var(--paper); font-family: var(--label); font-weight: 500; }


/* `.door--mine` (her own seat, resolved onto the landing) and `.door--off` (the
   disabled Play door) both lived here and are DELETED — the landing's table door
   is static [L245] and there has been no pre-open Play door since [L86]. Two
   rules describing screens that no longer exist, with rationale explaining how
   they worked; that is the defect CLAUDE.md names by example. The treatment
   `.door--mine` carried is not lost: --surface plus a gold inset rule is still
   how `.opt--chosen` and `.lb__row--you` mark a row as yours. */

/*
 * The game unit's own line: what the game is and when it closes, in the
 * invitation's warm host voice. Italic because the card reserves italic for the
 * host talking and letterspaced caps for the facts, and this sentence is an
 * invitation rather than a schedule.
 */
.gameunit__cap {
  font-family: var(--body);
  font-style: italic;
  font-size: 16px;
  line-height: 1.5;
  color: var(--muted);
  padding: 2px 0 14px;
}

/*
 * The Play row. The one control in the guest app that leads to a write, and the
 * only row outside the ballot that carries the chosen treatment — it is the thing
 * on the screen you are meant to take.
 *
 * Both a <button> (inside button_to, on /find/:id) and an <a> (on the standings)
 * wear it, so every UA button default is reset here rather than assumed away. A
 * <button> carries a border on all four sides, so `border: 0` comes FIRST: setting
 * only one edge leaves three strays and the row draws as a box.
 */
.playrow {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 14px;
  /*
   * NOT `width: 100%`. A percentage resolves against the containing block, which
   * is the column INSIDE the gutters — so with the negative margins the row
   * started at the screen edge and stopped 40px short of the other one, a fill
   * that bled left and not right. Caught by measuring the painted box; every
   * declaration read correctly on its own.
   */
  width: calc(100% + var(--gutter) * 2);
  min-height: var(--row-h);
  margin: 0 calc(var(--gutter) * -1);
  padding: 10px var(--gutter);
  border: 0;
  border-radius: 0;
  -webkit-appearance: none;
  background: var(--surface);
  box-shadow: inset 3px 0 0 var(--gold);
  font-family: var(--body);
  font-size: 19px;
  color: var(--ink);
  text-align: left;
  text-decoration: none;
  cursor: pointer;
  transition: transform 160ms cubic-bezier(.2, .8, .3, 1);
}
/*
 * The deadline on its own line inside the Play row [L583]. `nowrap` as well as
 * `block`: the phrase is one fact and `4:30pm PDT on` / `10/3` is the wrap this
 * exists to prevent.
 */
.playrow__when { display: block; white-space: nowrap; }

.playrow:active { transform: scale(.985); transition-duration: 90ms; }
.playrow__arrow {
  font-family: var(--label);
  font-size: 15px;
  letter-spacing: .1em;
  color: var(--muted);
}

/* ------------------------------------------- the landing's game block -- */

/*
 * ONE sage island, carrying the whole pitch, and the availability line under it.
 * Replaced a `THE GAME` eyebrow plus two `.door` rows [L73].
 *
 * THREE DESIGN.md rules are broken here on purpose, and the file is amended in the
 * same change rather than left to contradict the screen:
 *
 *   1. "No cards, no boxes"        — this is a filled block with an edge.
 *   2. "left-aligned throughout"   — the block is a centred OBJECT. Its prose is
 *                                    still left-aligned, so "centred body text"
 *                                    on the Banned list is untouched.
 *   3. the illustration budget     — the dance card is a third drawing.
 *
 * IT USED TO BE FOUR. The fourth was gold as "a fill and a mark" — this block was
 * the app's one large field of it, and the exception is gone [L680]: gold is a mark
 * everywhere now, with no field anywhere to argue from.
 *
 * `--vine` therefore carries two jobs, and that is the cost of this change: it is
 * the masthead's botanical line AND this field. THEY SHARE THE LANDING — the vine
 * draws in `.arrival` at the top of `/` and this block sits ~500px below it, so
 * the page now shows the same value as a hairline drawing and as a 333px field.
 * They do not collide (`shared/_dancecard` already records the separation), but
 * the value is no longer doing one thing. If a third call site appears, give the
 * field its own token rather than letting one value mean two things in three
 * places.
 *
 * WHY AN ISLAND rather than the full-bleed field that was tried first: the info
 * doors run gutter to gutter, and a full-bleed block keeps their left edge and
 * their vertical rhythm, so it reads as the next row in that list no matter how
 * much air sits above it. Changing the WIDTH is the thing that makes it a
 * different kind of object. Air alone was rendered and rejected.
 */
.gameunit .cta {
  display: block;
  /* 12px, down from 22 by way of 18 — the filled bar costs more height than the
     line of type it replaced, the say line now runs to three, and the fold rule
     is hard. The closing hairline above already ends Wedding day info, so the gap
     is separating two things that are already separated by an edge.

     The 12px padding is the same bill: at 14px the block clears a 375x667 screen
     by 6px rather than 10px, and 10px is the headroom the fold test was written
     against. Restore either number and re-run `fold_test.rb` — do not restore
     both. */
  margin: 12px 0 0;
  padding: 12px 16px 12px;
  /* --vine, not --gold [L680]. Full `--paper` on `--vine` is 6.48:1, so every line
     inside the block clears the 4.5:1 floor by inheritance and nothing here sets a
     colour of its own — measured, because an alpha of --paper on this field cannot
     be reasoned about from the token table (the gold version shipped once at
     rgba(42,58,49,.82) and failed at 4.36:1). */
  background: var(--vine);
  color: var(--paper);
  text-decoration: none;
  transition: transform 160ms cubic-bezier(.2, .8, .3, 1);
  /*
   * RADIUS 12, and DESIGN.md § Layout says "Border radius: 0. Everywhere." with
   * the stamp's circle as the only exception. Mike's call, 2026-09-04: the island
   * needed to stand out further, and on a screen where every other edge is a
   * hairline or a square field, a radius is the cheapest remaining way to say
   * "this is a distinct object" without a second colour or more size.
   *
   * The Banned list's "rounded buttons" entry is about the category's pill-shaped
   * accent buttons — a 12px radius on a 333px field is a card corner, not a pill,
   * and radius 0 still holds on every door, row, option, stamp field and rule in
   * the app. Amended in DESIGN.md in the same change; if a second radius shows up
   * anywhere, this exception is being read as a licence and it is not one.
   */
  border-radius: 12px;
}
/* Press, per DESIGN.md § Motion. Applies to the <a> only — the pre-4pm span is
   not a control and must not answer a tap like one. */
.gameunit a.cta:active { transform: scale(.985); transition-duration: 90ms; }

/*
 * THE TYPE LADDER, and the reason it is only two axes wide.
 *
 * DESIGN.md § Text roles assigns a COLOUR per tier — T1/T2 --ink, T3 --ink-soft,
 * T4 --muted — and not one of them is legible on the sage field. So inside a filled block
 * the colour axis is gone and Rule 1 ("adjacent tiers differ on at least two of
 * {face, size, colour}") has face and size left. Roman versus italic is doing the
 * work colour does everywhere else in the app; see the inverted-ground clause
 * added to DESIGN.md in this change.
 *
 * 23px is a measured ceiling, not a preference: at 24px the header wraps to two
 * lines and the block runs 25px past the fold, which is now a hard rule. The
 * previous 20px was one pixel off `.door__name` at 19px — same tier, same face,
 * same case — and DESIGN.md Rule 2 says sizes within a tier are emphasis, not
 * hierarchy, so the header was ranking nothing at all.
 */
.gameunit .cta__hook {
  display: block;
  font-family: var(--body);
  /*
   * 23px, and 1.06 leading that only ever has to hold one line — see the hook's
   * copy note in the view for why it is A LINE and not a sentence. It briefly ran
   * at 21/1.12 over a two-line hook and Mike called it ("the text is really weird
   * and large"): a wrapping display line at 1.06 sets cramped, and dropping the
   * size to fix the cramping cost the step over `.door__name`. Shortening the
   * copy fixes both, which is why the constraint lives in the view.
   */
  font-size: 23px;
  line-height: 1.06;
}
/*
 * The couple speaking — Host voice, which is what makes the block read as
 * something they did rather than something the app offers.
 *
 * FULL --paper, not a softened alpha. It shipped for one afternoon at
 * rgba(42,58,49,.82), which measured 4.36:1 on the gold this field used to be [L680]
 * and failed DESIGN.md's own
 * 4.5:1 floor — on the single line that explains the game to a guest who has
 * never seen it. Full --paper is 7.14:1. The italic was already carrying the
 * voice, so the lighter colour bought no hierarchy and cost the contrast.
 */
.gameunit .cta__say {
  display: block;
  font-family: var(--body);
  font-style: italic;
  font-size: 17px;
  /* 1.22, not 1.28: this line wraps — it carries the window as well as what the
     game is — and 1.28 was leading picked for a single line. */
  line-height: 1.22;
  margin-top: 5px;
}
/*
 * THE ROTATING PEEK, and the whole design is in the word "stack".
 *
 * Every specimen sits in the same grid cell, so the box is as tall as the tallest
 * of them at first paint and CANNOT change height when the visible one changes.
 * That is not a nicety: this box sits directly above the only control in the
 * block, the block clears a 375x667 screen by 31px, and a box that grew a line
 * mid-fade would slide PLAY THE GAME out from under a thumb already moving. Height
 * is settled before anything animates; the animation is opacity and nothing else.
 *
 * `Question::PEEK_MAX_LENGTH` keeps every specimen to two lines so the reserved
 * height is two lines rather than the height of the worst question ever written.
 * `landing_peek_test.rb` measures that in a browser.
 */
.gameunit .cta__say--peek {
  display: grid;
}
.gameunit .cta__peek {
  grid-area: 1 / 1;
  /*
   * DESIGN.md § Motion: this is the 200ms opacity fade the vocabulary already
   * defines as its reduced-motion collapse, used here as the effect itself. No
   * slide, no typewriter, no fifth motion — a line of type on a filled field that
   * moved horizontally would read as a marquee, which is the one register this
   * whole design is not in.
   */
  opacity: 0;
  transition: opacity 200ms ease-in;
}
.gameunit .cta__peek--on {
  opacity: 1;
  transition-timing-function: ease-out;
}
/*
 * The controller declines to rotate at all under reduced motion, so this only
 * catches the transition on a page that was already mid-fade when the setting
 * changed. Belt and braces, one line.
 */
@media (prefers-reduced-motion: reduce) {
  .gameunit .cta__peek { transition: none; }
}
/*
 * THE WINDOW [L244]. NOTE tier, Factual voice, and the face is what ranks it: on
 * the sage field every line is --paper by necessity, so this is separated from the 17px Host
 * italic above by face (EB Garamond against Libre Baskerville), 3px of size and
 * roman against italic — DESIGN.md § Inverted grounds, where style substitutes for
 * the colour axis inside a filled block.
 *
 * Sentence case at .12em rather than the Factual voice's .22em caps. The measured
 * reason was the spelled date — "OPEN UNTIL 4:30PM ON SATURDAY 3 OCTOBER" ran past
 * the block's 303px measure and took two lines to say one fact — and that string is
 * gone since L322 made the date "10/3". The case stays: this line sits under a Host
 * italic inside a filled block, where caps at this width read as a second heading
 * rather than as the note it is.
 *
 * 6px clear of the say line and the bar's own 10px below it. This line is the
 * reason the say line is back to one or two lines rather than three — the deadline
 * moved off the pitch and became a stated fact.
 */
.gameunit .cta__when {
  display: block;
  font-family: var(--label);
  font-size: 14px;
  letter-spacing: .12em;
  line-height: 1.3;
  margin-top: 6px;
}
/*
 * The affordance. NOTE tier in the Factual voice, and it stays 3px clear of the
 * say line: at 16px the two would sit 1px apart at the same colour, which is
 * DESIGN.md Rule 4 ("never two tiers at the same colour within 3px"). The block
 * itself is the control, so this line is its label — Rule 5, a tier is a job.
 */
/*
 * THE AFFORDANCE IS A BAR, not a line of type. Mike, 2026-09-04: "I dont like how
 * PLAY THE GAME is so light. think of that holistically."
 *
 * It was 14px EB Garamond caps in --paper at .22em, and every one of those four
 * facts made it lighter: small, a light-ish serif, regular weight, and wide
 * tracking thins a word by pushing its stems apart. The holistic read is that a
 * line of TEXT was being asked to look like a control, and ink on the field cannot get
 * heavier without changing face — --paper is already the darkest ink the palette
 * has.
 *
 * So it stops being text on the field and becomes a filled bar. This also retires
 * the NOTE-tier problem the design review flagged and could not fix by size: at
 * 16px the label sat 1px from the 17px say line at the same colour, breaking rule
 * 4. On its own ground the colour axis comes back and the constraint dissolves.
 *
 * THE BAR IS CREAM, AND IT IS THE APP'S PRIMARY [L681]. It shipped inverted —
 * --paper ground, --ink text — and the pair is the same 11.38:1 either way, so
 * nothing inside the bar changed legibility. What changed is that L664 on
 * `game-signin-name-selection` filled every `.btn` with --ink on --paper, and a
 * guest who taps PLAY THE GAME meets those buttons on the next screen. One
 * primary language across the app is worth more here than the extra weight the
 * dark bar had, because the aunt this is designed against learns "the cream
 * rectangle is the thing you press" exactly once.
 *
 * THE 1px RING IS LOAD-BEARING, AND IT IS NOT A DROP SHADOW. Cream on the sage
 * field is a 1.76:1 value step — against 6.48:1 when the bar was dark — so the
 * bar keeps its legibility (the ink inside it is unchanged) and loses its
 * OUTLINE. The ring draws that outline back at zero offset and zero blur, which
 * is a hairline, not the shadow DESIGN.md bans. Delete it and the bar starts
 * dissolving into the field at arm's length in daylight, which is the one
 * condition the ballot window actually runs in.
 *
 * WHY AN INLINE rgba AND NOT A TOKEN: --hair and --hair-strong are both cream,
 * for hairlines on the DARK ground, and this is the app's only dark-on-light
 * hairline. One call site does not earn a token. A second one does — tokenise it
 * then rather than copying this value.
 */
.gameunit .cta__go {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  /* 10 and 11, down from 12 and 12: the bar is the last thing above the fold and
     these four pixels are what a three-line say line costs. Its 44px tap target
     is unaffected — 11 + 15 + 11 plus the label's own leading clears it. */
  margin-top: 10px;
  padding: 11px 14px;
  background: var(--ink);
  color: var(--paper);
  box-shadow: 0 0 0 1px rgba(42, 58, 49, .45);
  /* Inner radius = outer radius - gap, so the corners stay concentric rather than
     reading as two unrelated roundings. 12 - 4 = 8. */
  border-radius: 8px;
  font-family: var(--label);
  font-size: 15px;
  letter-spacing: .16em;
  text-transform: uppercase;
}
.gameunit .cta__golabel { display: flex; align-items: center; }
/*
 * The drawing sits HERE rather than beside the header, and that placement is
 * load-bearing. In the header row it left ~275px of measure against the ~300px
 * the 23px header needs, so the header broke to two lines and the block lost the
 * fold. Nothing required it beside the header; it only has to be in the block.
 */
.gameunit .cta__golabel .cta__art { flex: none; margin-right: 10px; }
/* The arrow is unconditional now. It used to be withheld before 4:00pm, on the
   rule that an arrow is a promise that something is behind it — and the block no
   longer renders at all in that phase [L86], so every bar that draws has somewhere
   to go. `.cta--off` and its hollowed-out bar went with it. */
.gameunit .cta__arrow { flex: none; }

/*
 * The only line outside the block. NOTE tier, Factual voice in sentence case, so
 * it cannot be mistaken for a second affordance: the label above it is the same
 * face and size and is separated by case, tracking, colour and ground.
 */
.gameunit .gamenote {
  display: block;
  font-family: var(--label);
  font-size: 14px;
  letter-spacing: .12em;
  color: var(--muted);
  margin-top: 6px;
}

/*
 * Wedding day info gets a closing edge, because the game block no longer provides
 * one. `.door + .door` notes that a world's own rule closes the group at the top
 * and "the next world's rule closes it at the bottom" — the game world's top rule
 * WAS that bottom edge, and the island carries its own edge instead, which left
 * the group opened by a rule and closed by nothing.
 *
 * Scoped to `.arrival` deliberately: `.world` also appears on the standings and
 * on /find/:id, where nothing has lost a closing edge and a bottom rule would be
 * a second line nobody asked for.
 */
.arrival .world { padding-bottom: 4px; border-bottom: 1px solid var(--hair-strong); }

/*
 * ...unless it is the last thing on the page, which before the ballot opens it now
 * is [L86]. The closing rule exists to close a group that the game block used to
 * bound; with no block under it, the same rule becomes a boundary with half a
 * screen of empty ground beneath it — L82 named that exact shape on /address as a
 * "boundary promising more with nothing arriving", and it is the reason that screen
 * pins its rule to the bottom frame.
 *
 * `:last-child` rather than a modifier class, because the condition IS structural:
 * the rule is wanted precisely when something follows and unwanted when nothing
 * does. A phase test in the view would be the same fact written twice, and the two
 * copies are what drift.
 */
.arrival .world:last-child { padding-bottom: 0; border-bottom: 0; }

/* ------------------------------------------------------- join / forms -- */

.field {
  display: block;
  width: 100%;
  border: 0;
  border-bottom: 1px solid var(--hair-strong);
  background: none;
  padding: 12px 0;
  font-family: var(--body);
  font-size: 19px;
  color: var(--ink);
  border-radius: 0;          /* iOS Safari rounds inputs by default */
  -webkit-appearance: none;
}
.field::placeholder { color: var(--muted); opacity: 1; }
.field:focus { outline: none; border-bottom-color: var(--gold); }

/* Roster rows. Full-bleed, 56px+ so a thumb finds them, hairline separated. */
.namerow {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  width: 100%;
  min-height: 58px;
  padding: 8px 2px;
  border: 0;
  border-bottom: 1px solid var(--hair);
  background: none;
  color: var(--ink);
  font-family: var(--body);
  font-size: 17px;
  text-align: left;
  cursor: pointer;
  transition: transform 160ms cubic-bezier(.2, .8, .3, 1);
}
.namerow:active { transform: scale(.985); transition-duration: 90ms; }
.namerow__table {
  font-family: var(--label);
  font-size: 14px;
  letter-spacing: .1em;
  color: var(--muted);
  white-space: nowrap;
}

/*
 * Flex-centred rather than relying on the UA's button centring, because the done
 * screen's blank state uses an <a class="btn"> ("Answer question 7") and an anchor
 * would otherwise sit left-aligned and top-aligned inside the 58px box.
 */
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  text-align: center;
  /* An <a class="btn"> would otherwise drag the anchor underline through the
     label, which a <button class="btn"> never does. Caught by rendering. */
  text-decoration: none;
  width: 100%;
  min-height: 58px;
  /*
   * A PRIMARY IS FILLED [L664]. Mike, 2026-09-12: *"i dont like empty CTA as the
   * primary CTA, and secondary a link. I think the first one should be filled in.
   * and this should be updated across the board on primary."*
   *
   * It was a 1px `--hair-strong` outline on the ground — rgba(cream, .38), the same
   * weight the app draws a DIVIDER at. So the loudest control on any screen was
   * built from the quietest mark in the system, and on a dark ground outdoors in
   * dappled light a hairline rectangle is the first thing to disappear.
   *
   * CREAM, NOT GOLD, and that is the decision worth keeping — but NOT for the reason
   * first written here [L681]. The original argument was "gold is the landing's game
   * block, the one object DESIGN.md lets be noticed, and a gold fill on every primary
   * would make gold mean 'button' instead of 'the game'." That block is SAGE as of
   * L680, so the premise is gone. Two branches, two true comments, false once merged,
   * and git flagged nothing.
   *
   * The half that survives is the half that was never about the game block: `--ink`
   * inverted is neutral, carries the same ~15:1 it does as text, and introduces no new
   * hue — where a gold primary would add one to every screen in the app. Gold is a
   * MARK now, with no field anywhere [L680], and that is a stronger reason not to fill
   * a button with it than the one this comment used to give.
   *
   * NOT INHERITED BY `.btn--mini`: that is an admin row toggle whose selected state
   * fills with `--surface`, so a filled base would render the OFF state louder than
   * the ON one. See its rule below — a toggle is not a primary.
   */
  border: 1px solid transparent;
  background: var(--ink);
  color: var(--paper);
  font-family: var(--display);
  font-size: 20px;
  letter-spacing: .12em;
  text-transform: uppercase;
  border-radius: 0;
  -webkit-appearance: none;
  cursor: pointer;
}
/* Filled, so the pressed state darkens the FILL rather than adding one. `--ink-soft`
   is the next step down the same ramp, which keeps the label's contrast well clear. */
.btn:active { background: var(--ink-soft); }

/* Row-level action on /admin. Auto width so it sits under a name rather than
   spanning the column, but still 44px tall for a thumb. */
/* A ROW TOGGLE, NOT A PRIMARY — so it keeps the outline the base rule gave up
   [L664]. `.btn--on` marks the selected side with a `--surface` fill and a gold
   inset rail; inheriting the filled base would have made the UNSELECTED button the
   loud one and inverted every toggle on /admin. Caught by rendering the guest row
   after the base changed, not by any test. */
.btn--mini {
  border-color: var(--hair-strong);
  background: none;
  color: var(--pine);
  width: auto;
  align-self: flex-start;
  min-height: 44px;
  padding: 0 16px;
  margin-top: 4px;
  font-size: 15px;
  letter-spacing: .16em;
}

/* THE SECOND ANSWER TO A YES/NO QUESTION [L663]. Same width, same 58px, same face
   and case as `.btn` — the rank is the border and the ink, because the two controls
   are siblings rather than an action and an escape hatch. Used on /join/confirm,
   where a mis-tap is a normal event and "no" is an answer.

   NOT a filled `.btn` with this as the outline one: the only fill in the guest app
   is the landing's gold block, gold carries no text by rule, and a cream-filled
   button would be a new object in the system for one screen.

   `margin-top: 10px`, not the 26px between unrelated blocks — they are one control
   group and have to read as a pair, not as a button followed by a link. */
/* THE SECOND ANSWER, AND IT IS AN OUTLINE NOW THAT THE FIRST IS FILLED [L664].
   It was borderless, which was the right rank against an outlined primary and the
   wrong one against a filled one — filled-over-nothing reads as a button and an
   afterthought, which is the "secondary a link" shape Mike rejected. Filled over
   outlined is a pair. */
.btn--quiet {
  border-color: var(--hair-strong);
  background: none;
  color: var(--pine);
}
.btn--quiet:active { background: var(--surface); }

/* A TWO-LINE LABEL, AUTHORED RATHER THAN WRAPPED [L664]. `CONTINUE AS HUGO ABBOTT`
   is 360px at the button's 20px caps against 307px of inner width, so the shortest
   name on the roster already wrapped — and an unauthored wrap breaks mid-name.

   The name drops `text-transform`, and because Baskervville SC is a SMALL-CAPS face
   that renders it in small capitals rather than mixed case — which is what makes it
   fit: the widest shape measured (`Wilhelmina Okonkwo-Mwangi`) sets at 292px against
   305px of inner width, and 417px if forced to full caps. `column` with
   `justify-content: center` rather than the base rule's row, and the height floor
   comes off `min-height` so a two-line label cannot be crushed. */
.btn--stacked {
  flex-direction: column;
  justify-content: center;
  gap: 2px;
  padding: 12px 14px;
  min-height: 72px;
  line-height: 1.15;
}
.btn__lead {
  font-size: 15px;
  letter-spacing: .16em;
  opacity: .72;
}
.btn__name {
  font-size: 20px;
  letter-spacing: .02em;
  text-transform: none;
}
.btn + .button_to > .btn, .button_to + .btn { margin-top: 10px; }

/* A resolver's secret URL. Wraps mid-token rather than overflowing the viewport,
   and stays selectable so it can be copied into a message. 15px, not 13: it is
   not letterspaced caps, so § Text roles rule 3 gives it the NOTE floor, and it
   is read off a phone by someone typing it into another one. [L208] */
.tokenlink {
  font-family: var(--label);
  font-size: 15px;
  line-height: 1.4;
  color: var(--gold);
  overflow-wrap: anywhere;
  user-select: all;
}

.opt__picklabel {
  font-family: var(--label);
  font-size: 14px;
  letter-spacing: .22em;
  text-transform: uppercase;
  color: var(--gold);
  white-space: nowrap;
}

/* Review rows: denser than answer rows, since you scan these rather than tap-target them. */
.reviewrow {
  display: flex;
  gap: 14px;
  align-items: baseline;
  padding: 14px 0;
  border-top: 1px solid var(--hair);
  text-decoration: none;
  color: var(--ink);
}
.reviewrow:last-of-type { border-bottom: 1px solid var(--hair); }
.reviewrow__num {
  font-family: var(--label);
  font-size: 14px;
  color: var(--muted);
  font-variant-numeric: tabular-nums;
}
.reviewrow__body { display: flex; flex-direction: column; gap: 4px; }
.reviewrow__q { font-size: 15px; line-height: 1.35; color: var(--muted); }
.reviewrow__a { font-size: 17px; }
.reviewrow__a--blank { color: var(--miss-ink); font-style: italic; }

/*
 * THE RESULTS-ERA COMPARISON [L143]. Two labelled lines where there used to be one
 * sentence, because "Lian · called Michael" named one of its two values and left a
 * middot to carry the difference between them.
 *
 * A 96px label column, not a stack: at 375px the row already spends 14px on the
 * number column and 20px of gutter each side, and "YOUR GUESS" / "THE ANSWER" set
 * at 12px with .16em tracking measure ~88px, so the values line up in a column of
 * their own and the eye compares them by dropping straight down. Stacking label
 * over value would have doubled a fifteen-row sheet to sixty lines.
 *
 * `align-items: baseline` so a 12px label and a 17px value sit on one line rather
 * than on the label's box.
 */
.reviewrow__pair { display: flex; gap: 10px; align-items: baseline; }
.reviewrow__lab {
  font-family: var(--label);
  font-size: 12px;
  letter-spacing: .16em;
  text-transform: uppercase;
  color: var(--muted);
  width: 96px;
  flex: none;
}
.reviewrow__val { font-size: 17px; color: var(--ink); }
.reviewrow__val--blank { color: var(--miss-ink); font-style: italic; }
/* The MISS dims HER GUESS and leaves THE ANSWER at full --ink. Same token and same
   grammar as `.opt--miss` on a ballot card, which dims the option that lost — and
   the other way round, caught by rendering, read as the app fading out the one
   thing she opened this screen to find out. Holds in both palettes. */
.reviewrow__val--miss { color: var(--miss-ink); }

/*
 * THE HIT [L143]. Mike: "'right' should be more celebtorary. maybe an emoji as
 * well." The old mark was the word "right" in --gold at the size of its neighbours,
 * after a middot.
 *
 * Gold, small caps register, indented to the value column so it lands under the
 * two words it is a verdict on. It does NOT get a background or a border — a chip
 * is a box, and DESIGN.md § Layout forbids one; the colour and the glyph carry it.
 *
 * The emoji is a scoped amendment to § Banned, approved by Mike 2026-09-04. Set in
 * the system emoji face explicitly: the app self-hosts three serif faces and none
 * of them has the glyph, so without this it falls through the whole --label stack
 * to whatever the UA picks last.
 */
.reviewrow__hit {
  font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", var(--label);
  font-size: 14px;
  /* .10em, not the .14em the other caps labels on this row carry. Tracking adds to
     the word gaps as well as the letter gaps, and at .14em "YOU GOT IT" rendered as
     three separated words rather than one phrase — measured at 375x667. */
  letter-spacing: .1em;
  text-transform: uppercase;
  color: var(--gold-ink);
  margin-left: 106px;
  margin-top: 2px;
}
/* The glyph a step above its own line, so the celebration is the thing that catches
   the eye scrolling fifteen rows rather than three more words of caps.

   ON A CLASS, NOT ON `span` [L641]. This was `.reviewrow__hit span`, written when
   the glyph was the only span in here. The crowd count is a second one, and a
   descendant selector that means "the party popper" by position silently restyles
   whatever is added beside it. */
.reviewrow__pop { font-size: 17px; }

/* THE CROWD COUNT UNDER THE CELEBRATION [L641]. Its own line, and the line is what
   makes the two registers legible next to each other: `.reviewrow__hit` is 14px
   letterspaced caps in `--gold-ink` and this is body-face sentence case in
   `--muted`, which reads as a footnote to the cheer rather than a continuation of
   it.

   NOT INLINE. Measured at 375x667: the hit sits at a 106px indent, leaving ~253px,
   and `YOU GOT IT — 11 of 162 guessed this` needs more than one line of it — so
   inline wraps anyway, and wraps mid-phrase with the caps and the prose sharing a
   line. A break that is designed beats a break that is left to the measure [L583].

   The 106px indent is inherited deliberately: it holds the crowd count in the same
   column as the row's other annotations rather than starting a new left edge. */
.reviewrow__crowd {
  display: block;
  font-family: var(--body);
  font-size: 14px;
  font-style: italic;
  letter-spacing: normal;
  text-transform: none;
  color: var(--muted);
  margin-top: 2px;
}

/* The bottom CTA on the answer sheet [L142]. `.btn` carries no margin of its own —
   it is spaced by whatever precedes it everywhere else in the app — and the run of
   rows ends on a --hair border, so without this the button's own 1px frame lands
   directly on it and the two read as a double rule. */
.reviewrow + .btn { margin-top: 30px; }

/*
 * The admin roster row. Same .reviewrow bones as the ballot receipt and the watch
 * screen — a figure, then a two-line body — because it is the same kind of object:
 * something you scan rather than tap-target.
 *
 * Two lines, not one. Five fields (rank, name, answered, table, status) do not fit
 * 375px across, and the couple read this row hundreds of times over six hours.
 */

/*
 * min-width, NOT a fixed width. 34px is the leaderboard's rank column and holds
 * every rank up to 170, so all ~160 player names start on one left edge. A
 * spectator prints the word "watching" here instead of a number and runs wider —
 * which is fine, and is the point: spectators sort to the bottom of the list as one
 * block, so the wider column reads as a second section rather than as jitter.
 *
 * Fixing the width to fit "watching" instead would spend ~20px of a 335px column on
 * every player row to serve the handful of rows that are not player rows.
 */
.rosterrow__rank { min-width: 34px; }

/*
 * The roster search field's hint, set the same way the arrival screen's is and for
 * the same reason: the invitation reserves letterspaced caps for the factual voice.
 * "Find a guest" is the instruction and sits in the label above; "name, table, or
 * watching" is a fact about the field.
 *
 * It is also what stops the hierarchy inverting. Left at --body 19px the placeholder
 * is larger and louder than the 14px label naming it, which is the exact failure
 * DESIGN.md records twice — the loudest type on a block belonging to the thing that
 * disappears on the first keystroke.
 */
.field--search::placeholder {
  font-family: var(--label);
  font-size: 14px;
  letter-spacing: .12em;
  text-transform: uppercase;
}

/* min-width:0 is load-bearing: a flex item's default min-width is auto, so a long
   guest name would refuse to shrink and push the answered count off the row. */
.rosterrow__body { flex: 1 1 auto; min-width: 0; }

.rosterrow__line {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 12px;
}
.rosterrow__count {
  font-family: var(--label);
  font-size: 14px;
  letter-spacing: .04em;
  color: var(--muted);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

/*
 * The masthead doubling as the way back, on a guest's own page. inline-flex with a
 * 44px box because it is the only exit above the fold and it is being tapped with a
 * thumb; text-decoration:none because an underline dragged through .22em tracking
 * reads as a strikethrough rather than a link.
 */
/*
 * The one back link in the app that is not the way home: `← Roster`, on an admin
 * guest screen. Self-contained rather than `.eyebrow .eyebrow--back` since L74 made
 * `.eyebrow` mean masthead and only masthead — this is a control that happens to sit
 * in the masthead position, not a name for the screen. The screen's name is the
 * guest's, in `.question` below it.
 */
a.backlink {
  font-family: var(--label);
  font-size: 14px;
  letter-spacing: .22em;
  text-transform: uppercase;
  color: var(--muted);
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  text-decoration: none;
}

/*
 * Two buttons that SET a value rather than one that flips it (paid/not paid,
 * playing/watching). Both stay live even when one of them is already true: a
 * double-tap on venue wifi has to write the same state twice, and a toggle would
 * flip twice and land back where it started.
 */
.setrow { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
.setrow .btn { margin-top: 0; }

/*
 * The current value, marked with the sanctioned selected treatment — lifted surface,
 * cream text, 3px gold inset rule — same as .opt--chosen and .lb__row--you.
 *
 * The scar this rule is shaped around: the first draft of the selected row in this
 * app used `background: var(--pine)`, which resolves to cream on a dark ground and
 * made the marked element vanish. This rule marks with a fill the ink already
 * contrasts against and a gold rule that reads on the ground, so there is no
 * inherited cream-ground assumption left in it to break.
 */
.btn--on {
  background: var(--surface);
  color: var(--ink);
  border-color: transparent;
  box-shadow: inset 3px 0 0 var(--gold);
}

/* ------------------------------------------------------------- the stamp -- */

.stamp {
  width: 56px; height: 56px;
  flex: 0 0 auto;
  border: 1.5px solid var(--gold);
  border-radius: 50%;
  color: var(--gold);
  display: flex; align-items: center; justify-content: center;
  font-family: var(--display);
  font-size: 11px;
  letter-spacing: .14em;
  /* Randomised per render so no two land alike — that is the whole difference
     between "hand-applied" and "CSS". Inline style supplies the angle. */
  transform: rotate(-5deg);
}

/* ---------------------------------------------------- vine + festoon -- */

/*
 * The vine. ONE placement: the arrival screen, top-right, cropped at the frame.
 * Reproduces the printed invitation's botanical — outline only, never filled.
 *
 * absolute, NOT fixed: it is ink on the letterhead and scrolls away with it.
 * Fixed leaves it hanging over 170 scrolling roster rows.
 *
 * Do not rescale. At 375x667 this clears every glyph by 47px; the same drawing
 * at 1.3x overlaps type. See DESIGN.md for the check to re-run.
 */
.vine {
  position: absolute;
  /*
   * -34px, not -6px: the drawing has to be CROPPED by the top of the screen,
   * the way the printed card's vine runs off its own edge. `.arrival` is inset
   * by the page's 22px top padding, so -6px left the entering stem starting
   * 18px *below* the frame — floating in the green, anchored to nothing.
   * -34px puts the svg box 12px above the viewport, and the browser clips the
   * stem at the frame edge. Moving the drawing up only increases its clearance
   * to type; the collision check below is unaffected.
   */
  top: -34px;
  /* 24px, not 0. At 0 the drawing overlaps the lede by 13px on a 375px screen;
     at 24 it clears every glyph by 11px and simply crops a little further at the
     right edge, which is what the printed card does anyway. Measured, not eyed —
     re-run the check in DESIGN.md before changing this or the vine's scale. */
  left: 24px;
  width: 100%;
  height: 330px;
  color: var(--vine);
  pointer-events: none;
  z-index: 0;
}
.arrival > *:not(.vine) { position: relative; z-index: 1; }

/* The other illustration: the hairline festoon, final card only (L36). */
.festoon { display: block; width: 100%; height: 34px; color: rgba(252, 248, 245, .30); }
.festoon .bulbs { fill: var(--gold); stroke: var(--gold); }

/* ---------------------------------------------------------- leaderboard -- */

/* A real <table>, not a grid of divs. Two reasons, and the first is the one that
   drove it: the board had NO column headers, so it read "01 Amara Abbott 05" —
   two zero-padded two-digit columns with nothing saying which was position and
   which was score. A header row needs somewhere legitimate to live. The second is
   that a div soup of bare spans reads to a screen reader as three unrelated
   strings per row, with nothing tying rank to name to score.

   Deliberately NOT `display: grid` on the <tr>: that makes the row a grid
   container and drops it out of the table layout algorithm, which throws away the
   row/cell semantics this markup exists for. Column widths are set on the cells. */
.lb { width: 100%; border-collapse: collapse; }

/* For the table's <caption>, which names the board to a screen reader without
   repeating the <h1> on screen. NOT `display: none` — that removes it from the
   accessibility tree too, which is the whole point of having it. */
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

.lb__head th {
  font-family: var(--label);
  font-size: 14px;
  letter-spacing: .18em;
  text-transform: uppercase;
  font-weight: 400;
  color: var(--muted);
  text-align: left;
  padding: 0 0 10px;
  border-bottom: 1px solid var(--hair);
}
.lb__head th:last-child { text-align: right; }
/* The rank column's width and gutter live on .lb__rank, which is only on the <td>.
   Without this the header cells had no right padding and rendered "PLACENAME" —
   every computed style correct, the words touching. Caught by looking at it. */
.lb__head th:first-child { width: 1%; padding-right: 14px; white-space: nowrap; }

.lb__row td {
  padding: 14px 0;
  border-bottom: 1px solid var(--hair);
  vertical-align: baseline;
}
.lb__rank,
.lb__score {
  font-family: var(--label);
  font-size: 16px;
  letter-spacing: .04em;
  color: var(--muted);
  font-variant-numeric: tabular-nums;
}
/* Ordinals are wider than "01" and must never wrap: "101st" at 170 guests. */
.lb__rank  { white-space: nowrap; width: 1%; padding-right: 14px; }
.lb__score { text-align: right; white-space: nowrap; }

/* Same treatment as .opt--chosen, for the same reason. A <tr> cannot take the
   negative margin the div version used to bleed into the gutter, so the fill is
   painted on the cells and pushed out past them by an offset box-shadow on the
   two end cells. --surface is the one token that carries the fill and it is
   defined once, in :root, so there is no second ground for this rule to go
   dark-on-dark against. That exact bug has already shipped once, on this exact
   component — see the note on `.opt--chosen`. */
.lb__row--you td {
  background: var(--surface);
  color: var(--ink);
}
/* Two stacked outer shadows, not an inset rule: the gold has to land at the SCREEN
   edge to match .opt--chosen, and an inset shadow can only paint at the cell edge,
   which is one gutter in. Shadows paint first-on-top, so the surface slab (offset
   one gutter minus 3px) covers all of the gold slab (offset one full gutter) except
   the 3px sticking out at x=0. */
.lb__row--you td:first-child {
  box-shadow:
    calc(var(--gutter) * -1 + 3px) 0 0 var(--surface),
    calc(var(--gutter) * -1) 0 0 var(--gold);
}
.lb__row--you td:last-child {
  box-shadow: var(--gutter) 0 0 var(--surface);
}
.lb__row--you .lb__rank,
.lb__row--you .lb__score { color: var(--gold); }
/* THE ONE 13px RULE THAT STAYS, and it is not an exception — it renders `◆`, a
   MARK beside a name, and § Text roles rule 3 is a rule about text. It sat on
   DESIGN.md's sweep list for weeks because the list was built by grepping for a
   size rather than by reading what each rule set. [L208] */
.lb__pot { color: var(--gold); font-family: var(--label); font-size: 13px; }

.verdict__who {
  font-family: var(--label);
  font-size: 15px;
  letter-spacing: .14em;
  text-transform: uppercase;
  color: var(--muted);
  display: flex;
  align-items: center;
  gap: 8px;
}
.verdict__who i { width: 8px; height: 8px; background: var(--gold); display: block; flex: 0 0 auto; }
.verdict__said { font-size: 22px; margin-top: 8px; }

/* `.lb__gap` — the `· · ·` rule between the top five and the guest's own row —
   is gone with the window it separated [L520]. Do not re-add it: the board has
   no break in it any more, because it has no two halves. */

/* --------------------------------------------------------- standings -- */

/*
 * ONE skeleton, three states: the board, then the one call that just moved it, in
 * every one of them. Pre-call there is neither — the warm line and the way in.
 * Signed in adds the tally, the gold you-row, and her own guess struck through
 * inside the call. A guest who learns this screen at 4:15 is still reading the same
 * screen at 9pm — nothing rearranges under her, only fills in.
 *
 * The fifteen-questions list that sat between the board and the call is gone [L72],
 * and so are its `.qitem*` rules. Do not re-add either without reading that row.
 */

/* Names a band of the screen. It was 13px to sit "quieter than an .eyebrow's
   14px" — a distinction of one pixel, below the floor, doing no work anyone could
   see. It is letterspaced caps, so 14 is its size; what keeps it quieter than a
   masthead is `--muted` against the eyebrow's own colour. [L208] */
.sect {
  font-family: var(--label);
  font-size: 14px;
  font-weight: 400;
  letter-spacing: .22em;
  text-transform: uppercase;
  color: var(--muted);
  margin: 22px 0 8px;
}
/*
 * A band that opens a genuinely NEW block after a finished one, rather than the next
 * band inside a run of peers. 44px, and that is the only difference.
 *
 * This exists to kill the inline styles [L74]. Fourteen section bands were `.eyebrow`
 * plus `style="margin-top: Npx"` where N was 26, 34, 40 or 44 — four values, none of
 * them a token, chosen a screen at a time. There are two real cases and now two
 * classes: the next band in a run (22px) and the top of a new block (44px). The old
 * 26 and 34 collapse into the base; 40 and 44 into this.
 */
.sect--major { margin-top: 44px; }

/*
 * A BLOCK NAME [L381]. The two named blocks on /standings — her scorecard and the
 * board — wear this; every `.sect` left in the app is on /admin, /resolver and
 * /ballot/review.
 *
 * WHY IT IS NOT A `.sect`. That band is 14px EB Garamond `--muted` caps at .22em,
 * which is also `.lb__head th` and also `.score__name`. The results screen rendered
 * as YOUR SCORECARD / PLACE / RIGHT / WHO'S AHEAD / PLACE NAME RIGHT — five bands of
 * one register down one column, PLACE twice, and no line anywhere saying which block
 * a figure belonged to. The rule below this one used to spend 4px of air on the last
 * two of those five, which was a spacing answer to a register problem.
 *
 * `.eyebrow` was a sixth band of it when this shipped. L320 has since taken the
 * masthead to 19px `--ink` at .12em, so the screen's name is out of the smear and
 * the two headers below it now sit under a line that outranks the field labels —
 * which is the ladder this was reaching for, arriving from the other direction.
 *
 * 26px Baskervville SC small caps at `--ink` is DESIGN.md § Text roles' own answer:
 * "A card name is a LABEL, and takes the Display voice." /travel's `.travel__name`
 * is the first instance and 26 is its measured floor — at 20px a Display name over
 * a roman payload read as the same line twice [L206], because this face's caps at
 * 20 ink about what Libre Baskerville's lowercase inks at 17. A THIRD CALLER MAKES
 * THIS ONE CLASS: `.travel__name` is the same rule with a different margin, and it
 * stays screen-scoped only while there are two of them.
 *
 * The size and the face come from separate rules the way L82 left the two axes:
 * `.voice-display` in the markup carries Baskervville SC, and this carries nothing
 * but size, colour and rhythm.
 *
 * TIER 2, NOT TIER 1, and that is what keeps it under the one 34px figure this
 * screen is allowed. A header that outranked her place would make the loudest
 * thing on the results screen the word "RANKINGS".
 */
.blockname {
  margin: 0 0 14px;
  font-size: 26px;
  line-height: 1.25;
  font-weight: 400;
  color: var(--ink);
}
/* The board's header opens a new block under a finished one, so it takes
   `.sect--major`'s 44px rather than the 22px a band inside a run gets. The
   scorecard's own name needs none: `hr.rule` in the masthead is 26px above it. */
/* The board's header opens a new block under a finished one, so it takes
   `.sect--major`'s 44px rather than the 22px a band inside a run gets. The
   scorecard's own name needs none: `hr.rule` in the masthead is 26px above it.

   30px, NOT THE 26 THE SCORECARD'S NAME TAKES [L643]. Mike, 2026-09-12: *"the
   balance between the text size of RANKINGS and this sucks"*. Both are tier 2 in the
   Display voice and rule 2 says sizes inside a tier are emphasis rather than
   hierarchy — this is that emphasis, and it is doing real work: this header carries a
   17px SAY caption directly under it, and Baskervville SC's caps at 26 have an
   apparent height close enough to 17px roman that the two read as one block.

   IT IS ALSO WHY THE SCORECARD'S NAME STAYS AT 26. Rendered at 30, `YOUR SCORECARD`
   runs nearly gutter to gutter and becomes the loudest thing above her `1st` — which
   is L381's warning ("a header that outranked her place would make the loudest thing
   on the results screen the word RANKINGS") arriving from the other direction. The
   34px figure is still the one tier-1 object on the screen, and `text_roles_test`
   pins the whole ladder rather than either number. */
.blockname--board { margin-top: 44px; font-size: 30px; }

/* THE CAPTION BELONGS TO THE HEADER, AND THE SPACING IS WHAT SAYS SO [L644]. Mike,
   2026-09-12: the two caption lines *"feel like they are floating"*.

   They were, and the measurements say why: 14px above them (`.blockname`'s own
   margin) and 16px below (the caption's padding) put the block very nearly
   equidistant between the header and the column labels. A line equidistant between
   two things belongs to neither, so it read as a third object with nothing holding
   it — on a screen whose other supporting lines (`.gameunit__cap`, `.rulecard__how`)
   all sit tight under the thing they support.

   6px above, 24px below. RANKINGS and its two rules are now one object and the table
   is the next one. Nothing is added to the markup to achieve that — no rule, no box,
   no indent. § Layout forbids the box outright, and a hairline under the caption
   would land ~40px above the one `.lb__head th` already draws, which is the L85
   double-rule defect on a third screen.

   `.blockname--board + .lb` ZEROES THE TABLE'S OWN MARGIN, and that is not
   decoration: adjacent block margins collapse to the larger of the two, so the 10px
   below would have won against the 6px above and the gap would not have moved. The
   scorecard's header keeps the 14px — it has no table under it and nothing to group
   with. */
.blockname--board { margin-bottom: 6px; }
.blockname--board + .lb { margin-top: 0; }

/* Under a 26px header the board's column labels no longer smear into the line
   above them, so this is air for the table rather than a rescue: 10px, matched to
   the `padding-bottom` inside `.lb__head th`, so the labels sit centred in the gap
   between the header and their own hairline. Kept for a `.blockname` with a table
   under it that is NOT the board's, should one ever exist; the board overrides it
   above. */
.blockname + .lb { margin-top: 10px; }

/* THE PARTIAL-BOARD LINE, and it is the only thing left of the reveal band [L140].
   It sat above the 50px tally saying the result was final; the scorecard says that
   itself now, so the class survives for the one case the scorecard cannot cover —
   a reader with no row of her own while the resolver is still calling.

   Tier 3 SAY, so it stays --ink-soft: on the screens that render it there is no
   tier-1 object at all, and promoting the caveat would make the loudest thing on
   the board a statement about what is missing from it. */
.reveal {
  font-size: 19px;
  line-height: 1.4;
  color: var(--ink-soft);
  margin: 0 0 6px;
}

/*
 * THE SCORECARD [L140]. Mike, 2026-09-04: "It should be a score card, structured,
 * where it talks about ranking ... and then how many the person got right out of
 * the total."
 *
 * It replaces `.tally` + `.rankline`: a 50px figure over a 14px caption saying what
 * the figure counted. That pairing was the app's documented worst offender in
 * miniature — the number arrives first and the word explaining it arrives a line
 * later, which is exactly backwards for a reader who is deciding whether to keep
 * looking. Both rules are deleted rather than left dormant; nothing renders them.
 *
 * NO BOX, AND NO OPENING RULE. DESIGN.md § Layout forbids a box outright, so the
 * first draft used `.world` — a --hair-strong rule with a knocked-out label sitting
 * on it, the app's existing grammar for a named group. Rendered at 375x667 that put
 * TWO full-width hairlines 53px apart with nothing between them: `.world` opens on
 * one and the masthead's `hr.rule` had just closed on another. It is L85's
 * double-rule defect on a new screen, and like L85's it was invisible to every
 * check short of looking at the page.
 *
 * A block name sits above it instead, drawing no rule — a `.sect` until L381, a
 * `.blockname` now. The hairlines are all INTERNAL, separating the cells from each
 * other, plus the one closing the object at the bottom [L380] — which is the rule
 * DESIGN.md § Layout actually states, and it never opens a group here.
 *
 * TWO STACKED CELLS, NOT TWO COLUMNS. "28th" and "of 45 playing" side by side with
 * "5" and "of 15 questions" leaves ~160px per column at 375px, and the denominators
 * wrap to two lines under figures of different sizes — measured, not guessed. Down
 * the page each cell is one label and one sentence-shaped line, read left to right.
 *
 * IT IS A CLOSED OBJECT [L380]. Mike, 2026-09-07: "I want the score card to be
 * 'self contained' such that it's nicely separated from the standings."
 *
 * Three things do that, and none of them is a box:
 *
 *   1. A CLOSING RULE at `--hair-strong`. The masthead's `hr.rule` is already this
 *      object's top edge, so the card was a group opened by a rule and closed by
 *      nothing — the same defect `.arrival .world` fixes on the landing, with the
 *      same fix. `--hair-strong` (.38) and not `--hair` (.20), because the cells
 *      divide on `--hair` and an edge that matches its own internal dividers is not
 *      an edge.
 *   2. THE LINK IS INSIDE IT. A block whose last row is the door it hands you
 *      finishes; one that ends on a figure just stops.
 *   3. 44px of air below, carried by `.blockname--board` rather than by a margin
 *      here, so the gap belongs to the thing it separates the card FROM. The old
 *      30px foot existed to keep a `.sect` band from reading as a third cell of
 *      this card; a 26px Display header cannot be mistaken for one, and the air is
 *      now about the boundary rather than about the confusion.
 */
.scorecard {
  padding-bottom: 18px;
  border-bottom: 1px solid var(--hair-strong);
}
.score { margin: 0; }
.score__cell + .score__cell {
  margin-top: 16px;
  padding-top: 16px;
  border-top: 1px solid var(--hair);
}
/* 12px, a step BELOW the `.sect` band naming the block. At 14px/.18em the field
   labels outweighed "YOUR SCORECARD" above them (13px/.22em) and the group read as
   quieter than its own contents. Same register as `.reviewrow__lab` on the answer
   sheet, which is the other screen where a word names a value beside it. */
.score__name {
  font-family: var(--label);
  font-size: 12px;
  letter-spacing: .16em;
  text-transform: uppercase;
  color: var(--muted);
}
/* Tier 1 lives on --lead, and only there: DESIGN.md's tier table allows one
   ANSWER per screen, and the ranking is the one Mike asked this screen to lead
   with. The second figure is a step down in size at the same --ink, so the pair
   still reads as one object rather than a figure and a footnote. */
.score__fig {
  margin: 2px 0 0;
  font-family: var(--label);
  font-size: 26px;
  line-height: 1.15;
  letter-spacing: .04em;
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}
.score__fig--lead { font-size: 34px; }
/* The denominator, inline and NOT tabular: it is words, and the figure it follows
   is the only thing on the line that has to align to anything. --muted drops it a
   full tier below the number so "28th" still reads first at arm's length.

   THE GAP IS A REAL SPACE IN THE MARKUP plus 4px here, not 12px of margin. With
   margin alone the DOM text is "28thof 45 playing" — correct on screen and wrong
   in every reading of the page that is not visual, which on this app means a
   screen reader and the assertion in standings_test. The margin only tunes what
   the space already establishes. */
.score__of {
  font-family: var(--body);
  font-size: 15px;
  letter-spacing: 0;
  color: var(--muted);
  margin-left: 4px;
}

/*
 * THE CARD'S LAST ROW [L380]: the door to her answer sheet, which used to be a
 * full-width `.btn` at the foot of a 45-row page.
 *
 * IT IS A ROW LIKE THE CELLS ABOVE IT — same top hairline, same left edge, no fill
 * — because it is the third thing in the card's sentence (where she came, how many
 * she got, what she actually said) rather than a button parked underneath it. The
 * arrow is the affordance: § Layout says a row a guest is meant to tap says so with
 * a mark, and every other door in this app says it the same way.
 *
 * NOT full-bleed, and this is the one place it differs from `.door` and `.playrow`.
 * Those bleed to the screen edge because they are the whole list they sit in; this
 * row is inside an object whose other edges are all one gutter in, and bleeding it
 * would break the only left edge the card has.
 *
 * 58px, which is `.btn`'s height and comfortably past the 44px floor — the row it
 * replaces was a 58px button and a thumb should not notice the change. Tier 2 ASK
 * at 19px `--ink`: it is a thing to tap, ranked with the options and doors, under
 * the 34px figure above it.
 */
.scorelink {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 14px;
  min-height: 58px;
  margin-top: 16px;
  padding-top: 16px;
  border-top: 1px solid var(--hair);
  font-size: 19px;
  color: var(--ink);
  transition: transform 160ms cubic-bezier(.2, .8, .3, 1);
}
.scorelink:active { transform: scale(.985); transition-duration: 90ms; }
/* The arrow is chrome, so it drops to NOTE tier the way `.playrow__arrow` and
   `.door__arrow` do — the words are what she reads. */
.scorelink__arrow {
  font-family: var(--label);
  font-size: 15px;
  letter-spacing: .1em;
  color: var(--muted);
}

/* THE BOARD'S SCORING RULES, AS ITS <caption> [L640, L642]. This replaces
   `.tienote`, which was the same sentence as a footnote 8px UNDER the table —
   correct for the eleven-row window L141 was written against, and eighty rows from
   the reader once L520 made the board every player.

   display: table-caption puts it above the rows with no positioning, which is the
   reason to use the element rather than a <p>: it cannot drift away from the table
   in a later edit, and a screen reader announces it before the first row instead of
   after the 170th.

   text-align: left, because a caption inherits the table's centre in some UA sheets
   and this is prose, not a title.

   SAY TIER IN THE FACTUAL VOICE — 19px EB Garamond at `--ink-soft` [L645]. Mike,
   2026-09-12: *"i still think both of these lines suck as sub lines. The text looks
   so damn similar to RANKINGS... but it shouldn't also be this little dinky text
   either."*

   THE PROBLEM WAS THE FACE, NOT THE SIZE. It was 17px Libre Baskerville against a
   30px Baskervville SC header — a 13px step, which is a large one, and it still read
   as the same thing twice because BOTH are Baskervilles. § Text roles rule 1 asks
   adjacent tiers to differ on two of {face, size, colour} and counts this pair as
   differing on all three; what the rule cannot see is that `--display` and `--body`
   are one typeface family wearing two cuts. EB Garamond is a different face by eye,
   which is the axis that was actually missing.

   FACTUAL IS THE RIGHT VOICE ON ITS OWN TERMS, not just a way out of the collision:
   the voice table assigns it "every figure, label and stated fact", and the rule
   that breaks a tie is a stated fact about the board. The scorecard's own
   denominators (`.score__of`) are already set in it.

   19px, BECAUSE GARAMONDS RUN SMALL — § Scale says so in its first line ("14, not
   12. Garamonds run small"), so holding the 17px would have shrunk the line while
   changing its face, and shrinking it is the "dinky" half of the report. Two
   variants were rendered at 375x667, 18px and 19px; 19 is inside the 17-22 SAY band
   and is the one kept.

   .02em, NOT THE .12em THE FACTUAL VOICE SPECIFIES FOR SENTENCE CASE. Rendered at
   .12em this is a two-line sentence set like a label — airy, thinner-looking, and
   rhyming with `.lb__head th` (EB Garamond, tracked) 24px underneath it, which
   rebuilds L381's smear in a new register. The tracking is a rule about labels and
   short strings; § Text roles' own preamble says a rule that fights the beat loses to
   the beat. Case and size carry the separation from the column labels instead:
   sentence case at 19 against tracked caps at 14.

   1.45 LEADING. Rule 3 puts SAY-tier prose that would read as one dark block at 1.4,
   as `.greet` and `.reveal` do; Garamond's smaller eye takes a touch more than the
   Baskerville those two are set in. */
.lbcap {
  display: table-caption;
  caption-side: top;
  text-align: left;
  font-family: var(--label);
  font-size: 19px;
  letter-spacing: .02em;
  line-height: 1.45;
  color: var(--ink-soft);
  /* 24px, AND THE ASYMMETRY IS THE POINT [L644]. The caption sits 6px under its
     header and 24px above the column labels, so it groups upward. At the old 16px it
     sat almost exactly between the two — 14 above, 16 below — and belonged to
     neither, which is the floating Mike reported. `.lb__head th` carries its own
     padding below this, so the labels still sit centred in their own gap. */
  padding-bottom: 24px;
}
/* Each rule is its own line. A run-on paragraph of two clauses is the thing the
   70-year-old aunt stops reading; one sentence per line lets her take the first and
   leave the rest. Block, not <br>, so a wrapped sentence still indents as one.

   THE POT LEGEND IS THE SAME OBJECT, NOT A SMALLER ONE [L642]. Mike: *"make it the
   same solution for '◆ in the pot'"*. Both lines are the app saying how to read the
   board; giving the legend its own quieter register would make it chrome again,
   which is what L520 spent a fix getting it out of. */
.lbcap__line { display: block; }
.lbcap__line + .lbcap__line { margin-top: 6px; }
/* THE GLOSS DOES NOT BREAK FROM ITS MARK [L520, carried over from `.tienote__pot`].
   The mark and the three words that say what it means must not land on two lines —
   an unlabelled mark is the failure CLAUDE.md names first. */
.lbcap__pot { white-space: nowrap; }

/* THE LEGEND'S MARK IS THE ROW'S MARK [L646]. Mike, 2026-09-12: *"make the diamond
   yellow in the instructions"*.

   GOLD IS LEGAL HERE AND THE WORDS BESIDE IT ARE NOT. § Gold: "Gold stays a fill and
   a mark" — the rule that keeps gold off type is a rule about type, and `◆` is a
   mark. `.lb__pot` already sets exactly this on the rows, which is the whole point:
   a legend whose mark is a different colour from the thing it explains is not a
   legend. The three words stay `--ink-soft` with the rest of the caption.

   13px, MATCHING `.lb__pot` RATHER THAN INHERITING THE CAPTION'S 19. A legend works
   by the eye matching two glyphs, and at 19px this one was visibly bigger than every
   ◆ on the board under it. Same reasoning as the colour, on the other axis. It is
   also the same rule-3 exemption `.lb__pot` carries: a mark is not text.

   NOT `aria-hidden`, deliberately. The row markers are not hidden either, so a
   screen reader that meets a `◆` in a name cell needs this line to have named one. */
.lbcap__mark { color: var(--gold); font-size: 13px; }

/* Before the first call there is no score and no rank, so the count of ballots is
   the only live number on the screen. It is what says the room is playing. */
.ballotsin {
  font-family: var(--label);
  font-size: 14px;
  letter-spacing: .12em;
  color: var(--muted);
  margin-top: 10px;
}

/* The hour nobody designed for: the ballot is in, nothing has been called, and
   this line is the whole screen. Body type at full --ink, because a screen whose
   only content is muted 15px copy reads as an error state. */
.warm { font-size: 19px; line-height: 1.55; }

/* EVERY FLASH IN THIS APP IS A SENTENCE, so it stopped being `.micro` [L122].
   `.micro` is `--label` at 15px with .12em tracking — a caps-register chrome
   class, right for "Answered 8 of 15" and wrong for a line of prose.

   It matters more since L122 than it did before: finishing the ballot now
   redirects to the landing, and the flash is what carries the handback beat
   ("All fifteen in. Put your phone away — results at 10pm"). The design review
   found the line answering "so what should I do?" cannot be set as chrome, and
   putting it in a 15px letterspaced label was exactly that.

   Gold ink rather than --ink: it is 7.1:1 on this ground and it is the app's
   attention colour, so the sentence reads as ADDRESSED to her rather than as
   more page. Sized at tier 3 SAY. */
.flash {
  font-size: 17px;
  line-height: 1.45;
  color: var(--gold-ink);
  margin: 0 0 18px;
}

/* THE POT CARD [L803, L806] — shared/_pot, on the landing under the finishing flash
   and on the review sheet. It is `.rulecard`, and its lines are the rules card's own
   `.rulecard__what` and `.rulecard__how` — the Play door's labelled frame and step
   text, reused rather than restyled. Its lines are <p>s [L804], so these rules only
   take the UA margin off and put back the 6px `.rulecard__how` already uses between
   a step and its sub-text. */
.potcard__line { margin: 0; }
.potcard__line + .potcard__line { margin-top: 6px; }

/* ON THE LANDING THE CARD SITS ABOVE `.arrival`, and `.vine` hangs 34px above
   `.arrival` on purpose so the top of the screen crops it. With the card there the
   drawing ran 12px into the card's frame at 375x667 — a collision a printed card
   never has. 46px is the vine's 34 plus 12 of clearance; measured, not eyed. */
.flash + .potcard { margin-bottom: 46px; }

/* The link is the card's one affordance, so it is its one full-`--ink`, underlined
   run — the `.travel__link` precedent for an inline link in 17px prose. The 12px of
   vertical padding does not move the line box — padding on an inline box never
   does — but it is hit-tested, which takes the link past 44px. The handle is
   `nowrap` because its hyphens are break points. */
.pot__handle { white-space: nowrap; }
.pot__pay {
  font-style: normal;
  color: var(--ink);
  text-decoration: underline;
  text-underline-offset: 3px;
  padding: 12px 0;
}

/* --------------------------------------------- the order of the day -- */

/*
 * A vertical timeline, drawn the only way this design system draws anything:
 * one hairline and a square mark. No cards, no boxes, no connectors with
 * gradients in them — DESIGN.md § Layout forbids all three, and the rail is the
 * `Rule` device standing on its end.
 *
 * Two columns, and the left one is FIXED at 74px so every time starts on the
 * same x. EB Garamond was chosen for uniform digit widths (§ Typography) and
 * this is the second place in the app that leans on them: the eight times that
 * share a digit count set to exactly the same width without a table.
 *
 * 74px is a measurement, not a round number. Measured against tight glyph rects
 * (Range.getClientRects, never the element box — the span is a grid item and
 * fills the column, so its own right edge IS the rail and reports a gap of 0):
 * at 360px every time sets on one line, and "10:00pm" — the longest — clears the
 * rail by 11.5px against 20.2px for the rest. Shrink this and the longest time
 * wraps.
 */
.tl { list-style: none; margin: 30px 0 0; padding: 0; }

/*
 * 132px, and the number is the FALLBACK face, not the webfont [L602]. Three of the
 * five rows carry a span and a span sets on one line, so this column holds the
 * longest range, `8:45 – 10:00pm`. Measured at 375px, 15px `--label`, .1em
 * tracking: 112.5px in EB Garamond but 128.5px in Georgia, the next face in the
 * stack. Cutting this to the webfont's 112.5 would fit every render EXCEPT the swap
 * window and a failed font file — where `nowrap` turns a wrap into an overflow
 * across the rail, on the screen a guest opens first. The slack over the fallback
 * is the price of that never happening, and it is the only reason this is not 113.
 *
 * WAS 156px, for `8:45pm – 10:00pm` with both meridiems. Dropping the leading one
 * on a same-half span [L602] returned 24px to the note beside it. It returned no
 * HEIGHT: Guest arrival's note sets on three lines at any column at or above 104px
 * and the tightest one-line range needs 132, so the third line is the price of the
 * one-line range itself and no column that fits one can avoid it. The list is 457px
 * at both widths, against 436px when the range was split over two lines.
 */
.tl__item { display: grid; grid-template-columns: 132px 1fr; }

.tl__time {
  font-family: var(--label);
  font-size: 15px;
  letter-spacing: .1em;
  line-height: 1.5;
  color: var(--muted);
  font-variant-numeric: tabular-nums;
  /* A range is one phrase; breaking it is what L581 and L600 were both working
     around. The column above is sized so this can never overflow. */
  white-space: nowrap;
}

/*
 * The rail is this element's left border, so it runs the full height of the row
 * INCLUDING its bottom padding and meets the next row's without a seam. Drawing
 * it once on the container instead would need the height of the last row, which
 * nothing knows.
 */
.tl__body {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: 3px;
  border-left: 1px solid var(--hair);
  padding: 0 0 26px 20px;
}

/*
 * The mark. A 7px square, the same one the standings puts beside a called
 * answer — one vocabulary met twice, rather than a second shape meaning
 * something adjacent. Centred on the rail: -4px is half the mark plus the
 * border's own pixel.
 */
.tl__body::before {
  content: "";
  position: absolute;
  left: -4px;
  top: 7px;
  width: 7px;
  height: 7px;
  background: var(--hair-strong);
}

/*
 * The rail stops at the last mark rather than running past it into nothing.
 * `transparent` rather than `border: 0` so the 1px column does not collapse and
 * shift the last row 1px left of the eight above it — the kind of drift that is
 * invisible in review and obvious on a phone. The stub then redraws the segment
 * from the top of the row down to the mark.
 */
.tl__item:last-child .tl__body { border-left-color: transparent; }
.tl__item:last-child .tl__body::after {
  content: "";
  position: absolute;
  left: -1px;
  top: 0;
  width: 1px;
  height: 11px;
  background: var(--hair);
}

.tl__what {
  display: flex;
  align-items: baseline;
  gap: 10px;
  font-size: 17px;
  line-height: 1.45;
}
/* 15px: sentence case at .1em is not letterspaced caps, so rule 3's 14px
   exemption does not apply to it. */
.tl__note {
  font-family: var(--label);
  font-size: 15px;
  letter-spacing: .1em;
  line-height: 1.4;
  color: var(--muted);
}

/*
 * Behind the evening. Dimmed to --muted and no further: 6.0:1 is AA, and a past
 * entry is history rather than disabled — a guest arriving at 8pm still needs to
 * read what she missed. The note under it is already --muted, so the whole row
 * simply settles to one weight; dropping the note to --miss-ink was tried and is
 * 3.4:1 on this ground, which is the opposite of what the sentence above wants.
 */
.tl__item--past .tl__what,
.tl__item--past .tl__time { color: var(--muted); }

/*
 * Where the evening is. Gold as a MARK, which is the only thing gold does on a
 * fill — and the word NOW beside it, because § Color forbids signalling state by
 * colour alone and this is the one row on the screen whose whole job is to be
 * found at a glance in a dark canyon.
 */
.tl__item--now .tl__body::before { background: var(--gold); }
.tl__item--now .tl__time { color: var(--ink); }
/* 14px, not 13px. DESIGN.md § Text roles, rule 3: nothing below 14px, and 14px is
   reserved for letterspaced caps, whose apparent size runs larger than nominal —
   which this is, at .18em uppercase. It also sits beside a 15px .tl__time and is
   the one word on the screen a guest is looking for, outdoors, at arm's length. */
.tl__now {
  font-family: var(--label);
  font-size: 14px;
  letter-spacing: .18em;
  text-transform: uppercase;
  color: var(--gold);
  white-space: nowrap;
}

/*
 * The end of a span, under its start. A block so the break is designed rather
 * than lucky, and it keeps `to` glued to the figure it qualifies — CLAUDE.md's
 * first rule about unlabeled numbers, met inside a 88px column.
 */

.schedule__note { margin-top: 4px; }

/* ---------------------------------------------- how to get there -- */

/*
 * TWO CARDS, ONE SKELETON EACH: a flag, a name, one line of prose, then a
 * labelled payload. Both cards are the same object pointed at different
 * choices, so a guest who has read the top one has learned how to read the
 * bottom one.
 *
 * It replaced a single column of prose that Mike called "very heavy with text
 * and hard to scan, lots of text with little visual affordance of separation"
 * (2026-09-06) — five paragraphs and two hairlines, every line the same face at
 * nearly the same weight. Four other treatments were rendered against it and
 * are in `git log`; this one won because the page is a CHOICE between two
 * things, and a choice is best drawn as two objects rather than as an argument
 * followed by a list. [L205]
 *
 * THE FLOOR IS GONE, and the reason it existed is worth keeping [L201]. This
 * screen was four lines and a button, and the empty ground underneath them was
 * NOT the problem — the footer's hairline was. `.door--home` draws a rule across
 * the full bleed wherever the content happens to stop, so on the app's shortest
 * screen it landed around 700px with 240px of dead green beneath it. If a future
 * edit ever cuts this screen back to one fact, the fix is measured: 22px body
 * padding top + 24px `.door--home` margin + 76px of door + 64px body padding
 * bottom = 186px of chrome.
 */

/*
 * FILLS, NEVER BORDERS. DESIGN.md § Layout bans a box as a grouping device, and
 * § Inverted grounds sanctions a second ground as the way out of a hierarchy
 * squeeze — so the recommended option is lifted to `--surface` and the other is
 * left on the plain ground. The difference in weight IS the recommendation; no
 * line of copy has to carry it.
 *
 * Full-bleed like every other row in the app, so the lifted ground runs edge to
 * edge and reads as a surface rather than as a floating panel.
 */
.travel__card {
  margin: 22px calc(var(--gutter) * -1) 0;
  padding: 20px var(--gutter) 24px;
}
.travel__card--pick { background: var(--surface); }
/* The divider carries the gap, so the second card does not add its own. */
.travel__or + .travel__card { margin-top: 0; }

/*
 * `OR`, centred on a rule and knocked out of it — the shape every printed form
 * uses for a fork. It was the second card's FLAG, in the slot the first card
 * gives to `RECOMMENDED`, and those are not the same kind of word: one names a
 * category and the other is a conjunction between two. Set identically, "Or"
 * read as the name of the driving option. [L206]
 *
 * `--hair-strong` opens a group boundary; `--hair` divides rows inside one. The
 * knockout ground MUST stay a token — a hard-coded green becomes a bright bar
 * through the word the moment the press flips at 6:30pm.
 */
.travel__or {
  position: relative;
  margin: 30px 0 0;
  text-align: center;
  font-family: var(--label);
  font-size: 14px;
  letter-spacing: .22em;
  text-transform: uppercase;
  color: var(--muted);
}
.travel__or::before {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  top: 50%;
  height: 1px;
  background: var(--hair-strong);
}
.travel__or span { position: relative; background: var(--paper); padding: 0 16px; }

/* The flag, and there is exactly one on this screen. It was a pair — gold
   `RECOMMENDED` here and `--muted` `OR` on the other card — and "Or" is a
   conjunction rather than a category, so it left the slot and became the divider
   between the two cards [L206]. Gold is now unconditional, because the only card
   that flags itself is the one making a claim. */
.travel__flag {
  margin: 0;
  font-family: var(--label);
  font-size: 14px;
  letter-spacing: .22em;
  text-transform: uppercase;
  color: var(--gold-ink);
}

/*
 * THE CARD NAME IS A LABEL, NOT CONTENT, so it takes the Display voice and gets
 * out of the payload's way. It shipped for one afternoon as 26px Libre
 * Baskerville roman `--ink`, two pixels and no other difference from the address
 * block under it — § Text roles rule 4 (never two tiers at one colour within
 * 3px) and rule 1 (adjacent tiers differ on two of face/size/colour) broken by
 * the same pair, which is why the driving card read as four paragraphs of one
 * texture. Mike, on the render: "all kinds of weird with same font size".
 *
 * Size only here; the face comes from `.voice-display` in the markup, so the two
 * axes stay apart the way L82 left them.
 */
.travel__name {
  margin: 6px 0 0;
  font-size: 26px;
  line-height: 1.25;
}

/*
 * 26, NOT 20, and the reason is optical rather than arithmetic. At 20px this
 * name sat over a 17px `.travel__say` and cleared every written rule — different
 * face, different colour, three pixels — and still read as the same line twice.
 * Mike, on the render: "Two shuttles each way… clash with TAKE THE SHUTTLE, too
 * similar in font size/weight."
 *
 * Baskervville SC is a SMALL-CAPS face: its caps at 20px ink about what Libre
 * Baskerville's lowercase inks at 17. The size ladder was measuring nominal
 * sizes for two faces whose apparent sizes do not line up — the same trap
 * § Typography already names for EB Garamond, where every label is set ~1.18x
 * larger than the value it replaced to hold the same optical size. [L206]
 *
 * It does not collide with the 24px address: they are in different cards and are
 * never adjacent, and small caps against roman is the largest face difference
 * this system has.
 */

/* One line of prose per card, in the same slot in both: the count on the
   shuttle card, the warning on the driving one. SAY tier, and it needs the air —
   see the note on `.travel__name` for why a written rule was not enough here. */
.travel__say {
  margin: 12px 0 0;
  font-size: 17px;
  line-height: 1.5;
  color: var(--ink-soft);
}

/*
 * ONE SUB-LABEL SHAPE, USED THREE TIMES — over the outbound departures, over the
 * ones home, and over the address. It is what makes the two cards the same
 * object: flag, name, prose, then a LABELLED payload. The driving card shipped
 * without one over its address, so the address arrived as a fourth paragraph
 * rather than as the thing the card hands over.
 */
.travel__sub {
  margin: 20px 0 0;
  font-family: var(--label);
  font-size: 15px;
  letter-spacing: .12em;
  color: var(--muted);
}

.ride { list-style: none; margin: 4px 0 0; padding: 0; }

/*
 * ONE DEPARTURE PER ROW, EVERY ROW SAYING A TIME AND WHAT IT IS.
 *
 * The two times per leg were one line — `3:00pm · 3:40pm` — and a middot between
 * two clock times reads as a RANGE, which would tell a guest the journey takes
 * forty minutes rather than that there are two coaches. Mike, 2026-09-06: "you
 * need to make it more clear that there are two times out and two times back.
 * 70 year old aunt won't understand your current design." That is CLAUDE.md's
 * own rule met again — a figure says what it counts, in words, on the same line.
 * So each departure is its own row with its own name, the hairline between them
 * says they are two things, and the card's prose line states the count out loud
 * before either.
 *
 * 84px of time column, measured, not copied from `.tl`: this clock is 22px where
 * the running order's is 15px, and `10:30pm` at 22px overruns 74 and butts
 * straight into its own label. The explicit column-gap is the second half of
 * that fix.
 *
 * THE TIME IS `--ink` HERE AND `--muted` ON `/schedule`, and the inversion is
 * the content's rather than a drift: the running order's nine rows carry nine
 * distinct names and the guest scans for one of them, while these rows are
 * called "First shuttle" and "Last shuttle" and the figure is the entire thing
 * anyone came for.
 */
.ride__row {
  display: grid;
  grid-template-columns: 84px 1fr;
  column-gap: 14px;
  align-items: baseline;
  min-height: 44px;
  padding: 8px 0;
}
.ride__row + .ride__row { border-top: 1px solid var(--hair); }

/* Factual voice. Tabular figures are why this face is in the system at all —
   `9:00pm` over `10:30pm` must set its colon in the same column. */
.ride__time {
  font-family: var(--label);
  font-size: 22px;
  letter-spacing: .04em;
  line-height: 1.4;
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}

/* Plain voice, SAY tier. Differs from the time on face, size AND colour —
   § Text roles rule 1, with nothing borrowed. */
.ride__what {
  font-size: 17px;
  line-height: 1.45;
  color: var(--ink-soft);
}

/* The pickup point, now the place name inside the card's own sentence [L209].
   Underlined, because the app's global `text-decoration: none` would otherwise
   leave the only tappable thing in this card indistinguishable from the prose
   around it — and an inline link has no other affordance to spend. Underlining
   at 17px roman is safe; the ban is at .22em, where the rule drags through the
   tracking and reads as a strikethrough. */
.travel__link { text-decoration: underline; }

/*
 * The address block. 20px, DOWN FROM 24 [L207], and the reason the old number
 * existed no longer holds.
 *
 * 24 was a CEILING for a block that was the whole screen: every documented
 * tier-1 size (34/46/50) breaks `1055 Hunsaker Canyon Rd` after "Canyon", three
 * unwrapped lines is the entire point of a block read out loud, and with nothing
 * else on the page mass had to carry the tier [L82]. It is not the whole screen
 * any more — it is the payload of the second card, under a 26px name and its own
 * `The address` label — so 24px made it the largest thing in a card whose entire
 * job is to be the option nobody takes. Mike, on the render: "that's weirdly
 * large."
 *
 * WHAT KEEPS IT FROM READING AS CHROME IS NOW STRUCTURAL, not its own size: the
 * label above it and the card around it. That is why the L78 floor — "at 19px
 * the address is the same size and colour as the footer's back-link" — can be
 * approached without meeting it. It is still the loudest thing in its card, and
 * still three unwrapped lines, which is the constraint that actually matters.
 *
 * `font-style: normal` turns off the browser's italic on <address>; italic is
 * the host's voice in this app, not a fact's.
 */
.venue__address {
  font-style: normal;
  font-size: 20px;
  line-height: 1.5;
  margin-top: 2px;
}

/*
 * THE ADDRESS IS THE LINK, and there is no button [L211]. A full-width `.btn`
 * sat under this block — the loudest control on a screen whose whole argument is
 * that most people should not drive, naming a place the three lines above it had
 * already named. Same grammar as the hotel in the card above: the place name is
 * the affordance.
 *
 * Underlined, because the app's global `text-decoration: none` would leave it
 * indistinguishable from the prose; offset so the rule clears the descenders on
 * three stacked lines rather than cutting through them. `user-select: text`
 * keeps the long-press-to-copy gesture L82 bought working wherever the browser
 * allows it on a link.
 *
 * Its tap target is the whole block — about 90px of three 20px lines — so this
 * is the one map link on the screen that clears the 44px floor comfortably.
 */
.venue__link {
  text-decoration: underline;
  text-underline-offset: 3px;
  user-select: text;
  -webkit-user-select: text;
}

/* -------------------------------------------------------------- footer -- */

/* 44px of tappable height, the documented floor for a touch target. `.micro` is
   15px type, so its natural box is about 24px — half a target — and every link in
   this app that carries the class had the same problem. Applied here rather than
   to `.micro` itself because `.micro` is also used for static labels, where 24px
   of extra vertical space would open holes in the layout. */
.tappable {
  display: inline-block;
  padding: 11px 0;
  min-height: 22px;
}

/* One hairline for the whole footer, on the wrapper, so it lands in the same place
   whether or not the navigation row is rendered. */
.foot {
  margin-top: 44px;
  padding-top: 12px;
  border-top: 1px solid var(--hair);
}

/* Same rule as `.whoami` below, and it was missing entirely — the class was in the
   partial twice and in this stylesheet never. Two `.micro.tappable` anchors are
   inline-blocks, so they packed onto one line five pixels apart in the same face,
   size and colour: "Find your table → Lian & Michael's page →" read as one sentence
   with two arrows in it. Every computed style was correct; only a screenshot showed
   it. This is the whole of the chrome a stranger gets — the person shown the board
   over a shoulder at 8pm, who by design has no name on the app. */
.footnav {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
}

/* The identity line and the sign-out sit on separate rows on purpose. "Not you?"
   used to sit inline beside the guest's name, and the footer now also carries a
   navigation link she taps all evening — three controls in one row is how someone
   signs herself out reaching for the standings. */
.whoami {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
}

/* NOTE tier, and the app's eyebrow grammar — uppercase at .22em, same as
   `.world__label` on the landing two inches above it. `--muted` rather than that
   rule's `--ink`: this one labels a line that is itself quiet chrome, and it must
   not outrank the name under it. */
.whoami__label {
  font-family: var(--label);
  font-size: 14px;
  letter-spacing: .22em;
  text-transform: uppercase;
  color: var(--muted);
  margin-bottom: 3px;
}

/* ASK TIER [L661]. 19px `--ink` in the Factual voice — the masthead's own
   treatment since L320, and for the identical reason: this was 15px `--muted`,
   the same tier as the control under it, so the two lines had no rank between
   them. Against `.whoami__label` above it and `.whoami__leave` below, it differs
   on size AND colour, which is DESIGN.md rule 1.

   EB Garamond runs ~1.18x small, so 19px here inks about the 16px this looks
   like on the page. Verified by rendering at 375x667, not by computing it. */
.whoami__name {
  font-family: var(--label);
  font-size: 19px;
  letter-spacing: .12em;
  color: var(--ink);
}
.whoami__leave {
  background: none;
  border: 0;
  color: var(--muted);
  text-decoration: underline;
  cursor: pointer;
  font-family: var(--label);
  font-size: 15px;
  letter-spacing: .12em;
  padding: 11px 0;
  /* Measured, not assumed: the padding alone gave a 41.5px box because a button's
     line box is shorter than the equivalent inline-block link's. */
  min-height: 44px;
}


/* --------------------------------------------------------- resolver -- */

/* Bigger targets than the guest UI: this person is standing, in the dark,
   holding a drink, with a room waiting on them. */
/*
 * THE COUNT [L541]. The one figure on this screen, and the caller reads it from
 * arm's length while a room waits — so it is not the 15px `--muted` NOTE-tier line
 * it used to be, which was the same treatment as each card's own meta band on a
 * screen carrying fifteen of those. Factual voice, `--ink`, and the WORDS SIT ON
 * THE FIGURE'S OWN LINE: `3 of 15 called`, never `3 of 15` over `called`. EB
 * Garamond runs ~1.18x small, so 26px here inks about the 22px this tier wants.
 */
.rescount {
  font-family: var(--label);
  font-size: 26px;
  letter-spacing: .04em;
  color: var(--ink);
  margin: 0 0 26px;
}

/*
 * ONE LIST, AND A CARD NEVER MOVES [L540]. There is no `--live` variant: the gold
 * left rail marked "the next one to call", which was a queue this app does not
 * have — any question is callable at any time. A called card holds its position
 * and says so, and the only difference between the two states is the outcome
 * chip, which is a fact rather than a rank.
 */
.rescard { padding: 20px 0; border-top: 1px solid var(--hair); }
.rescard__meta {
  font-family: var(--label);
  font-size: 14px;
  letter-spacing: .2em;
  text-transform: uppercase;
  color: var(--muted);
  margin-bottom: 8px;
}
/* THE META BAND STAYS `--muted` ON A CALLED CARD. Rendered gold, the whole band
   went gold — `QUESTION 1 · CEREMONY · CALLED 1:47PM` — which made the fifteen
   FINISHED cards the loudest thing on a screen whose job is the unfinished ones.
   The outcome chip states the state; the card's own label is not a second place
   to state it. */
.rescard__q { font-size: 17px; line-height: 1.4; }
.rescard__btns { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; }

/* THE FLEX ITEM IS THE WRAPPER, NOT THE BUTTON. `button_to` puts each button
   inside its own <form>, so `flex: 1 1 46%` on `.resbtn` was being applied to a
   child of that form and the row sized itself to the option text instead of
   halving the measure. The wrapper takes the track; the control fills it. */
.rescard__btns > .button_to,
.rescard__btns > .resbtn--won { flex: 1 1 46%; display: flex; }
.resbtn {
  flex: 1 1 auto;
  min-height: 88px;             /* ~2x Apple's 44pt minimum. Deliberate. */
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 6px;
  border: 1px solid var(--hair-strong);
  background: none;
  color: var(--ink);
  font-family: var(--body);
  font-size: 16px;
  line-height: 1.3;
  text-align: center;
  padding: 12px 10px;
  border-radius: 0;
  -webkit-appearance: none;
  cursor: pointer;
}
button.resbtn:active { background: var(--surface); }

/* THE OUTCOME, IN THE OPTION'S OWN SLOT — and it is a <span>, not a button. It
   says CALLED above the option it is stating, because a gold-bordered option with
   no word on it is a legend the caller has to have been taught. The other options
   stay live beside it, so changing the state is naming the new outcome. */
.resbtn--won {
  border-color: var(--gold);
  color: var(--gold);
  cursor: default;
}
.resbtn__state {
  font-family: var(--label);
  font-size: 14px;              /* NOTE floor for letterspaced caps. See DESIGN.md § Text roles. */
  letter-spacing: .12em;        /* the sentence-case Factual setting, and a MEASUREMENT: at .22em
                                   `CALLED BY` alone runs past a 46%-of-375px chip. Same trade the
                                   masthead made at L320, for the same reason. */
  line-height: 1.3;
  text-transform: uppercase;
}
/* THE CALLER'S NAME TAKES ITS OWN LINE. A designed break holds at any name
   length; leaving it to wrap holds until the coordinator is called Christina. */
.resbtn__caller { display: block; }

/* Undo is on every called card, per the non-negotiable. Full-measure and worded
   with its consequence — `Undo` alone, beside four option buttons, reads as
   undoing the last thing rather than this card. */
.rescard__undo { margin-top: 8px; }
.resundo {
  width: 100%;
  min-height: 48px;
  border: 1px solid var(--hair-strong);
  background: none;
  color: var(--muted);
  font-family: var(--label);
  font-size: 15px;
  letter-spacing: .12em;
  padding: 14px 20px;
  border-radius: 0;
  -webkit-appearance: none;
  cursor: pointer;
}
.resundo:active { background: var(--surface); }

/* ---------------------------------------------------------- photos -- */
/*
 * PHOTO BOOTH PHOTOS [L900, L904, L909] — the first imagery the app has shown.
 * Square corners, hairline-quiet, on the paper ground like everything else. The
 * only filled surfaces are the full-screen controls' backings, and they are
 * functional: there the arrows sit over the photo, and half the frames are shot
 * against a cream backdrop, where a bare cream chevron disappears.
 */
.photos__lede { color: var(--muted); margin: 22px 0 18px; }
.pcount { color: var(--muted); margin: 18px 0 14px; }

.pgrid { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; margin: 0 0 28px; }
.pgrid__cell { display: block; aspect-ratio: 3 / 2; background: var(--surface); }
.pgrid__cell img { display: block; width: 100%; height: 100%; object-fit: cover; }

.viewer {
  position: relative;
  margin: 18px 0 20px;
  aspect-ratio: 3 / 2;
  background: var(--surface);
  touch-action: pan-y;          /* sideways is ours, vertical scroll stays native */
  user-select: none;
  -webkit-user-select: none;
}
.viewer__open { display: block; width: 100%; height: 100%; -webkit-touch-callout: default; }
.viewer__img { display: block; width: 100%; height: 100%; object-fit: contain; transition: opacity 200ms ease;
               transform-origin: 0 0; /* the zoom maths measures from the frame's top-left [L913] */ }
/*
 * THE NEXT ORIGINAL IS LATE [L910, L912]. A gold hairline draws left to right in
 * the gap under the photo, then retracts to the right, and repeats until the photo
 * swaps in. It is DESIGN.md's Rule motion — "the only loading device in the
 * product. Nothing spins" — looped: 380ms in, 380ms out. It sits in the gap, so it
 * never covers the photo, and the photo still on screen only fades a little.
 */
.viewer.is-loading .viewer__img { opacity: .75; }
.viewer::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: -11px;
  height: 2px;
  background: var(--gold);
  transform: scaleX(0);
  transform-origin: left;
  pointer-events: none;
}
.viewer.is-loading::after { animation: viewer-loading 760ms cubic-bezier(.22, 1, .36, 1) infinite; }
@keyframes viewer-loading {
  0%      { transform: scaleX(0); transform-origin: left; }
  50%     { transform: scaleX(1); transform-origin: left; }
  50.01%  { transform: scaleX(1); transform-origin: right; }
  100%    { transform: scaleX(0); transform-origin: right; }
}
/* Full screen: the same line, right under the photo as it is fitted to the screen.
   The photo is `object-fit: contain`, so its height is the smaller of the screen's
   and the width over its ratio, and the band under it is half of what is left.
   Turned sideways the photo fills the height and there is no band, so the line
   sits 8px inside its bottom edge — the one place it touches the photo, and only
   while a photo is loading. */
.viewer.is-full::after {
  left: 0;
  right: 0;
  bottom: max(8px, calc((100vh - min(100vh, 100vw / (var(--photo-ratio, 3 / 2)))) / 2 - 11px));
  bottom: max(8px, calc((100dvh - min(100dvh, 100vw / (var(--photo-ratio, 3 / 2)))) / 2 - 11px));
}
/* Reduced motion: the line simply shows, full width, while it waits. */
@media (prefers-reduced-motion: reduce) {
  .viewer.is-loading::after { animation: none; transform: scaleX(1); }
}

/*
 * THE ARROWS LIVE IN THE SIDE MARGINS, AND THE PHOTO KEEPS ITS FULL WIDTH [L909].
 * Mike, 2026-10-07: "break the margin width rules and add those little arrows to
 * the left and right... without shrinking the size of the photo." The one thing
 * allowed in the 20px gutters. Each tap strip is 44px wide (CLAUDE.md's floor) and
 * the photo's full height: 20px of margin, where the arrow is drawn, plus 24px laid
 * INVISIBLY over the photo's edge — nothing is painted on the photo itself.
 */
.paddle {
  position: absolute;
  top: 0;
  bottom: 0;
  width: 44px;
  display: flex;
  align-items: center;
  color: var(--ink);
  text-decoration: none;
}
.paddle--prev { left: -20px; justify-content: flex-start; }
.paddle--next { right: -20px; justify-content: flex-end; }
.paddle svg { width: 18px; height: 18px; margin: 0 1px; }
.paddle:active svg { opacity: .6; }

.viewer__close { display: none; }

/* FULL SCREEN — the app's own overlay, because iPhone Safari has no element
   fullscreen. Fixed over everything, photo fitted to the screen, same controls. */
.viewer.is-full {
  position: fixed;
  inset: 0;
  z-index: 50;
  margin: 0;
  aspect-ratio: auto;
  background: var(--paper);
  overflow: hidden;    /* a zoomed photo is clipped by the screen, not scrolled */
  touch-action: none;  /* every gesture here is the viewer's: pinch, pan, swipe [L913] */
}
.viewer.is-full .viewer__img { will-change: transform; }
.viewer.is-zoomed { cursor: grab; }
.viewer.is-full .viewer__close {
  display: flex;
  position: absolute;
  top: max(8px, env(safe-area-inset-top));
  right: 8px;
  width: 44px;
  height: 44px;
  align-items: center;
  justify-content: center;
  border: 0;
  background: rgba(42, 58, 49, .55);
  color: var(--ink);
  font-size: 24px;
  line-height: 1;
}
/* No margins in full screen, so the arrows come back over the photo's edge with a
   backing, at the screen edges. Turned sideways the photo is letterboxed and the
   arrows land in the bands beside it, clear of the photo. */
.viewer.is-full .paddle {
  top: 50%;
  bottom: auto;
  height: 64px;
  transform: translateY(-50%);
  justify-content: center;
  background: rgba(42, 58, 49, .55);
}
.viewer.is-full .paddle--prev { left: 0; }
.viewer.is-full .paddle--next { right: 0; }
.viewer.is-full .paddle svg { margin: 0; }
body.has-full { overflow: hidden; }

.photoview__hold { margin: 12px 0 28px; }

/* ------------------------------------------------------------- a11y -- */

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: .01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 200ms !important;
    scroll-behavior: auto !important;
  }
  .opt:active { transform: none; }
}

/* Every state stays readable from fill, rule and strike alone, so a forced-colours
   user loses nothing. */
@media (forced-colors: active) {
  .opt--chosen { border: 2px solid; }
}
