/*
 * THE FACES. Nothing above this file in the cascade may declare a family that is not served here.
 *
 * ── THE DEFECT THIS FILE CLOSES ──────────────────────────────────────────────────────────────
 *
 * `tokens.css` has named Inter, Archivo and IBM Plex Mono since the site was drawn. The built
 * stylesheet contained ZERO `@font-face` rules and ZERO `url(` references, so not one byte of any
 * of them was ever requested. Measured on the served asset:
 *
 *     $ curl -s https://minirestaurants.online/assets/styles.css | grep -c '@font-face'   ->  0
 *     $ curl -s https://minirestaurants.online/assets/styles.css | grep -c 'url('         ->  0
 *
 * The families still RESOLVED — for the fraction of visitors with Inter or Archivo installed
 * locally, which on a restaurant owner's phone is approximately none. Everyone else silently got
 * `system-ui`. The typography was therefore a description of a design rather than the design, and
 * nothing failed: no 404, no console error, no gate. A stack whose first entry never loads reads
 * exactly like a stack that has no first entry.
 *
 * ── SELF-HOSTED, AND WHY THERE IS NO PACKAGE HERE ────────────────────────────────────────────
 *
 * An external font CDN is the wrong answer for this property regardless of host: a third-party
 * request on every page, for a site whose entire pitch is that it does not depend on someone
 * else's uptime. The `.woff2` files are committed under `public/fonts/` and copied verbatim by
 * `build.mjs`'s `copyDir`, so they are served by whichever path is live — the Cloudflare Pages
 * direct upload the site answers from today, or the `deploy/nginx.conf` image.
 *
 * ON CONTENT-TYPE, and the part of it that is NOT measured. `serve.mjs` — the dev server the
 * browser tests read — answered `application/octet-stream` for these four files and now answers
 * `font/woff2`; that one IS measured (`curl -w '%{content_type}'`, and the four faces load in
 * Chrome from it). What is NOT measured here is the response the `nginx:1.27-alpine` image sends:
 * stock `mime.types` is expected to carry `font/woff2`, no rule was added on that expectation, and
 * no Docker daemon exists on this machine to check it. If that image ever becomes the live path,
 * run `docker run --rm nginx:1.27-alpine grep woff /etc/nginx/mime.types` before trusting this
 * paragraph. It would not break rendering either way — browsers sniff woff2 from its signature and
 * ignore the declared type for `@font-face` — which is exactly why it would go unnoticed.
 *
 * They are NOT an npm dependency, deliberately: `build.mjs` is zero-dependency and this package is
 * not a pnpm workspace member, which is the property that keeps a copy edit here from re-running
 * the Rust, iOS and Android lanes. Adding `@fontsource/*` to a manifest would spend that.
 *
 * PROVENANCE — the files were extracted from these published tarballs and copied unmodified:
 *
 *   inter-latin-variable.woff2      @fontsource-variable/inter 5.3.0
 *                                   files/inter-latin-wght-normal.woff2         48,256 bytes
 *                                   sha256 3100e775e8616cd2611beecfa23a4263d7037586789b43f035236a2e6fbd4c62
 *   archivo-latin-variable.woff2    @fontsource-variable/archivo 5.3.0
 *                                   files/archivo-latin-wdth-normal.woff2       90,104 bytes
 *                                   sha256 e3a28eade21a900c7155a247757f4b2834c07bb7ef07ad7efa55cebaac1e8f5e
 *   ibm-plex-mono-latin-400.woff2   @fontsource/ibm-plex-mono 5.3.0
 *                                   files/ibm-plex-mono-latin-400-normal.woff2  14,708 bytes
 *                                   sha256 08949f728dc52d528e69b1667d15c89a5686a4ee9a296ff90983985f99c380f7
 *   ibm-plex-mono-latin-600.woff2   @fontsource/ibm-plex-mono 5.3.0
 *                                   files/ibm-plex-mono-latin-600-normal.woff2  15,620 bytes
 *                                   sha256 0d1f0b8d0722224e32e9f28261bdc86c79115be73444ae5eceb73976a1bcdf83
 *
 * All three families are SIL Open Font License 1.1. The OFL requires the licence to travel with
 * the files, so it does: `public/fonts/OFL-inter.txt`, `OFL-archivo.txt`, `OFL-ibm-plex-mono.txt`,
 * served next to the fonts they cover. Nothing here is renamed in a way the OFL reserves.
 *
 * ── WHAT IS DELIBERATELY NOT SERVED, stated rather than left to be discovered ────────────────
 *
 * 1. LATIN ONLY. `latin-ext`, `cyrillic` and `vietnamese` subsets exist upstream and are not
 *    shipped. The site is English and Spanish; every Spanish character (á é í ó ú ü ñ ¿ ¡) is
 *    inside the latin range below. `unicode-range` is declared on every face, so a character
 *    outside it falls to `system-ui` and RENDERS — it does not become a tofu box. That is a real
 *    fallback and it is the reason the ranges are declared rather than omitted.
 *
 * 2. NO ITALICS. Grepped: the stylesheet sets `font-style: italic` nowhere, and no page markup
 *    uses `<em>` or `<i>`. Shipping four more files for a style the site does not use would be
 *    ~90 KB spent on nothing. If an italic ever lands, `font-synthesis-style` is left at its
 *    default so the browser slants — visible, not silent.
 *
 * 3. NO "Inter Fallback" / "Archivo Fallback". Those two names sat in the token stacks and were
 *    never defined anywhere, in any file — a metric-matched fallback that does not exist is the
 *    same silent nothing this whole file is about. They are removed from the stacks rather than
 *    left in place, per the brief: a family that cannot be served does not stay in the stack.
 *
 * 4. NO "Archivo Expanded" EITHER, and this one is an upgrade rather than a removal. The expanded
 *    display face is not a separate family — it is the `wdth` axis of the one Archivo file below,
 *    which is why the `wdth` variable subset is shipped (90 KB) rather than the `wght`-only one
 *    (35 KB). `font-stretch: 62% 125%` on the face is what makes base.css's EXISTING
 *    `font-stretch: expanded` on `.display-1` at >=1024px do something. Until now it did nothing,
 *    twice over: no font, and no width axis to move.
 *
 * `font-display: swap` on every face. `--size-*` are clamped and the fallback is `system-ui`, so
 * the swap moves text; it does not blank it. `font-synthesis-weight: none` is already set on
 * `body`, which is why real 400/600 mono files ship instead of one file and a faux bold.
 */

/* ── Inter — body. Variable, wght 100..900. ──────────────────────────────────────────────────── */
@font-face {
  font-family: "Inter";
  font-style: normal;
  font-display: swap;
  font-weight: 100 900;
  src: url("/fonts/inter-latin-variable.woff2") format("woff2-variations");
  unicode-range:
    U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
    U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

/* ── Archivo — display. Variable, wght 100..900 AND wdth 62%..125%. ──────────────────────────── */
@font-face {
  font-family: "Archivo";
  font-style: normal;
  font-display: swap;
  font-weight: 100 900;
  font-stretch: 62% 125%;
  src: url("/fonts/archivo-latin-variable.woff2") format("woff2-variations");
  unicode-range:
    U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
    U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

/* ── IBM Plex Mono — data. Static, because upstream publishes no variable build. ─────────────── */
@font-face {
  font-family: "IBM Plex Mono";
  font-style: normal;
  font-display: swap;
  font-weight: 400;
  src: url("/fonts/ibm-plex-mono-latin-400.woff2") format("woff2");
  unicode-range:
    U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
    U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

@font-face {
  font-family: "IBM Plex Mono";
  font-style: normal;
  font-display: swap;
  font-weight: 600;
  src: url("/fonts/ibm-plex-mono-latin-600.woff2") format("woff2");
  unicode-range:
    U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
    U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

/*
 * Design tokens — WEB-DESIGN-SPEC.md §3. One source, and the console (doc 17 §11) is the second
 * consumer, so nothing here names a page or a component.
 *
 * Direction: THE TICKET RAIL — stainless and heat. Exactly three hues: steel (dominant), paper
 * (neutral), ember (accent, capped at ~10% of any viewport).
 *
 * ── WHERE THESE NUMBERS COME FROM, AS OF 2026-08-23 ──────────────────────────────────────────
 *
 * The CEO ratified `MiniRestaurants-Mockups-Round2/THE-RAIL-picked.html` as the design standard
 * for the WHOLE site — "the characteristics of this page are what we're going to use throughout
 * this website." Its `:root` block (lines 5-61 of that file) is now the SOURCE OF TRUTH for the
 * primitives below, and where it disagreed with this file, it won:
 *
 *     --steel-900   #16202b -> #16212c
 *     --steel-800   #1b2733 -> #22303d
 *     --ember-500   #c85107 -> #c65310
 *     --ember-300   #f59b45 -> #f2a24a
 *
 * ⛔ THE SEMANTIC LAYER BELOW WAS NOT REPLACED BY THE MOCKUP'S NAMES, AND THAT IS THE POINT.
 * 22 UI components and 5 stylesheets read `--surface-*`, `--text-*`, `--rule-*`, `--action-*`.
 * Porting the mockup by renaming them to `--ground` / `--ink` / `--accent` would have been a
 * 1,800-line diff across `components.css` and every `src/ui/*.mjs`, and every one of those edits
 * is a chance to repaint one component slightly wrong. Instead the mockup's names are added as
 * PRIMITIVES and the existing semantic names are REDEFINED in terms of them. One file changes;
 * the whole site repaints; zero component edits. If you are here to "finish the port" by deleting
 * the semantic layer, read this paragraph again — the indirection is the migration, not debt.
 *
 * ⚠️ ONE PLACE WHERE "THE WHOLE SITE REPAINTS" IS NOT TRUE, STATED HERE RATHER THAN DISCOVERED.
 * `.ticket-rail__card` and the `.ticket-card__*` rules in `components.css` read PRIMITIVES
 * (`--paper-card`, `--steel-950`, `--steel-100`, `--steel-600`) and two literal hexes, not the
 * semantic layer. Primitives do not move between themes, so the ticket cards stay light in dark
 * mode — measured on `/` at 1440px, 2026-08-23. This is PRE-EXISTING and unchanged by this pass:
 * the same cards stayed light for an OS-dark visitor before the toggle existed, and the diff that
 * introduced the toggle adds 71 lines to `components.css` and removes none.
 * It may also be CORRECT — those cards are a replica of the product's own OrderCard, and a real
 * kitchen ticket is a light card on a dark board. Whether they follow the theme or stay a product
 * replica is a design decision for Felicity, not a token decision, so nothing here changes them.
 *
 * Every contrast ratio in the comments below was RE-MEASURED on 2026-08-23 against the new
 * values, with sRGB relative luminance per WCAG 2.2. None was carried forward: adopting
 * `--ground: #ffffff` as the page changed every single one of them.
 */

:root {
  /* ── Ember ramp — the mockup's, verbatim ──────────────────────────────── */
  --ember-900: #5e2305;
  --ember-800: #7d2f06;
  --ember-600: #a8420a;
  --ember-500: #c65310;
  --ember-400: #e4780b;
  --ember-300: #f2a24a;
  --ember-100: #fbdcc4;
  --ember-050: #fdf1e7;

  /* ── Steel ramp. 950/900/800 are the mockup's; 850/600/500/300/200/100 are this repo's and
   * have no counterpart there — the mockup only ever paints three steels. They are kept because
   * `components.css` reads them and because dropping a step from a ramp is how a mid-tone gets
   * invented at a call site later. */
  --steel-950: #0f1720;
  --steel-900: #16212c;
  --steel-850: #1e2a37;
  --steel-800: #22303d;
  --steel-600: #4a5d71;
  --steel-500: #64798f;
  --steel-300: #c3cedaff;
  --steel-200: #dde4eb;
  --steel-100: #edf1f5;
  --paper-base: #f4f6f8;
  --paper-card: #fbfcfd;

  /*
   * ── The mockup's own primitive vocabulary ────────────────────────────────
   * These are the names its ~1,800 lines of component CSS actually reference. Every band, card,
   * rail and button ported in steps 4-6 will ask for THESE, so they exist here from step 1 rather
   * than arriving one at a time inside whichever component needed one first.
   */
  --ground: #ffffff;
  --ground-2: #f4f6f8;
  --ground-3: #e9edf1;
  --panel: #ffffff;
  --ink: #0f1720;
  --ink-2: #3d4a57;
  --ink-3: #5f6a74;
  --line: #dfe5ea;
  --line-2: #78868f;

  --accent: #a8420a;
  --accent-hi: #e4780b;
  --accent-ink: #ffffff;
  --accent-wash: #fdf1e7;

  /* Status. `--warn` is deliberately the accent, not a fourth hue: this palette is capped at
   * three, and an amber warning next to an ember brand reads as a rendering fault. */
  --ok: #166534; /* 7.13:1 on --ground */
  --warn: #a8420a; /* 6.09:1 on --ground */
  --late: #9f1239; /* 8.02:1 on --ground */

  /* Mockup radii. The repo's own `--radius-*` vocabulary is kept below — see the note there. */
  --r-xs: 8px;
  --r-sm: 12px;
  --r: 18px;
  --r-lg: 28px;
  --r-xl: 40px;
  --r-pill: 999px;

  /* Mockup shadows, alongside `--elev-*`. Same note as the radii. */
  --sh-1: 0 2px 8px rgb(15 23 32 / 6%);
  --sh-2: 0 10px 30px -12px rgb(15 23 32 / 18%);
  --sh-3: 0 32px 70px -30px rgb(15 23 32 / 42%);

  --grad-ember: linear-gradient(135deg, #c65310 0%, #a8420a 55%, #7d2f06 100%);
  --grad-steel: linear-gradient(135deg, #22303d 0%, #0f1720 100%);
  --grad-wash: linear-gradient(180deg, #fdf1e7 0%, rgb(253 241 231 / 0%) 100%);

  /* ── Semantic roles. Themes reassign these and nothing else. ──────────────
   * Everything from here down is expressed through the primitives above. A hex literal in this
   * block would be a colour no theme can move. */
  --surface-page: var(--ground);
  --surface-raised: var(--ground-2);
  --surface-sunken: var(--ground-3);
  --surface-card: var(--panel);
  --surface-dark: var(--steel-950);
  --surface-accent-wash: var(--accent-wash);

  --text-primary: var(--ink); /* 18.05:1 on --ground, 16.66:1 on --ground-2 */
  --text-secondary: var(--ink-2); /* 9.07:1 on --ground, 8.37:1 on --ground-2 — passes 1.4.3 */
  /*
   * NON-TEXT ONLY, by name and by policy — but note it no longer has to be. On the old
   * `--paper-base` page this was #64798f at 4.15:1, under the 4.5:1 body-copy floor, which is why
   * the rule exists. On `--ground` it is #5f6a74 at 5.53:1 (5.10:1 on --ground-2) and WOULD pass
   * for body copy. The restriction is kept anyway: the token's job is icons, dividers and
   * disabled affordances, and the moment it carries a paragraph somewhere, the next person to
   * darken the page background silently breaks that paragraph.
   */
  --text-nontext: var(--ink-3); /* 5.53:1 on --ground — still non-text only, see above */
  --text-on-dark: #e8eef4; /* 15.44:1 on --steel-950 */
  --text-on-dark-secondary: #9fb0c2; /* 8.13:1 on --steel-950 */
  --text-accent: var(--accent); /* 6.09:1 on --ground, 5.48:1 on --accent-wash */

  --rule-decorative: var(--line); /* 1.27:1 — decoration only, never a control boundary */
  --rule-structural: var(--line-2); /* 3.75:1 on --ground — passes 1.4.11 at 3:1 */

  --action-fill: var(--accent); /* resting fill; label reads at 6.09:1 */
  /*
   * HOVER ONLY, and the reason has CHANGED rather than gone away. Under the old `--ember-500`
   * (#c85107) this measured 4.18:1 and outright failed 1.4.3 as a resting state. The mockup's
   * #c65310 is darker: white on it is 4.52:1, which PASSES normal text — by 0.02. A 0.02 margin
   * is a rounding error, not a decision, so this stays a hover value and `--action-fill` stays
   * the resting one. Do not promote it on the strength of that number.
   */
  --action-fill-hover: var(--ember-500); /* 4.52:1 with --action-label — hover only */
  --action-label: var(--accent-ink); /* #ffffff on --action-fill = 6.09:1 */

  /*
   * THEME-DEPENDENT FOCUS RING. WEB-DESIGN-SPEC.md §3.2 and §9.1: a single ember ring across both
   * themes fails 1.4.11 in one of them (ember/400 on the light page measures 3.00:1 against white
   * — exactly at the floor, with nothing left for antialiasing). This variable is reassigned in
   * the dark block below and must never be flattened to one value.
   */
  --focus-ring: var(--accent); /* 6.09:1 on --ground */

  /*
   * ...AND THE SAME ARGUMENT ONE LEVEL DOWN, WHICH THE §3.2 NOTE ABOVE MISSED.
   *
   * "Both themes" is not the whole set of backgrounds. The LIGHT theme paints two of them: the
   * page (`--ground`) and the dark surfaces cut into it (`--surface-dark`, i.e. `--steel-950`)
   * — the site footer on all 47 routes, and `.section--bleed.section--dark`. `--focus-ring` above
   * is chosen for the first and was never measured against the second.
   *
   * Measured by Cisco on the served page via `getComputedStyle`, 2026-08-21, re-measured here
   * 2026-08-23 and UNCHANGED by the token port: `--accent` #a8420a on `--steel-950` #0f1720 is
   * **2.96:1**, under 1.4.11's 3:1 floor for a focus indicator. WCAG 2.2 AA FAIL, every published
   * route. On `/404` the footer IS the entire navigation, so the worst case is the page where the
   * ring matters most.
   *
   * The fix reuses the value the dark theme already vetted rather than minting a third ember:
   * `--ember-400` #e4780b on #0f1720 is **6.01:1**. Scoped to the surface, not to the theme, so it
   * holds in light AND dark — in dark the block below has already set this exact value globally
   * and this re-declaration is a no-op there.
   *
   * `.section--dark` is included with the footer because it is the SAME background painted by the
   * same token; excluding it would leave the identical failure waiting on the first focusable
   * control placed in a dark band. One selector, one defect class.
   */
  --focus-ring-on-dark: var(--ember-400); /* 6.01:1 on --steel-950 */

  /* ── Type ─────────────────────────────────────────────────────────────── */
  /*
   * Every family named here is SERVED by `fonts.css`, which is the first file in the cascade. It
   * was not: these four stacks named Inter, Archivo and IBM Plex Mono against zero `@font-face`
   * rules, so the first entry silently resolved to `system-ui` for anyone without them installed.
   *
   * ⛔ AND IT IS SELF-HOSTED. The ratified mockup loads all three from `fonts.googleapis.com`.
   * That link is NOT ported and must never be: `check.mjs:284` ("every script, frame and image is
   * first-party") is the mechanical half of the reason, and the product's own pitch — that it does
   * not depend on someone else's uptime — is the other half. The `.woff2` files are committed
   * under `public/fonts/` with their OFL licences.
   *
   * Three names are GONE rather than left as decoration. "Inter Fallback" and "Archivo Fallback"
   * were never defined in any file — a metric-matched fallback that does not exist is a comment
   * with a comma in it. "Archivo Expanded" is not a family either: expanded is the `wdth` axis of
   * Archivo itself, and `.display-1`'s existing `font-stretch: expanded` reaches it now that the
   * face declares `font-stretch: 62% 125%`. `--font-display-expanded` is kept — base.css uses it,
   * and it stays as the name for "the display face, at its wide end".
   */
  --font-display: "Archivo", system-ui, sans-serif;
  --font-display-expanded: "Archivo", system-ui, sans-serif;
  --font-body: "Inter", system-ui, sans-serif;
  --font-data: "IBM Plex Mono", ui-monospace, "SFMono-Regular", monospace;

  --size-display-1: clamp(2.5rem, 1.6rem + 4.5vw, 4.5rem);
  --size-display-2: clamp(2rem, 1.4rem + 3vw, 3.25rem);
  --size-h2: clamp(1.75rem, 1.4rem + 1.75vw, 2.5rem);
  --size-h3: clamp(1.375rem, 1.2rem + 0.9vw, 1.75rem);
  --size-h4: clamp(1.125rem, 1.05rem + 0.25vw, 1.25rem);
  --size-lede: clamp(1.1875rem, 1.14rem + 0.19vw, 1.3125rem);
  --size-body: clamp(1.0625rem, 1.04rem + 0.1vw, 1.125rem);
  --size-small: 0.9375rem; /* 15px floor. Nothing on this site is smaller, legal pages included. */
  --size-label: 0.875rem;
  --size-data: clamp(0.9375rem, 0.92rem + 0.1vw, 1rem);
  --size-data-lg: clamp(1.5rem, 1.15rem + 1.5vw, 2rem);

  /* ── Spacing — 8pt, and nothing off the scale ─────────────────────────── */
  --space-1: 4px;
  --space-2: 8px;
  --space-3: 12px;
  --space-4: 16px;
  --space-6: 24px;
  --space-8: 32px;
  --space-10: 40px;
  --space-12: 48px;
  --space-16: 64px;
  --space-20: 80px;
  --space-24: 96px;
  --space-32: 128px;
  --space-40: 160px;

  /*
   * ── Radii ────────────────────────────────────────────────────────────────
   * TWO vocabularies live here on purpose, and this is the one place in the file where that is
   * true. `--radius-*` is what the 22 shipped components ask for; `--r-*` above is what the
   * ratified mockup's components ask for. They are NOT aliased to each other, because they are
   * not the same design: the mockup rounds hard (18px cards, 28-40px bands), the shipped site
   * rounds at 12px. Aliasing would silently re-round all 47 pages inside a token commit, which is
   * a visual change nobody asked this step to make. Steps 4-6 port band by band, and each band
   * that moves to `--r-*` moves visibly, in its own diff.
   */
  --radius-sm: 2px;
  --radius-md: 6px;
  --radius-lg: 12px;
  --radius-pill: 999px;

  /* ── Elevation. Same two-vocabulary note as the radii: `--sh-*` above is the mockup's. ─── */
  --elev-1: 0 1px 2px rgb(15 23 32 / 6%), 0 2px 8px rgb(15 23 32 / 6%);
  --elev-2: 0 8px 24px rgb(15 23 32 / 10%);
  --elev-3: 0 -6px 20px rgb(15 23 32 / 12%);

  /*
   * ── Motion ───────────────────────────────────────────────────────────────
   * RECONCILED with the mockup rather than replaced by it. `src/client/core/motion.js`'s
   * `duration()` reads these by name off the computed root style (`--dur-${token}`), and
   * `main.js`, `ticket-rail.js` and `timer.js` pass "slow" / "rail" / "fast" as that name. Renaming
   * them to the mockup's `--dur-1..4` would compile, ship, and fall back to the hardcoded second
   * argument at runtime — a silent 320ms-becomes-320ms coincidence today and a wrong number the
   * first time one of them is retuned. So the NAMES are this repo's and the VALUES are the
   * mockup's, and `--dur-1..4` are declared as aliases so ported component CSS reads unmodified.
   */
  --dur-instant: 90ms;
  --dur-fast: 160ms; /* mockup --dur-1 .16s */
  --dur-base: 240ms; /* mockup --dur-2 .24s */
  --dur-slow: 340ms; /* mockup --dur-3 .34s */
  --dur-rail: 520ms; /* mockup --dur-4 .52s — already identical */
  --dur-1: var(--dur-fast);
  --dur-2: var(--dur-base);
  --dur-3: var(--dur-slow);
  --dur-4: var(--dur-rail);
  --stagger: 60ms;

  /*
   * Easings. The three `--ease-*` names are the repo's and are referenced 38 times across the
   * stylesheets and by `motion.js`'s `transition({ easing })`, so they keep their names. Their
   * curves are now the mockup's, which is the honest way to adopt its FEEL:
   *   --ease-out       the mockup's default cubic-bezier(.33,1,.68,1)
   *   --ease-standard  the mockup's in-out cubic-bezier(.62,.03,.36,1)
   *   --ease-in        unchanged; the mockup has no in-only curve
   * `--ease-pop` is the mockup's overshoot cubic-bezier(.34,1.4,.64,1) and is NEW — nothing in the
   * repo overshoots today. It is added here so the ported micro-interactions in step 6 do not each
   * invent their own bounce. `--ease-rail` aliases `--ease-out` for the same alias reason as the
   * durations above.
   */
  --ease-out: cubic-bezier(0.33, 1, 0.68, 1);
  --ease-standard: cubic-bezier(0.62, 0.03, 0.36, 1);
  --ease-in: cubic-bezier(0.4, 0, 1, 1);
  --ease-pop: cubic-bezier(0.34, 1.4, 0.64, 1);
  --ease-rail: var(--ease-out);
  --ease-inout: var(--ease-standard);
  /*
   * The template's spring, used where something should feel physical — rail dots, chips, taps.
   * Blueprint §11 counts NINE uses of `cubic-bezier(.34,1.4,.64,1)` across the ported CSS. It is
   * declared once here rather than typed nine times; `--ease-pop` above is the identical curve
   * under the repo's own name, and this is its template alias so ported CSS reads unmodified.
   */
  --ease-spring: var(--ease-pop);

  /* ── Layout ───────────────────────────────────────────────────────────── */
  --container-md: 720px;
  --container-lg: 960px;
  --container-xl: 1160px;
  --container-2xl: 1240px;
  --gutter: var(--space-4);
  /*
   * ⛔ ONE HEADER NAME, DEFINED ONCE, AND `--hdrh` IS THE ONE. `--header-height` is now an ALIAS
   * rather than a second opinion.
   *
   * Both names shipped, both meant "the height of the site header", and only `--hdrh` was ever
   * connected to reality — `shell.js` overwrites it with the header's real `offsetHeight`, which
   * on a wrapped header at 390px is 71px, not the 64px literal that sat here. So every rule
   * reading `--header-height` was anchoring to a number 7px short at 390 and 8px long at 1024,
   * forever, and no test could see it because both values are plausible.
   *
   * That was not theoretical. `.stopsec` was declared TWICE in `components.css` with the two
   * different names, the later block won on source order, and the earlier block's mobile
   * allowance never applied to anything. The memo at `components.css:4588` had recorded the
   * hazard and deferred it as out of scope; this is the pass that was deferred to.
   *
   * Aliasing rather than deleting: `--header-height` is read by `.hdr`'s own `min-height` and by
   * the legal-page table of contents, and renaming those is churn that changes no pixel. The
   * alias makes the two names ONE number; it does not pretend one of them is gone.
   */
  --header-height: var(--hdrh);

  /*
   * ── THE SHELL TOKENS THE TEMPLATE DEPENDS ON, AND WHY THEIR ABSENCE WAS THE 772px BUG ───────
   *
   * Felicity's blueprint §11 measured six names the template's layout reads and this file did not
   * define. Four of them are the bleed system, and the bleed system never being ported is the
   * mechanism behind the defect the CEO saw: every band fell back inside a reading container, so
   * the hero spanned 143→1295px at 1440 while every text band below it was boxed at 341→897px.
   * The right half of the desktop was empty. That is not a styling miss; it is a layout system
   * that was two-thirds absent.
   *
   * ⚠️ FOUR OF THESE ARE SET BY JAVASCRIPT, and the fallbacks here are what a no-JS visitor gets.
   * `src/client/widgets/shell.js` measures them (`THE-RAIL-picked.html:1596-1614`). The fallbacks
   * are deliberately CONSERVATIVE: `--bleed-l/-r` default to 0, so with JS off a band sits inside
   * its column rather than bleeding to a viewport edge it cannot measure. A wrong bleed is a
   * horizontal scrollbar; a missing bleed is a narrower band. Only one of those is a defect.
   *
   * ⛔ `calc(50% - 50vw)` IS BANNED INSIDE `.railcol`, and the template records why at L828-831:
   * the content column is offset by the rail, so that expression overshoots by the rail's width
   * and pushed the document 125px wider than the viewport — measured at 1440px. The template
   * shipped the fix; port the fix, not the bug.
   */
  --hdrh: 64px; /* header height. Overwritten by shell.js from the real offsetHeight. */
  /*
   * ⛔ `--pinoff` IS THE ANCHOR OFFSET FOR THE WHOLE SITE, AND ITS FALLBACK IS NOT A HEADER.
   *
   * It means "everything pinned above the content": the header, PLUS the rail while the rail is
   * lying down under it (≤1040px). `shell.js` writes the real sum. Above 1041 the rail is a
   * column beside the content and pins nothing, so `--pinoff` and `--hdrh` are the same number
   * there — which is why anchor offsets can read `--pinoff` unconditionally and still leave the
   * desktop untouched, with no media query and no second rule to keep in sync.
   *
   * ⚠️ THE `64px` FALLBACK THAT USED TO SIT HERE WAS A HEADER, AND IT MADE THE TOKEN A LIE WITH
   * JAVASCRIPT OFF. The mobile fallback below is MEASURED, not guessed: at 390px the lying-down
   * rail renders 186px tall (banner row + the horizontal stop strip, two-line stops), so 190px is
   * that measurement with 4px of slack. Overshooting lands a band slightly low; undershooting
   * hides the heading the visitor just asked for. Only one of those is a defect, so the no-JS
   * branch takes the other one — the same reasoning `--bleed-l/-r` use above.
   */
  --pinoff: var(--hdrh); /* header + the lying-down rail, for pinned stages. shell.js. */
  --bleed-l: 0px; /* measured distance from the content column's left edge to the viewport. */
  --bleed-r: 0px; /* measured distance from its right edge to the viewport. */
  --railw: 206px; /* the rail column's track width, `THE-RAIL-picked.html:364`. */
  /*
   * THE TYPE FLOOR, 15px. The template's own micro-type is below it in five places — `.stop__t`
   * at .66rem, `.rail__foot` at .58rem, `.chip__k` at .6rem, `.fig__k` and `.bar__l` at .58rem.
   * A 9px navigation label is not a style. Every ported label is rebuilt at this value, which is
   * the one place this port deliberately does not match the mockup pixel for pixel.
   */
  --size-floor: 0.9375rem;
}

@media (min-width: 768px) {
  :root {
    --gutter: var(--space-8);
  }
}

/*
 * ⚠️ THE RAIL'S OWN HEIGHT, FOR THE NO-JAVASCRIPT CASE ONLY. `shell.js` sets `--pinoff` as an
 * inline style on `documentElement`, and an inline style beats every rule in every stylesheet
 * including this one — so with JavaScript on, this block is inert and the real measurement wins.
 */
@media (max-width: 1040px) {
  :root {
    --pinoff: calc(var(--hdrh) + 190px);
  }
}

@media (min-width: 1024px) {
  :root {
    --gutter: var(--space-10);
    /*
     * ⚠️ THIS USED TO SET `--header-height: 72px` AND IT NOW SETS `--hdrh`, WHICH IS THE SAME
     * DECISION MOVED TO THE NAME THAT SURVIVED THE ALIAS ABOVE. Setting the alias here would
     * break the aliasing — `--header-height` would stop resolving to the measured value at
     * exactly the widths where the header is tallest.
     */
    --hdrh: 72px;
  }
}

@media (min-width: 1280px) {
  :root {
    --gutter: var(--space-12);
  }
}

/*
 * ── DARK THEME — EXPLICIT OPT-IN ONLY. CEO RULING 2026-08-22. ────────────────────────────────
 *
 * This block used to be `@media (prefers-color-scheme: dark)`. It is not any more, and the
 * REASONING THAT PUT IT THERE IS NOT BEING DELETED, because it was not wrong: WEB-DESIGN-SPEC.md
 * §3.2 says unrequested permanent dark mode is a banned default, and honouring the OS preference
 * was the argument for why this site's dark was not "unrequested". The product itself ships dark
 * because kitchens are dim, so honouring the preference read as consistency rather than
 * decoration. That argument still stands on its own terms.
 *
 * The CEO overruled it on 2026-08-22: **LIGHT IS THE DEFAULT, dark only behind the explicit
 * toggle.** A visitor whose operating system is set to dark now gets the LIGHT page on first
 * visit. The ban the old comment cites is still real and is now satisfied a different way —
 * light-default plus an explicit, persisted opt-in is a stricter reading of "unrequested", not a
 * looser one, since previously an OS setting the visitor made for their email client decided what
 * this website looked like.
 *
 * ⚠️ THIS REPAINTS EVERY PAGE FOR OS-DARK VISITORS. It is a site-wide visual diff, it is correct,
 * and it is deliberately landed in this commit alone rather than buried inside a homepage change.
 *
 * Do not re-add the media query without a new ruling from the CEO. NOTHING that ships reads
 * `prefers-color-scheme` for colour any more — not this file, not `src/client/widgets/theme.js`,
 * which carries the reasoning for the strict reading: a visitor with no stored choice gets light,
 * whatever their operating system says. Measured on the served page 2026-08-23, a browser context
 * with `colorScheme: "dark"` renders `data-theme=null`, `body background rgb(255, 255, 255)`.
 * `prefers-reduced-motion` is a different query and is untouched.
 *
 * Every ratio below re-measured 2026-08-23.
 */
:root[data-theme="dark"] {
  --ground: #0f1720;
  --ground-2: #16212c;
  --ground-3: #22303d;
  --panel: #16212c;
  --ink: #eef3f7;
  --ink-2: #b6c4cf;
  --ink-3: #8b9aa6;
  --line: #2b3945;
  --line-2: #6b7a87;

  --accent: #e4780b;
  --accent-hi: #f2a24a;
  --accent-ink: #0f1720;
  --accent-wash: #2a1a0d;

  --ok: #4ade80; /* 10.36:1 on --ground */
  --warn: #f2a24a; /* 8.63:1 on --ground */
  --late: #fb7185; /* 6.70:1 on --ground */

  --sh-1: 0 2px 8px rgb(0 0 0 / 40%);
  --sh-2: 0 10px 30px -12px rgb(0 0 0 / 60%);
  --sh-3: 0 32px 70px -30px rgb(0 0 0 / 80%);
  --grad-wash: linear-gradient(180deg, #2a1a0d 0%, rgb(42 26 13 / 0%) 100%);

  /* The semantic layer re-derives itself from the primitives above with no further work — these
   * six lines are here only because `--surface-dark` must NOT follow `--ground` (a dark band cut
   * into a dark page is not a band) and because stating the set makes the reassignment readable. */
  --surface-page: var(--ground);
  --surface-raised: var(--ground-2);
  --surface-sunken: var(--ground-3);
  --surface-card: var(--panel);
  --surface-dark: var(--steel-950);
  --surface-accent-wash: var(--accent-wash);

  --text-primary: var(--ink); /* 16.15:1 on --ground, 14.59:1 on --panel */
  --text-secondary: var(--ink-2); /* 10.13:1 on --ground, 9.15:1 on --panel — passes 1.4.3 */
  --text-nontext: var(--ink-3); /* 6.25:1 on --ground — non-text only, same policy as light */
  --text-accent: var(--accent); /* 6.01:1 on --ground, 5.43:1 on --panel */

  --rule-decorative: var(--line); /* 1.52:1 — decoration only, never a control boundary */
  --rule-structural: var(--line-2); /* 4.09:1 on --ground — passes 1.4.11 at 3:1 */

  --action-fill: var(--accent);
  --action-fill-hover: var(--accent-hi);
  /*
   * INK ON EMBER, NOT WHITE ON EMBER — 6.01:1. `--accent-ink` is #0f1720 in this block, which is
   * the same value as `--steel-950`; it is written through the primitive so a future palette move
   * cannot leave the label pointing at a steel the accent no longer sits against. White here
   * would measure 2.99:1 and fail 1.4.3 outright, which is the mistake this comment exists to
   * stop someone "fixing" their way into.
   */
  --action-label: var(--accent-ink);

  --focus-ring: var(--accent); /* 6.01:1 on --ground */

  --elev-1: 0 1px 0 rgb(255 255 255 / 4%) inset;
  --elev-2: 0 1px 0 rgb(255 255 255 / 4%) inset;
  --elev-3: 0 -6px 20px rgb(0 0 0 / 40%);
}

/*
 * Base: reset, type scale, layout primitives, motion policy.
 * WEB-DESIGN-SPEC.md §3.3 (type), §4.2 (the three layout primitives), §4.4 (vertical rhythm),
 * §7.4 (reduced motion).
 */

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

html {
  -webkit-text-size-adjust: 100%;
  /* 2.4.11 Focus Not Obscured: the sticky header and the sticky mobile CTA bar can both cover a
   * focused element, so anchored jumps and focus scrolling clear both. */
  scroll-padding-top: 88px;
  scroll-padding-bottom: 80px;
}

body {
  margin: 0;
  background: var(--surface-page);
  color: var(--text-primary);
  font-family: var(--font-body);
  font-size: var(--size-body);
  line-height: 1.6;
  font-synthesis-weight: none;
  text-rendering: optimizeLegibility;
}

img,
svg,
video {
  max-width: 100%;
  height: auto;
  display: block;
}

/* ── Type scale ─────────────────────────────────────────────────────────── */

.display-1,
.display-2,
.h2,
.h3,
.h4 {
  font-family: var(--font-display);
  color: var(--text-primary);
  margin: 0;
  text-wrap: balance;
}

.display-1 {
  font-size: var(--size-display-1);
  font-weight: 800;
  line-height: 1.03;
  letter-spacing: -0.02em;
  max-width: 22ch; /* 14–22 characters per line, enforced by measure not by manual <br> */
}

@media (min-width: 1024px) {
  .display-1 {
    font-family: var(--font-display-expanded);
    font-stretch: expanded;
  }
}

.display-2 {
  font-size: var(--size-display-2);
  font-weight: 800;
  line-height: 1.08;
  letter-spacing: -0.015em;
  max-width: 26ch;
}

.h2 {
  font-size: var(--size-h2);
  font-weight: 700;
  line-height: 1.15;
  letter-spacing: -0.01em;
  max-width: 30ch;
}

.h3 {
  font-size: var(--size-h3);
  font-weight: 700;
  line-height: 1.25;
  max-width: 40ch;
}

.h4 {
  font-size: var(--size-h4);
  font-weight: 600;
  line-height: 1.35;
}

.lede {
  font-size: var(--size-lede);
  line-height: 1.55;
  max-width: 70ch;
  margin: 0;
  color: var(--text-secondary);
}

.body {
  font-size: var(--size-body);
  line-height: 1.6;
  max-width: 75ch;
  margin: 0;
}

.body-strong {
  font-weight: 600;
}

.small {
  font-size: var(--size-small);
  line-height: 1.5;
  max-width: 75ch;
  color: var(--text-secondary);
}

.label {
  font-size: var(--size-label);
  font-weight: 600;
  line-height: 1.3;
  letter-spacing: 0.02em;
  color: var(--text-secondary);
  margin: 0;
}

/* The mono is for numbers and machine-emitted strings ONLY. If it appears in body copy that is
 * drift (WEB-DESIGN-SPEC.md §2). */
.data {
  font-family: var(--font-data);
  font-size: var(--size-data);
  line-height: 1.4;
  font-variant-numeric: tabular-nums;
}

.data-lg {
  font-family: var(--font-data);
  font-size: var(--size-data-lg);
  font-weight: 600;
  line-height: 1.1;
  letter-spacing: -0.01em;
  font-variant-numeric: tabular-nums;
}

/* ── Machine-emitted strings must wrap, or they take the page off the screen ──────────────────
 *
 * MEASURED 2026-08-21, on the published subset artifact, at a 390px viewport (iPhone-class, the
 * width most restaurant owners will open this site at):
 *
 *   /help/reading-ticket-status-without-colour            929px of content in a 390px viewport
 *   /help/which-printers-and-connections-are-supported    868px
 *   /help/holding-an-order-and-when-it-comes-back         868px
 *   /help/how-a-screen-arranges-its-tickets               837px
 *   /help/sounds-tones-and-the-repeat-alert               827px
 *   /hardware                                             750px
 *   /help/language-and-text-size-on-a-screen              449px
 *
 * Seven of the twenty-six published pages, all from one cause: `citedSources()` in
 * src/pages/help.mjs and the "Read from …" line in src/pages/hardware.mjs print real repository
 * paths, and a path like
 * `android/app/src/main/kotlin/online/minirestaurants/kds/audio/SoundPolicy.kt` is 74 characters
 * with no space in it. There was no `code` rule in this stylesheet at all, so it stayed one
 * unbreakable word and pushed the whole document wider than the screen.
 *
 * ⚠️ IT MUST BE `anywhere`, NOT `break-word`. They are NOT interchangeable here, and the wrong
 * one is a SILENT NO-OP — it looks like a fix and changes nothing. Measured on the 929px page,
 * changing only this value:
 *
 *   overflow-wrap: normal       -> scrollWidth 929px
 *   overflow-wrap: break-word   -> scrollWidth 929px   <- fixes NOTHING
 *   overflow-wrap: anywhere     -> scrollWidth 390px   <- fixes it
 *
 * The reason is the container. `.plain-list` is `display: grid`, so its track sizes to the item's
 * MIN-CONTENT width. `break-word` breaks glyphs but does NOT reduce min-content, so the track
 * stays as wide as the longest unbroken path and the page still overflows. `anywhere` does reduce
 * min-content, so the track can shrink. Anyone "simplifying" this to `break-word` reintroduces all
 * seven pages while the CSS still reads as correct.
 *
 * Deliberately NOT `word-break: break-all`: that breaks ordinary words too, and `/hardware`
 * renders `<code>` INSIDE a sentence ("Read from …"), where break-all would shred the prose around
 * it. `overflow-wrap: anywhere` breaks a word only when it cannot otherwise fit.
 *
 * This rule sets WRAPPING ONLY. `<code>` still has no typography of its own — it renders in the
 * browser's default monospace rather than `--font-data`. That is a separate design question and is
 * flagged for Felicity rather than decided here.
 */
code {
  overflow-wrap: anywhere;
}

/* Vertical rhythm inside a flow of blocks. Intra-group gap is always strictly smaller than
 * inter-group gap (§3.4 grouping rule). */
.stack > * + * {
  margin-top: var(--space-4);
}
.stack-tight > * + * {
  margin-top: var(--space-3);
}
.stack-loose > * + * {
  margin-top: var(--space-8);
}
.stack-block > * + * {
  margin-top: var(--space-12);
}

/* ── Layout primitives (§4.2) ───────────────────────────────────────────── */

.container {
  width: 100%;
  max-width: var(--container-2xl);
  margin-inline: auto;
  padding-inline: var(--gutter);
}

@media (min-width: 768px) {
  .container {
    max-width: calc(var(--container-md) + 2 * var(--gutter));
  }
}
@media (min-width: 1024px) {
  .container {
    max-width: calc(var(--container-lg) + 2 * var(--gutter));
  }
}
@media (min-width: 1280px) {
  .container {
    max-width: calc(var(--container-xl) + 2 * var(--gutter));
  }
}
@media (min-width: 1536px) {
  .container {
    max-width: calc(var(--container-2xl) + 2 * var(--gutter));
  }
}

/*
 * A. Inset column — content in columns 1–8 of 12, right margin deliberately empty, left-aligned.
 * Never centred.
 */
.section--inset .section__body {
  max-width: 68ch;
}

@media (min-width: 1024px) {
  .section--inset .section__inner {
    display: grid;
    grid-template-columns: repeat(12, 1fr);
    column-gap: var(--gutter);
  }
  .section--inset .section__label {
    grid-column: 1 / span 2;
  }
  .section--inset .section__body {
    grid-column: 3 / span 6;
  }
  /* A hung label needs its own column; without one the body takes the inset span itself. */
  .section--inset .section__inner:not(:has(.section__label)) .section__body {
    grid-column: 1 / span 8;
  }
}

/* B. Full-bleed instrument — a table breaking the container to the viewport edge. */
.section--bleed {
  background: var(--surface-raised);
}
.section--bleed.section--dark {
  background: var(--surface-dark);
  color: var(--text-on-dark);
  /* W2 / SC 1.4.11. This band repaints the background out from under the page-level focus ring,
   * so it re-points the token at the value measured against the background it actually paints.
   * See the --focus-ring-on-dark note in tokens.css for the two ratios. */
  --focus-ring: var(--focus-ring-on-dark);
}
.section--dark .display-1,
.section--dark .display-2,
.section--dark .h2,
.section--dark .h3,
.section--dark .h4 {
  color: var(--text-on-dark);
}
.section--dark .lede,
.section--dark .small,
.section--dark .label {
  color: var(--text-on-dark-secondary);
}

/* C. The rail — see components.css. */

/* Vertical rhythm MUST vary (§4.4). These are the only section paddings on the site. */
.section {
  padding-block: var(--space-16);
}
.section--rhythm-hero {
  padding-block: var(--space-12) var(--space-16);
}
.section--rhythm-argument {
  padding-block: var(--space-16);
}
.section--rhythm-instrument {
  padding-block: var(--space-12);
}
.section--rhythm-aside {
  padding-block: var(--space-10);
}
.section--rhythm-closing {
  padding-block: var(--space-20);
}

@media (min-width: 1024px) {
  .section--rhythm-hero {
    padding-block: var(--space-20) var(--space-24);
  }
  .section--rhythm-argument {
    padding-block: var(--space-24);
  }
  .section--rhythm-instrument {
    padding-block: var(--space-16);
  }
  .section--rhythm-aside {
    padding-block: var(--space-12);
  }
  .section--rhythm-closing {
    padding-block: var(--space-32);
  }
}

.section--wash {
  background: var(--surface-accent-wash);
}

/* Hung label: hangs in the left margin at lg+, sits above the heading below that (§4.3). */
.section__label {
  display: block;
  margin-bottom: var(--space-4);
}

@media (min-width: 1024px) {
  .section__label {
    margin-bottom: 0;
    padding-top: 0.6em;
  }
}

/* Asymmetric proof blocks: 5/4/3 of 12, never 4/4/4, widest carries the load-bearing claim. */
.proof-blocks {
  display: grid;
  gap: var(--space-12);
}

@media (min-width: 768px) {
  .proof-blocks {
    grid-template-columns: 1fr 1fr;
  }
  .proof-blocks > :first-child {
    grid-column: 1 / -1;
  }
}

@media (min-width: 1024px) {
  .proof-blocks {
    grid-template-columns: 5fr 4fr 3fr;
  }
  .proof-blocks > :first-child {
    grid-column: auto;
  }
}

.two-col {
  display: grid;
  gap: var(--space-6) var(--space-12);
}

@media (min-width: 768px) {
  .two-col {
    grid-template-columns: 1fr 1fr;
  }
}

/* ── String expansion (§10) ─────────────────────────────────────────────── */
/*
 * Spanish runs 25–35% longer. Display type steps down ONE notch so a headline that grew 25% still
 * holds the 14–22 characters-per-line target instead of running to five lines on a phone. Body
 * sizes do not change — the floor is a legibility decision, not a fitting one.
 */
:lang(es) .display-1 {
  font-size: clamp(2.375rem, 1.6rem + 3.6vw, 3.75rem);
}

:lang(es) .display-2 {
  font-size: clamp(1.875rem, 1.4rem + 2.4vw, 2.875rem);
}

:lang(es) .h2 {
  font-size: clamp(1.625rem, 1.35rem + 1.4vw, 2.25rem);
}

/* ── Focus, links, skip link ────────────────────────────────────────────── */

:where(a, button, input, textarea, select, summary, [tabindex]):focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: 2px;
  border-radius: var(--radius-sm);
}

.skip-link {
  position: absolute;
  left: var(--space-4);
  top: var(--space-2);
  z-index: 100;
  transform: translateY(-200%);
  background: var(--action-fill);
  color: var(--action-label);
  padding: var(--space-3) var(--space-4);
  border-radius: var(--radius-md);
  text-decoration: none;
  transition: transform var(--dur-fast) var(--ease-standard);
}

.skip-link:focus {
  transform: translateY(0);
}

.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* ── Reduced motion (§7.4) ──────────────────────────────────────────────── */
/*
 * Collapse the movement, KEEP the state change perceptible. A global-only override silently kills
 * state feedback, which is worse than the animation was — so the exceptions below are explicit.
 */
@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }

  /* Exception 1 — the hero rail renders in final position, no stagger, no fade. */
  .ticket-rail__card {
    opacity: 1 !important;
    transform: none !important;
    animation: none !important;
  }

  /* Exception 2 — the bump undo affordance still shows for its full 4s. */
  /* Exception 3 — the connection chip still changes COLOUR AND TEXT: state is information. */
  /* Exception 4 — the accordion chevron keeps its static rotation, it just does not tween. */
  /* Exception 5 — the sticky CTA bar appears with no transform. */
  .sticky-cta {
    transform: none !important;
  }
  /* Exception 6 — the mobile header stops hiding on scroll rather than snapping. */
  .site-header[data-hidden="true"] {
    transform: none !important;
  }
}

/*
 * Components. The split mirrors WEB-DESIGN-SPEC.md §6: everything above the SITE-LOCAL divider is
 * site-agnostic and is the `ba-ui` candidate set; everything below it is MiniRestaurants-specific.
 */

/* ══ THE PAGE SHELL — .wrap / .wrap-wide / .railpage / .railcol ══════════ */
/*
 * PORTED FROM `THE-RAIL-picked.html:73-77, 364-366, 444-446`. Blueprint §1.
 *
 * ─────────────────────────────────────────────────────────────────────────────────────────────
 * ## THIS IS THE 772px DEFECT, AND THIS BLOCK IS THE FIX
 *
 * Measured by Artie at 1440 on the built page: the hero spanned 143→1295px and EVERY text band
 * below it was boxed at 341→897px. The right half of the desktop was empty. Two grids on one
 * page. The cause was that the template's container system was never ported, so bands fell back
 * inside the repo's `.container` reading cap.
 *
 * THE TEMPLATE HAS EXACTLY TWO CONTAINER WIDTHS AND NO OTHERS:
 *
 *   .wrap       min(1200px, 100% - 2.5rem)   chrome — the header row and the footer
 *   .wrap-wide  min(1400px, 100% - 2rem)     the page body, which carries the rail grid
 *
 * ⛔ `p{max-width:68ch}` and `.lede{max-width:56ch}` are the ONLY reading caps in the template,
 * and they apply to PARAGRAPHS. Never to a band, a grid, a board, or a photo. A band that is
 * 772px wide is a paragraph rule applied to a layout element. Do not put a band inside
 * `Section`'s `.section__body`.
 *
 * ## `minmax(0, 1fr)` IS NOT A STYLE CHOICE
 *
 * `1fr` means `minmax(auto, 1fr)`, and `auto` refuses to shrink below its content. The template
 * records the measurement at L74-75: that default pushed two designs 74px and 95px past 320px.
 * Every flexible track here is `minmax(0, 1fr)` and every direct child of a wrap gets
 * `min-width: 0`. This is the single most common cause of horizontal scroll on this site and it
 * is fixed here once rather than per-component.
 * ─────────────────────────────────────────────────────────────────────────────────────────────
 */

.wrap {
  width: min(1200px, 100% - 2.5rem);
  margin-inline: auto;
}

.wrap-wide {
  width: min(1400px, 100% - 2rem);
  margin-inline: auto;
}

/* See the L74-75 note above. This one declaration is why the shell holds at 320px. */
.wrap > *,
.wrap-wide > * {
  min-width: 0;
}

/*
 * THE RAIL GRID. 206px of rail + the content track, above 1041px; one column below it.
 *
 * The breakpoint is 1040/1041 and it is declared HERE, in `shell.js`'s matchMedia string, and in
 * the rail's own media queries — three places, one number, matched by string so they cannot
 * drift. `--railw` in `tokens.css` carries the width for anything that needs to reason about it.
 */
.railpage {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: 0;
  align-items: start;
  padding-top: 1.4rem;
}

@media (min-width: 1041px) {
  .railpage {
    grid-template-columns: var(--railw) minmax(0, 1fr);
    gap: clamp(1.4rem, 3vw, 3rem);
  }

  /*
   * ⛔ A RESERVED RAIL TRACK WITH NO RAIL IN IT SWALLOWS THE PAGE. MEASURED, 28 PAGES.
   *
   * The rule above reserves `--railw` (206px) as the FIRST track unconditionally, and `.railcol`
   * was placed only by auto-flow — so it landed in whichever track was free. Column 2 on a page
   * that ships a `.rail`; column ONE, 206px wide, on a page that does not.
   *
   * That is not a near miss. Measured at 1440, DPR 1, on the built page, `/features/all/`:
   * `.railcol` 206 → `.container` 206 → `.section__inner` 110, a twelve-track grid inside 110px,
   * and `.section__body` coming out at 240px and 336px because its text would not compress any
   * further. A ribbon of content down the left with ~1000px of white beside it, on 28 of 58 pages.
   *
   * ⚠️ `document.scrollWidth === clientWidth` throughout, so no overflow gate ever saw this. The
   * content was CRUSHED, not spilled, and crushing is invisible to a scroll-width check. That is
   * the reason this shipped: every horizontal-overflow assertion on the site passed while it did.
   *
   * ONE declaration fixes it: pin `.railcol` to the content track explicitly, so its placement
   * stops depending on what else happens to be in the grid. Measured after, at 1440: `.railcol`
   * 206 → 1151, `.container` 206 → 1151, `.section__body` 240/336 → 503/687 on all 28 pages, and
   * page height falls 25-43% because the text is no longer forced to wrap every two or three
   * words. A railless `.railpage` now measures EXACTLY what a railed one measures, which is the
   * equivalence `test/rail-track.browser.mjs` asserts as a floor.
   *
   * ⚠️ THE EMPTY RAIL TRACK IS LEFT STANDING, DELIBERATELY, AND IT IS A DESIGN QUESTION NOT A BUG.
   * A railless page still reserves 206px of column 1 for a rail that is not there, so its content
   * begins 206px in. Collapsing that track as well — `.railpage:not(:has(> .rail))` with
   * `grid-template-columns: minmax(0, 1fr)` — was built and measured: it widens those pages to
   * `.container` 1256 / body 556/757. It was NOT shipped here because it makes the design-parity
   * allowlist RISE by two entries, and a rising ratchet needs a ruling that is Felicity's to give,
   * not an implementer's to write. The narrower result is the one that needs no ruling AND makes
   * every `.railpage` page agree; the wider one is a deliberate second step behind that ruling.
   */
  .railcol {
    grid-column: 2;
  }
}

/*
 * The content track. Named, and not merely "the second child", because `--bleed-l/-r` are
 * measured off THIS element by `shell.js` — a band asking to bleed needs a box whose distance to
 * each viewport edge is a real measured number rather than an assumption about the grid.
 */
.railcol {
  min-width: 0;
}

/*
 * Section anchoring, so a jump does not land under the sticky furniture, and the vertical rhythm
 * between stops. `THE-RAIL-picked.html:444-446`.
 *
 * The rhythm is an ADJACENT-SIBLING rule, not padding on each section: padding on each section
 * doubles at every join and gives the first and last section a margin nothing asked for.
 */
.stopsec {
  position: relative;
  /*
   * ⛔ `--pinoff`, NOT `--hdrh`, AND THE DIFFERENCE IS THE WHOLE DEFECT.
   *
   * Below 1041px the rail lies down UNDER the header as a second sticky strip, so the furniture a
   * jump has to clear is header + rail, not header. Measured at 390 before this change: tapping
   * "23:15 Close" put the `<h2>` at y=176-248 and its paragraph at 248-382 with the rail's bottom
   * edge at 257 — 72% of the heading and the whole first line of prose underneath the thing the
   * visitor had just tapped. Nine of the eleven stops failed the same way.
   *
   * `--pinoff` already existed FOR THIS, and `shell.js` had been computing it correctly the whole
   * time — header height plus the rail's height while the rail is lying down, header alone above
   * 1041. It had ZERO readers. Every anchor offset on the site reached for a header-only token
   * instead.
   *
   * ⛔ SO THERE IS NO 1041px MEDIA QUERY HERE ANY MORE, AND ITS ABSENCE IS THE POINT. The old
   * pair declared the offset twice at two widths, which is two rules to keep in sync with a
   * breakpoint declared in a third place. `--pinoff` collapses to `--hdrh` above 1041 by
   * construction, so ONE declaration is already correct at both widths and the desktop gains no
   * extra offset — verified at 1440, where every stop's `scroll-margin-top` computes to the
   * header plus 1.5rem exactly as it did before.
   */
  scroll-margin-top: calc(var(--pinoff, 64px) + 1.5rem);
}

.stopsec + .stopsec {
  margin-top: clamp(3rem, 6vw, 5.5rem);
}

/* ══ ba-ui: Button ═══════════════════════════════════════════════════════ */

.button {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-2);
  font-family: var(--font-body);
  font-weight: 600;
  font-size: var(--size-body);
  line-height: 1.25;
  /* Spanish runs 25–35% longer: buttons are padding-sized with a min-width, never width-sized,
   * and a two-line label produces a taller button rather than a clipped one (§10 rule 3). */
  min-width: 8rem;
  min-height: 48px;
  padding: var(--space-3) var(--space-6);
  border: 0;
  border-radius: var(--radius-md);
  text-decoration: none;
  text-align: center;
  cursor: pointer;
  transition:
    background-color var(--dur-fast) var(--ease-standard),
    transform var(--dur-fast) var(--ease-standard);
}

.button--primary {
  background: var(--action-fill);
  color: var(--action-label);
}

.button--secondary {
  background: transparent;
  color: var(--text-primary);
  box-shadow: inset 0 0 0 1px var(--rule-structural);
}

.button--ghost {
  background: transparent;
  color: var(--text-accent);
  min-width: 0;
  padding-inline: var(--space-2);
}

.button--sm {
  min-height: 40px;
  padding: var(--space-2) var(--space-4);
}
.button--lg {
  min-height: 56px;
  padding: var(--space-4) var(--space-8);
}

@media (hover: hover) {
  .button--primary:hover {
    background: var(--action-fill-hover);
    transform: translateY(-1px);
  }
  .button--secondary:hover {
    background: var(--surface-raised);
    transform: translateY(-1px);
  }
}

.button:active {
  transform: translateY(0) scale(0.985);
  transition-duration: var(--dur-instant);
}

.button-row {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-4);
  align-items: center;
}

/* At 390px the primary is full-width and the secondary is a text link beneath it: two stacked
 * full-width buttons read as indecision (§5.1). */
@media (max-width: 479px) {
  .button-row .button--primary {
    width: 100%;
  }
  .button-row .button--secondary {
    background: none;
    box-shadow: none;
    color: var(--text-accent);
    text-decoration: underline;
    text-underline-offset: 3px;
    min-width: 0;
    padding-inline: 0;
  }
}

/* ══ ba-ui: TextLink ═════════════════════════════════════════════════════ */

.text-link {
  color: var(--text-accent);
  text-decoration: underline;
  text-underline-offset: 3px;
  text-decoration-thickness: 1px;
  transition: text-decoration-thickness var(--dur-fast) var(--ease-standard);
}

.text-link:hover,
.text-link:focus-visible {
  text-decoration-thickness: 2px;
}

.section--dark .text-link {
  color: var(--ember-400);
}

/* ══ THE HEADER `.hdr` ═══════════════════════════════════════════════════ */
/*
 * PORTED FROM `THE-RAIL-picked.html:133-156` / `source/kit.py` BASE + `header()`. Blueprint §2.
 *
 * ─────────────────────────────────────────────────────────────────────────────────────────────
 * ## `.site-header` / `.wordmark` ARE GONE. THEY WERE NOT ALIASED.
 *
 * The CEO on the first shell build: *"You were supposed to remove the old changes and update
 * with the new change."* The ratified header had been pasted BESIDE the old one and the old
 * eight-row nav survived on all 55 pages. Felicity named the mechanism: the repo's chrome was
 * written in a class vocabulary the template's CSS cannot reach, so nothing ratified styled any
 * of it. If `.site-header` reappears in this file, two headers shipped.
 * ─────────────────────────────────────────────────────────────────────────────────────────────
 */

.hdr {
  position: sticky;
  top: 0;
  z-index: 80;
  /*
   * 92% of the page colour, not a solid fill, and the blur behind it. This is the template's own
   * treatment (`saturate(150%) blur(12px)`) and it is affordable because `backdrop-filter` is
   * STATIC here — it is never transitioned, so it costs one composited layer rather than a filter
   * pass per frame. `color-mix` degrades to nothing in an engine that lacks it, which would leave
   * a transparent header, so the fallback below it is a real declaration rather than a hope.
   */
  background: var(--surface-page);
  background: color-mix(in srgb, var(--ground) 92%, transparent);
  backdrop-filter: saturate(150%) blur(12px);
  border-bottom: 1px solid var(--line);
  transition: transform var(--dur-base) var(--ease-standard);
}

.hdr[data-hidden="true"] {
  transform: translateY(-100%);
}

.hdr__in {
  display: flex;
  align-items: center;
  gap: 1.4rem;
  padding: 0.8rem 0;
  /*
   * ⛔ A LITERAL, AND NEVER `--header-height` / `--hdrh` AGAIN. THIS ONE DECLARATION WAS AN
   * UNBOUNDED FEEDBACK LOOP.
   *
   * `--hdrh` is `.hdr`'s OWN measured height, written by `shell.js` on every resize and on
   * `document.fonts.ready`. `.hdr__in` is INSIDE `.hdr`. Feeding the measurement of a box into
   * the `min-height` of a box it contains closes the loop:
   *
   *     shell.js writes --hdrh = .hdr height  ->  --header-height  ->  .hdr__in min-height
   *       ->  .hdr__in grows  ->  .hdr grows  ->  next observation writes a bigger number  ->  ...
   *
   * Measured 2026-08-26, nine resize round trips at a CONSTANT viewport: `.hdr` 76 -> 101 at
   * 1440, and 73 -> 204 at 390. Monotone, no ceiling, on all 56 pages — the header is shared
   * chrome. It compounded worst on a phone, where a few rotations produce a 204px header.
   *
   * TWO terms drove it and BOTH had to go. (1) `.hdr`'s `border-bottom: 1px` makes the border-box
   * a pixel taller than the inner box. (2) `offsetHeight` ROUNDS UP a fractional height. Removing
   * only the border still grew 75 -> 82 at 1440 and 72 -> 185 at 390 — a border-only repair ships
   * looking fixed. Breaking THIS link kills both, because both are amplifiers inside the loop;
   * `shell.js` also now reads the unrounded height so term 2 cannot return.
   *
   * ⚠️ WHY A LITERAL AND NOT A NAMED TOKEN. `--hdr-min-h: 64px` was the first cut and it is the
   * better expression of intent, but a new token is a new `token-shipped-only:` row in
   * `design-parity.allow.json`, which sits exactly AT its ceiling (`max: 486`, 486 used). Raising
   * that ceiling is a ruling and it is Felicity's, not an engineer's, so this fix does not spend
   * it. `var(--space-16)` is also 64px and was rejected: a spacing-scale token and a header floor
   * are unrelated quantities that happen to share a value today, and coupling them means retuning
   * the spacing scale silently moves the header floor.
   *
   * 64px is what `--hdrh`'s own no-JS fallback has always been (`tokens.css`), so the floor is
   * unchanged. It is inert in practice — the header's natural content height measures 70.59px at
   * 390 and 73.69px at 1440 — and it stays comfortably above the 44px tap-target minimum this
   * declaration is recorded as guaranteeing in `design-parity.allow.json`.
   *
   * Gate: `MR-R-40907` in `test/header-resize-stable.browser.mjs`. A future edit that points this
   * back at `--hdrh` or `--header-height` re-opens the loop and that gate will fail. Do not
   * retire it; the failure is the point.
   */
  min-height: 64px;
}

/* ── The mark ─────────────────────────────────────────────────────────────── */

.mark {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  text-decoration: none;
  flex: 0 0 auto;
  color: var(--text-primary);
  /*
   * ⛔ THE ONE PLACE THE HEADER'S FLEXIBLE GAP LIVES (CEO ruling, 2026-08-25). *"I see the menu is
   * still in the same place at the top. Left side, not right side."* The wordmark stays hard left
   * and everything else — the spine, the theme control, the call to action, the drawer button —
   * forms one cluster on the right, with all the slack between the two.
   *
   * ⚠️ ON `.mark` RATHER THAN ON THE FIRST RIGHT-HAND ELEMENT, AND THAT IS THE WHOLE POINT. The
   * first right-hand element CHANGES: it is `.nav` above ~1377px and `.hdr__accessory` below it,
   * because a collapsed spine is `display: none` and a `display: none` element is not a flex item,
   * so its auto margin does not exist. Hanging the gap on whichever element happens to be first
   * needs a rule per state and goes wrong in the state nobody screenshots. The mark is on the line
   * in every state. `.nav` keeps the template's own `margin-left: auto` (`THE-RAIL-picked.html:142`)
   * and the two autos are ADJACENT, so they share one gap and land the slack in the same place.
   *
   * This was already declared under `@media (max-width: 620px)` for the wrapped phone header. That
   * copy is deleted rather than left beside this one: two declarations of one rule is how the pair
   * above drifted into splitting the space 50/50 in the first place.
   */
  margin-right: auto;
  /* 2.5.8 Target Size, from padding rather than a larger glyph. */
  min-height: 44px;
}

.mark__plate {
  width: 32px;
  height: 32px;
  border-radius: var(--r-xs);
  background: var(--grad-ember);
  display: grid;
  place-items: center;
  flex: 0 0 auto;
  box-shadow: var(--sh-1);
}

.mark__plate svg {
  width: 21px;
  height: 21px;
}

.mark__word {
  font-family: var(--font-display);
  font-weight: 800;
  font-size: 1.05rem;
  letter-spacing: -0.02em;
}

/* ── The nav row ──────────────────────────────────────────────────────────── */

/*
 * ── THE SPINE SITS ON THE HEADER'S ONE FLEX LINE (`THE-RAIL-picked.html:985-996`) ──────────────
 *
 * ⛔ `margin-left: auto` IS BACK, AND THE SPINE'S ROW WRAPPER IS DELETED RATHER THAN HIDDEN. A
 * wrapper shipped here on 2026-08-25 and put the spine on a second full-width strip under the
 * controls. It was reverted the same day: `.hdr` measured 219px tall, the top strip carried a 921px
 * empty gap between the mark and the theme toggle, and the spine sat left-aligned with ~500px of
 * void beside it. There is no wrapper rule to override here because there is no wrapper; one left
 * behind at `display: contents` is the same element with a quieter failure mode.
 *
 * ⛔ `min-width: 0` IS LOAD-BEARING FOR THE MEASUREMENT, NOT THE LOOK. A flex item's automatic
 * minimum size is its content, so without this `.nav` can never be narrower than the row it holds,
 * `clientWidth` never falls below `scrollWidth`, and `widgets/nav.js` reads "it fits" at every
 * width — the drawer toggle never appears and a phone gets no navigation at all. That is the WCAG
 * 2.4.5 failure recorded in that file, reached by a fifth route. With it, an overflowing row
 * shrinks, the two widths diverge, and the measurement collapses to the drawer EARLY.
 *
 * MEASURED at 1440, DPR 1: mark 175.70 + nav 809.78 + toggle 139.56 + CTA 133.61 + 3 gaps 67.20
 * = 1325.85 inside a 1400px `.wrap-wide`, so 74px of air. One line stops fitting near 1358px and
 * the drawer takes over below that, above the 1120 media floor.
 *
 * The row is still a rail: mono index, ember underline, current marker. It scrolls horizontally
 * rather than wrapping, because a wrapped spine changes the header's height between pages and
 * every sticky offset on the site reads `--hdrh`.
 */
.nav {
  display: flex;
  align-items: stretch;
  gap: 0;
  margin-left: auto;
  min-width: 0;
  overflow-x: auto;
  scrollbar-width: none;
  -ms-overflow-style: none;
}

.nav::-webkit-scrollbar {
  display: none;
}

/*
 * ⛔ `flex: 0 0 auto` ON EVERY ROW, AND IT IS LOAD-BEARING FOR THE MEASUREMENT, NOT THE LOOK.
 *
 * A flex child defaults to `flex-shrink: 1`, so without this the browser SQUEEZES the seven rows
 * to fit and `scrollWidth` comes back equal to `clientWidth` — the row reports that it fits at
 * every width, `data-nav` never flips to "drawer", and the toggle stays `display: none`. That is
 * the exact shape of the WCAG 2.4.5 failure recorded in `src/client/widgets/nav.js`: a phone
 * visitor with no navigation at all. The old header hit it by the wrapping route; a squeezed
 * scroller reaches the same place by the opposite one.
 */
.nav > * {
  flex: 0 0 auto;
}

/*
 * THE MONO INDEX. Real text in a span, never a `::before` counter — see the memo in
 * `src/ui/site-header.mjs`. `--size-floor` rather than the template's raw `.66rem`, so the numeral
 * cannot fall under the site's own minimum type size at any root scale.
 */
.nav__ix {
  font-family: var(--font-data);
  font-size: var(--size-floor);
  /*
   * ⛔ NOT the site's `--ink-3`, which is what the template uses here. `tokens.css:136` binds that
   * value to `--text-nontext` and reserves it with the note "still non-text only" — and this
   * numeral is REAL TEXT, it is the door's name in this team's mouth ("door 03"). `--text-secondary`
   * is 9.07:1 on `--ground` and is the same token the rail's identical `.stop__t` numeral already
   * uses, so the site's two numeral columns cannot drift apart in contrast.
   */
  color: var(--text-secondary);
  letter-spacing: 0.06em;
  font-variant-numeric: tabular-nums;
}

/*
 * The separator between the three ways to BUY and the four ways to DECIDE. It carries the grouping
 * the banned vocabulary is not allowed to name out loud, and it opens nothing.
 */
.nav__sep {
  align-self: center;
  width: 1px;
  height: 20px;
  background: var(--line);
  margin: 0 0.8rem;
  flex: 0 0 auto;
}

/*
 * A HELD ROW. Same shape, same position, no href, and visibly not a link — the reader can see the
 * door exists and is not open. It keeps the numeral, which is the whole point: the numbers either
 * side of it must not close up over the gap.
 *
 * ⚠️ IT IS NOT DIMMED TO "LOOK DISABLED". Going quieter would put a permanent sub-4.5:1 row in the
 * header on all 55 pages and trade a WCAG 1.4.3 failure for a visual cue `aria-disabled` already
 * carries. It reads as ordinary secondary text that is not a link, which is exactly what it is.
 */
.nav__held {
  color: var(--text-secondary);
  cursor: default;
}

/*
 * ⛔ `.nav a` AND `.nav__held` ARE TWO RULES, NOT ONE GROUPED SELECTOR, AND THE REASON IS THE
 * PARITY GATE RATHER THAN TASTE.
 *
 * `src/checks/design-parity.mjs` compares rules by SELECTOR STRING against the design source.
 * Grouping them produced a rule named `.nav a, .nav__held`, which the source has never heard of,
 * so the gate reported `.nav a` as ABSENT and then reported all fourteen of its declarations as
 * shipped-only drift — twenty-two failures for a change that altered no pixel. The gate was right
 * and the grouping was the defect: a selector the source cannot match is a selector the ratchet
 * cannot watch.
 */
.nav a {
  font-family: var(--font-display);
  font-weight: 600;
  font-size: 0.94rem;
  text-decoration: none;
  color: var(--text-secondary);
  /*
   * ⛔ 0.55rem HORIZONTAL, NOT 0.95rem, AND IT IS MEASURED RATHER THAN TASTE. The row is back on
   * the header's one flex line and at 0.95rem it wanted 884.06px, which put the line 0.13px OVER
   * a 1400px container at 1440 — a header that fits or collapses depending on a scrollbar. At
   * 0.55rem the row wants 809.78px and the line has 74px of air. The template's own value is
   * `.1rem` plus a 1.3rem flex gap; this keeps the padding on the link so the ember underline and
   * the 44px target still span the whole row, and lands between the two.
   *
   * ⚠️ THE VERTICAL VALUE DOES NOT MOVE. The 44px floor declared at the end of this rule is WCAG
   * 2.5.5, it comes from the 0.72rem, and trimming this pair symmetrically would have traded a
   * target size for 20px of row.
   *
   * ⛔ DO NOT QUOTE THAT DECLARATION, OR A BRACE, ANYWHERE INSIDE THIS RULE. `MR-R-40132` mutates
   * it with a LAZY, BRACE-BOUNDED pattern to prove the parity gate still catches a shrunken tap
   * target. Both failure modes were reached here in one pass while writing this comment: a prose
   * copy of the declaration is what the lazy pattern reaches FIRST, so the mutant lands in the
   * comment and the real value survives; and a brace in the prose ends the character class early,
   * so the pattern matches nothing and the row fails as vacuous instead. Describe the value in
   * words.
   */
  padding: 0.72rem 0.55rem;
  position: relative;
  /* 44px from padding on the flex line, never from a bigger font. */
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  white-space: nowrap;
  min-height: 44px;
}

/* The held row is dimensionally identical to a link so the spine does not jog where one sits. */
.nav__held {
  font-family: var(--font-display);
  font-weight: 600;
  font-size: 0.94rem;
  padding: 0.72rem 0.95rem;
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  white-space: nowrap;
  min-height: 44px;
}

/* The first row sits flush with the container edge, so the spine lines up with the content below
 * it rather than starting 0.95rem in. `rail-shell.css:293`. */
.nav a:first-child {
  padding-left: 0;
}

/*
 * THE EMBER UNDERLINE. `scaleX(0)` → `1`, origin left, 240ms. It never animates `width`, which is
 * the whole reason it is a pseudo-element with a transform rather than a border that grows: a
 * width transition is a layout pass per frame and `perf-guard.mjs` rejects it outright.
 */
.nav a::after {
  content: "";
  position: absolute;
  left: 0.95rem;
  right: 0.95rem;
  bottom: 0;
  height: 2px;
  border-radius: 2px 2px 0 0;
  background: var(--accent);
  transform: scaleX(0);
  transform-origin: left;
  transition: transform 240ms var(--ease-out);
}

.nav a:first-child::after {
  left: 0;
}

.nav a:hover {
  color: var(--text-primary);
}

/*
 * THE CURRENT PAGE IS MARKED. The spine is on all 55 pages now, so "where am I" is a question it
 * has to answer — a seven-row menu that looks identical on every page is a menu that has stopped
 * navigating. Set server-side from `aria-current`, so it survives with JavaScript off.
 */
.nav a[aria-current="page"] {
  color: var(--text-primary);
}

.nav a[aria-current="page"]::after {
  transform: scaleX(1);
}

/*
 * `:focus-visible` gets the underline too, and that is not decoration. A keyboard visitor should
 * see the same affordance a mouse visitor does; the template only styled `:hover`, which is the
 * mockup's privilege.
 */
.nav a:hover::after,
.nav a:focus-visible::after {
  transform: scaleX(1);
}

/*
 * The CTA. `Button` emits `.button`, not the template's `.btn`, and it is NOT renamed here: it is
 * one component used on all 55 pages and in bands that are three other agents' lanes tonight.
 * Renaming it site-wide is the right follow-up and the wrong thing to do inside a shell port.
 * ⚠️ DECLARED SHORTCUT (SO-28): the template's `.btn` sizing is applied to `.button` INSIDE the
 * chrome only. The tradeoff is that `.btn` and `.button` coexist until that follow-up lands.
 */
.hdr .button {
  min-width: 0;
  min-height: 44px;
  padding: 0.6rem 1.25rem;
  font-size: 0.92rem;
  border-radius: var(--r-pill);
}

/*
 * The theme toggle, styled as the template's `#theme` ghost button (`kit.py header()`:
 * `<button id="theme" class="btn btn--ghost">`). The MODULE is the repo's — it announces the
 * state being switched on and its visible label is its accessible name — and only the skin is
 * the template's.
 */
.hdr__accessory {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  /*
   * ⛔ NO `margin-left: auto` HERE, AND ITS REMOVAL IS THE WHOLE OF THE CEO'S 2026-08-25 RULING.
   *
   * It was added when the spine moved to a second row, because `.nav` had given the `auto` up and
   * nothing had picked it up — the controls bunched against the wordmark. `.nav` has it back now,
   * and for part of a day BOTH elements carried one. Two auto margins on one flex line SPLIT the
   * free space between them rather than one winning: MEASURED at 1440, 57.4px of slack divided
   * 28.7px each, so the nav sat 22.4 + 28.7 = 51.1px after the wordmark. Hard left, which is what
   * the CEO saw: *"I see the menu is still in the same place at the top. Left side, not right
   * side."* Deleting the second `auto` is what hands the whole gap to `.nav` and pulls the menu,
   * the theme control and the call to action into one right-hand cluster.
   *
   * ⚠️ THE DRAWER STATE IS WHY THIS COULD NOT SIMPLY MOVE. A `display: none` element is not a flex
   * item and its auto margin does not exist, so at every drawer width `.nav`'s `auto` vanishes and
   * the controls would slam back against the wordmark — the exact defect this declaration was
   * added for, returning at the widths nobody screenshots. `.mark { margin-right: auto }` holds
   * the gap open instead: the mark is on the line in ALL FOUR states (JavaScript on or off, spine
   * shown or collapsed), so the space lands in the same place whatever the nav is doing.
   */
}

.hdr__accessory .theme-toggle {
  min-height: 44px;
  padding: 0.6rem 1.25rem;
  border-radius: var(--r-pill);
  border: 2px solid var(--line-2);
  background: transparent;
  color: var(--text-primary);
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 0.92rem;
  cursor: pointer;
  transition:
    transform 180ms var(--ease-out),
    border-color 180ms var(--ease-out),
    color 180ms var(--ease-out);
}

.hdr__accessory .theme-toggle:hover {
  border-color: var(--accent);
  color: var(--accent);
  transform: translateY(-2px);
}

.nav-toggle {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 44px;
  min-height: 44px;
  background: none;
  border: 0;
  color: var(--text-primary);
  font: inherit;
  font-size: var(--size-small);
  cursor: pointer;
  gap: var(--space-2);
}

/*
 * ── THE COLLAPSE, AND THE ONE MANDATORY DEPARTURE FROM THE TEMPLATE ──────────────────────────
 *
 * `THE-RAIL-picked.html:151` is `@media(max-width:980px){.nav{display:none}}` and NOTHING TAKES
 * ITS PLACE — on a phone the mockup has no navigation at all. That is a mockup's privilege.
 * Blueprint §2 marks it a MANDATORY DELTA: take the chrome from the template and the collapse
 * behaviour from the shipped drawer.
 *
 * So the template's 980px is the media-query floor, and `data-nav` from the header's
 * ResizeObserver can collapse EARLIER when a translated row does not fit. Never later.
 */
/*
 * ⚠️ 1119px, NOT 980px, AND THE NUMBER CAME FROM THE TEMPLATE (`rail-shell.css:319-321`).
 *
 * The floor moved because the row did. A four-item nav sharing tier 1 with the mark, the toggle
 * and the CTA could survive to 980; a SEVEN-item spine measures 958px on its own and needs the
 * container's padding on top of that. Below 1119 it goes to the drawer, which is the one surface
 * that shows all seven at once anyway.
 *
 * `data-nav` from the header's ResizeObserver can still collapse EARLIER when a translated row
 * does not fit. Never later.
 */
@media (max-width: 1119px) {
  .nav {
    display: none;
  }
}

@media (min-width: 1120px) {
  .nav-toggle {
    display: none;
  }
}

/*
 * ⛔ THE `inline` RULES LIVE INSIDE THE MEDIA QUERY, AND THAT IS A BUG FIX FOUND BY MEASURING.
 *
 * Outside it they out-specify the breakpoint — `.hdr[data-nav="inline"] .nav` is (0,2,1) against
 * `@media … .nav` at (0,1,0) — so JavaScript could turn the spine back ON below the floor.
 * MEASURED at 1024 when this was found: `data-nav="inline"`, the spine computed `display: block`,
 * the drawer toggle computed `display: none`. The spine happened to fit at that width so nothing
 * looked broken, which is exactly what makes it worth fixing: the rule was one translated string
 * away from showing a row that does not fit and hiding the only control that opens the menu.
 *
 * The contract this restores is the one `components.css` already stated in words and did not
 * enforce: THE MEDIA QUERY IS THE FLOOR. `data-nav` may collapse EARLIER than 1120 when a row does
 * not fit; it may never un-collapse below it. Scoping the rules to `min-width: 1120px` makes that
 * true by construction rather than by the measurement always happening to agree.
 *
 * ⚠️ THE SUBJECT CHANGED BACK WITH THE ROW AND THE SCOPING DID NOT. The fix was written against
 * the spine's short-lived wrapper; the wrapper is deleted and these rules now name `.nav` itself,
 * which is where the same latent bug lived before the wrapper existed and never surfaced, because
 * `measureNav` always chose "drawer" at those widths anyway. Re-verified at 1024 after the revert.
 */
@media (min-width: 1120px) {
  .hdr[data-nav="inline"] .nav {
    display: flex;
  }
  .hdr[data-nav="inline"] .nav-toggle {
    display: none;
  }
}
/*
 * ⛔ `display: none`, AND THE `<nav>` IS THE WHOLE SUBJECT NOW. While the spine had a row wrapper,
 * hiding `.nav` alone left that wrapper's 1px top border and its background behind as an empty
 * stripe under the header at every drawer width. The wrapper is gone, so there is nothing
 * left to leave behind — and this rule comes AFTER the `inline` block on purpose: both selectors
 * are (0,2,1), so source order is what makes `drawer` win when JavaScript flips the attribute.
 */
.hdr[data-nav="drawer"] .nav {
  display: none;
}
.hdr[data-nav="drawer"] .nav-toggle {
  display: inline-flex;
}

/*
 * ≤620: the wordmark goes and the row is allowed to wrap (`THE-RAIL-picked.html:152-156`).
 * Hiding the WORD rather than the whole mark keeps the plate, which is the recognisable half, and
 * the link's `aria-label` carries the name so the accessible name survives the breakpoint the
 * visible one does not. That is ~140px off the row before any other control has to yield.
 */
@media (max-width: 620px) {
  .hdr__in {
    gap: 0.6rem;
    flex-wrap: wrap;
  }

  .mark__word {
    display: none;
  }

  /*
   * `.mark { margin-right: auto }` USED TO BE REPEATED HERE and is now declared unconditionally on
   * the base rule, because the CEO's 2026-08-25 ruling wants that gap at EVERY width, not only on
   * a wrapped phone header. Re-adding it here would be a second declaration of one rule, which is
   * how `.nav` and `.hdr__accessory` ended up holding an `auto` each and splitting the header's
   * slack 28.7px apiece instead of one of them winning it.
   */

  .hdr .button {
    padding: 0.55rem 1rem;
    font-size: 0.87rem;
  }

  .hdr__accessory .theme-toggle {
    padding: 0.55rem 1rem;
    font-size: 0.87rem;
  }
}

/* ── NavDrawer ────────────────────────────────────────────────────────────── */

.nav-drawer {
  position: fixed;
  inset: 0 0 0 auto;
  width: min(88vw, 380px);
  background: var(--surface-page);
  border-inline-start: 1px solid var(--rule-decorative);
  padding: var(--space-6) var(--space-6) var(--space-12);
  z-index: 90;
  overflow-y: auto;
  transform: translateX(100%);
  transition: transform var(--dur-base) var(--ease-out);
}

.nav-drawer[data-open="true"] {
  transform: translateX(0);
}

.nav-drawer[hidden] {
  display: none;
}

.nav-drawer__backdrop {
  position: fixed;
  inset: 0;
  background: rgb(15 23 32 / 50%);
  z-index: 85;
  border: 0;
}

.nav-drawer a {
  display: block;
  padding: var(--space-3) 0;
  min-height: 44px;
  color: var(--text-primary);
  text-decoration: none;
  font-size: var(--size-lede);
}

/* ══ THE FOOTER `.ftr` ═══════════════════════════════════════════════════ */
/*
 * PORTED FROM `THE-RAIL-picked.html:252-268` / `source/kit.py` FOOTER. Blueprint §9.
 *
 * THE FOOTER CARRIES THE FULL ESTATE; the rail carries seven doors. That split is the standing
 * mitigation for the IA collision, and it is why no route was deleted in this pass.
 */

.ftr {
  /* `--surface-dark`, never `--steel-950` directly. MR-R-32000 asserts that every rule painting
   * that token also re-points the focus ring, and a raw hex bypasses the assertion rather than
   * satisfying it. The token resolves to the same colour; the difference is that the guard can
   * see it. */
  background: var(--surface-dark);
  color: var(--text-on-dark);
  padding: 4.5rem 0 2.2rem;
  margin-top: 5rem;
  /*
   * W2 / SC 1.4.11 Non-text Contrast — Cisco, all published routes, measured 2026-08-21. The
   * footer links are the only focusable controls on `--surface-dark`, and on `/404` they are the
   * ENTIRE navigation. The page-level ring measured 2.96:1 here against a 3:1 floor. Re-pointing
   * the TOKEN rather than the outline rule keeps one focus declaration on the site (base.css) and
   * lets the surface that changed the background own the correction.
   */
  --focus-ring: var(--focus-ring-on-dark);
}

/*
 * ⛔ DO NOT "IMPROVE" THIS TO `auto-fit`. The template's own CSS comment at L254-256 is the
 * verified record of why:
 *
 *   auto-fit CANNOT be combined with a flexible 1.4fr track — the whole declaration is
 *   rejected and the grid collapses to one column. Verified: the declared value reads
 *   back as an empty string and all five children rendered at 1200px. Explicit count.
 *
 * Five tracks: the brand cell, then four link columns. The column count is explicit and stays
 * explicit. This is one of five bugs the template already found and logged, and rediscovering it
 * costs an evening.
 */
/*
 * ── THE TRACK COUNT FOLLOWS THE RESOLVED COLUMN COUNT ────────────────────────────────────────
 *
 * ⛔ NEVER `auto-fit`, AND THIS IS NOT A PREFERENCE. It CANNOT be combined with the flexible
 * `1.4fr` brand track: the whole declaration is rejected, the computed value reads back as an
 * EMPTY STRING, and every child renders at full width. Verified twice, recorded at
 * `docs/design-system/rail-shell.css:413-415`. Keep the explicit count.
 *
 * So the count varies through ONE EXPLICIT RULE PER VALUE. `SiteFooter` writes `data-cols` from
 * the number of columns that actually resolved, which is what stops a four-track declaration
 * holding three children — the 232px of dead space measured at 1440 that started this ticket.
 *
 * The default below is the four-column case and stays first so anything without the attribute
 * (the Spanish footer, and any caller predating this) renders exactly as it did.
 */
.ftr__cols {
  display: grid;
  gap: 2.4rem;
  grid-template-columns: 1.4fr repeat(4, minmax(140px, 1fr));
}

.ftr__cols[data-cols="3"] {
  grid-template-columns: 1.4fr repeat(3, minmax(140px, 1fr));
}

.ftr__cols[data-cols="2"] {
  grid-template-columns: 1.4fr repeat(2, minmax(140px, 1fr));
}

.ftr__cols[data-cols="1"] {
  grid-template-columns: 1.4fr minmax(140px, 1fr);
}

/*
 * A merged column's sub-heading. It is an `h3` under the column's `h2`, so a column that absorbed
 * Legal reads as "Company … Legal" in the outline rather than as two peers, which is what it is.
 */
.ftr__sub {
  font-family: var(--font-data);
  font-size: var(--size-floor);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--text-on-dark-secondary, #8fa0ad);
  font-weight: 500;
  margin: 1.4rem 0 0.9rem;
}

/*
 * The link columns are a `<nav>` landmark and therefore ONE grid child, not four. `display:
 * contents` puts its four `.ftr__col` children back on the parent's grid, so the landmark exists
 * for a screen reader and is invisible to the layout.
 *
 * ⚠️ `display: contents` removed a landmark from the accessibility tree in Chrome and Safari until
 * 2022 and in Firefox until 2023. Every browser in the site's support matrix has shipped the fix;
 * this note exists so nobody "discovers" the old bug in a stale user-agent and reverts the shape.
 */
.ftr__nav {
  display: contents;
}

/*
 * ⛔ `[data-cols]` IS REPEATED IN BOTH MEDIA QUERIES, AND IT IS THE WHOLE FIX. DO NOT "SIMPLIFY".
 *
 * A MEDIA QUERY ADDS NO SPECIFICITY. `.ftr__cols` is (0,1,0); `.ftr__cols[data-cols="3"]` above is
 * (0,2,0). So the count rules beat these responsive rules AT EVERY WIDTH, and a footer that had
 * dropped a column never collapsed to one track on a phone — it kept `1.4fr repeat(3, minmax(140px,
 * 1fr))`, which needs 1.4fr + 3x140px + gaps and cannot fit 320px. Adding `[data-cols]` makes these
 * (0,2,0) too, and they come LATER, so they win on source order.
 *
 * ⚠️ WHY THIS HID FOR SO LONG, WHICH IS THE PART WORTH REMEMBERING. A full build renders
 * `data-cols="4"`, and there is deliberately NO `[data-cols="4"]` rule — four is the default. So
 * the bug is INVISIBLE in `dist` and only appears when a column actually drops. `MR-R-32246` builds
 * a SUBSET, fewer routes resolve, the footer renders `data-cols="3"`, and 45 of 58 pages overflow
 * to 698px at once. That is not a test artefact: it is what ships the day a gate closes a route.
 * `dist` measuring clean at 320 proved nothing, and I checked it first and believed it.
 */
@media (max-width: 1000px) {
  .ftr__cols,
  .ftr__cols[data-cols] {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
}

@media (max-width: 560px) {
  .ftr__cols,
  .ftr__cols[data-cols] {
    grid-template-columns: 1fr;
    gap: 1.8rem;
  }
}

.ftr__brand .mark {
  margin-bottom: 1rem;
}

.ftr .mark__word {
  color: #fff;
  display: inline;
}

.ftr__blurb {
  color: #8fa0ad;
  font-size: 0.93rem;
  max-width: 34ch;
  margin: 0;
}

/*
 * The column headings are `<h2>`, styled to the template's `<h4>` treatment.
 *
 * ⚠️ DELIBERATE DEPARTURE. A heading LEVEL is a document-outline fact and the footer's headings
 * are top-level sections of the footer; `<h4>` under a page whose only other heading is the `<h1>`
 * skips two levels, which is a 1.3.1 defect a stylesheet cannot fix. The mono/uppercase/tracked
 * look is entirely in this rule, so it is the template to look at and correct to navigate.
 */
.ftr h2 {
  font-family: var(--font-data);
  font-size: 0.68rem;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: #8fa0ad;
  margin: 0 0 0.9rem;
  font-weight: 500;
}

.ftr ul {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: 0.5rem;
}

.ftr a {
  color: #c3d0da;
  text-decoration: none;
  font-size: 0.93rem;
  /* 44px tap height from PADDING, never from a bigger font (2.5.8). */
  display: inline-flex;
  align-items: center;
  min-height: 44px;
}

.ftr a:hover {
  color: var(--ember-300);
  text-decoration: underline;
  text-underline-offset: 3px;
}

/*
 * The site's only contact channel, above the columns on every route.
 *
 * ⚠️ THIS BLOCK MUST STAY BELOW `.ftr a`, AND THAT IS NOT TIDINESS. `.ftr a` and `.ftr__contact a`
 * have IDENTICAL specificity (0,1,1), so the later rule wins and nothing warns you which that is.
 * Written above it, this block lost its colour and its underline in silence: measured at 8.13:1
 * in Chrome instead of the 15.44:1 it asks for — a real value, a passing value, and the wrong one.
 * Found by measuring the rendered page, not by reading the CSS.
 *
 * Underlined, in `--text-on-dark`, at READING size — not `.93rem` like the column links, and not
 * the accent colour. It is the highest-intent element in the footer and the only one a visitor may
 * need to READ and copy by hand rather than click.
 */
.ftr__contact {
  margin: 0 0 var(--space-10);
  color: #c3d0da;
  font-size: var(--size-body);
  line-height: 1.6;
}

.ftr__contact a {
  display: inline-block;
  min-height: 44px;
  padding-block: var(--space-3);
  color: var(--text-on-dark);
  text-decoration: underline;
  text-underline-offset: 3px;
  /* An address wraps mid-string on a 320px viewport rather than taking the page off the screen. */
  overflow-wrap: anywhere;
}

.ftr__contact a:hover {
  text-decoration-thickness: 2px;
}

.ftr__base {
  margin-top: 3rem;
  padding-top: 1.5rem;
  border-top: 1px solid var(--steel-800);
  display: flex;
  flex-wrap: wrap;
  gap: 1rem;
  justify-content: space-between;
  color: #93a3b0;
  font-size: 0.82rem;
}

/* ══ ba-ui: DataTable / ComparisonMatrix / PricingTable ══════════════════ */

.data-table {
  width: 100%;
  border-collapse: collapse;
  font-variant-numeric: tabular-nums;
}

.data-table caption {
  text-align: start;
  font-size: var(--size-small);
  color: var(--text-secondary);
  margin-bottom: var(--space-4);
  max-width: 75ch;
}

.section--dark .data-table caption {
  color: var(--text-on-dark-secondary);
}

.data-table th,
.data-table td {
  text-align: start;
  padding: var(--space-3) var(--space-4);
  border-bottom: 1px solid var(--rule-decorative);
  /* Columns are auto-width with a min on the label column, never percentage-locked to English
   * headers (§10 rule 1). */
  min-width: 8ch;
}

.data-table th[scope="row"] {
  font-weight: 600;
  min-width: 14ch;
}

.data-table thead th {
  font-family: var(--font-body);
  font-size: var(--size-label);
  font-weight: 600;
  letter-spacing: 0.02em;
  color: var(--text-secondary);
}

.section--dark .data-table thead th {
  color: var(--text-on-dark-secondary);
}

.data-table .num {
  text-align: end;
}

@media (hover: hover) {
  .data-table tbody tr:hover {
    background: var(--surface-raised);
  }
  .section--dark .data-table tbody tr:hover {
    background: var(--steel-900);
  }
}

/*
 * Mobile strategy is a DECLARED PROP, never an accidental horizontal scroll. Every table on this
 * site uses `transpose` or `stack`; a horizontal scroll on the most important proof on the site
 * would be a design failure (§5.2), and it would break 1.4.10 Reflow at 320px.
 */
.table-transposed {
  display: block;
}

@media (min-width: 768px) {
  .table-transposed {
    display: none;
  }
}

.table-wide {
  display: none;
}

@media (min-width: 768px) {
  .table-wide {
    display: block;
    overflow-x: visible;
  }
}

.transposed-group {
  padding: var(--space-6) 0;
  border-bottom: 1px solid var(--rule-decorative);
}

.transposed-group dl {
  margin: var(--space-4) 0 0;
  display: grid;
  gap: var(--space-3);
}

.transposed-group dt {
  font-weight: 600;
}

.transposed-group dd {
  margin: 0 0 var(--space-3);
  color: var(--text-secondary);
}

.section--dark .transposed-group dd {
  color: var(--text-on-dark-secondary);
}

/*
 * 1.4.1 Use of Colour: a matrix cell is NEVER colour-only. Word + glyph, always. The glyph is a
 * meaning-bearing graphic, so it carries 1.4.11's 3:1 in its own right (steel/600 = 6.26:1).
 */
.state-cell {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  white-space: nowrap;
}

.state-glyph {
  width: 12px;
  height: 12px;
  flex: none;
  border: 2px solid currentColor;
  border-radius: var(--radius-sm);
}

.state-glyph--yes {
  background: currentColor;
}

.state-glyph--partial {
  background: linear-gradient(to right, currentColor 50%, transparent 50%);
}

.state-glyph--no {
  background: none;
}

/* Do NOT soften the "No" cells — the negative cells are what make the table believable. */
.state-cell--no {
  color: var(--text-primary);
  font-weight: 600;
}

.section--dark .state-cell--no {
  color: var(--text-on-dark);
}

/* ══ ba-ui: PlanCard ═════════════════════════════════════════════════════ */

.plan-cards {
  display: grid;
  gap: var(--space-6);
}

@media (min-width: 768px) {
  .plan-cards {
    display: none;
  }
}

/* No tier is visually promoted. Mutually exclusive alternatives get equal weight — and a
 * "most popular" ribbon is a lie until somebody has bought something (§5.5). */
.plan-card {
  background: var(--surface-raised);
  border-radius: var(--radius-md);
  padding: var(--space-8);
}

.plan-card__price {
  margin: var(--space-4) 0;
}

.plan-card ul {
  list-style: none;
  margin: var(--space-4) 0 0;
  padding: 0;
  display: grid;
  gap: var(--space-3);
}

/* ══ ba-ui: Accordion / FaqList ══════════════════════════════════════════ */

.accordion {
  border-top: 1px solid var(--rule-decorative);
}

.accordion__item {
  border-bottom: 1px solid var(--rule-decorative);
}

/* <details>/<summary> so every disclosure works with JavaScript switched off. */
.accordion__item > summary {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-4);
  min-height: 56px;
  padding: var(--space-4) 0;
  cursor: pointer;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: var(--size-h4);
  list-style: none;
}

.accordion__item > summary::-webkit-details-marker {
  display: none;
}

.accordion__chevron {
  flex: none;
  width: 16px;
  height: 16px;
  border-inline-end: 2px solid var(--text-nontext);
  border-block-end: 2px solid var(--text-nontext);
  transform: rotate(45deg);
  transition: transform var(--dur-base) var(--ease-standard);
}

.accordion__item[open] .accordion__chevron {
  transform: rotate(225deg);
}

.accordion__panel {
  padding-bottom: var(--space-6);
}

/* ══ ba-ui: DefinitionList ═══════════════════════════════════════════════ */

.definition-list {
  margin: 0;
  display: grid;
  gap: var(--space-10);
}

@media (min-width: 768px) {
  .definition-list--two-col {
    grid-template-columns: 1fr 1fr;
    gap: var(--space-10) var(--space-12);
  }
}

.definition-list dt {
  font-family: var(--font-display);
  font-weight: 600;
  font-size: var(--size-h4);
  margin-bottom: var(--space-3);
}

.definition-list dd {
  margin: 0;
  color: var(--text-secondary);
}

/* Label ABOVE value below md, always. This is where +25% Spanish breaks bilingual layouts first
 * (§10 rule 6) — so there is no side-by-side label/value row anywhere on this site. */

/* ══ ba-ui: FormField / Input ════════════════════════════════════════════ */

.form-field {
  display: grid;
  gap: var(--space-2);
  margin-bottom: var(--space-6);
}

.form-field label {
  font-weight: 600;
  font-size: var(--size-small);
  color: var(--text-primary);
}

.form-field .form-field__description {
  font-size: var(--size-small);
  color: var(--text-secondary);
  margin: 0;
}

.form-field input,
.form-field textarea,
.form-field select {
  font: inherit;
  font-size: var(--size-body);
  color: var(--text-primary);
  background: var(--surface-card);
  min-height: 48px;
  padding: var(--space-3) var(--space-4);
  border: 1px solid var(--rule-structural);
  border-radius: var(--radius-md);
  transition:
    border-color var(--dur-fast) var(--ease-standard),
    box-shadow var(--dur-fast) var(--ease-standard);
}

.form-field textarea {
  min-height: 140px;
  resize: vertical;
}

.form-field input:focus,
.form-field textarea:focus {
  border-color: var(--focus-ring);
  box-shadow: 0 0 0 2px var(--focus-ring);
  outline: none;
}

/*
 * VALIDATION (S-223). Colour is never the carrier and never the only signal. An invalid field gets
 * WORDS under it, `aria-invalid` on the control, and a link to it from the summary at the top of
 * the form; the ember border is the fourth signal, for people who can see it. Removing all colour
 * from this stylesheet would not remove a single piece of information.
 *
 * The message node is always in the DOM and starts empty, so it is associated with the field from
 * the first paint rather than at the moment it fills. `:empty` keeps an empty one out of the flow
 * without hiding it from the accessibility tree the way `display:none` would once it has words.
 */
.form-field .form-field__error {
  margin: 0;
  font-size: var(--size-small);
  font-weight: 600;
  color: var(--text-accent);
}

.form-field .form-field__error:empty {
  display: none;
}

/*
 * ⛔ AN INVALID FIELD MUST NOT LOOK LIKE A FOCUSED ONE. Fixed 2026-08-25.
 *
 * This read `var(--text-accent)`, and on this branch BOTH `--text-accent` and `--focus-ring`
 * resolve to `var(--accent)` — so "this field is wrong" and "your cursor is here" drew the
 * identical border. On main they were different tokens (`--ember-600` vs the ring), which is why
 * the regression is ours and why it only surfaced in CI: the a11y lane's focus lands elsewhere
 * than it does locally, so the two states collided in the same frame and MR-R-35205 caught it.
 *
 * `--late` is the site's existing error red, already measured at 8.02:1 light and 6.70:1 dark,
 * and it is nowhere near the ember ring. The 2px width stays: colour is not the only carrier,
 * which is the other half of what this row asserts.
 */
.form-field input[aria-invalid="true"],
.form-field textarea[aria-invalid="true"] {
  border-color: var(--late);
  border-width: 2px;
}

/* The error summary. Focus lands here, so it is a focus target with a visible ring of its own. */
.form-errors {
  margin-bottom: var(--space-6);
  padding: var(--space-4) var(--space-6);
  border-radius: var(--radius-md);
  border: 2px solid var(--text-accent);
  background: var(--surface-accent-wash);
  color: var(--text-primary);
}

.form-errors:focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: 2px;
}

.form-errors[hidden] {
  display: none;
}

/* A file input is not a text input; it keeps the 48px target and drops the text padding. */
.form-field input[type="file"] {
  padding: var(--space-2) var(--space-3);
}

/* Honeypot: spam control with no cognitive function test, per 3.3.8. */
.form-honeypot {
  position: absolute;
  left: -9999px;
  width: 1px;
  height: 1px;
  overflow: hidden;
}

/* ══ ba-ui: Banner ═══════════════════════════════════════════════════════ */

.banner {
  padding: var(--space-4) var(--space-6);
  border-radius: var(--radius-md);
  background: var(--surface-raised);
  border-inline-start: 0;
  font-size: var(--size-small);
}

.banner--accent {
  background: var(--surface-accent-wash);
  color: var(--text-primary);
}

/*
 * `warn` is what the importer uses when it read nothing at all. It is the accent wash with a real
 * border, because a refusal has to be distinguishable from a result at a glance — and the words
 * inside it say what happened, so the border is confirmation rather than information.
 */
.banner--warn {
  background: var(--surface-accent-wash);
  color: var(--text-primary);
  border: 2px solid var(--text-accent);
}

/* ══ Import report (S-226) ═══════════════════════════════════════════════ */

.import-report {
  margin-top: var(--space-8);
  display: grid;
  gap: var(--space-6);
}

.import-report[hidden] {
  display: none;
}

.import-report .data-table {
  width: 100%;
}

/* ══ ba-ui: StickyCtaBar ═════════════════════════════════════════════════ */

.sticky-cta {
  position: fixed;
  inset-inline: 0;
  bottom: 0;
  z-index: 40;
  min-height: 64px;
  display: flex;
  align-items: center;
  gap: var(--space-4);
  padding: var(--space-2) var(--gutter);
  background: var(--surface-page);
  box-shadow: var(--elev-3);
  transform: translateY(100%);
  transition: transform var(--dur-slow) var(--ease-out);
}

.sticky-cta[data-visible="true"] {
  transform: translateY(0);
}

/* 2.5.8: the mobile CTA is the largest target on the site, not the smallest. */
.sticky-cta .button {
  min-height: 56px;
}

/* Inert until the hero has left the viewport, so a focused control can never sit under it. */
.sticky-cta[inert] {
  pointer-events: none;
}

@media (min-width: 768px) {
  .sticky-cta {
    display: none;
  }
}

/* ══ ba-ui: Prose + TableOfContents ══════════════════════════════════════ */

.prose {
  max-width: 68ch;
}

.prose h2 {
  font-family: var(--font-display);
  font-size: var(--size-h2);
  font-weight: 700;
  line-height: 1.15;
  margin: var(--space-12) 0 var(--space-3);
  /*
   * The table-of-contents jump target on the legal pages. `88px` was a hand-typed constant — the
   * 64px header literal plus 24px — so it was wrong by the same amount as everything else and,
   * being a literal, it stayed wrong when the header was measured. `--pinoff` is the same offset
   * every other anchor on the site uses; the legal pages carry a rail too.
   */
  scroll-margin-top: calc(var(--pinoff, 64px) + 1.5rem);
}

.prose h3 {
  font-family: var(--font-display);
  font-size: var(--size-h3);
  font-weight: 700;
  margin: var(--space-8) 0 var(--space-3);
}

.prose p,
.prose li {
  font-size: var(--size-body);
  line-height: 1.6;
}

.prose ul,
.prose ol {
  padding-inline-start: var(--space-6);
  display: grid;
  gap: var(--space-3);
}

.legal-layout {
  display: grid;
  gap: var(--space-12);
}

@media (min-width: 1024px) {
  .legal-layout {
    grid-template-columns: minmax(0, 1fr) 260px;
    gap: var(--space-16);
    align-items: start;
  }
  .toc {
    position: sticky;
    top: calc(var(--header-height) + var(--space-6));
    order: 2;
  }
}

.toc ol {
  list-style: none;
  margin: var(--space-3) 0 0;
  padding: 0;
  display: grid;
  gap: var(--space-2);
}

.toc a {
  color: var(--text-secondary);
  text-decoration: none;
  font-size: var(--size-small);
  display: inline-flex;
  min-height: 44px;
  align-items: center;
}

.toc a[aria-current="true"] {
  color: var(--text-accent);
  font-weight: 600;
}

/* ══ ba-ui: LanguageSwitcher ═════════════════════════════════════════════ */

.language-switcher {
  white-space: nowrap;
  /* 1.4.3 Contrast: this rule and the target-size rule above both style the switcher for LAYOUT
   * and neither set a colour, and there is no global `a { color }` anywhere in src/styles — so the
   * label fell through to the UA default link blue, #0000ee. On the light page that measures
   * 8.67:1 and looks deliberate; in dark mode it is #0000ee on --steel-950 and measures 1.92:1,
   * against a 4.5:1 floor. /es is the only page that renders the switcher, so this was the whole
   * Spanish surface. Matches `.nav a`, its neighbour in the header. */
  color: var(--text-primary);
}

/* ══════════════════════════════════════════════════════════════════════════
 * SITE-LOCAL — MiniRestaurants only. Nothing below this line generalizes.
 * ══════════════════════════════════════════════════════════════════════ */

/* ── TicketRail / TicketCard / BumpableCard ─────────────────────────────── */

.ticket-rail {
  display: flex;
  gap: var(--space-4);
  /* Runs off the right edge deliberately: the visual signal that there is more than fits, which
   * is the truth about a kitchen at 7pm (§4.2 primitive C). */
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  padding-block: var(--space-4);
  padding-inline: var(--gutter) 25vw;
  margin-inline: calc(-1 * var(--gutter));
  scrollbar-width: thin;
}

.ticket-rail:focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: 4px;
}

.ticket-rail__card {
  flex: none;
  width: 280px;
  scroll-snap-align: start;
  background: var(--paper-card);
  color: var(--steel-950);
  border-radius: var(--radius-lg);
  box-shadow: var(--elev-1);
  overflow: hidden;
  text-align: start;
}

@media (min-width: 768px) {
  .ticket-rail__card {
    width: 320px;
  }
}
.ticket-card__header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-3);
  /* 88pt-equivalent bump header, faithful to the product's OrderCard. */
  min-height: 88px;
  padding: var(--space-4);
  background: var(--steel-100);
  font-family: var(--font-data);
  font-weight: 600;
}

.ticket-card__body {
  padding: var(--space-4);
  display: grid;
  gap: var(--space-2);
}

.ticket-card__item {
  display: flex;
  gap: var(--space-3);
  font-size: var(--size-small);
  color: var(--steel-950);
}

.ticket-card__qty {
  font-family: var(--font-data);
  font-weight: 600;
  min-width: 2ch;
}

.ticket-card__modifier {
  padding-inline-start: var(--space-6);
  color: var(--steel-600);
  font-size: var(--size-small);
}

.ticket-card__allergen {
  margin-top: var(--space-2);
  padding: var(--space-2) var(--space-3);
  border-radius: var(--radius-sm);
  background: #fdecdf;
  color: #7a2d05;
  font-size: var(--size-small);
  font-weight: 600;
}

.ticket-card__timer {
  font-family: var(--font-data);
  font-variant-numeric: tabular-nums;
}

button.ticket-card__header {
  width: 100%;
  border: 0;
  cursor: pointer;
  font-size: var(--size-body);
  transition: background-color var(--dur-fast) var(--ease-standard);
}

button.ticket-card__header:hover {
  background: var(--steel-200);
}

.ticket-rail__card[data-bumped="true"] {
  animation: bump-out var(--dur-rail) var(--ease-in) forwards;
}

@keyframes bump-out {
  to {
    opacity: 0;
    transform: translateX(40%);
  }
}

.rail-undo {
  margin-top: var(--space-4);
  min-height: 44px;
}

/* ── ThemeToggle ────────────────────────────────────────────────────────────
 *
 * The ratified mockup's `.btn--ghost`: transparent fill, a real 1px border, accent on hover. It is
 * a control rather than a link, so it takes a border where the header's text links take none.
 *
 * ⛔ HIDDEN IN THE HEADER ROW BELOW 768px, VISIBLE IN THE DRAWER — the same rule `.connection-chip`
 * already follows, and for the same measured reason. `site-header.mjs` records the header row at
 * 390px on /es under 1.4.12 text-spacing: wordmark 176 + switcher 69 + CTA 82 + toggle 44 + gaps =
 * 424px in a 390px viewport, already over before this control existed. Adding an unconditionally
 * visible button to that row would have put ~120px more into it. `SiteHeader` renders its
 * accessory twice — header and drawer — so nothing is lost: the phone visitor reaches the toggle
 * by opening the menu, which is where they reach the nav and the language switcher too.
 */

.theme-toggle {
  display: none;
  align-items: center;
  gap: var(--space-2);
  /* 2.5.8 Target Size: 44px from padding, like every other control in this header. */
  min-height: 44px;
  padding: var(--space-1) var(--space-3);
  border: 1px solid var(--rule-structural);
  border-radius: var(--radius-pill);
  background: none;
  color: var(--text-primary);
  font: inherit;
  font-size: var(--size-small);
  white-space: nowrap;
  cursor: pointer;
  /* Colour only — both properties are on the perf guard's allowlist, and there is no movement to
   * collapse under prefers-reduced-motion because nothing here moves. */
  transition:
    color var(--dur-fast) var(--ease-out),
    border-color var(--dur-fast) var(--ease-out);
}

.theme-toggle:hover {
  border-color: var(--action-fill);
  color: var(--text-accent);
}

@media (min-width: 768px) {
  .hdr__accessory .theme-toggle {
    display: inline-flex;
  }
}

/*
 * ⛔ THE PRUNE IS MADE MONOTONIC HERE, AND THE RULE IS: THE TOGGLE IS NEVER IN NEITHER PLACE.
 *
 * Cisco's MAJOR 3 (a11y-phase4-2026-08-24) found the theme toggle unreachable at 540-620px and
 * 740-760px. Neither number is a breakpoint anybody wrote, which is why nobody found it by
 * reading the stylesheet. What happens there is a COLLISION of two independent prunes:
 *
 *   - the header copy is hidden below 768px, on the measured argument that the phone row is
 *     already over its width before this control joins it;
 *   - the drawer copy is always displayed, but the DRAWER is only reachable through
 *     `.nav-toggle`, and `.nav-toggle` is hidden whenever the header's ResizeObserver decides the
 *     nav fits inline (`.hdr[data-nav="inline"]`).
 *
 * Below 620px the wordmark's own media query hides `.mark__word` and hands ~140px back to the
 * row, so the nav starts fitting inline again — the observer flips to `inline`, the drawer button
 * disappears, and the header copy is still below its 768px floor. The toggle is in the document
 * twice and reachable zero times. The prune ran backwards.
 *
 * The fix is not a third breakpoint. It is a statement of the invariant: when there is no drawer,
 * the header copy shows, whatever the width. `data-nav="inline"` is exactly "there is no drawer",
 * and it is set by the same observer that hides the button, so the two can no longer disagree.
 * At those widths the row has already proven it has room — that is what `inline` MEANS.
 */
.hdr[data-nav="inline"] .hdr__accessory .theme-toggle {
  display: inline-flex;
}

/* The drawer copy is not width-constrained and is the only one a phone visitor can reach. */
.nav-drawer__accessory .theme-toggle {
  display: inline-flex;
}

/*
 * SHAPE AND TEXT, NEVER COLOUR ALONE (1.4.1) — the same vocabulary as `.connection-chip__dot`.
 * Unpressed is a half-filled disc; pressed fills it. The visible label does not change, so the
 * header cannot reflow when the button is pressed, and `aria-pressed` carries the state for
 * anyone who is not looking at the disc.
 */
.theme-toggle__icon {
  width: 12px;
  height: 12px;
  flex: none;
  border: 2px solid currentcolor;
  border-radius: var(--radius-pill);
  background: linear-gradient(to right, currentcolor 50%, transparent 50%);
}

.theme-toggle[aria-pressed="true"] .theme-toggle__icon {
  background: currentcolor;
}

/* ── ConnectionChip ─────────────────────────────────────────────────────── */

.connection-chip {
  display: none;
  align-items: center;
  gap: var(--space-2);
  padding: var(--space-1) var(--space-3);
  border-radius: var(--radius-pill);
  background: var(--surface-raised);
  color: var(--text-secondary);
  font-size: var(--size-small);
  text-decoration: none;
  white-space: nowrap;
}

@media (min-width: 768px) {
  .connection-chip {
    display: inline-flex;
  }
}

/* Colour + dot SHAPE + text. Three signals, never colour alone. */
.connection-chip__dot {
  width: 10px;
  height: 10px;
  flex: none;
  border: 2px solid currentColor;
  border-radius: var(--radius-pill);
  background: currentColor;
}

.connection-chip[data-state="local"] {
  color: #7a4a05;
}
.connection-chip[data-state="local"] .connection-chip__dot {
  border-radius: var(--radius-sm);
  background: transparent;
}
.connection-chip[data-state="down"] {
  color: var(--ember-600);
}
.connection-chip[data-state="down"] .connection-chip__dot {
  border-radius: var(--radius-sm);
  background: linear-gradient(to right, currentColor 50%, transparent 50%);
}

/* ── NetworkStateControl ────────────────────────────────────────────────── */

.network-control {
  border: 0;
  margin: 0 0 var(--space-6);
  padding: 0;
}

.network-control legend {
  padding: 0;
  margin-bottom: var(--space-3);
}

.network-control__options {
  display: grid;
  gap: var(--space-3);
}

@media (min-width: 768px) {
  .network-control__options {
    grid-auto-flow: column;
    justify-content: start;
  }
}

.network-control__option {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  min-height: 48px;
  padding: var(--space-3) var(--space-4);
  border-radius: var(--radius-md);
  background: var(--surface-raised);
  cursor: pointer;
  font-size: var(--size-small);
}

.network-control__option:has(input:checked) {
  box-shadow: inset 0 0 0 2px var(--focus-ring);
  font-weight: 600;
}

.network-control__option:has(input:focus-visible) {
  outline: 2px solid var(--focus-ring);
  outline-offset: 2px;
}

/* ── SetupStepRail ──────────────────────────────────────────────────────── */

.setup-steps {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: var(--space-12);
}

.setup-step {
  display: grid;
  gap: var(--space-3);
  position: relative;
  padding-inline-start: var(--space-10);
}

/* A real connector for a real sequence. This is the only numbered content on the site. */
.setup-step::before {
  content: "";
  position: absolute;
  inset-block: 0;
  inset-inline-start: var(--space-2);
  width: 2px;
  background: var(--rule-decorative);
}

.setup-step:last-child::before {
  bottom: auto;
  height: 2.5rem;
}

.setup-step__number {
  color: var(--text-nontext);
}

@media (min-width: 1024px) {
  .setup-step {
    grid-template-columns: 5rem minmax(0, 1fr);
    padding-inline-start: 0;
    column-gap: var(--space-6);
  }
  .setup-step::before {
    inset-inline-start: 5.75rem;
  }
  .setup-step__number {
    grid-row: 1 / span 3;
  }
}

/* ── PosConnectionTable wordmarks ───────────────────────────────────────── */

/* Functional identification, not social proof: monochrome, uniform cap height, always paired with
 * the vendor name in text, so the mark itself is decorative and aria-hidden. */
.pos-wordmark {
  color: var(--text-secondary);
  font-weight: 700;
  font-family: var(--font-display);
  font-size: 20px;
  line-height: 1;
}

/* ── PauseProof ─────────────────────────────────────────────────────────── */

.pause-proof {
  display: grid;
  gap: var(--space-6);
}

@media (min-width: 768px) {
  .pause-proof {
    grid-template-columns: 1fr 1fr;
  }
}

.pause-proof figure {
  margin: 0;
}

.pause-proof figcaption {
  margin-top: var(--space-3);
  font-size: var(--size-small);
  color: var(--text-secondary);
}

/* ── Press boilerplate ──────────────────────────────────────────────────── */

/*
 * A block of copy whose only job is to be selected and pasted, so it is set apart by a left rule
 * and a sunken surface rather than by a card. This is the one place the Anti-Slop Rulebook's
 * borderless default gives way and says why: a paste target needs a visible edge, or a journalist
 * cannot tell where the quote stops. No gradient, no icon, no rounded card, no colour strip as
 * decoration — the rule here is the boundary of the quote.
 */
.boilerplate {
  background: var(--surface-sunken);
  border-left: 3px solid var(--rule-structural);
  padding: var(--space-6);
  border-radius: var(--radius-sm);
}

.boilerplate .label {
  margin-bottom: var(--space-3);
  color: var(--text-secondary);
}

/* ── SlotPlaceholder ────────────────────────────────────────────────────── */

/*
 * An unfilled CEO-input slot renders VISIBLY and could never be mistaken for finished content.
 * `build.mjs --production` refuses to emit a page that still contains one, so this can only ever
 * be seen in a draft build. This is Harper's "leave it visible and let it fail review", made
 * mechanical instead of hopeful.
 */
.slot-placeholder {
  display: block;
  margin-block: var(--space-6);
  padding: var(--space-6);
  border: 3px dashed #8a5a00;
  border-radius: var(--radius-md);
  background: repeating-linear-gradient(135deg, #fff8e6, #fff8e6 12px, #ffefc4 12px, #ffefc4 24px);
  color: #4a3000;
}

.slot-placeholder__tag {
  display: inline-block;
  margin-bottom: var(--space-2);
  padding: var(--space-1) var(--space-3);
  border-radius: var(--radius-sm);
  background: #4a3000;
  color: #fff8e6;
  font-family: var(--font-data);
  font-size: var(--size-small);
  font-weight: 600;
}

.slot-placeholder__question {
  margin: 0;
  font-size: var(--size-small);
  line-height: 1.5;
  max-width: 75ch;
  color: #4a3000;
}

.slot-placeholder--gate {
  border-color: #0d4f6c;
  background: repeating-linear-gradient(135deg, #eaf6fb, #eaf6fb 12px, #d3ecf7 12px, #d3ecf7 24px);
  color: #062b3c;
}

.slot-placeholder--gate .slot-placeholder__tag {
  background: #062b3c;
  color: #eaf6fb;
}

.slot-placeholder--gate .slot-placeholder__question {
  color: #062b3c;
}

/* ── `.placeholder-person` — RETAINED GATE VOCABULARY, RENDERED BY NOTHING ────── */
/*
 * ⛔ NO PAGE EMITS THIS CLASS SINCE 2026-08-25 AND THE RULES BELOW STAY ANYWAY.
 *
 * `check.mjs` still scans every built page for a block wearing this class and fails the
 * build if one appears without its marker. The stylesheet is the other half of that
 * arrangement: the day somebody adds one back they will reach for this class, because it is
 * what the gate reads and what is styled here. Removing the styling is how the gate becomes
 * something a developer routes around instead of something they land on.
 */

/*
 * A quote from an operator WHO DOES NOT EXIST. It reuses `.slot-placeholder` for its banner
 * rather than inventing a second warning treatment, so every "this is not real" block on the site
 * looks the same to a reader who has already learned what that hatch means.
 *
 * The quote itself is deliberately NOT styled to look finished — no large display type, no
 * decorative quotation mark, no card elevation. A placeholder testimonial that looks like a
 * designed testimonial is the thing this component exists to prevent.
 */
.placeholder-person {
  margin: 0;
  padding: 0;
  border-left: none;
}

.placeholder-person__quote {
  margin: var(--space-6) 0 0;
  padding: 0;
  max-width: 62ch;
}

.placeholder-person__quote p {
  margin: 0;
}

.placeholder-person__who {
  display: flex;
  align-items: center;
  gap: var(--space-4);
  margin-top: var(--space-6);
}

.placeholder-person__avatar {
  flex: 0 0 auto;
  width: 56px;
  height: 56px;
}

.placeholder-person__avatar-disc {
  fill: #4a3000;
}

.placeholder-person__avatar-initials {
  fill: #fff8e6;
  font-family: var(--font-data);
  font-size: 24px;
  font-weight: 600;
}

.placeholder-person__attribution {
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
}

.placeholder-person__name {
  font-weight: 600;
}

/* ── /404 ───────────────────────────────────────────────────────────────── */

.error-links {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-4) var(--space-6);
  padding: 0;
  margin: 0;
  list-style: none;
}

.error-links a {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
}

.search-form {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-3);
  align-items: end;
}

.search-form .form-field {
  margin-bottom: 0;
  flex: 1 1 240px;
}

@media (min-width: 768px) {
  .search-form .form-field {
    flex: 0 1 380px;
  }
}

/* ── Misc site lists ────────────────────────────────────────────────────── */

.marker-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: var(--space-6);
}

.marker-list li {
  position: relative;
  padding-inline-start: var(--space-8);
  font-size: var(--size-h4);
  font-family: var(--font-display);
  font-weight: 600;
}

.marker-list li::before {
  content: "";
  position: absolute;
  inset-inline-start: 0;
  top: 0.7em;
  width: var(--space-6);
  height: 2px;
  background: var(--text-nontext);
  /*
   * The rule draws in with scaleX from its start edge, not by animating `width`.
   * `transition: width` was here and the performance guard added in this pass rejected it — the
   * first thing the guard caught, and it was already shipped. Identical picture, no layout pass
   * per frame, no CLS contribution.
   */
  transform: scaleX(0.35);
  transform-origin: left center;
  transition: transform var(--dur-fast) var(--ease-standard);
}

.feature-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: var(--space-6);
}

@media (min-width: 768px) {
  .feature-list {
    grid-template-columns: 1fr 1fr;
    gap: var(--space-6) var(--space-12);
  }
}

.feature-list dt,
.feature-list__term {
  font-family: var(--font-display);
  font-weight: 600;
  font-size: var(--size-h4);
  margin-bottom: var(--space-2);
}

.plain-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: var(--space-6);
}

/*
 * B3. The restaurant directory (`/restaurants`).
 *
 * The name sits on its own line above its summary in EVERY row, linked or not, so the row's shape
 * does not depend on whether the page it names is in this build. The name keeps the display font
 * and weight in both states; only the underline and the link colour differ, which is what a link
 * is supposed to differ by. Before this, an unlinked name ran straight into its summary as one
 * grey sentence beside five underlined siblings, and read as a link that had failed to render.
 */
.segment-index__item {
  min-width: 0;
}

.segment-index__name {
  font-family: var(--font-display);
  font-weight: 600;
  font-size: var(--size-h4);
  line-height: 1.3;
  margin: 0 0 var(--space-2);
}

.segment-index__summary {
  margin: 0;
  color: var(--text-secondary);
}

.breadcrumbs {
  font-size: var(--size-small);
  color: var(--text-secondary);
  margin-bottom: var(--space-6);
}

.breadcrumbs ol {
  list-style: none;
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2);
  margin: 0;
  padding: 0;
}

/* ── S-220: competitor citation cells and the chooser ───────────────────── */

/*
 * A price, its qualification, its source and the day it was read are FOUR LINES IN ONE CELL, and
 * they stack rather than wrap into each other. `display: block` on each is the whole layout: the
 * moment a source line is allowed to run inline after a number it starts reading as part of the
 * price, and at 320px it wraps into it.
 */
.competitor-price {
  display: block;
  font-size: var(--size-h4);
}

.competitor-note,
.competitor-checked {
  display: block;
  margin-top: var(--space-2);
  color: var(--text-secondary);
}

/* The date is the half of a citation people skip. It is not allowed to be the quietest thing. */
.competitor-checked {
  font-weight: 600;
  color: var(--text-primary);
}

.chooser {
  border: 0;
  margin: 0;
  padding: 0;
  display: grid;
  gap: var(--space-8);
}

.chooser__question {
  border: 0;
  margin: 0;
  padding: 0;
  min-inline-size: 0; /* a fieldset defaults to min-content and blows out the grid at 320px */
}

.chooser__question legend {
  padding: 0;
}

.chooser__help {
  color: var(--text-secondary);
  margin: var(--space-2) 0 var(--space-4);
}

.chooser__options {
  display: grid;
  gap: var(--space-3);
}

.chooser__option {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  min-height: 48px;
  padding: var(--space-3) var(--space-4);
  border-radius: var(--radius-md);
  background: var(--surface-raised);
  cursor: pointer;
}

.chooser__option:has(input:checked) {
  box-shadow: inset 0 0 0 2px var(--focus-ring);
  font-weight: 600;
}

.chooser__option:has(input:focus-visible) {
  outline: 2px solid var(--focus-ring);
  outline-offset: 2px;
}

.chooser__result {
  margin-top: var(--space-8);
  padding: var(--space-6);
  border-radius: var(--radius-md);
  background: var(--surface-card);
}

.chooser__outcomes {
  margin-top: var(--space-8);
}

.chooser__outcome {
  padding: var(--space-6);
  border-radius: var(--radius-md);
  background: var(--surface-card);
}

/*
 * The verdict is a WORD. There is deliberately no colour rule keyed off `data-verdict` here: the
 * day "Not us" is drawn in red and "Us" in green is the day the meaning starts living in the
 * colour, and a comparison surface on a public page must survive being read without it.
 */
.chooser__verdict {
  margin-bottom: var(--space-2);
}

.chooser__when {
  margin-top: var(--space-6);
  color: var(--text-secondary);
}

/* ── RadioGroup (ui/radio-group.mjs) ────────────────────────────────────── */

.radio-group {
  border: 0;
  margin: 0 0 var(--space-6);
  padding: 0;
}

.radio-group legend {
  padding: 0;
  margin-bottom: var(--space-3);
}

.radio-group__options {
  display: grid;
  gap: var(--space-3);
}

@media (min-width: 768px) {
  .radio-group__options {
    grid-auto-flow: column;
    justify-content: start;
  }
}

.radio-group__option {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  min-height: 48px;
  padding: var(--space-3) var(--space-4);
  border-radius: var(--radius-md);
  background: var(--surface-raised);
  cursor: pointer;
  font-size: var(--size-small);
}

.radio-group__option:has(input:checked) {
  box-shadow: inset 0 0 0 2px var(--focus-ring);
  font-weight: 600;
}

.radio-group__option:has(input:focus-visible) {
  outline: 2px solid var(--focus-ring);
  outline-offset: 2px;
}

.radio-group__note {
  display: block;
  color: var(--text-secondary);
  font-weight: 400;
}

/*
 * The cost comparison's answer (/roi). There is deliberately NO colour rule keyed off
 * `data-roi-state`, exactly as the chooser above carries none keyed off `data-verdict`. The day
 * "you spend less" is drawn green and "we cost more" is drawn red is the day the verdict lives in
 * a colour, and the unflattering answer is the one that would be quietly easier to miss. The
 * sentence says which way it went, in words, in the first line of the region.
 */
.roi-output {
  padding: var(--space-6);
  border-radius: var(--radius-md);
  background: var(--surface-card);
}

.roi-output dl {
  margin-bottom: 0;
}

/*
 * A FORM THAT CANNOT BE SENT.
 *
 * The refusal reads as text a person can read, not as a warning triangle: the border and wash are
 * the third signal, after the words and the disabled state of the controls themselves. It sits
 * ABOVE the fields in document order, so it is met before the first label rather than after the
 * last one.
 */
.form-unavailable {
  max-width: 36rem;
  margin-top: var(--space-8);
  padding: var(--space-6);
  border-radius: var(--radius-md);
  border: 2px solid var(--text-accent);
  background: var(--surface-accent-wash);
  color: var(--text-primary);
}

.form-unavailable p + p {
  margin-top: var(--space-4);
}

/*
 * The inert fields. Browsers dim a disabled control by default and the dimming is what drops it
 * below 4.5:1, so the colours are restated here rather than inherited: the text stays readable and
 * `not-allowed` plus the announced disabled state carry the meaning. Showing the real fields is
 * the point — this is what we will ask for, and we cannot ask for it yet.
 */
.inert-fieldset {
  max-width: 36rem;
  margin: var(--space-8) 0 0;
  padding: 0;
  border: 0;
}

.inert-fieldset :disabled {
  opacity: 1;
  color: var(--text-primary);
  background: var(--surface-sunken);
  cursor: not-allowed;
}

/* ── Class C blog header schematics: the label-size ladder ─────────────────
 *
 * An SVG `font-size` is in the drawing's own units and is multiplied by the viewBox scale, so one
 * constant in `diagram.mjs` is one legible size at one width and an illegible one everywhere else.
 * Measured in Chrome on /blog/what-a-missed-ticket-costs/, the old 22 units arrived at 13.88px on
 * a 1280 viewport, 12.91 at 768, 11.15 at 640 and 6.29 at 375 — every rung under `--size-small`,
 * which `tokens.css:70` declares as this site's absolute 15px floor with no exemption.
 *
 * The rungs are keyed to the FRAME'S OWN WIDTH, not the viewport, because the frame's width is the
 * SVG's rendered width (the root is `width:100%`) and that is the only quantity that decides the
 * label's real size. A viewport media query would be a proxy for it and would be wrong the moment
 * the column changes. Every rung below is `18000 / container-width` rounded up, i.e. the unit size
 * that puts a 1200-unit canvas's label at 15 CSS px or better:
 *
 *   container >= 750px  ->  25 units  (757.33px measured at 1280 viewport -> 15.78px)
 *   container >= 690px  ->  27 units  (704px    measured at  768 viewport -> 15.84px)
 *   container >= 600px  ->  31 units  (608px    measured at  640 viewport -> 15.71px)
 *   container <  600px  ->  hidden, and `[data-post-schematic-legend]` carries the same words
 *
 * The ladder STOPS at 600px rather than continuing, and that is a geometric limit rather than a
 * choice: the longest label on this canvas is 37 characters, and 37 characters of Plex Mono at the
 * ~52 units a 343px render would need overflows the 1200-unit canvas outright. Below the last rung
 * the words are re-presented as HTML at `--size-small` instead of being shrunk past legibility or
 * dropped — dropping them would take the non-colour cue off the two headers whose accent is an
 * outline rectangle, and cost SC 1.4.1.
 *
 * NOTHING HERE ANIMATES OR TRANSITIONS: `src/checks/perf-guard.mjs` rejects animating a layout
 * property, and `font-size` is one. These are static rungs, not a transition. */
[data-post-schematic-frame] {
  container-type: inline-size;
  container-name: post-schematic;
}

/* Default state is the SAFE one: labels off, legend on. A browser without container query support
 * therefore degrades to the version that keeps every word on the page at 15px, rather than to the
 * version with 6px labels. */
[data-post-schematic] text {
  display: none;
}

[data-post-schematic-legend] {
  margin: var(--space-3) 0 0;
  font-size: var(--size-small);
  line-height: 1.5;
  color: var(--text-secondary);
}

@container post-schematic (min-width: 600px) {
  [data-post-schematic] text {
    display: initial;
    font-size: 31px;
  }

  [data-post-schematic-legend] {
    display: none;
  }
}

@container post-schematic (min-width: 690px) {
  [data-post-schematic] text {
    font-size: 27px;
  }
}

@container post-schematic (min-width: 750px) {
  [data-post-schematic] text {
    font-size: 25px;
  }
}
/*
 * Photo — a real, approved photograph. Lives here rather than in its own stylesheet because
 * `buildStyles()` in build.mjs keeps an explicit cascade list, and one more component does not
 * justify one more file in it.
 *
 * The block is deliberately thin. The component already renders explicit `width`/`height`
 * attributes, so the browser reserves the correct box from the aspect ratio with no CSS at all;
 * everything below is about NOT undoing that. `height: auto` is the one line that matters — set a
 * fixed CSS height on a responsive image and the attributes stop protecting against layout shift.
 */
.photo {
  display: block;
}

.photo__img {
  display: block;
  width: 100%;
  height: auto;
  border-radius: var(--radius-md);
  background: var(--surface-sunken);
}

/*
 * Photo band — the same photograph whole on a phone, cropped to a letterbox on a desktop.
 *
 * WHY THE CROP IS CSS AND NOT A SECOND SET OF FILES
 * A 16/9 derivative of every photograph would be eight more names on disk, eight more chances for
 * a name to point at a picture the CEO did not approve, and an art-direction `<picture>` with a
 * media query in it. `aspect-ratio` + `object-fit` gets the same rendered result from the same
 * approved file, and `scripts/encode-photos.mjs` stays a resize-and-re-encode step with no crop
 * decision in it at all.
 *
 * ZERO LAYOUT SHIFT ON BOTH SIDES OF THE BREAKPOINT. Below 768px there is no rule here at all, so
 * the box comes from the `width`/`height` attributes (1264x848, the native 3/2). At and above
 * 768px `aspect-ratio` replaces that ratio in the SAME cascade pass that lays the page out, before
 * the image loads — a ratio swap costs nothing at load because nothing was ever painted at the
 * other ratio. Never animate or transition `aspect-ratio` here; that would be a layout property in
 * motion and `src/checks/perf-guard.mjs` would reject it, correctly.
 */
@media (min-width: 768px) {
  .photo--band .photo__img {
    aspect-ratio: 16 / 9;
    object-fit: cover;
  }
}

/*
 * Focal points. WHICH pixels a crop discards is a design decision per photograph, so it arrives as
 * data (`photo.focal` in the content module) and lands here as one class per named position. The
 * vocabulary is closed and is validated in `src/site/photo-band.mjs` — an unknown focal throws at
 * build time rather than falling back to a centre crop that nobody would notice in review.
 *
 * Below 768px these are inert: nothing is cropped, so there is nothing to position.
 */
.photo--focal-center .photo__img {
  object-position: 50% 50%;
}
.photo--focal-center-left .photo__img {
  object-position: 25% 50%;
}
.photo--focal-center-right .photo__img {
  object-position: 75% 50%;
}
.photo--focal-lower-left .photo__img {
  object-position: 25% 75%;
}

/*
 * The pass band — the home page's two-photograph composition, below the hero rail.
 *
 * ASYMMETRIC BY CONSTRUCTION. 7fr/5fr at a shared 16/9 ratio makes the two frames different
 * heights, and `align-self: end` on the short one turns that difference into a deliberate offset
 * rather than a ragged bottom edge. Two equal frames would be a two-up card grid, which is the
 * tell this site's whole layout system exists to avoid.
 *
 * WHY THE FRAME OWNS THE CROP HERE AND THE IMAGE DOES NOT.
 * The site-wide `.photo--band` rule crops with `aspect-ratio` + `object-fit` on the <img> itself,
 * which is right for a band that fills the column and needs no overflow. This band MOVES its
 * photographs (see `.pass-band__frame--lead` in motion.css), and you cannot translate an image
 * that is exactly the size of its box without dragging a hole in behind it. So the frame takes
 * the 16/9 and clips, and the image is deliberately 106% of the frame's height: 6% of vertical
 * headroom, centred at rest, which is the entire budget the motion layer is allowed to spend.
 *
 * That headroom is FREE. All eight approved sources are 1264x848 (ratio 1.49) and the desktop
 * target is 16/9 (1.78), so `object-fit: cover` is already discarding vertical pixels — the drift
 * spends pixels that were being thrown away, and no amount of it can expose an edge.
 *
 * BELOW 768px THERE IS NO RULE HERE AT ALL. The frames stack, each photograph is shown whole at
 * its native 3/2 straight from the width/height attributes, and the box is reserved before a byte
 * arrives. Zero layout shift on both sides of the breakpoint.
 */
.pass-band {
  display: grid;
  gap: var(--space-6);
}

@media (min-width: 768px) {
  .pass-band {
    grid-template-columns: 7fr 5fr;
    align-items: start;
    gap: 24px;
  }

  .pass-band__frame {
    aspect-ratio: 16 / 9;
    overflow: hidden;
    border-radius: var(--radius-md);
    background: var(--surface-sunken);
  }

  /* The short frame sits low, so the height difference reads as composition, not as a mistake. */
  .pass-band__frame--trail {
    align-self: end;
  }

  .pass-band__frame .photo {
    height: 100%;
  }

  /*
   * `aspect-ratio: auto` deliberately UNDOES the site-wide `.photo--band` crop for these two
   * images only. The class stays on the <picture> because it is what the placement test keys on
   * and what carries the focal vocabulary; what changes is which box does the cropping.
   *
   * `border-radius: 0` because the frame is now the rounded thing. Rounding both leaves a hairline
   * of frame background showing through the corners at every drift position.
   */
  .pass-band__frame .photo__img {
    aspect-ratio: auto;
    width: 100%;
    height: 106%;
    object-fit: cover;
    border-radius: 0;
  }
}

/*
 * ══ F-E2-S1: the feature index (`/features/all`) and its demo pages ═════════════════════════
 *
 * Two shapes live here and they are deliberately different, because they answer different
 * questions. The CARDS are the five features a visitor can operate — they are the point of the
 * page and they are sized like it. The ROWS are 439 catalog line items inside a disclosure — a
 * reference, scanned rather than read, and every pixel spent decorating one of them is spent 439
 * times.
 *
 * The card is BORDERLESS: separation comes from whitespace first and a background shift second,
 * per the Anti-Slop Rulebook, and never from a 1px grey rectangle around everything.
 */

.feature-cards {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: var(--space-8);
}

@media (min-width: 768px) {
  .feature-cards {
    grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
  }
}

.feature-card {
  background: var(--surface-raised);
  border-radius: var(--radius-md);
  padding: var(--space-8);
  min-width: 0;
}

.feature-card h3 {
  margin: 0 0 var(--space-4);
}

.feature-card p {
  margin: 0 0 var(--space-4);
}

.feature-card p:last-child {
  margin-bottom: 0;
}

.feature-rows {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: var(--space-6);
}

/*
 * A row is a stack on a phone and stays a stack at every width. The status is the LAST thing in
 * DOM order and the last thing on screen, so reading order and visual order agree everywhere —
 * the same rule `ui/data-table.mjs` follows, for the same 1.4.10 Reflow reason.
 *
 * The hairline is on the row and is `--rule-decorative` (1.42:1), which the token file allows for
 * decoration and forbids as a control boundary. It separates two lines of text; nothing about
 * where a row ends is information, so nothing here depends on seeing it.
 */
.feature-row {
  min-width: 0;
  padding-bottom: var(--space-6);
  border-bottom: 1px solid var(--rule-decorative);
}

.feature-row:last-child {
  padding-bottom: 0;
  border-bottom: none;
}

.feature-row > p {
  margin: 0 0 var(--space-2);
}

.feature-row__head {
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: var(--space-2) var(--space-4);
}

.feature-row__name {
  font-family: var(--font-display);
  font-weight: 600;
  font-size: var(--size-h4);
  line-height: 1.3;
}

/*
 * THE ROW'S TAP TARGET. A feature name is a short string and a short string is a small target,
 * and these are the rows somebody reaches for on a phone. 44px is the floor (2.5.8), set on the
 * LINK rather than on the row, so a name with nothing behind it never grows a pressable-looking
 * box. The underline is not decoration: it is the only difference between a row you can open and
 * the 427 you cannot, and colour alone must never carry that.
 */
a.feature-row__name {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  /* `--text-accent` (5.62:1) is the LINK token. `--action-fill` is the same hex today and is the
     BUTTON token; borrowing it here would break the day the two are themed apart. */
  color: var(--text-accent);
  text-decoration: underline;
  text-underline-offset: 3px;
  text-decoration-thickness: 1px;
  transition: text-decoration-thickness var(--dur-fast) var(--ease-standard);
}

a.feature-row__name:hover,
a.feature-row__name:focus-visible {
  text-decoration-thickness: 2px;
}

/* `--text-secondary` (6.26:1), never `--text-nontext` (4.15:1): this is text, and it is read. */
.feature-row__id {
  font-size: var(--size-small);
  color: var(--text-secondary);
}

.feature-row__status {
  margin-bottom: 0;
}

/* ==BA:STEP4-BAND-COMPONENTS:BEGIN==
 * Shared band vocabulary: the seven-door rail, the kitchen-board frame, a door as a row, and the
 * label every sample surface wears. Appended as one block so the step 4 diff is reviewable and
 * removable in one piece.
 *
 * Type floor: every label here is at least --size-small (0.9375rem / 15px). The mockup's rail set
 * its mono labels at .58rem-.66rem, which is under the floor this site holds site-wide including
 * on legal pages. They were RAISED on port, deliberately. A 9px navigation label is not a style.
 *
 * Motion: only transform and opacity are transitioned anywhere in this block, so it clears
 * src/checks/perf-guard.mjs. Nothing here transitions `all`.
 */

/* ── The rail `nav.rail` ──────────────────────────────────────────────────── */
/*
 * PORTED FROM `THE-RAIL-picked.html:368-441` / `source/d1.py` STYLE. Blueprint §3.
 *
 * ─────────────────────────────────────────────────────────────────────────────────────────────
 * ## The class names are the template's, and the rename is the fix rather than tidying
 *
 * This block was `.door-rail__*` until 2026-08-23. Felicity measured the root cause of the whole
 * shell failure: the repo's shipped chrome was written in a class vocabulary the template's CSS
 * CANNOT REACH, so none of the ratified rail styling applied to any of it. Blueprint Rule 2 —
 * rename to the template, never translate the template into an existing class name.
 *
 * `.door-rail`→`.rail` · `__banner`→`__hd`/`__k` · `__spine`→`__track::before` + `__fill` ·
 * `__row`/`__door`→`.stop` · `__n`→`.stop__t` · `__label`→`.stop__n`.
 *
 * ## THE ONE PLACE THIS DELIBERATELY DOES NOT MATCH THE MOCKUP: TYPE SIZE
 *
 * The template sets `.stop__t` at `.66rem` (~10.5px) and `.stop__n` at `.83rem` (~13px), and its
 * `.rail__foot` at `.58rem` (~9px). Every one of those is below the 15px floor this site holds
 * site-wide, including on legal pages. They are rebuilt at `--size-floor` (0.9375rem). A 9px
 * navigation label is not a style, and this is the deviation to defend at the side-by-side.
 *
 * ## Motion
 *
 * Only `transform` and `background` transition anywhere in this block, so it clears
 * `src/checks/perf-guard.mjs`. Nothing transitions `all`. The hover `translateX(3px)` and the
 * dot's `scale(1.35)` are the template's own values and durations.
 * ─────────────────────────────────────────────────────────────────────────────────────────────
 */

/*
 * `.rail` is a grid child of `.railpage`, which ships in the shell block at the top of this file
 * rather than here. That was a deliberate move: leaving the container to "the page" means the
 * first page gets it right, the second copies it, and the third gets it wrong — and the rail's
 * mobile behaviour would silently depend on markup this component does not control.
 */
.rail {
  min-width: 0;
  padding: 1rem 0 1.4rem;
}

.rail__hd {
  display: grid;
  gap: 0.15rem;
  margin: 0 0 1.1rem;
}

/*
 * The banner. `.rail__k` in the template held "One Saturday" above a live clock; here it holds
 * "Seven ways in", which is a claim a reader can falsify by counting the rows under it. The
 * count is asserted at module load in `door-rail.mjs` so the build stops rather than shipping a
 * wrong number word.
 *
 * Raised from the template's `.6rem` to the floor. Its letter-spacing and uppercase treatment are
 * the template's, unchanged.
 */
.rail__k {
  margin: 0;
  font-family: var(--font-data);
  font-size: var(--size-floor);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--text-secondary);
}

.rail__track {
  position: relative;
  padding-left: 1.35rem;
}

/* The unfilled line. Decorative, and never the thing that communicates order — the <ol> and the
 * rendered numbers do that. */
.rail__track::before {
  content: "";
  position: absolute;
  left: 5px;
  top: 0.55rem;
  bottom: 0.55rem;
  width: 2px;
  background: var(--line);
  border-radius: 2px;
}

/*
 * The filled line. `--p` is HOW FAR THROUGH THE SEVEN YOU ARE, written once as an inline style by
 * `DoorRail` from the current door's index — page position, not a scroll scrub. The template drove
 * it from a rAF loop tracking sections on one long page; that page is gone and the loop with it.
 *
 * Default 0, so a page that is not one of the seven renders no fill at all rather than inventing
 * a position it has no business claiming.
 */
.rail__fill {
  position: absolute;
  left: 5px;
  top: 0.55rem;
  bottom: 0.55rem;
  width: 2px;
  background: var(--accent);
  border-radius: 2px;
  transform: scaleY(var(--p, 0));
  transform-origin: top;
}

/*
 * The puck. The template rides it on `translateY(var(--y))` in pixels, measured per frame by the
 * scrub. With no scrub there is no `--y`, and a pixel offset is not something the server can know
 * — row height depends on the rendered font. So it is positioned as a FRACTION OF THE TRACK from
 * the same `--p` the fill uses, which is the one number that means the same thing in both.
 *
 * ⚠️ `top` here is a STATIC calc, never a transition. Animating `top` is exactly what the perf
 * guard rejects; setting it once costs one layout at paint, which is what any position costs.
 */
.rail__puck {
  position: absolute;
  left: -2px;
  width: 12px;
  height: 12px;
  border-radius: 50%;
  background: var(--accent);
  box-shadow: 0 0 0 4px var(--ground);
  top: calc(0.17rem + var(--p, 0) * (100% - 1.1rem));
  z-index: 3;
  pointer-events: none;
}

/* No door, no position, no puck. Hidden rather than parked at the top, where it would read as
 * "you are on door 01" from every page on the site that is not door 01. */
.rail:not([style*="--p"]) .rail__puck,
.rail:not([style*="--p"]) .rail__fill {
  display: none;
}

.rail__list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: 0.05rem;
}

.stop {
  display: flex;
  align-items: baseline;
  gap: 0.55rem;
  position: relative;
  text-decoration: none;
  /* 44px minimum tap target (2.5.8), held by padding rather than a fixed height so a label may
   * wrap. The template's `.42rem .5rem .42rem .55rem` is the resting look; the min-height is what
   * makes it pressable on the tablet these owners actually hold. */
  min-height: 44px;
  padding: 0.42rem 0.5rem 0.42rem 0.55rem;
  border-radius: var(--r-xs);
  color: var(--text-primary);
  transition:
    transform 160ms var(--ease-out),
    background 160ms var(--ease-out);
}

.stop::before {
  content: "";
  position: absolute;
  left: -1.35rem;
  top: 0.85rem;
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--line-2);
  box-shadow: 0 0 0 3px var(--ground);
  transition:
    transform 180ms var(--ease-spring),
    background 180ms var(--ease-out);
}

.stop:hover {
  background: var(--ground-2);
  transform: translateX(3px);
}

.stop:hover::before {
  transform: scale(1.35);
  background: var(--accent);
}

.stop:focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: 3px;
}

.stop__t {
  font-family: var(--font-data);
  font-size: var(--size-floor);
  color: var(--text-secondary);
  flex: 0 0 auto;
  font-variant-numeric: tabular-nums;
}

.stop__n {
  font-family: var(--font-display);
  font-weight: 600;
  font-size: var(--size-floor);
  color: var(--text-secondary);
  line-height: 1.25;
}

/*
 * ⚠️ `aria-current="page"`, NOT the template's `="true"`. The template set it from JavaScript and
 * "true" is not a valid token for a navigation location, so a visitor with JS off was told nothing
 * about where they were. This is set SERVER-SIDE by `DoorRail({ current })`.
 */
.stop[aria-current="page"] {
  background: var(--accent-wash);
}

.stop[aria-current="page"] .stop__n {
  color: var(--accent);
  font-weight: 800;
}

.stop[aria-current="page"] .stop__t {
  color: var(--accent);
}

.stop[aria-current="page"]::before {
  background: var(--accent);
  transform: scale(1.3);
}

/* Desktop: the sticky column. `--hdrh` is measured by `shell.js`; the 64px fallback in tokens.css
 * is what a no-JavaScript visitor gets, and it is 44px short of a wrapped header — which is why
 * the fallback is only ever the fallback. */
@media (min-width: 1041px) {
  .rail {
    position: sticky;
    top: calc(var(--hdrh, 64px) + 1.1rem);
    align-self: start;
  }
}

/*
 * ── ≤1040: the rail lies down under the header ────────────────────────────────────────────────
 *
 * `THE-RAIL-picked.html:416-437`. Sticky strip, full-bleed background, the fill flips from
 * `scaleY` to `scaleX`, the puck goes.
 *
 * SEVEN stops is the known risk on a 390px viewport: ~820px of stops in a 390px box with the
 * scrollbar suppressed, so four stops sit entirely off-screen. The two mitigations below are both
 * measured, and neither should be rediscovered.
 */
@media (max-width: 1040px) {
  .rail {
    position: sticky;
    top: var(--hdrh, 64px);
    z-index: 60;
    padding: 0.5rem 0 0;
    background: var(--ground);
    border-bottom: 1px solid var(--line);
    /*
     * ── 🔴 `calc(50% - 50vw)` WAS HERE AND IT SHIPPED 8px OF HORIZONTAL SCROLL AT 1024 ──────────
     *
     * MEASURED, in a real browser, navigated to that width rather than resized into it:
     *
     *     documentElement.scrollWidth 1017  vs  clientWidth 1009      -> 8px of scroll
     *     nav.rail box                L-7 -> R1017, W1024             -> escapes its 977px wrap
     *     .stop children running out to R1086 and R1234 under `overflow-x: visible`
     *     clean at 1440 (1425 = 1425) and at 1280 (1265 = 1265)
     *
     * The memo that stood here was RIGHT about the thing it examined and blind to the thing that
     * broke. It reasoned about the two-column OFFSET — correctly: below 1040 `.railpage` is a
     * single column and the rail is not offset by anything. It never asked about the SCROLLBAR.
     * `50%` is half the container, which excludes the classic scrollbar; `50vw` is half the
     * viewport, which INCLUDES it. On any machine with non-overlay scrollbars the two disagree by
     * half the scrollbar's width at each edge, and the element pushes the document wider than the
     * viewport — which makes the scrollbar appear, which is the condition that caused it.
     *
     * `shell.js` had already written this defect down, one file away, about `--bleed-*`:
     * "`innerWidth` includes the classic scrollbar … so a bleed derived from it is 15-17px too
     * wide and produces the horizontal scrollbar this whole mechanism exists to avoid — on
     * precisely the machines nobody on this team tests on." It uses `root.clientWidth` for exactly
     * that reason. This rule was the one bleed on the site not using the measured tokens.
     *
     * ⛔ SO THE VIEWPORT UNIT IS GONE AND THE MEASURED TOKENS ARE USED, the same pair `.band` uses
     * at its own bleed. They default to `0px` with JavaScript off, which degrades to "no bleed" —
     * a rail sitting inside its container, which is correct and boring, rather than a document
     * 8px too wide.
     */
    margin-left: calc(-1 * var(--bleed-l, 0px));
    margin-right: calc(-1 * var(--bleed-r, 0px));
    padding-inline: 1rem;
  }

  .rail__hd {
    display: flex;
    align-items: baseline;
    gap: 0.6rem;
    margin-bottom: 0.5rem;
  }

  .rail__track {
    padding-left: 0;
    overflow-x: auto;
    overscroll-behavior-inline: contain;
    scrollbar-width: none;
    -webkit-overflow-scrolling: touch;
    /*
     * ⛔ NO scroll-snap HERE, AND THIS WAS MEASURED, NOT ASSUMED.
     *
     * The first version carried `scroll-snap-type: x proximity` with `scroll-snap-align: start`
     * on each door, copying the .ticket-rail idiom. It broke keyboard reachability: tabbing to a
     * door fired scrollIntoView, the browser scrolled minimally, and then SNAP RE-ALIGNED the
     * strip to the nearest snap point and undid it. Measured at 390px, doors 03 and 06 finished
     * 28px and 35px past the right edge WHILE FOCUSED — scrollLeft moved in snap-sized jumps
     * (16 → 340 → 461) instead of the minimum needed. A focused control the visitor cannot see
     * fails WCAG 2.2 2.4.11, and it fails it invisibly, because a mouse user never meets it.
     *
     * A carousel of equal-width cards wants snap. A navigation strip of seven variable-width
     * links does not: snapping buys a nicety and costs the keyboard. It was removed.
     */
    /* Focus rings must not be clipped by the scroll box; the block padding is what saves them. */
    padding-block: var(--space-2);
    padding-inline: 0 22vw;
    /* So a focus ring clears the fade ramp rather than landing under it. */
    scroll-padding-inline: 32px;
    /*
     * THE SCROLLABILITY AFFORDANCE, made independent of how long the labels happen to be.
     *
     * Measured at 320px: doors 01 and 02 together came to within a pixel of the viewport, so NO
     * partial door showed at the right edge and nothing told the visitor there were five more.
     * Relying on a sliver is relying on label lengths, which change with copy and with
     * translation. This fade is content-independent. At the far right of the scroll the fade sits
     * over the trailing padding rather than over the last door, so it never dims a live label.
     */
    -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 2rem), transparent 100%);
    mask-image: linear-gradient(to right, #000 calc(100% - 2rem), transparent 100%);
  }

  .rail__track::-webkit-scrollbar {
    display: none;
  }

  /* The line and the fill lie down with it: bottom edge, full width, `scaleX` from the left. */
  .rail__track::before {
    left: 0;
    right: 0;
    top: auto;
    bottom: 0;
    width: auto;
    height: 2px;
  }

  .rail__fill {
    left: 0;
    right: 0;
    top: auto;
    bottom: 0;
    width: auto;
    height: 2px;
    transform: scaleX(var(--p, 0));
    transform-origin: left;
  }

  .rail__puck {
    display: none;
  }

  .rail__list {
    grid-auto-flow: column;
    grid-auto-columns: max-content;
    gap: 0.15rem;
    padding-bottom: 0.5rem;
  }

  .stop {
    flex-direction: column;
    align-items: flex-start;
    gap: 0.1rem;
    padding: 0.35rem 0.7rem 0.6rem;
    white-space: nowrap;
  }

  .stop::before {
    display: none;
  }

  .stop:hover {
    transform: none;
  }
}

/*
 * `THE-RAIL-picked.html:439-441`. Collapse the movement, keep the state change — the hover
 * background and the `aria-current` wash both survive; only the 3px slide and the dot's pop go.
 */
@media (prefers-reduced-motion: reduce) {
  .stop,
  .stop::before {
    transition: none;
  }
}

/* ── The kitchen-board frame ──────────────────────────────────────────────── */

.board {
  background: var(--steel-950);
  border-radius: var(--radius-lg);
  overflow: hidden;
  box-shadow: var(--elev-2, var(--elev-1));
  border: 1px solid var(--steel-800);
}

.board__bar {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  padding: var(--space-3) var(--space-4);
  background: var(--steel-900);
  border-bottom: 1px solid var(--steel-800);
  font-family: var(--font-data);
  font-size: var(--size-small);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-on-dark-secondary);
}

.board__dots {
  display: flex;
  gap: 5px;
  flex: none;
}

.board__dots i {
  width: 9px;
  height: 9px;
  border-radius: 50%;
  background: var(--steel-800);
  display: block;
}

.board__dots i:first-child {
  background: var(--text-accent);
}

.board__label {
  flex: 1 1 auto;
  min-width: 0;
}

.board__clock {
  font-variant-numeric: tabular-nums;
  flex: none;
}

.board__body {
  padding: var(--space-4);
  color: var(--text-on-dark);
}

.board__foot {
  display: flex;
  gap: 6px;
  padding: 0 var(--space-4) var(--space-4);
  flex-wrap: wrap;
}

.board__foot span {
  font-family: var(--font-data);
  font-size: var(--size-small);
  letter-spacing: 0.06em;
  padding: var(--space-1) var(--space-3);
  border-radius: var(--radius-pill);
  background: var(--steel-900);
  color: var(--text-on-dark-secondary);
}

/* ── A door as a row ──────────────────────────────────────────────────────── */

/*
 * ⚠️ STACKED, NOT A THREE-ACROSS PRICE TABLE. Do not "tidy" this into a grid.
 *
 * Measured, because the reason this was handed down with turned out to be false: three-across is
 * SHORTER than stacked at every width tested (267px vs 588px at 1440, 413px vs 636px at 600), so
 * "the table is taller" is not the argument. The argument is the price COPY — "Price not published
 * yet." occupies 1 line stacked at every width, and 2 lines at 1440, 3 at 900, FIVE at 600 in a
 * column. Two of the three doors carry that sentence. See src/site/door-row.mjs for the table.
 */
.door-row {
  display: block;
}

.door-row .board__body {
  display: grid;
  gap: var(--space-2);
}

.door-row__heading {
  margin: 0;
  font-family: var(--font-display);
}

/*
 * ⚠️ NOT UNDERLINED AT REST, since 2026-08-23. Artie: the door headings "rendered underlined inside
 * the dark board, reading as links that are not links." The template's board headings carry no
 * underline; the whole row is the target and the heading is the thing you read, not a run of link
 * text inside a sentence.
 *
 * ⛔ THE AFFORDANCE IS NOT REMOVED, IT IS MOVED. An underline that appears on hover AND on focus is
 * still a non-colour indication of a link, so 1.4.1 Use of Colour holds for a keyboard user and a
 * pointer user alike. Deleting it outright would leave colour as the only cue, which is the defect
 * this comment exists to stop somebody "simplifying" into.
 */
.door-row__link {
  color: var(--text-on-dark);
  text-decoration: none;
  text-decoration-thickness: 1px;
  text-underline-offset: 3px;
  transition: color 140ms var(--ease-out);
}

.door-row__link:hover,
.door-row__link:focus-visible {
  text-decoration: underline;
}

.door-row__link:focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: 4px;
}

.door-row__evidence {
  margin: 0;
  color: var(--text-on-dark-secondary);
}

.door-row__price {
  margin: 0;
  font-family: var(--font-data);
  font-size: var(--size-small);
  color: var(--text-on-dark-secondary);
}

.door-row__price[data-price-state="published"] {
  color: var(--text-on-dark);
}

/* ── The sample label ─────────────────────────────────────────────────────── */

.sample-note {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: var(--space-2);
  margin: var(--space-3) 0 0;
  font-size: var(--size-small);
  color: var(--text-secondary);
}

.sample-note__tag {
  flex: none;
  font-family: var(--font-data);
  font-size: var(--size-small);
  letter-spacing: 0.1em;
  padding: var(--space-1) var(--space-2);
  border-radius: var(--radius-sm);
  background: var(--surface-sunken);
  color: var(--text-secondary);
}

.sample-note--inline {
  display: inline-flex;
  margin: 0;
}

.sample-note__text {
  flex: 1 1 12rem;
}

/* ==BA:STEP4-BAND-COMPONENTS:END== */

/* ═══ THE FEATURE TICKET ═══════════════════════════════════════════════════════════════════════
 *
 * `src/site/feature-ticket.mjs`. The unit of the `/features` hub and the header of every
 * `/features/<slug>` page — one component at two zoom levels.
 *
 * ⛔ BOARD CHROME, NEVER BOARD CARGO. The mono eyebrow, the steel ground and the rule across the
 * head are site-wide vocabulary and every ticket may wear them. What is NOT here, and must never
 * be added: a `.tk`, a ticket number, an age timer, a bump control. The hub renders gift cards
 * and reservations beside kitchen features, and kitchen-ticket chrome under a gift card is a lie
 * about the product. `assertTicketMarkup()` in `src/content/feature-catalog.mjs` fails the build
 * for anyone who tries.
 *
 * ⚠️ MEASURED LAYOUT TRAP — READ BEFORE CHANGING THE GRID. A three-column row can measure TALLER
 * than a stacked one here, because "Price not published yet." wraps at 390px and a row is as tall
 * as its tallest wrapped cell. That is why `.ftmeta` is a TWO-column grid that collapses to one
 * at 390, and why `.ftprice` is its own flat line underneath instead of a third column.
 * Re-measure at 390×844 before any redesign of this block.
 */

.ftgrid {
  display: grid;
  gap: var(--space-4);
  grid-template-columns: 1fr;
}

@media (min-width: 720px) {
  .ftgrid {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
}

@media (min-width: 1040px) {
  .ftgrid {
    grid-template-columns: repeat(3, minmax(0, 1fr));
    gap: var(--space-6);
  }
}

/* A single full ticket on a feature page is never a column in a three-up grid. */
.ftgrid--single {
  grid-template-columns: 1fr;
  max-width: 46rem;
}

.ft {
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
  padding: var(--space-6);
  border-radius: var(--radius-md);
  background: var(--surface-card);
  /*
   * The card ships BORDERLESS by default and reads as a card through a background shift against
   * the page, per the anti-slop rule. The hairline below is the elevation of last resort and is
   * deliberately `--surface-sunken`, not a grey — a 1px grey rule around a rounded card is the
   * single most recognisable AI-default card in existence.
   */
  box-shadow: inset 0 0 0 1px var(--surface-sunken);
  text-decoration: none;
  color: inherit;
  /* Only transform and opacity animate here, so the motion allowlist in perf-guard passes. */
  transition:
    transform var(--dur-fast) var(--ease-out),
    box-shadow var(--dur-fast) var(--ease-out);
}

/* The whole ticket is the link. There is no second anchor inside it and no second tab stop. */
a.ft--go:hover {
  transform: translateY(-2px);
  box-shadow: inset 0 0 0 1px var(--text-accent);
}

a.ft--go:focus-visible {
  transform: translateY(-2px);
}

@media (prefers-reduced-motion: reduce) {
  /*
   * The movement collapses; the STATE CHANGE stays perceptible. Not a blanket `animation: none` —
   * a hover with no feedback at all is a worse outcome than a hover that does not travel.
   */
  .ft {
    transition: box-shadow var(--dur-fast) var(--ease-out);
  }

  a.ft--go:hover,
  a.ft--go:focus-visible {
    transform: none;
  }
}

/*
 * INERT — gift cards today. Visibly present, honestly incomplete, NOT hidden and NOT a link.
 * It is dimmed to about 78%, which is a deliberate floor: below that the build-state chip stops
 * clearing 4.5:1 and the honest explanation becomes unreadable, which defeats the point of
 * showing it at all.
 */
.ft--inert {
  opacity: 0.78;
  background: var(--surface-sunken);
}

/* The eyebrow is the STATION NAME, never an order number. See the cargo rule above. */
.ftstation {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2);
  margin: 0;
  font-family: var(--font-data);
  font-size: var(--size-label);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-secondary);
}

.ftname {
  margin: 0;
  font-size: var(--size-h3);
  line-height: 1.15;
}

.ftdoes {
  margin: 0;
  color: var(--text-secondary);
}

/*
 * ⛔ THE CAROUSEL SLIDE'S ONE VARYING FIELD, GIVEN THE LARGEST NON-HEADING TYPE ON THE CARD.
 *
 * MEASURED: of the five fields the hub card carries, FOUR are identical on all ten catalog
 * records — the door strip, the price line, and both are constant by construction. Paging the
 * homepage carousel therefore repeated four fields nine times, and the only thing changing under
 * the visitor's eye was the name. Two of the five were dropped from the slide; this is what
 * happens to the one that was always doing the work.
 *
 * `--text-primary`, not `--text-secondary`: at body size in a secondary ink it reads as a caption
 * under the name, which is the role it has on a grid of ten and the wrong one when it is the
 * sentence the slide exists to deliver.
 */
.ftdoes--lead {
  font-size: var(--size-body);
  color: var(--text-primary);
  margin-top: var(--space-2);
}

/*
 * The slide's own card. It has two fewer rows than the hub's, so it would otherwise sit shorter
 * than its neighbours in a three-across window — and a row of three cards at three heights is the
 * thing that reads as broken rather than as varied. `align-items: stretch` on the track already
 * equalises them; this gives the shorter content somewhere to sit inside the taller box.
 */
.ft--slide {
  justify-content: flex-start;
  gap: var(--space-2);
}

.ftdetail {
  margin: 0;
}

.ftwho {
  margin: 0;
  color: var(--text-secondary);
}

.ftwho__label {
  display: block;
  font-family: var(--font-data);
  font-size: var(--size-label);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-nontext);
}

/* Build state. Three states, closed vocabulary, and none of them is a colour-only signal — each
 * chip carries its own words ("Live", "Being drawn up", "Described, not yet built"). */
.ftchip {
  display: inline-block;
  padding: 0 var(--space-2);
  border-radius: var(--radius-pill);
  font-size: var(--size-label);
  letter-spacing: 0.04em;
  background: var(--surface-sunken);
  color: var(--text-secondary);
}

.ftchip--live {
  background: var(--surface-accent-wash);
  color: var(--text-accent);
}

/* Doors. The marks are decorative; the words beside them are what a screen reader reads. */
.ftdoors {
  display: flex;
  gap: var(--space-2);
  margin: 0;
  padding: 0;
  list-style: none;
}

.ftdoor__mark {
  display: inline-block;
  padding: 0 var(--space-2);
  border-radius: var(--radius-sm);
  font-family: var(--font-data);
  font-size: var(--size-label);
  letter-spacing: 0.08em;
  background: var(--surface-sunken);
  color: var(--text-secondary);
}

.ftdoor--off .ftdoor__mark {
  opacity: 0.4;
}

.ftmeta {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--space-2);
  margin: 0;
}

@media (min-width: 420px) {
  .ftmeta {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
}

.ftmeta dt {
  font-family: var(--font-data);
  font-size: var(--size-label);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-nontext);
}

.ftmeta dd {
  margin: 0;
  font-size: var(--size-small);
  color: var(--text-secondary);
}

/*
 * ⛔ THE PRICE LINE. It renders `NO_PRICE` — "Price not published yet." — and there is no numeral
 * in this file. The two real prices ($19.99/mo, $14.99/mo billed yearly) live in
 * `src/content/pricing.mjs` and are published by `/pricing` alone. `$12` and `$9` are RETIRED.
 */
.ftprice {
  margin: 0;
  font-family: var(--font-data);
  font-size: var(--size-small);
  color: var(--text-nontext);
}

.ftgo {
  margin-top: auto;
  padding-top: var(--space-2);
  font-size: var(--size-small);
  font-weight: 600;
  color: var(--text-accent);
}

a.ft--go .ftgo::after {
  content: " \2192";
}

/* States the reason instead of promising a destination. */
.ftgo--inert {
  color: var(--text-nontext);
  font-weight: 400;
}

/*
 * THE STANDING HONESTY NOTE on every level-2 page. It reads at body size, not small print: the
 * add-ons do not reach the kitchen screen yet, and a page that buries that is a page that implies
 * the opposite.
 */
.ftnote {
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
  font-size: var(--size-body);
}

.ftnote__title {
  margin: 0;
}

.ftnote p {
  margin: 0;
}

/* ═══ THE FOLDING BAND ═════════════════════════════════════════════════════════════════════════
 *
 * `Disclosure()` in `src/pages/features.mjs`. Native `<details>`/`<summary>` — no JavaScript, no
 * ARIA, keyboard and screen-reader behaviour supplied by the browser.
 *
 * ⚠️ IT EXISTS BECAUSE OF A MEASUREMENT. The hub rendered every station and every instrument
 * expanded and came out at 13,239px at 390×844 against a 1,194–3,450px ceiling. Folding is what
 * brought it back. Do not replace this with a JS tablist: this site must be complete with JS off,
 * and a scripted tab that shows nothing without JS hides nine of the ten features.
 */

.ftfold {
  border-radius: var(--radius-md);
}

.ftfold__summary {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  padding: var(--space-3) 0;
  cursor: pointer;
  /* The marker is replaced below; `list-style` is the Firefox/standards half of that. */
  list-style: none;
}

.ftfold__summary::-webkit-details-marker {
  display: none;
}

/*
 * The chevron is a CSS triangle, not an icon font and not an emoji — the check refuses emoji as
 * icons, and a glyph here would need a font this site does not load for it.
 */
.ftfold__summary::after {
  content: "";
  flex: none;
  margin-inline-start: auto;
  width: 0.55em;
  height: 0.55em;
  border-right: 2px solid currentColor;
  border-bottom: 2px solid currentColor;
  transform: rotate(45deg);
  transform-origin: center;
  transition: transform var(--dur-fast) var(--ease-out);
}

.ftfold[open] > .ftfold__summary::after {
  transform: rotate(-135deg);
}

@media (prefers-reduced-motion: reduce) {
  /* The rotation is the state change, so it stays — only its travel time collapses. */
  .ftfold__summary::after {
    transition-duration: 1ms;
  }
}

/*
 * A 44×44px target. The summary is the only control on the band and it is the one a thumb hits.
 */
.ftfold__summary > h2 {
  display: flex;
  align-items: center;
  min-height: 44px;
  margin: 0;
}

/* The count on the face of the control, so a thin station states its own size before you open it. */
.ftcount {
  margin-inline-start: var(--space-3);
  padding: 0 var(--space-2);
  border-radius: var(--radius-pill);
  background: var(--surface-sunken);
  color: var(--text-secondary);
  font-family: var(--font-data);
  font-size: var(--size-label);
}

.ftfold__body {
  padding-top: var(--space-4);
}

/* ===========================================================================================
   THE HOMEPAGE'S LOWER BANDS — ported from THE-RAIL-picked.html / source/d1.py STYLE.
   Owner: lane 3. Four bands: `01a` (logo wall + carousel), the doors band, `01o` (the post),
   and the closing CTA.

   ⛔ NOTHING IN THIS BLOCK MAY BE GIVEN A READING MEASURE. The first build of this page put
   every band inside the repo's `Section`, whose body caps at 772px, and at 1440 the right half
   of the desktop was empty (CEO 2026-08-23: "some of the sections are not full-page width").
   The template has exactly two reading caps — `p{max-width:68ch}` and `.lede{max-width:56ch}` —
   and both apply to PARAGRAPHS. A band that is 772px wide is a paragraph rule applied to a
   layout element.

   Every animated property here is `transform`, `opacity` or paint-only (`checks/perf-guard.mjs`
   rejects anything else, including `transition: all`).

   ⚠️ DEPENDS ON LANE 1's shell for `.dsp` / `.d1` / `.lede` / `.btn` / `.stamp` / `.prov` and for
   the `.wrap-wide.railpage` grid + `--size-floor`. Those are site-wide type and layout, defined
   once there, not re-declared per band.
   =========================================================================================== */

/* ── `01a` — the wall of room types ───────────────────────────────────────── */

/*
 * ⚠️ THIS COMMENT WAS STALE AND IT WAS ALSO SERVED. Rewritten 2026-08-25.
 *
 * It described `.logos__note`, a caption that no longer exists — the rule it stated could not be
 * followed and the class it named could not be found. `dist/assets/styles.css` is not minified,
 * so every word in this file is served to visitors and to crawlers as text; a stale comment here
 * is both a wrong map for the next reader and a string on the public record.
 *
 * WHAT THE BAND IS NOW: five wordmarks set as type under `.logos__lbl`, which reads "The kinds of
 * places this is built for". That label is what makes the row a statement about a market, and
 * `labelClaimsRelationship()` in `src/site/room-carousel.mjs` refuses to render the band under a
 * label that claims a relationship instead.
 *
 * ⛔ SO `.logos__lbl` IS LOAD-BEARING COPY, NOT A CAPTION. Styling it down to invisibility leaves
 * five business names floating with nothing framing them, which is the one presentation this band
 * may never have. It keeps the floor size and the mono micro-label treatment on purpose.
 */
.logos {
  padding: var(--space-6) 0 var(--space-8);
}

.logos__lbl {
  text-align: center;
  font-family: var(--font-data);
  font-size: var(--size-floor, 0.9375rem);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink-3);
  margin: 0 0 var(--space-6);
  max-width: none;
}

.logos__row {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-4) var(--space-10);
  justify-content: center;
  align-items: center;
}

/*
 * ⛔ THE RECESSION IS DONE IN COLOUR, NEVER IN OPACITY. Cisco's MAJOR 2
 * (a11y-phase4-2026-08-24) measured this wordmark at 3.27:1 effective in light and 4.06:1 in
 * dark, against a 4.5:1 floor. It read as passing to every eye and to most tools because the
 * DECLARED colour, `--ink-3`, is 5.53:1 — the 0.75 opacity multiplied it down afterwards, and a
 * contrast checker that reads `color` and stops never sees the second step. That is the
 * opacity-blind-contrast trap this program has now hit twice.
 *
 * So the opacity is gone rather than raised, and the colour carries the whole look:
 * `--ink-2` is the site's secondary-text token, 9.07:1 on `--ground` in light and 10.13:1 in
 * dark, and it is still visibly quieter than the `--ink` the hover state moves to. Raising the
 * opacity to 0.9 would also have passed and would have left the multiply in place for the next
 * person to tune down.
 */
.logo {
  font-family: var(--font-display);
  font-variation-settings:
    "wdth" 92,
    "wght" 800;
  font-size: 1.18rem;
  letter-spacing: -0.02em;
  color: var(--ink-2);
  transition: color 220ms var(--ease-out);
}

.logo:hover {
  color: var(--ink);
}

/*
 * ⛔ UNUSED SINCE 2026-08-25. NOTHING RENDERS `.logos__note` AND IT IS LISTED, NOT DELETED.
 *
 * It styled the caption that used to sit under the wordmark row. `LogoWall` in
 * `src/site/room-carousel.mjs` no longer emits the element: the band's honesty now rests on
 * `.logos__lbl` above the names, which frames them as kinds of room rather than as a roster.
 * Deletion is one of the Four Gates, so the rule stays and is labelled instead.
 */
.logos__note {
  text-align: center;
  font-size: var(--size-small);
  color: var(--ink-2);
  margin: var(--space-6) auto 0;
  max-width: 60ch;
}

/* ── `01a` — the seven-room carousel ───────────────────────────────────────── */

.car {
  margin-top: var(--space-6);
}

.car__vp {
  overflow: hidden;
  border-radius: var(--r-lg);
}

/*
 * The whole rotation, in one declaration. `--i` is set by `client/widgets/carousel.js`; the
 * document's horizontal scroll is never touched.
 */
.car__track {
  display: flex;
  transform: translateX(calc(var(--i, 0) * -100%));
  transition: transform 420ms var(--ease-out);
}

.car__slide {
  flex: 0 0 100%;
  min-width: 0;
}

.car__card {
  display: grid;
  grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr);
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  overflow: hidden;
  box-shadow: var(--sh-1);
}

.car__card > * {
  min-width: 0;
}

/*
 * ⚠️ FELICITY'S BINDING RULING, and it is NOT what the template says.
 *
 * The template writes `height:clamp(230px,30vw,360px)`, which re-crops the same photograph
 * differently at every viewport — the food truck's window is in frame at 1440 and gone at 1100.
 * The slide gets its OWN 4/3 box and is constrained by WIDTH. Never re-add a height cap.
 */
.car__img {
  width: 100%;
  max-width: 720px;
  margin: 0;
}

.car__card .photo__img {
  width: 100%;
  height: auto;
  aspect-ratio: 4 / 3;
  object-fit: cover;
  display: block;
}

.car__t {
  padding: clamp(1.1rem, 2.2vw, 1.9rem);
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: var(--space-2);
}

/*
 * The room's name, as a link to that room's page. `.text-link`'s treatment rather than a second
 * inline-link idiom — see the memo in `room-carousel.mjs` for why the heading carries the link and
 * the card is not whole-card clickable.
 *
 * `currentColor` so the heading keeps the card's own type colour and gains only the underline: a
 * seven-card carousel with seven accent-coloured headings reads as seven buttons.
 */
.car__link {
  color: currentColor;
  text-decoration: underline;
  text-underline-offset: 4px;
  text-decoration-thickness: 1px;
  transition: text-decoration-thickness var(--dur-fast) var(--ease-standard);
}

.car__link:visited {
  color: currentColor;
}

.car__link:hover,
.car__link:focus-visible {
  text-decoration-thickness: 2px;
}

.car__t h3 {
  margin: 0;
  font-family: var(--font-display);
  font-variation-settings:
    "wdth" 100,
    "wght" 800;
  font-size: clamp(1.2rem, 2vw, 1.6rem);
}

.car__t p {
  margin: 0;
  color: var(--ink-2);
  font-size: var(--size-body);
  max-width: 38ch;
}

.car__ix {
  font-family: var(--font-data);
  font-size: var(--size-floor, 0.9375rem);
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--accent);
  margin: 0;
  max-width: none;
}

@media (max-width: 820px) {
  .car__card {
    grid-template-columns: minmax(0, 1fr);
  }
}

.car__ctl {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  flex-wrap: wrap;
  margin-top: var(--space-4);
}

.car__b {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-2);
  min-width: 44px;
  min-height: 44px;
  padding: var(--space-2) var(--space-4);
  border-radius: var(--r-pill);
  border: 1px solid var(--line-2);
  background: var(--panel);
  color: var(--ink);
  cursor: pointer;
  font-family: var(--font-data);
  font-size: var(--size-floor, 0.9375rem);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  transition:
    transform 140ms var(--ease-out),
    border-color 140ms var(--ease-out),
    color 140ms var(--ease-out);
}

.car__b:hover {
  transform: translateY(-2px);
  border-color: var(--accent);
  color: var(--accent);
}

.car__b:active {
  transform: scale(0.96);
}

.car__b svg {
  width: 11px;
  height: 12px;
  flex: 0 0 auto;
}

.car__count {
  font-family: var(--font-data);
  font-size: var(--size-floor, 0.9375rem);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--ink-3);
  margin: 0 var(--space-1);
  max-width: none;
}

.car__dots {
  display: flex;
  gap: 2px;
  margin-left: auto;
  flex-wrap: wrap;
}

/* 44x44 is the TAP TARGET; the 9px disc inside it is the only thing that is drawn. */
.car__dot {
  width: 44px;
  height: 44px;
  display: grid;
  place-items: center;
  background: none;
  border: 0;
  padding: 0;
  cursor: pointer;
  border-radius: 50%;
}

.car__dot i {
  width: 9px;
  height: 9px;
  border-radius: 50%;
  background: var(--line-2);
  display: block;
  transition:
    transform 180ms var(--ease-pop),
    background-color 180ms var(--ease-out);
}

.car__dot:hover i {
  transform: scale(1.4);
  background: var(--accent);
}

.car__dot[aria-current="true"] i {
  background: var(--accent);
  transform: scale(1.65);
}

@media (max-width: 520px) {
  .car__dot {
    width: 38px;
    height: 44px;
  }

  .car__dots {
    margin-left: 0;
  }
}

@media (prefers-reduced-motion: reduce) {
  .car__track {
    transition: none;
  }
}

/* ── The doors band — the steel slab ──────────────────────────────────────── */

/*
 * ⚠️ `--bleed-r` / `--bleed-l` ARE MEASURED IN JS AND DEFAULT TO ZERO.
 * `calc(50% - 50vw)` is BANNED here: the content track is offset by the rail, so it overshoots by
 * the rail's width and pushes the document 125px past the viewport (measured at 1440). `100vw`
 * also includes the scrollbar. See `client/widgets/steel-band.js`.
 * With no JavaScript both are `0px` and the band fills its track exactly — correct, just unbled.
 */
.band {
  background: var(--steel-950);
  color: var(--paper-base);
  position: relative;
  border-radius: var(--r-xl) 0 0 var(--r-xl);
  margin-right: calc(-1 * var(--bleed-r, 0px));
  padding: clamp(3rem, 6vw, 5rem) 0 0;

  /*
   * ⛔ THE COLLISION FIX (CEO saw it, 2026-08-24). `.band` declared NO `margin-top` at all.
   *
   * It is not a `.stopsec`, so the `.stopsec + .stopsec` rhythm rule never reached it — that is
   * the whole bug, and `.cta` (which DOES declare `margin-top: clamp(3rem,6vw,5.5rem)`) is the
   * comparison that proves the band was simply missed rather than deliberately flush.
   *
   * Then `.band__esc` lifts the door column above the band's own top edge, so the band needs
   * clearance equal to that lift or the doors land in whatever is above — the carousel.
   *
   * `--esc-lift` IS THE RELATIONSHIP, and it is declared once here and consumed once by
   * `.band__esc`. The number used to be typed as a bare `2.5rem` in the negative margin with
   * nothing above compensating for it; two places, one of which nobody would think to change.
   */
  --esc-lift: 2.5rem;

  /*
   * THE SAME RELATIONSHIP AT THE OTHER END. `.band__esc` also hangs BELOW the band's bottom edge
   * (`margin-bottom: -1rem`, and -3rem below 940). One band could get away with that: the page
   * ended there. Two stacked door containers cannot — measured at 768/390/320, door 02's card
   * overhung door 03's slab by 72px and sat ON it. The band reserves what its escape hangs by.
   */
  --esc-drop: 2.5rem;

  margin-top: var(--esc-lift);

  /*
   * `--esc-drop` is the MEASURED overhang, not the esc's declared negative margin — the two differ
   * because the escape is pulled up 120px first and the band's height is settled by what is left.
   * Measured with `getBoundingClientRect` at all five widths: 40px at 1440/1024, 72px at
   * 768/390/320. Re-measure before changing either number; do not derive it on paper.
   *
   * `+ --esc-lift` is the visible gap that survives after the overhang is paid for. Without it the
   * reservation exactly equals the overhang and the two bands render flush — legal, and it reads
   * as one slab with a seam rather than as two doors.
   */
  margin-bottom: calc(var(--esc-drop) + var(--esc-lift));

  /*
   * ⛔ `flow-root`, AND IT IS THE FIX RATHER THAN A TIDY-UP. NOT `overflow: hidden` — that CLIPS
   * the escape block, which is the measured 1024 defect already recorded above this rule.
   *
   * `.band` has no bottom padding, so `.band__esc`'s negative bottom margin COLLAPSED THROUGH the
   * band and combined with the next band's top margin: 40px of reservation plus a -16px child
   * margin resolved to 23px of real clearance against a 24px overhang, and door 02's card sat on
   * door 03's slab by a pixel. Measured, at all five widths.
   *
   * `flow-root` gives the band its own block formatting context, so the escape's margin belongs to
   * the band and `--esc-drop` reserves a number that is actually the number.
   */
  display: flow-root;

  /*
   * ⚠️ EXPLICIT, AND IT IS A FIX RATHER THAN A DEFAULT. `_RAIL-SHELL-FINAL.css:401` declares
   * `.band{overflow:hidden}`. With that in force the escape block is CLIPPED at the band's top
   * edge — measured at 1024: door 02's whole board bar disappeared, and it disappeared silently,
   * because a clipped element still measures at its full size. The band must not clip; the thing
   * that gives it a straight bleeding edge is the border-radius, not an overflow rule.
   */
  overflow: visible;
}

@media (max-width: 1040px) {
  .band {
    margin-left: calc(-1 * var(--bleed-l, 0px));
    border-radius: 0;
  }
}

.band__in {
  display: grid;
  grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.15fr);
  gap: clamp(1.4rem, 4vw, 3rem);
  align-items: end;
  padding-left: clamp(1.2rem, 3vw, 2.6rem);
  padding-right: clamp(1.2rem, 3vw, 2.6rem);
}

@media (max-width: 940px) {
  .band__in {
    grid-template-columns: minmax(0, 1fr);
  }
}

.band h2 {
  color: var(--paper-base);
  max-width: 15ch;
}

/*
 * ══ THE DOOR CONTAINERS ═══════════════════════════════════════════════════
 *
 * One `.band` per door, each carrying a photograph, replacing the single band that held two door
 * rows and NO image — `grep -c "Photo|<img>" steel-band.mjs` was 0, so no picture could ever
 * render there. CEO D4, 2026-08-24.
 */

/*
 * The intro block runs full-width: it is one paragraph and a key strip, and putting it in a
 * two-column grid would leave a hole where a picture is not allowed to go (MR-R-3724).
 */
.band--intro {
  /*
   * THE SITE'S OWN RHYTHM, at every width. `--esc-lift` is clearance for an escape block and the
   * intro has none; what it needs is the gap every other band gets from `.stopsec + .stopsec` and
   * that `.cta` declares directly. Same value, deliberately — this is the rule `.band` was missing.
   */
  margin-top: clamp(3rem, 6vw, 5.5rem);
}

.band__in--intro {
  grid-template-columns: minmax(0, 1fr);
}

.band__in--intro .band__txt p {
  max-width: 60ch;
}

/*
 * The figure cell. `align-items: end` on `.band__in` is right for copy-beside-doors and wrong for
 * copy-beside-photo — a short paragraph would float off the picture's foot — so the door grid
 * aligns to the top instead.
 */
.band__in--door {
  /*
   * CENTRED, not top-aligned. The copy column is one short display heading and the picture is
   * ~380px tall, so `start` left a 350px void under two words — measured at 1440, and it read as a
   * missing paragraph rather than as space. It IS a missing paragraph: the door's sentence and its
   * evidence both live in the card below, because `DoorRow` prints them and this lane may not
   * change that file. Centring is the honest interim; the real fix is a compact `DoorRow` that
   * prints its label only. Owner: the integration lane. See the note on `DoorContainer`.
   */
  align-items: center;
  padding-bottom: clamp(1.4rem, 3vw, 2.4rem);

  /* Copy narrow, picture wide. The base `.band__in` ratio was tuned for copy-beside-DOORS. */
  grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.15fr);

  /*
   * ⚠️ THE BAND BLEEDS RIGHT, SO ITS OWN RIGHT PADDING IS OFF-SCREEN. `.band` carries
   * `margin-right: calc(-1 * var(--bleed-r))`, which is the point of the slab — but it means a
   * child laid out to the padding box ends past the viewport edge. Measured at 1440: the
   * photograph's right rounded corner was outside the window and the picture read as a cut-off
   * texture. Add the bleed back for the door grid only; the intro block has nothing at that edge.
   */
  padding-right: calc(clamp(1.2rem, 3vw, 2.6rem) + var(--bleed-r, 0px));
}

/*
 * MIRRORED, and the TRACKS mirror with the content. `order` alone swapped which column each child
 * sat in and left the wide track on the copy — measured at 1440, door 03's photograph rendered
 * ~460px against door 02's ~580px and the two doors stopped looking like peers, which is the exact
 * thing the mirror exists to prevent.
 */
.band--right .band__in--door {
  grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.85fr);
}

/*
 * ⛔ THE UPWARD ESCAPE IS RETIRED FOR A DOOR CONTAINER, AND THIS IS THE REASONING, NOT A TIDY-UP.
 *
 * The lift (`-1 * band top padding - --esc-lift`) was written when `.band__esc` was the RIGHT
 * COLUMN of `.band__in`, top-aligned beside the copy: cancelling the band's top padding put the
 * card level with the slab's edge and `--esc-lift` pushed it half out. That is the visual idea the
 * rule above defends, and it was correct for that structure.
 *
 * After the split the photograph occupies that column and the card is a full-width block BELOW the
 * grid. The same formula now lifts it 120px into the picture instead of out of the slab — measured
 * at 1440, the card's top edge landed exactly on the photograph's bottom and hid its two lower
 * rounded corners. The card still escapes; it escapes the slab's BOTTOM edge, by `--esc-drop`.
 *
 * `--esc-lift` and `.band { margin-top: var(--esc-lift) }` stay: that is the named relationship the
 * fix is built on, and it still governs any band whose escape lifts upward. A door container's
 * does not, so it reserves the site's normal rhythm instead and the collision the CEO saw is
 * structurally impossible rather than merely paid for.
 */
.band--door {
  margin-top: clamp(3rem, 6vw, 5.5rem);
}

.band--door .band__esc {
  margin-top: 0;
}

/* The escape block sits in the bled slab too, and the card must not run off the same edge. */
.band__esc {
  padding-left: clamp(1.2rem, 3vw, 2.6rem);
  padding-right: calc(clamp(1.2rem, 3vw, 2.6rem) + var(--bleed-r, 0px));
}

.band__fig {
  min-width: 0;
  border-radius: var(--r-lg);
  /* The photo is the only thing here that must not bleed past its own corners. */
  overflow: hidden;
  box-shadow: var(--sh-2);
}

.band__fig .photo,
.band__fig .photo__img {
  display: block;
  width: 100%;
  height: auto;
}

/*
 * ⛔ THE MIRROR IS AN ARGUMENT, NOT DECORATION. Door 03 (BYOW) is door 02's PEER, not the next
 * item in a list — the CEO's ruling that neither door is the lesser one (2026-08-23 pt5). Two
 * identically-ordered containers read as 02 then "04 repeated"; flipping the grid is how the
 * layout says they are two of the same rank.
 *
 * `order` rather than a reversed `grid-template-columns`, so the DOM order stays copy-then-picture
 * and a screen reader hears the door before its illustration at every width.
 */
.band--right .band__in--door > .band__txt {
  order: 2;
}

.band--right .band__in--door > .band__fig {
  order: 1;
}

/* One column below 940 (`.band__in` already collapses); `order` must not survive the collapse or
 * the picture would jump above the heading it illustrates. */
@media (max-width: 940px) {
  .band--right .band__in--door > .band__txt,
  .band--right .band__in--door > .band__fig {
    order: 0;
  }
}

/* The door heading is an h2 inside a container, not the page's own title — 15ch is a display
 * measure for "Two ways in." and truncates a full sentence into a column. */
.band--door .band__txt h2 {
  max-width: 22ch;
}

/*
 * ⚠️ SCOPED TO THE COPY COLUMN. NEVER WRITE A BARE `.band p`.
 * The template's own defect log (L841-843): a bare selector also matched `.chip__k` inside the
 * board's floating chips and blew them from 9.6px to 16.3px — same specificity, later rule wins.
 */
.band__txt p {
  color: var(--ember-100);
  font-size: 1.02rem;
  margin: var(--space-4) 0 0;
  max-width: 42ch;
}

.band .stamp {
  color: var(--ember-300);
}

.band .stamp i {
  background: var(--ember-300);
}

/*
 * THE NEGATIVE TOP MARGIN IS THE BAND'S WHOLE VISUAL IDEA. The doors sit half out of the slab.
 * Remove it and this is a plain dark box with two cards in it. Do not "tidy" it.
 *
 * ⚠️ THE NUMBER IS NOT THE TEMPLATE'S, AND IT IS EXPRESSED AS A RELATIONSHIP RATHER THAN A GUESS.
 *
 * The template lifts by `clamp(-9rem,-7vw,-4rem)`, which is a value tuned against ONE board of a
 * particular height. This band escapes TWO stacked door rows, and both failure modes were
 * measured at 1440: at -9rem the first door's bar rendered ABOVE the band's own top edge (the card
 * escaped the thing it was meant to be escaping FROM), and at -3.5rem it did not clear the band's
 * 86px top padding and did not escape at all.
 *
 * So the lift is stated as what it MEANS: cancel the band's own top padding, then rise 2.5rem
 * past it. That is the same overlap at every width and it stays correct if either the padding or
 * the doors change height, which a tuned constant does not.
 */
.band__esc {
  position: relative;
  z-index: 3;

  /* Cancel the band's own top padding, then rise by `--esc-lift` past it. The band reserves
   * exactly `--esc-lift` above itself, so the overlap is internal and nothing above is hit. */
  margin-top: calc(-1 * clamp(3rem, 6vw, 5rem) - var(--esc-lift));
  margin-bottom: calc(-1 * var(--esc-drop));
  align-self: start;
  display: grid;
  gap: var(--space-4);
}

@media (max-width: 940px) {
  .band__esc {
    /* No upward escape below 940 — the grid is one column and there is nothing to escape past. */
    margin-top: 0;
  }

  .band--door {
    /* VERIFIED, not assumed: with the lift zeroed above, the clearance a DOOR container reserves
     * for it is unspent, and leaving it in place would open a gap nothing is using.
     *
     * ⛔ SCOPED TO `.band--door`, NOT `.band`. The intro block has no escape block to zero, so
     * zeroing its top margin gave it no clearance at all and it butted the carousel above —
     * measured 0px at 768, 390 and 320, which is the CEO's original complaint in miniature. */
    margin-top: 0;

    /* The overhang GROWS below 940 — the card tucks harder under a one-column band. Measured 72px
     * at 768, 390 and 320. This is the overlap that put door 02's card ON door 03's slab. */
    --esc-drop: 4.5rem;
  }
}

.band__foot {
  padding: 3.6rem clamp(1.2rem, 3vw, 2.6rem) 2.2rem;
}

/*
 * ⚠️ SELECTOR WRITTEN AS `.band p.band__k` ON PURPOSE — this is the `.band p` collision, live.
 *
 * `_RAIL-SHELL-FINAL.css:404` ships a bare `.band p{max-width:44ch}`, which is the unscoped
 * selector the template's own defect log warns about (L841-843). It outranks a plain `.band__k`
 * (0,1,1 against 0,1,0), and the key strip wrapped onto four lines at 1440 while measuring as a
 * full-width element. Raising specificity here is the local defence; the real fix is scoping that
 * rule to `.band__txt p` in the shell, which is flagged to lane 1.
 */
.band p.band__k {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2) var(--space-6);
  font-family: var(--font-data);
  font-size: var(--size-floor, 0.9375rem);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--ember-300);
  margin: 0;
  max-width: none;
}

/*
 * The door row's heading now lives in the board bar (see door-row.mjs's duplicated-heading fix),
 * so the bar has to carry heading weight rather than micro-label weight.
 */
.door-row h3.board__label {
  font-family: var(--font-display);
  font-variation-settings:
    "wdth" 100,
    "wght" 700;
  font-size: var(--size-h4);
  letter-spacing: -0.01em;
  text-transform: none;
  color: var(--text-on-dark);
  margin: 0;
}

.door-row .board__label-link {
  color: inherit;
  text-decoration-thickness: 1px;
  text-underline-offset: 3px;
  transition: color 140ms var(--ease-out);
}

.door-row .board__label-link:hover {
  color: var(--accent-hi);
}

.door-row .board__label-link:focus-visible {
  outline: 2px solid var(--focus-ring-on-dark, var(--ember-400));
  outline-offset: 4px;
}

/* ── `01o` — the invented post card (one card; the strip's own rules are below) ──── */

.post {
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: var(--r);
  padding: var(--space-4);
  box-shadow: var(--sh-1);
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
  max-width: 440px;
  margin: var(--space-6) 0 0;
  transition: transform 280ms var(--ease-out);
}

/* The tilt is the personality. Transform only, so it costs one composited frame. */
.post:hover {
  transform: translateY(-4px) rotate(-0.4deg);
}

.post__top {
  display: flex;
  align-items: center;
  gap: var(--space-2);
}

.who__face {
  width: 42px;
  height: 42px;
  border-radius: 50%;
  flex: 0 0 auto;
  background: var(--grad-ember);
  display: grid;
  place-items: center;
  color: #fff;
  font-family: var(--font-display);
  font-variation-settings:
    "wdth" 100,
    "wght" 800;
  font-size: 0.95rem;
}

/*
 * THE PHOTO VARIANT of the avatar circle. CEO ruling 2026-08-24 (RULINGS amendment 20) replaced
 * the two-letter monogram with real faces. The circle's own box is UNCHANGED at 42x42 — the
 * measurement Artie took at both 390 and 1440 before the swap — so nothing on the card reflows;
 * only what fills the circle changed. `display: grid` and the ember gradient are dropped here
 * because a photograph needs neither, and the gradient would show through a transparent edge.
 */
.who__face--photo {
  background: var(--surface-sunken, #e9edf1);
  overflow: hidden;
  padding: 0;
}

.who__face--photo picture,
.who__face--photo img {
  display: block;
  width: 100%;
  height: 100%;
  border-radius: 50%;
  object-fit: cover;
  object-position: center 40%;
}

.who__n {
  font-family: var(--font-display);
  font-variation-settings:
    "wdth" 100,
    "wght" 700;
  font-size: var(--size-small);
  line-height: 1.2;
  display: block;
}

.who__r {
  font-size: var(--size-small);
  color: var(--ink-3);
}

.post__body {
  margin: 0;
  color: var(--ink);
  border: 0;
  padding: 0;
}

.post__body p {
  margin: 0;
  font-size: var(--size-body);
}

.post__foot {
  display: flex;
  gap: var(--space-4);
  font-family: var(--font-data);
  font-size: var(--size-small);
  color: var(--ink-3);
  margin-top: auto;
  padding-top: var(--space-2);
  border-top: 1px solid var(--line);
}

.post__foot button {
  background: none;
  border: 0;
  padding: var(--space-2) 0;
  min-height: 44px;
  font: inherit;
  color: inherit;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
  transition:
    color 180ms var(--ease-out),
    transform 180ms var(--ease-pop);
}

.post__foot button:hover,
.post__foot button[aria-pressed="true"] {
  color: var(--accent);
}

.post__foot button:active {
  transform: scale(0.92);
}

/* "Nothing is sent." It is the answer to what those two buttons do, so it sits with them. */
.post__nothing {
  margin: 0;
  font-size: var(--size-small);
  color: var(--ink-3);
}

/*
 * ⛔ UNUSED SINCE 2026-08-25. NOTHING RENDERS `.fake` AND IT IS LISTED, NOT DELETED.
 *
 * It styled a caption that the 2026-08-25 ruling removed. Kept for one specific reason: this
 * stylesheet declares `.fake` TWICE and the later declaration wins on source order — a real bug
 * that once rendered the caption in the wrong colour. Removing one of two colliding declarations
 * while nothing renders either is a change no screenshot can verify, so both stay until the class
 * itself goes. Deletion is one of the Four Gates.
 *
 * The floor size stays on it: whatever ever wears this class, it is never below `--size-floor`.
 */
.fake {
  font-family: var(--font-data);
  font-size: var(--size-floor, 0.9375rem);
  letter-spacing: 0.06em;
  line-height: 1.45;
  text-transform: uppercase;
  color: var(--ink-2);
  margin: var(--space-4) 0 0;
  max-width: 60ch;
}

/* ── `01o` — THE FIVE REVIEWS ON THE ROTATOR ──────────────────────────────── */

/*
 * ⛔ THIS BLOCK EXISTS TO FILL A MEASURED HOLE, and the measurement is the whole justification.
 *
 * RULINGS.md amendment 16 (CEO 2026-08-24): "about five reviews on a rotating carousel instead of
 * just the one to fill up that dead space". Measured on the shipped page at 1440 before the
 * change: the card was 440px wide inside an 1150px track, so 710px of the proof strip — 62% of
 * its width — was empty, and the card was ~470px tall in a band that had room for more. Five
 * cards on the shared rotator answers the "five reviews" half. THIS answers the "dead space" half,
 * and without it the change is one card in the same void with dots underneath it.
 *
 * ⚠️ SCOPED TO `.rot--proof`, NEVER TO `.post`. `.post` is shared vocabulary — the segment pages
 * use it too — so widening it globally would relayout more than one surface to fix one. Every
 * selector below is a descendant of the instance class the rotator was given. (This line used to
 * name a third consumer, a module deleted on 2026-08-25; the scoping rule is unchanged.)
 *
 * ⚠️ THE CARD FILLS THE TRACK; THE PROSE DOES NOT. `max-width: none` lets the panel span the
 * band, and the two-column grid above 900px puts the reading matter in one column and the
 * disclaimer in the other — so the width is carrying content rather than being carried by a
 * single 1150px line of text, which is unreadable and is what "just make it wider" produces.
 */
.rot--proof .post {
  /* The 440px cap is the single-card measure and it is what left the hole. */
  max-width: none;
  /*
   * ⚠️ BORDERLESS, ON A BACKGROUND SHIFT. Two reasons, and the second one is a bug.
   *
   * The house rule is whitespace first, then a 3-5% background shift, and a 1px grey border only
   * if both fail (ANTI-SLOP-RULEBOOK, the untouched-shadcn-card ban). `--panel` is #ffffff on a
   * #ffffff page, so at 440px the border WAS doing the work; at full track width the shift does
   * it better and the panel reads as a surface rather than as an outlined box.
   *
   * And the border could not survive here anyway: `.rot__vp` is `overflow: hidden` at exactly the
   * slide width, so the card's left and right edges sit ON the clip and render as half a hairline
   * or none at all depending on the subpixel. A border that is present in the CSS and invisible in
   * the render is worse than no border, because the next reader trusts it.
   */
  background: var(--ground-2);
  border: 0;
  box-shadow: none;
  /* `.post` carries `margin: var(--space-6) 0 0`; inside a slide the strip owns the spacing. */
  margin: 0;
  /* Fill the slide, so five cards of different lengths present one steady box instead of a
   * panel that grows and shrinks under the reader every eight seconds. */
  height: 100%;
  padding: var(--space-6);
}

/*
 * The slide is a flex box so the card can be told to fill it. The track already equalises slide
 * HEIGHTS (`display: flex` on `.rot__track`, default `align-items: stretch`); this is what passes
 * that height down to the panel inside.
 */
[data-rotator][data-rotator-ready="true"].rot--proof .rot__slide {
  display: flex;
}

/*
 * ⛔ NO TILT ON THIS INSTANCE. `.post:hover` rotates the card -0.4deg, which is good personality
 * on a 440px card sitting still and is a full-width panel visibly skewing inside a clipping
 * viewport here — the rotate is applied to the same element the track is translating.
 */
.rot--proof .post:hover {
  transform: none;
}

/*
 * ═══════════════════════════════════════════════════════════════════════════════════════════════
 * ⛔ THE TWO-COLUMN CARD IS RETIRED, AND IT WAS CORRECT FOR THE LAYOUT IT WAS BUILT FOR.
 *
 * This block used to open `@media (min-width: 900px)` and turn each card into a
 * `1.4fr / 0.6fr` grid with the disclaimer running down a second column. That was the right answer
 * to ONE card filling a 1150px track: a single 1150px line of prose is unreadable, so the width had
 * to be spent on something.
 *
 * The CEO's 2026-08-25 instruction removes the premise — "little containers, not full-length,
 * three on a page, rotating the other two in and out". At three across, a card is ~340px. A
 * two-column grid inside 340px gives an 18rem minimum second column and a first column of nothing,
 * so the grid does not merely stop helping, it collapses the quote. The card goes back to a single
 * column and the width is carried by the STRIP rather than by the card.
 *
 * The disclaimer that used to occupy that second column is gone from the card entirely — CEO
 * ruling, RULINGS amendment 22: a stamp on each card, the full sentence once on the frame.
 * ═══════════════════════════════════════════════════════════════════════════════════════════════
 */

/*
 * ⛔ NO FRAME, NO STAMP, NO CLEARANCE RULE — ALL THREE DELETED 2026-08-25 WITH WHAT THEY STYLED.
 *
 * ⚠️ THIS STYLESHEET SHIPS ITS COMMENTS. `dist/assets/styles.css` is not minified, so every word
 * here is served to every visitor — which is why the removed copy is DESCRIBED below rather than
 * quoted. The exact strings are in the commit and in `src/site/proof-card.mjs`, a build-time
 * module that never reaches the browser.
 *
 * This span used to carry `.reviewframe` (a 1px bordered box), `.reviewframe__note` (the strip's
 * one-line disclosure), `.post .post__stamp` (the ember corner stamp each card wore) and a
 * `padding-inline-end: 5.5rem` on the card head so a social handle would not run under that stamp.
 *
 * Every one of them existed to present or to accommodate a disclosure, and the band no longer
 * makes a claim that needs one — see `site/proof-card.mjs`. The frame's own comment argued that a
 * border was the right answer here BECAUSE "whitespace cannot say 'this sentence governs these
 * three cards'". There is no such sentence now, so the argument expired with its subject and the
 * house order applies again unmodified: whitespace, then a 3-5% background shift, then a border
 * only if both fail. The cards carry the shift (`--ground-2`) and both do not fail.
 *
 * ⚠️ `.fake` IS LEFT IN PLACE ON PURPOSE and is now unused by any rendered element. It is declared
 * TWICE in this file and the later declaration wins on source order — the bug that once rendered
 * the stamp grey instead of ember. Removing one of two colliding declarations while nothing
 * renders either is a change no screenshot can verify, so it is left for whoever removes the
 * class itself.
 */

/*
 * The card's own measure. ~340px inside a ~384px slide at 1440, which is the "little container"
 * the CEO asked for — and centred, so the leftover width lands as gutter on both sides rather than
 * as a ragged right edge.
 */
.rot--proof .post {
  max-width: 340px;
  margin-inline: auto;
}

/*
 * ⛔ THE CARD LINK IS A 44px TARGET AND THAT IS A FLOOR, NOT A PREFERENCE.
 *
 * It is the only focusable control left on the card — the Like and Share toggles went with the
 * band this one replaced — so it is also the only thing a keyboard or touch user can reach. It sits
 * in `.post__foot`, which is a flex row, so `min-height` alone would not give it the height: the
 * item stretches to the row and the TEXT is what gets pressed. `display: inline-flex` plus
 * `align-items: center` makes the anchor's own box the target.
 */
.post__link {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  color: inherit;
  text-decoration-thickness: 1px;
  text-underline-offset: 0.25em;
  transition: color 180ms var(--ease-out);
}

.post__link:hover {
  color: var(--accent);
}

/*
 * ⛔ THE QUOTE GETS A FLOOR, NOT A CAP, AND THIS IS THE ONE MEASUREMENT THIS BLOCK CANNOT DROP.
 *
 * Five quotes of different lengths in three side-by-side boxes is three cards of three heights.
 * The track's `align-items: stretch` equalises the SLIDES; this passes that height into the panel
 * so the three read as one row. Without it the strip's bottom edge moves every time it advances,
 * which is the "panel that grows and shrinks under the reader" the single-card version fixed and
 * would have re-opened at three-up.
 */
.rot--proof .post__body {
  max-width: none;
}

/* ── `01v` — the closing CTA ──────────────────────────────────────────────── */

.cta {
  background: var(--grad-ember);
  color: #fff;
  border-radius: var(--r-xl);
  padding: clamp(2.2rem, 5vw, 3.4rem);
  text-align: center;
  box-shadow: var(--sh-3);
  margin-top: clamp(3rem, 6vw, 5.5rem);
}

.cta h2 {
  color: #fff;
  margin: 0 0 var(--space-4);
}

.cta p {
  color: rgb(255 255 255 / 92%);
  margin: 0 auto var(--space-6);
  max-width: 52ch;
}

.cta .btn {
  background: #fff;
  color: var(--ember-800);
  box-shadow: 0 10px 26px -12px rgb(0 0 0 / 50%);
}

.cta .btn:hover {
  background: #fff;
  transform: translateY(-2px);
}

/*
 * ⚠️ EMBER CAP. The full gradient band measures ~38% ember on a 900px viewport against a ~10%
 * accent ceiling, so `.cta--steel` is the DEFAULT variant on any page that has not spent its
 * accent budget elsewhere. Steel ground, ember button.
 */
.cta--steel {
  background: var(--grad-steel);
}

.cta--steel .btn {
  background: var(--accent);
  color: var(--accent-ink);
}

.cta--steel .btn:hover {
  background: var(--accent-hi);
}

/* ═══════════════════════════════════════════════════════════════════════════════════════════════
 * THE HOMEPAGE BANDS — `RAIL 01`, `RAIL 01a` and the two doors. Added 2026-08-23.
 *
 * These read the MOCKUP'S token vocabulary (`--r-*`, `--sh-*`) rather than the shipped site's
 * (`--radius-*`, `--elev-*`). That is deliberate and it is what `tokens.css:240-253` describes:
 * two vocabularies coexist, and a band that moves to the ratified rounding moves VISIBLY, in its
 * own diff, rather than by aliasing the tokens and silently re-rounding all fifty-five pages.
 *
 * ⛔ EVERY TRANSITION AND ANIMATION BELOW IS `transform`, `opacity` OR PAINT-ONLY.
 * `src/checks/perf-guard.mjs:26-47` rejects anything else and rejects `transition: all` outright.
 * The one place that bit is the hand-drawn ellipse: the mockup draws it with `stroke-dashoffset`,
 * which is NOT in the allowlist, so it is drawn here with `transform: scaleX()` on a clipping
 * wrapper instead. Same effect, compositor-only, no layout property in motion.
 * ═══════════════════════════════════════════════════════════════════════════════════════════════ */

/* ── The rotator shell. One engine, two instances on `/`. ─────────────────────────────────── */

/*
 * ⛔ SCOPED ON THE ATTRIBUTE, NOT ON `.rot`, AND THAT IS NOT A STYLE PREFERENCE.
 *
 * These rules read `[data-rotator]` rather than `.rot` because a caller may give the region the
 * TEMPLATE'S root class instead — the hero's root is `.stage`, per blueprint rule 2. Written as
 * `.rot[data-rotator-ready]` the flex track never applied to the hero, and its three slides
 * stacked VERTICALLY inside the panel: measured at 390, slide 1 at y 605→796 and slide 2 at
 * y 828→1023, both fully on screen. MR-R-3772 caught it; nothing else could.
 *
 * ⛔ IT FAILS OPEN, AND THIS IS THE SELECTOR THAT DOES IT.
 *
 * At rest — no JavaScript, bundle 404, observer never fires — there is no clipping viewport, no
 * flex track, and no control row. Every slide is stacked and readable and no dead button is
 * offered. The carousel behaviour is scoped entirely to `[data-rotator-ready="true"]`, an
 * attribute `src/client/widgets/rotator.js` sets as the LAST thing it does.
 *
 * The mockup does the reverse (clips in CSS, hides slides at rest), which turns a JavaScript
 * failure into a blank box on the front door.
 */
.rot__slide + .rot__slide {
  margin-top: var(--space-8);
}

.rot__ctl {
  display: none;
}

[data-rotator][data-rotator-ready="true"] .rot__vp {
  overflow: hidden;
  border-radius: var(--r-lg);
}

[data-rotator][data-rotator-ready="true"] .rot__track {
  display: flex;
  /* TRANSFORM ONLY. The document's horizontal scroll is never touched — the track slides inside a
   * fixed viewport, which is also why a 320px reflow cannot be broken by the slide count. */
  transform: translateX(calc(var(--i, 0) * -100% / var(--per, 1)));
  transition: transform var(--dur-rail) var(--ease-out);
}

[data-rotator][data-rotator-ready="true"] .rot__slide {
  flex: 0 0 calc(100% / var(--per, 1));
  min-width: 0;
  margin-top: 0;
}

/*
 * ═══════════════════════════════════════════════════════════════════════════════════════════════
 * THE VISIBLE WINDOW — how many slides are on screen at once.
 *
 * ⛔ THESE TWO WIDTHS ARE THE ONLY PLACE IN THE CODEBASE THAT KNOWS THEM. `client/widgets/
 * rotator.js` reads the resulting `--per` back out of `getComputedStyle` rather than running its
 * own `matchMedia`, because a second copy of 768 and 1041 produces the worst bug this widget has
 * available: at one width the CSS shows three cards while the script believes there is one, so two
 * cards are visible on screen AND `inert` AND `aria-hidden` — unreachable by keyboard, absent from
 * the accessibility tree, and completely correct-looking in a screenshot.
 *
 * ⚠️ `--per` IS ONLY DECLARED ON AN INSTANCE THAT ASKED FOR A WINDOW. The hero, the seven-room
 * strip and every other caller emit no `data-rotator-per-view`, never match these rules, fall
 * through to the `var(--per, 1)` defaults above, and render byte-identical markup and behaviour.
 *
 * THE COUNTS COME FROM THE COMPONENT, inline, as `--per-base` / `--per-md` / `--per-lg`. Counts
 * are a property of the cargo — three feature tickets fit where three review cards do — and widths
 * are a property of the layout. Each lives with the thing it belongs to.
 * ═══════════════════════════════════════════════════════════════════════════════════════════════
 */
[data-rotator][data-rotator-per-view] {
  --per: var(--per-base, 1);
}

@media (min-width: 768px) {
  [data-rotator][data-rotator-per-view] {
    --per: var(--per-md, 1);
  }
}

@media (min-width: 1041px) {
  [data-rotator][data-rotator-per-view] {
    --per: var(--per-lg, 1);
  }
}

/*
 * THE GUTTER IS PADDING ON THE SLIDE, NOT `gap` ON THE TRACK, and the reason is arithmetic rather
 * than preference. A flex `gap` is not part of the `100% / var(--per)` basis, so every card would
 * be a fraction of a gap too wide and the track's `-100% / var(--per)` step would drift further
 * out of true with each advance — visible as a creeping misalignment by the third press, which is
 * exactly the sort of defect that survives a screenshot review. Padding is inside the basis, so
 * the arithmetic stays exact at every window size.
 */
[data-rotator][data-rotator-per-view][data-rotator-ready="true"] .rot__slide {
  padding-inline: var(--space-3);
}

[data-rotator][data-rotator-ready="true"] .rot__ctl {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  flex-wrap: wrap;
  margin-top: var(--space-4);
}

.rot__b {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-2);
  /* 44x44 minimum. A carousel control is the one a thumb hits while the thing under it is moving. */
  min-width: 44px;
  min-height: 44px;
  padding: var(--space-2) var(--space-4);
  border: 1px solid var(--line-2);
  border-radius: var(--r-pill);
  background: var(--panel);
  color: var(--ink);
  cursor: pointer;
  font-family: var(--font-data);
  font-size: var(--size-label);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  transition:
    transform var(--dur-fast) var(--ease-out),
    border-color var(--dur-fast) var(--ease-out),
    color var(--dur-fast) var(--ease-out);
}

.rot__b:hover {
  transform: translateY(-2px);
  border-color: var(--accent);
  color: var(--accent);
}

.rot__b:active {
  transform: scale(0.96);
}

.rot__b svg {
  width: 11px;
  height: 12px;
  flex: 0 0 auto;
}

.rot__count {
  margin: 0 var(--space-1);
  font-family: var(--font-data);
  font-size: var(--size-label);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--ink-3);
}

.rot__dots {
  display: flex;
  gap: var(--space-1);
  margin-left: auto;
  flex-wrap: wrap;
}

.rot__dot {
  width: 44px;
  height: 44px;
  display: grid;
  place-items: center;
  background: none;
  border: 0;
  padding: 0;
  cursor: pointer;
  border-radius: 50%;
}

.rot__dot i {
  width: 9px;
  height: 9px;
  border-radius: 50%;
  background: var(--line-2);
  display: block;
  transition:
    transform var(--dur-fast) var(--ease-pop, var(--ease-out)),
    background var(--dur-fast) var(--ease-out);
}

.rot__dot:hover i {
  transform: scale(1.4);
  background: var(--accent);
}

.rot__dot[aria-current="true"] i {
  background: var(--accent);
  transform: scale(1.65);
}

@media (max-width: 520px) {
  .rot__dot {
    width: 38px;
    height: 44px;
  }
  .rot__dots {
    margin-left: 0;
  }
}

@media (prefers-reduced-motion: reduce) {
  /*
   * Collapse the TRAVEL, keep the STATE. The track still moves to the new slide — that is the
   * state change, and removing it would leave a control that appears to do nothing — it just
   * arrives instead of sliding. The widget separately refuses to autoplay at all and removes the
   * play button, which is the half that matters most here.
   */
  [data-rotator][data-rotator-ready="true"] .rot__track {
    transition-duration: 1ms;
  }
  .rot__b,
  .rot__dot i {
    transition-duration: 1ms;
  }
}

/* ── The template's page shell: `.wrap-wide` > `.railpage` > rail | `.railcol` ───────────── */

/*
 * ⛔ THE HOMEPAGE'S BANDS DO NOT USE `Section`, AND THIS IS THE MEASUREMENT THAT SETTLED IT.
 *
 * At 1440 every band below the hero used to go `.container[92→1348]` → `.section__inner[140→1300]`
 * → `.section__body[341→897, max-width 772.172px]`. That 772px is a PROSE MEASURE placed in the
 * middle columns of a 12-track grid — right for a legal page, wrong for every band here, and it
 * left 400px of empty desktop beside the content. The CEO: "some of the sections are not full-page
 * width. They're not fitting on desktop."
 *
 * `.stopsec` is the template's band and has NO reading-measure cap. Raising `Section`'s max-width
 * was the tempting fix and is the wrong one: it leaves the wrong grid in place and the next band
 * inherits it.
 */
.wrap-wide {
  width: min(1400px, 100% - 2rem);
  margin-inline: auto;
}

/* Grid and flex children default to `min-width: auto` and refuse to shrink below their content.
 * That is what pushed the mockup's sibling designs 74px and 95px past 320px. */
.wrap-wide > * {
  min-width: 0;
}

.railcol {
  min-width: 0;
}

/*
 * ⚠️ THE SECOND `.stopsec` BLOCK IS GONE FROM HERE, AND THIS IS THE PASS ITS OWN MEMO ASKED FOR.
 *
 * `.stopsec + .stopsec` was declared twice, identically — here and ~4100 lines above — and the
 * duplicate was deleted 2026-08-24. `.stopsec { scroll-margin-top }` was ALSO declared twice, and
 * that pair was not identical: the copy above read `--hdrh`, the copy here read `--header-height`,
 * and this one won on source order. So the mobile allowance written above applied to nothing, and
 * every anchor jump on all 55 pages was offset by a header-height literal rather than by the
 * measured header.
 *
 * The memo that used to sit here recorded exactly that and deferred it — "correcting it moves
 * scroll offsets on 55 pages and belongs in its own pass". This is the pass. Two things changed
 * together, because either alone leaves the defect: the two names became ONE (`--header-height`
 * is now `var(--hdrh)` in `tokens.css`), and the surviving declaration moved to `--pinoff`, which
 * is the only one of the three that knows the rail exists.
 *
 * ⛔ DO NOT RE-ADD A `.stopsec` RULE HERE. The declaration lives at the top of this file beside
 * the shell it belongs to. A second copy at the bottom silently outranks it, which is not a
 * hypothetical: it is what this comment is a gravestone for.
 */

.stopsec__label {
  margin: 0 0 var(--space-4);
}

/*
 * ⚠️ `--bleed` IS A NO-OP, DELIBERATELY, AND THE MEASUREMENT IS WHY.
 *
 * The first version pulled the hero out of the content track with
 * `margin-inline: calc(var(--space-8) * -1)` so the `.stage` would run to the viewport. Measured at
 * 1440: the hero rendered 226→1452 in a 1440 viewport and `documentElement.scrollWidth` went to
 * 1452. A 12px horizontal scroll on the front door, caused by the band that is supposed to look
 * widest.
 *
 * It was also wrong about the template. In `THE-RAIL-picked.html` the `.stage` sits INSIDE
 * `.railcol`, inside `.railpage`, inside `.wrap-wide` — it fills the content track and stops at the
 * page gutter like every other band. The full-bleed impression comes from the photograph filling
 * the panel's frame, not from the frame escaping the grid.
 *
 * The class is kept as a marker on the hero band because it is what `stopsec--hero` reads against
 * and because a future band may legitimately want to bleed — but it takes no margin, and anything
 * added here must be re-measured at all five widths before it lands.
 */

/* ── `RAIL 01` — the hero `.stage`. BLUEPRINT §4, L460-479. ───────────────────────────────── */

/*
 * ⛔ THE PANEL IS `.stage__type` AND IT IS ONE ELEMENT.
 *
 * The first attempt reproduced this panel's behaviour on `.rot__fixed` / `.rot__vp` / `.rot__ctl`
 * and `.stage__type` rendered ZERO times in the built page. Miranda's correction was exact: if the
 * panel has to split around the track, the track is in the wrong place — it goes IN, not around.
 * The track is now the panel's third child and every rule below is the template's own.
 *
 * Read the mechanism: the photograph is `position: absolute; inset: 0` BEHIND a `position:
 * relative` steel panel that is `min(66%, 720px)` wide. The photo runs UNDER the panel and shows
 * through the remaining ~34%. It does not sit flush beside it.
 */
.stage {
  position: relative;
  border-radius: var(--r-xl);
  overflow: hidden;
  background: var(--steel-950);
  box-shadow: var(--sh-3);
  isolation: isolate;
}

/*
 * The photograph layer. `inset: 0` and `z-index: 0` are the template's; the extra height rules are
 * what make a `<picture>` fill the layer the way the template's bare `<img>` does.
 */
.stage__img-track {
  position: absolute;
  inset: 0;
  z-index: 0;
}

.stage__img-track .rot__track,
.stage__img-track .rot__slide,
.stage__img,
.stage__img .photo__img {
  height: 100%;
}

.stage__img .photo__img {
  width: 100%;
  object-fit: cover;
  /* §4's measured value: right of centre, because the panel covers the left two-thirds. */
  object-position: 58% 50%;
  border-radius: 0;
}

.stage__type {
  position: relative;
  z-index: 2;
  background: var(--steel-950);
  width: min(66%, 720px);
  min-height: clamp(420px, 44vw, 600px);
  padding: clamp(1.5rem, 3vw, 2.9rem);
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 1.15rem;
}

.stage h1,
.stage h2 {
  color: #fff;
}

.stage .lede {
  color: rgb(255 255 255 / 88%);
  max-width: 40ch;
}

.stage .badge {
  background: var(--steel-800);
  border-color: var(--steel-800);
  color: #fff;
  box-shadow: none;
  align-self: flex-start;
}

.stage .badge svg {
  width: 9px;
  height: 11px;
  flex: 0 0 auto;
}

.stage .prov,
.stage-figure .prov {
  color: var(--text-secondary);
}

.stage-figure {
  margin: 0;
}

/*
 * ⛔ THE OFF-SCREEN SLIDES ARE CLIPPED HERE, AND THIS IS THE BUG A SCREENSHOT CAUGHT AND NO TEST
 * DID.
 *
 * At 1440 slide two's heading, lede and button were readable at x≈935-980 — outside the panel,
 * over the photograph — because the track translated but nothing clipped it. `.rot__vp` carries
 * `overflow: hidden` only once the widget has armed (that scoping is what makes the no-JavaScript
 * rendering a readable stacked list rather than a blank box), and inside a `justify-content:
 * center` flex panel the viewport had no width of its own to clip against.
 *
 * `min-width: 0` is the load-bearing line: a flex item defaults to `min-width: auto` and refuses
 * to shrink below its content, so the viewport was as wide as the widest slide rather than as wide
 * as the panel. That is the same `min-width: auto` defect the template documents at L74-75 for
 * `.wrap > *`, arriving one level down.
 *
 * `MR-R-3772` asserts it at every width: no slide but the active one has any part of its box
 * inside the viewport.
 */
/*
 * `.rot__fixed` holds the badge and the `<h1>`. It carries the panel's own `gap` so the three
 * groups — fixed, rotating, controls — read as one stack rather than as a stack with two glued
 * items at the top.
 */
.stage__type > .rot__fixed {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 1.15rem;
}

.stage__type > .rot__vp {
  min-width: 0;
  width: 100%;
  /*
   * ⚠️ CLEARANCE FOR THE ELLIPSE, AND IT IS A REAL COLLISION RATHER THAN A TASTE CALL.
   * `.stage .circled svg` is `top: -21.5%; height: 148%`, so the ellipse extends about a quarter
   * of a line BELOW the `<h1>`'s box — and `.dsp` sets `line-height: .98`, which leaves no
   * descender room to absorb it. The template never meets this because its panel has no `<h2>`
   * under the heading; the rotating message does. Measured at 1440: the message's cap-height sat
   * inside the stroke.
   *
   * ⚠️ PADDING, NOT MARGIN, AND THAT IS THE WHOLE FIX. `overflow: hidden` clips at the PADDING
   * edge, so vertical padding gives a tall glyph room while the horizontal clip that stops the
   * off-screen slides leaking stays exactly as tight. Written as `margin-block-start` the space
   * appeared OUTSIDE the clip box and the message's own ascender was cut instead — measured at
   * 1024 and 1440, the "T" of "This is who it's built for." lost its crossbar. `.dsp` sets
   * `line-height: .98`, so every heading in this panel overflows its line box by design and this
   * padding is what that costs.
   */
  padding-block: 0.35em 0.1em;
}

/*
 * ⚠️ THREE PIXELS OF INK ROOM, ON THE SLIDE AND NOT ON THE VIEWPORT. Measured, and the reason it
 * is on this element is the whole point.
 *
 * The message `<h2>` starts at exactly the viewport's left content edge (both at x=47 at 1024).
 * Archivo at `wght 800` with `.dsp`'s `letter-spacing: -0.028em` paints a hair left of its advance
 * box, so `overflow: hidden` sliced the crossbar off the "T" of "This is who it's built for."
 *
 * ⛔ PADDING THE VIEWPORT INSTEAD WOULD RE-OPEN THE LEAK. A clip box wider than the slide shows
 * that many pixels of the NEXT slide — there is no geometry where it does not, and the leak this
 * hero shipped with is exactly what MR-R-3772 exists to catch. Insetting the slide's CONTENT
 * leaves the clip box and the slide width identical, so the ink clears and nothing else moves.
 */
.stage__type .rot__slide {
  padding-inline-start: 3px;
}

/*
 * THE OFFER SUB-LINE ON SLIDE ONE (RULINGS amendment 2). Steel panel, white type, so it takes the
 * panel's secondary text colour rather than the page's — `--text-secondary` is measured against
 * the page ground and would be invisible here.
 *
 * ⚠️ IT IS DELIBERATELY NOT `.lede`. The lede above it is the argument; this is a price fact, and
 * two paragraphs at the same weight and size read as one paragraph that has been split. Smaller,
 * quieter, above the button — the same position and the same relationship `.cta__offer` already
 * has in `ember-cta.mjs`, so the offer looks like one thing in both places it appears.
 */
.stage__offer {
  margin: 0.9rem 0 0;
  max-width: 46ch;
  font-size: var(--size-small);
  color: rgb(255 255 255 / 88%);
}

.stage__type > .rot__ctl {
  margin-top: 0;
}

/* The controls sit on steel, so they take steel treatment rather than the page's panel treatment. */
.stage .rot__b {
  background: transparent;
  border-color: var(--steel-800);
  color: rgb(255 255 255 / 88%);
}

.stage .rot__b:hover {
  border-color: var(--accent-hi);
  color: var(--accent-hi);
}

.stage .rot__count {
  color: rgb(255 255 255 / 88%);
}

@media (max-width: 900px) {
  /*
   * §4, verbatim: at ≤900 the panel goes full width and the hero grows `padding-bottom: 170px` so
   * a strip of photograph survives underneath it. A 66% panel over a photograph at 390px leaves a
   * 40px sliver, which reads as a rendering fault rather than as a picture.
   */
  .stage {
    padding-bottom: 170px;
  }

  .stage__type {
    width: 100%;
    min-height: 0;
    padding: 1.4rem 1.2rem 1.9rem;
  }

  .stage__img .photo__img {
    object-position: 50% 40%;
  }
}

/* ── The accent device: `.circled`. kit.py:157-163, with d1.py's `.stage` overrides. ─────── */

/*
 * ⛔ ONE `.circled` PER PAGE, MAXIMUM. `_RAIL-SHELL-FINAL.css:437`: "accent devices — one per
 * page, max". It is on the word "floor" in the `<h1>` and nowhere else on `/`.
 *
 * ⛔ `stroke-dashoffset` IS SANCTIONED. `kit.py:15-25`, CEO 2026-08-22, verbatim: "transform and
 * opacity only (SVG stroke-dashoffset is the one sanctioned exception)". An earlier pass rebuilt
 * this draw-on with `transform: scaleX()` on a clipping wrapper because
 * `src/checks/perf-guard.mjs` rejects the property — that workaround was UNNECESSARY and it is
 * gone. Lane 1 is adding the property to the allowlist with the citation above.
 *
 * The path `d` is `kit.py:416`'s `CIRCLE_SVG` verbatim. A redrawn ellipse reads as clip art.
 */
.circled {
  position: relative;
  display: inline-block;
  white-space: nowrap;
}

.circled svg {
  position: absolute;
  left: -6%;
  top: -16%;
  width: 112%;
  height: 132%;
  overflow: visible;
  pointer-events: none;
}

/*
 * ✅ THE DRAW-ON IS LIVE. Flipped at integration on 2026-08-23, the moment item 4b landed
 * `stroke-dashoffset` in `src/checks/perf-guard.mjs`'s allowlist with the CEO citation below.
 * The two-line flip described at the end of this block HAS BEEN APPLIED. Kept for the record.
 *
 * kit.py:160-163 is:
 *
 *     stroke-dasharray: 1400;
 *     stroke-dashoffset: 1400;
 *     transition: stroke-dashoffset 1.1s cubic-bezier(.33, 1, .68, 1) .25s;
 *
 * `src/checks/perf-guard.mjs` REJECTS an animated `stroke-dashoffset` and FAILS THE BUILD — I ran
 * it: "components.css:2809 — animates `stroke-dashoffset`, which is not compositor-safe." The
 * property is CEO-SANCTIONED (`kit.py:15-25`, 2026-08-22: "transform and opacity only — SVG
 * stroke-dashoffset is the one sanctioned exception") and LANE 1 is adding it to the allowlist
 * with that citation. It has not landed here yet.
 *
 * So the ellipse ships DRAWN — `stroke-dashoffset: 0`, the end state — rather than absent, and
 * rather than rebuilt out of `transform: scaleX()` on a clipping wrapper, which is what an earlier
 * pass did and which the CEO's ruling makes unnecessary. Everything else on this selector is
 * kit.py's verbatim, so the ellipse LOOKS right today and only its arrival is missing.
 *
 * ⛔ THE FLIP, WHEN LANE 1's ALLOWLIST LANDS — two lines, and nothing else in this file changes:
 *     1. `stroke-dashoffset: 0` -> `stroke-dashoffset: 1400`
 *     2. uncomment the two `transition` declarations, here and on `.stage .circled svg path`
 * `.circled.in` and the reduced-motion block below are already correct for the animated state and
 * are inert while it is staged.
 */
.circled svg path {
  fill: none;
  stroke: var(--accent);
  stroke-width: 7;
  stroke-linecap: round;
  stroke-dasharray: 1400;
  /*
   * ⚠️ 0, NOT 1400 — THE DRAW-ON FAILS OPEN, AND THAT IS DELIBERATE. Item 4b landed
   * `stroke-dashoffset` in the perf-guard allowlist, so the property is legal now and the
   * transition below is live. But `.circled` carries NO `data-reveal` hook, so `reveal.js`
   * never lands `.in` on it — starting at 1400 made the ellipse PERMANENTLY INVISIBLE.
   * Caught at integration 2026-08-23 by the side-by-side, not by any test.
   * TO ANIMATE IT: add the reveal hook to the `.circled` span in the hero's H1 FIRST, prove
   * `.in` lands, THEN set this to 1400. Never the other way round.
   */
  stroke-dashoffset: 0;
  transition: stroke-dashoffset 1.1s cubic-bezier(0.33, 1, 0.68, 1) 0.25s;
}

/*
 * `.in` is the class `reveal.js` lands on a revealed element. The draw-on is a SCROLL-TRIGGERED
 * animation and the hero is above the fold, so it fires on load rather than on entry — which is
 * `main.js`'s rule 3: anything already on screen is marked revealed without ever being hidden.
 */
.circled.in svg path {
  stroke-dashoffset: 0;
}

/* d1.py:152-155 — the hero's own, larger, slower ellipse on the darker ground. */
.stage .circled svg {
  left: -12.3%;
  width: 142.3%;
  top: -21.5%;
  height: 148%;
}

.stage .circled svg path {
  stroke: var(--ember-400);
  stroke-width: 8;
  /* Staged with its sibling above. See the ⚠️⚠️ block. */
  transition: stroke-dashoffset 1.5s cubic-bezier(0.62, 0.03, 0.36, 1) 0.35s;
}

/*
 * kit.py:374. The end state is KEPT and the journey is dropped — the ellipse is simply drawn.
 * `!important` is the template's own, and it is right here: this has to beat the two transitions
 * above whatever their specificity.
 */
@media (prefers-reduced-motion: reduce) {
  .circled svg path {
    stroke-dashoffset: 0 !important;
  }
}

/* ── `RAIL 01a` — the invented wordmarks ──────────────────────────────────────────────────── */

.logo-wall {
  margin-bottom: var(--space-12);
}

.logo-wall__label {
  text-align: center;
  margin: 0 0 var(--space-6);
}

/*
 * ONE ROW AT DESKTOP. Artie measured the first build wrapping at 1440 with HALF MOON DINER orphaned
 * alone on a second line; the template holds all five on one. `flex-wrap` stays for narrow
 * viewports — five wordmarks cannot share a 390px row and forcing them to would either overflow or
 * shrink them below the 15px floor this site sets for every string.
 *
 * `clamp` on the gap rather than a fixed `--space-10`: the row is five items of very different
 * widths, and a fixed gap is what put it 20px over at 1440.
 */
.logo-wall__row {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-4) clamp(var(--space-4), 3vw, var(--space-10));
  justify-content: center;
  align-items: center;
}

@media (min-width: 1041px) {
  .logo-wall__row {
    flex-wrap: nowrap;
  }
}

/*
 * TEXT, NOT AN IMAGE. A wordmark rendered as an image is an asset that can be mistaken for a
 * trademark somebody licensed to us. See src/site/logo-wall.mjs.
 */
.logo-wall__mark {
  font-family: var(--font-display);
  font-variation-settings:
    "wdth" 92,
    "wght" 800;
  font-size: var(--size-label);
  letter-spacing: 0.08em;
  /*
   * ⛔ NO `opacity` DIMMING HERE, AND THIS IS A MEASURED CORRECTION RATHER THAN A PREFERENCE.
   *
   * The mockup renders these marks at `opacity:.55` rising to 1 on hover, and the first version of
   * this rule carried `color: var(--ink-3); opacity: .72`. A contrast probe that reads
   * `getComputedStyle().color` reports 5.53:1 for that and PASSES — because the computed colour is
   * the colour BEFORE the element's own opacity composites it against the background. Composited,
   * it is **3.10:1 in light and 3.57:1 in dark** at 14px, which is below the 4.5:1 floor in both
   * themes. Measured on the built page at 1440, both themes, 2026-08-23.
   *
   * These are five business names — the only readable content in the band — so they are real text
   * under 1.4.3, not decoration. `--text-secondary` is 9.07:1 light / 10.13:1 dark and needs no
   * opacity to look recessive. The hover still lifts to full ink, so the affordance survives.
   *
   * The wider lesson, worth more than the fix: AN OPACITY ON A TEXT ELEMENT IS INVISIBLE TO EVERY
   * CONTRAST TOOL THAT READS THE COMPUTED COLOUR. If you dim text here, compute the composite.
   */
  color: var(--text-secondary);
  transition: color var(--dur-fast) var(--ease-out);
}

.logo-wall__mark:hover {
  color: var(--ink);
}

.logo-wall .sample-note {
  margin-top: var(--space-6);
  max-width: 62ch;
  margin-inline: auto;
}

/* ── `RAIL 01a` — the seven rooms ─────────────────────────────────────────────────────────── */

.rooms-head {
  max-width: 42ch;
  margin-bottom: var(--space-8);
}

.rooms-head h2 {
  margin: 0;
  max-width: 18ch;
}

/*
 * ⚠️ EVERY CARD IS THE SAME HEIGHT, AND EVERY CARD HAS ITS CAPTION. Artie measured the first build
 * with card one's photograph clipped by the card edge, dead white below it, and NO caption, while
 * card two showed "Coffee shops" — unequal card heights across the track.
 *
 * The cause was the flex track: `.rot__slide` is `flex: 0 0 100%` and a flex item's default
 * `align-items: stretch` made each card as tall as the tallest, but the CARD itself was not a
 * column, so its caption sat wherever the image left it. `display: flex; flex-direction: column`
 * on the card and `margin-top: auto` on nothing — the caption simply follows the image — makes
 * every card the same shape by construction rather than by luck.
 */
[data-rotator-ready="true"].rooms .rot__slide {
  display: flex;
}

.room {
  margin: 0;
  width: 100%;
  display: flex;
  flex-direction: column;
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  overflow: hidden;
  box-shadow: var(--sh-1);
}

/*
 * ⛔ CONSTRAIN THE WIDTH. NEVER THE HEIGHT.
 *
 * These seven frames are 960x717 (1.339:1) full-room photographs and Felicity ruled they keep
 * their own 4/3 ratio at every breakpoint — the site's 16/9 band crop would discard a quarter of
 * a picture whose subject IS the whole room. `max-width` caps the picture; the height follows
 * from the intrinsic width/height attributes, so CLS stays structurally zero.
 *
 * There is deliberately NO `height` and NO `object-fit` here. The mockup has both
 * (`height:clamp(230px,30vw,360px);object-fit:cover`) and that is the 16/9 crop wearing different
 * numbers — invisible in review, because nobody measures a carousel frame against its original.
 */
.room__img {
  display: block;
  margin: 0 auto;
  width: 100%;
}

@media (min-width: 1024px) {
  .room__img {
    max-width: 720px;
  }
}

.room__img .photo__img {
  border-radius: 0;
}

.room__cap {
  padding: var(--space-6) var(--space-6) var(--space-8);
}

.room__cap h3 {
  margin: 0;
}

/* ── The two doors ────────────────────────────────────────────────────────────────────────── */

.door-stack {
  display: flex;
  flex-direction: column;
  gap: var(--space-12);
  margin-top: var(--space-10);
}

.door-block__eyebrow {
  margin: 0 0 var(--space-2);
  color: var(--accent);
}

/*
 * ⛔ THE "THIS IS FOR YOU IF…" LINE IS LOAD-BEARING, NOT DECORATION.
 *
 * Roma measured that neither door is legible without it: doors 02 and 03 are the SAME product
 * arrived at two ways ("the only difference is the API key" — CEO 2026-08-23), and with only the
 * headings a visitor reads BYOW as the cheaper, lesser one. This one line outperformed every icon
 * or photograph treatment considered. Do not cut it for length; length is not a constraint on this
 * site any more.
 */
.door-block__foryou {
  margin: 0 0 var(--space-4);
  color: var(--text-secondary);
  max-width: 52ch;
}

.door-block__lede {
  margin: var(--space-4) 0 0;
  max-width: 60ch;
}

/* ── The template's `.post` social card — `RAIL 01o` ──────────────────────────────────────── */

/*
 * Ported from `THE-RAIL-picked.html:1331-1350`. It replaces the repo's older placeholder idiom —
 * a yellow diagonal-striped hazard box over a loose paragraph — which Artie measured as "the one
 * band that genuinely looks like a different site."
 *
 * The disclosure moves from a hazard box ON TOP to the template's small mono `.fake` line
 * UNDERNEATH. It is not softened: it is still rendered text a sighted reader sees, it still says
 * the person is invented, and the card still carries the one literal marker that one grep finds.
 */
.post-wrap {
  max-width: 440px;
}

.post {
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: var(--r-sm);
  padding: var(--space-6);
  box-shadow: var(--sh-1);
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
  transition: transform var(--dur-slow) var(--ease-out);
}

.post:hover {
  transform: translateY(-4px) rotate(-0.4deg);
}

@media (prefers-reduced-motion: reduce) {
  /* The lift is decoration with no state behind it, so under reduced motion it simply does not
   * happen — there is nothing to keep perceptible. */
  .post {
    transition-duration: 1ms;
  }
  .post:hover {
    transform: none;
  }
}

.post__top {
  display: flex;
  align-items: center;
  gap: var(--space-3);
}

.post__face {
  width: 42px;
  height: 42px;
  border-radius: 50%;
  flex: 0 0 auto;
  background: var(--grad-ember);
  display: grid;
  place-items: center;
  color: var(--accent-ink);
  font-family: var(--font-display);
  font-variation-settings:
    "wdth" 100,
    "wght" 800;
  font-size: var(--size-label);
}

.post__n {
  font-family: var(--font-display);
  font-variation-settings:
    "wdth" 100,
    "wght" 700;
  line-height: 1.2;
  display: block;
}

.post__r {
  font-size: var(--size-small);
  color: var(--text-secondary);
}

.post__body {
  margin: 0;
  color: var(--text-primary);
}

.post__foot {
  display: flex;
  gap: var(--space-6);
  font-family: var(--font-data);
  font-size: var(--size-label);
  color: var(--text-secondary);
}

/*
 * Real controls, and 44px tall. They toggle `aria-pressed` and do nothing else — they post
 * nowhere and count nothing, because a count here would be a fabricated engagement figure on a
 * post already disclosed as invented.
 */
.post__foot button {
  background: none;
  border: 0;
  padding: 0;
  min-height: 44px;
  font: inherit;
  color: inherit;
  cursor: pointer;
  transition:
    color var(--dur-fast) var(--ease-out),
    transform var(--dur-fast) var(--ease-out);
}

.post__foot button:hover,
.post__foot button[aria-pressed="true"] {
  color: var(--accent);
}

.post__foot button:active {
  transform: scale(0.92);
}

.fake {
  font-family: var(--font-data);
  font-size: var(--size-small);
  letter-spacing: 0.1em;
  color: var(--text-secondary);
  margin: var(--space-6) 0 0;
  max-width: 62ch;
}

/* ── The wordmark plate, `/` only (SiteHeader `plate`) ────────────────────────────────────── */

.wordmark__plate {
  width: 32px;
  height: 32px;
  border-radius: var(--r-xs);
  background: var(--grad-ember);
  display: grid;
  place-items: center;
  flex: 0 0 auto;
  box-shadow: var(--sh-1);
  color: var(--accent-ink);
  margin-inline-end: var(--space-3);
}

.wordmark__plate svg {
  width: 21px;
  height: 21px;
}

/* ── Type scale ported for the homepage's bands only ──────────────────────────────────────── */

/*
 * `.dsp`/`.d-hero`/`.d1`/`.d2` are the template's display scale. They are ADDED rather than
 * aliased onto `.display-1`/`.h2`, for the same reason `tokens.css:240-253` keeps `--r-*` and
 * `--radius-*` apart: the two are not the same design, and aliasing would re-set the type on all
 * 55 pages inside a homepage commit.
 */
.dsp {
  font-family: var(--font-display);
  margin: 0;
  font-variation-settings:
    "wdth" 100,
    "wght" 800;
  line-height: 0.98;
  letter-spacing: -0.028em;
  text-wrap: balance;
}

.d-hero {
  font-size: clamp(2.9rem, 6.6vw, 5.4rem);
}

.d1 {
  font-size: clamp(2.3rem, 4.6vw, 3.5rem);
}

.d2 {
  font-size: clamp(1.6rem, 2.6vw, 2.15rem);
  letter-spacing: -0.02em;
}

.eyebrow {
  font-family: var(--font-data);
  font-size: var(--size-label);
  letter-spacing: 0.15em;
  text-transform: uppercase;
  color: var(--accent);
  margin: 0 0 var(--space-3);
}

/* ── kit.py BASE — `.badge`, `.btn`, `.lede`, `.prov`. Ported, not approximated. ──────────── */

/*
 * These four are `kit.py`'s BASE block, which is the design system the ratified template is the
 * OUTPUT of. They are ported here because the hero uses all four and the repo has no equivalent
 * for any of them under these names — `.button.button--primary` is a different vocabulary and,
 * per blueprint rule 2, a page built from the template carries the template's classes.
 *
 * ⚠️ THE 15px TYPE FLOOR. `kit.py` sets `.badge` at `.7rem` (11.2px) and `.prov` at `.62rem`
 * (9.9px). This site has a hard floor of `0.9375rem` / 15px and nothing on it is smaller, legal
 * pages included — the blueprint's own GOOD/BAD/UGLY names the template's micro-type as the thing
 * that must be raised at port time. Both are raised to `var(--size-label)` and `var(--size-small)`,
 * which is the ONLY deliberate change to these rules. Everything else is verbatim.
 */

/* kit.py:143-150 — pill badge. */
.badge {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.42rem 0.95rem;
  border-radius: var(--r-pill);
  background: var(--panel);
  border: 1px solid var(--line);
  box-shadow: var(--sh-1);
  font-family: var(--font-data);
  /* Raised from kit.py's .7rem. See the type-floor note above. */
  font-size: var(--size-label);
  letter-spacing: 0.13em;
  text-transform: uppercase;
  color: var(--ink-2);
}

.badge--solid {
  background: var(--accent-wash);
  border-color: transparent;
  color: var(--accent);
}

.badge__dot {
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--accent);
}

/* The play glyph the hero's badge carries inline (`THE-RAIL-picked.html:1023`). */
.badge svg {
  width: 9px;
  height: 11px;
  flex: 0 0 auto;
}

/* kit.py:166-181 — pills, per all three reference sites. */
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.55rem;
  font-family: var(--font-display);
  font-variation-settings:
    "wdth" 100,
    "wght" 700;
  font-size: 1rem;
  /* 44px minimum tap target held by padding, so a translated label makes it taller, not clipped. */
  min-height: 44px;
  padding: 0.92rem 1.7rem;
  border-radius: var(--r-pill);
  border: 2px solid transparent;
  background: var(--accent);
  color: var(--accent-ink);
  text-decoration: none;
  cursor: pointer;
  white-space: nowrap;
  box-shadow: 0 6px 18px -8px rgb(168 66 10 / 70%);
  transition:
    transform 0.18s cubic-bezier(0.33, 1, 0.68, 1),
    box-shadow 0.18s ease,
    background 0.18s ease;
}

.btn:hover {
  transform: translateY(-2px);
  box-shadow: 0 12px 26px -10px rgb(168 66 10 / 80%);
  background: var(--accent-hi);
}

.btn:active {
  transform: translateY(0);
}

.btn:focus-visible {
  /* kit.py has no focus rule; this site does, and a pill with no visible focus fails 2.4.11. */
  outline: 3px solid var(--focus-ring);
  outline-offset: 3px;
}

.btn--dark {
  background: var(--steel-950);
  color: #fff;
  box-shadow: 0 6px 18px -8px rgb(15 23 32 / 60%);
}

.btn--dark:hover {
  background: var(--steel-800);
}

.btn--ghost {
  background: transparent;
  color: var(--ink);
  border-color: var(--line-2);
  box-shadow: none;
}

.btn--ghost:hover {
  border-color: var(--accent);
  color: var(--accent);
  background: transparent;
  box-shadow: none;
}

.btn--lg {
  font-size: 1.08rem;
  padding: 1.05rem 2rem;
}

@media (prefers-reduced-motion: reduce) {
  /* The colour change IS the state and it stays; only the 2px lift goes. */
  .btn {
    transition-duration: 1ms;
  }
  .btn:hover {
    transform: none;
  }
}

/* kit.py:139 — the one reading measure that is legitimate, because it applies to a PARAGRAPH. */
.lede {
  font-size: clamp(1.08rem, 1.35vw, 1.28rem);
  color: var(--ink-2);
  max-width: 56ch;
  margin: 0;
}

/* kit.py:377-378 — the provenance line under a synthetic frame. */
.prov {
  font-family: var(--font-data);
  /* Raised from kit.py's .62rem. See the type-floor note above. */
  font-size: var(--size-small);
  letter-spacing: 0.09em;
  color: var(--ink-3);
  text-transform: uppercase;
  margin: 0.55rem 0 0;
}

/*
 * ── A RAIL STOP WITH NO TIME (CEO ruling, 2026-08-25) ─────────────────────────────────────────
 *
 * The homepage's last three rows are product doors, not moments in a service. They rendered as
 * `23:52 / 23:55 / 23:59` — timeline positions dressed as a clock — and converting the rail to
 * 12-hour US time turned that into "11:52 PM Restaurant Website", which is a claim nobody would
 * write down on purpose. They carry no time at all now.
 *
 * The numeral element is not emitted for these rows, so this only has to close the gap it left.
 */
.stop--untimed {
  gap: 0;
}

/*
 * The rule that separates the clock from the doors. It is the visual form of "the timeline ends
 * here" — without it the untimed rows read as timed rows whose numerals failed to render.
 *
 * ⛔ `border-top` ON THE FIRST ROW AFTER THE CLOCK, not a separate `<li>`. An extra list item
 * would put a decoration inside `<ol class="rail__list">` and change the count a screen reader
 * announces — "list, 12 items" for eleven stops.
 */
.stop--afterclock {
  border-top: 1px solid var(--line);
  margin-top: 0.4rem;
  padding-top: 0.6rem;
}

/* ══ Pricing structures (S-PRICING-1) ════════════════════════════════════
 *
 * Everything below is scoped to the pricing-structure pages' own components. No existing selector
 * is modified and no existing page changes by one pixel — asserted rather than assumed, because
 * `.toc` and `.marker-list` are shared and both are ADDED TO here through a modifier class only.
 *
 * Every colour is an existing semantic token. There are no new hex values in this block, and
 * `--text-nontext` (4.15:1) appears nowhere: it is non-text only, and a price is text.
 */

/* ── StructureSwitcher ─────────────────────────────────────────────────── */

.structure-switcher ol {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
}

.structure-switcher__item {
  display: flex;
  align-items: baseline;
  gap: var(--space-3);
  min-height: 48px;
  padding: var(--space-3) var(--space-4);
  border: 1px solid var(--rule-structural);
  background: var(--surface-page);
  color: var(--text-secondary);
  text-decoration: none;
  /* The 3px state bar lives on the TOP edge and is transparent until the item is current, so the
   * box does not resize when the state changes. A coloured LEFT border is a banned decoration;
   * this is state, and it is never the only signal — `aria-current`, the background and the
   * weight all carry it too (1.4.1). */
  border-top: 3px solid transparent;
  transition: background-color var(--dur-fast) var(--ease-standard);
}

.structure-switcher__number {
  font-size: var(--size-data);
  color: var(--text-secondary);
}

.structure-switcher__label {
  font-family: var(--font-body);
  font-size: var(--size-small);
}

.structure-switcher li + li .structure-switcher__item {
  margin-block-start: -1px;
}

.structure-switcher li:first-child .structure-switcher__item {
  border-start-start-radius: var(--radius-md);
  border-start-end-radius: var(--radius-md);
}

.structure-switcher li:last-child .structure-switcher__item {
  border-end-start-radius: var(--radius-md);
  border-end-end-radius: var(--radius-md);
}

/* An item with no destination keeps its words and reads as unavailable, rather than lying about
 * being a link. It is not focusable and has no hover state, because there is nothing to do. */
.structure-switcher__item--inert {
  color: var(--text-secondary);
  background: transparent;
}

a.structure-switcher__item:hover {
  background: var(--surface-raised);
  color: var(--text-primary);
}

a.structure-switcher__item:focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: 2px;
}

.structure-switcher__item[aria-current="page"] {
  background: var(--surface-sunken);
  color: var(--text-primary);
  font-weight: 600;
  border-top-color: var(--text-accent);
}

.structure-switcher__item[aria-current="page"] .structure-switcher__number {
  color: var(--text-primary);
}

@media (min-width: 768px) {
  .structure-switcher ol {
    grid-template-columns: repeat(3, 1fr);
  }
  .structure-switcher li + li .structure-switcher__item {
    margin-block-start: 0;
    margin-inline-start: -1px;
  }
  .structure-switcher li:first-child .structure-switcher__item {
    border-radius: var(--radius-md) 0 0 var(--radius-md);
  }
  .structure-switcher li:last-child .structure-switcher__item {
    border-radius: 0 var(--radius-md) var(--radius-md) 0;
  }
}

/* ── StatRow ───────────────────────────────────────────────────────────── */

.stat-row {
  margin: 0;
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: var(--space-6) var(--space-4);
}

/* Caption first in the DOM, numeral first on the screen. The pair reads as one phrase either
 * way; an orphan numeral above an unread caption does not. */
.stat-row__item {
  display: flex;
  flex-direction: column-reverse;
  gap: var(--space-1);
}

.stat-row__item dd {
  margin: 0;
}

.stat-row__item:nth-child(5) {
  grid-column: 1 / -1;
}

@media (min-width: 768px) {
  .stat-row {
    grid-template-columns: repeat(3, 1fr);
  }
  .stat-row__item:nth-child(5) {
    grid-column: auto;
  }
}

@media (min-width: 1024px) {
  .stat-row {
    grid-template-columns: repeat(5, 1fr);
    gap: var(--space-8);
  }
}

/* Tightened one step each in revision 1 (blueprint §13.3). The hairline still separates the rail
 * from the stats and the gap above it is still larger than the gap between chips, so the grouping
 * rule at `base.css:196` holds. */
.stat-row__rail {
  margin-top: var(--space-6);
  padding-top: var(--space-4);
  border-top: 1px solid var(--rule-decorative);
}

/* ── Status banner: the tag legend ─────────────────────────────────────── */

/* THE BANNER LIVES IN THE STAT BAND, NOT THE HERO — blueprint §13.2. It sits above `StatRow`
 * because the page's first price is in `StatRow`; it precedes every price in DOM order, which is
 * what the `aria-describedby` references on the proposed prices depend on. */
.status-banner {
  margin-bottom: var(--space-6);
}

/* Both tags define themselves once, before either is used, instead of each one trailing a sentence
 * as a ~124px `white-space: nowrap` chunk that always lands on a line of its own. `flex-wrap` is
 * what keeps 320px honest: two tags that do not fit stack rather than overflow. */
.status-banner__legend {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2);
  margin: 0 0 var(--space-3);
}

/* ── Jump rail: TableOfContents, .toc--rail modifier ───────────────────── */

/* `.toc` is `position: sticky` at ≥1024 for the legal sidebar. This page has no sidebar to stick
 * to, so the modifier turns it off explicitly rather than relying on source order. `/legal/*` is
 * untouched: nothing here matches a `.toc` without `.toc--rail`. */
.toc--rail {
  position: static;
}

.toc--rail ol {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2);
  list-style: none;
  margin: var(--space-3) 0 0;
  padding: 0;
}

.toc--rail a {
  min-height: 0;
  padding: var(--space-2) var(--space-3);
  border: 1px solid var(--rule-decorative);
  border-radius: var(--radius-pill);
  background: var(--surface-page);
  color: var(--text-secondary);
  font-size: var(--size-small);
  transition:
    background-color var(--dur-fast) var(--ease-standard),
    border-color var(--dur-fast) var(--ease-standard),
    color var(--dur-fast) var(--ease-standard);
}

.toc--rail a:hover,
.toc--rail a:focus-visible {
  color: var(--text-primary);
  border-color: var(--rule-structural);
  text-decoration: underline;
}

.toc--rail a:focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: 2px;
}

/* ── Hero continuation ─────────────────────────────────────────────────── */

/* The hero body continues below the switcher, so that the switcher is reachable on a phone without
 * a scroll (blueprint §13.1). It is the same paragraph the spec wrote, at body size because it is
 * no longer the standfirst — the standfirst is the one sentence above the control. No breakpoint:
 * the split is identical at 375px and 1440px, so DOM order and visual order agree everywhere. */
.hero-continuation {
  margin: var(--space-8) 0 0;
  max-width: 70ch;
  color: var(--text-secondary);
}

/* ── PriceStatus ───────────────────────────────────────────────────────── */

.price-status {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  padding: var(--space-1) var(--space-3);
  border-radius: var(--radius-pill);
  font-family: var(--font-data);
  font-size: var(--size-label);
  white-space: nowrap;
  vertical-align: middle;
}

/* The glyph's SHAPE carries the state: filled is settled, hollow is the outline of a decision.
 * Same device as `.state-glyph--yes`, so the state is never colour-only (1.4.1). */
.price-status__glyph {
  width: 8px;
  height: 8px;
  border-radius: var(--radius-sm);
  flex: 0 0 auto;
}

.price-status--locked {
  background: var(--surface-accent-wash);
  color: var(--text-accent);
}

.price-status--locked .price-status__glyph {
  background: currentColor;
}

.price-status--proposed {
  background: transparent;
  border: 1px dashed var(--rule-structural);
  color: var(--text-secondary);
}

.price-status--proposed .price-status__glyph {
  border: 2px solid currentColor;
}

/* A proposed price is literally less emphatic than a settled one, so at squint distance a reader
 * sees which numbers are real before reading a word. `--text-secondary` is 6.26:1 and passes
 * 1.4.3 for normal text: this is de-emphasis, not degradation, and nothing is hidden. */
.price-value {
  margin: 0;
}

.price-value--locked {
  color: var(--text-primary);
}

.price-value--proposed {
  color: var(--text-secondary);
}

/* ── LevelCard ─────────────────────────────────────────────────────────── */

.level-cards {
  display: grid;
  gap: var(--space-6);
}

.level-card {
  background: var(--surface-card);
  border-radius: var(--radius-md);
  padding: var(--space-6);
}

.level-card__head {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-3);
}

.level-card__head h3 {
  margin: 0;
}

.level-card__sub {
  margin: var(--space-2) 0 0;
  color: var(--text-secondary);
  max-width: 34ch;
}

.level-card__pricing {
  margin-top: var(--space-6);
}

.level-card__subprice {
  margin: var(--space-1) 0 0;
  color: var(--text-secondary);
}

.level-card__leadin {
  margin: var(--space-6) 0 0;
  color: var(--text-secondary);
}

.level-card__tail {
  margin-top: var(--space-4);
}

/* The bullets reuse `.marker-list`'s rule marker and drop its display-font h4 sizing: eight
 * bullets set at `--size-h4` in a card column is a heading list, not a feature list. Scoped to
 * this class, so every other `.marker-list` on the site is unchanged. */
.level-card__bullets {
  gap: var(--space-3);
}

.level-card__bullets li {
  font-family: var(--font-body);
  font-size: var(--size-small);
  font-weight: 400;
  line-height: 1.5;
}

.level-card__bullets li::before {
  top: 0.65em;
}

.level-card__action {
  margin: var(--space-6) 0 0;
}

@media (min-width: 768px) {
  .level-cards {
    grid-template-columns: repeat(2, 1fr);
  }
  .level-card {
    padding: var(--space-8);
  }
  .level-card__bullets li {
    font-size: var(--size-body);
  }
}

/*
 * SUBGRID, and it is the correct tool rather than a clever one. The three cards carry bullet lists
 * of very different lengths, so in a naive flex row the price line sits at three different
 * heights and the reader cannot compare the numbers without moving their eye. Rows 1–4 size to
 * the tallest card's content, so name, sub, price and lead-in are level across all three; row 5
 * takes the remainder so the card backgrounds reach the same bottom edge.
 *
 * The alternative that must never be built here is a JavaScript height-matcher.
 *
 * ── WHY 1280 AND NOT 1024, WHICH IS WHERE THIS ROW USED TO START ─────────────────────────
 * MEASURED on the built page at a 1024px viewport, before this line moved:
 *
 *   .level-cards grid box                                944px  (1024 - 2 x 40px gutter)
 *   grid-template-columns computed  366.141px 366.141px 208.75px   <- not three equal columns
 *   row total (tracks + 2 x 24px gap)                    989.03px
 *   document                                scrollWidth 1029 / clientWidth 1024
 *
 * The cause is `1fr`, which is `minmax(auto, 1fr)`: a track's FLOOR is its item's min-content, so
 * a card that cannot shrink pushes its track above its share and the last track pays for it. The
 * card that cannot shrink is this one, and the part that cannot shrink is the price:
 * `$19.99/screen/mo` in `--font-data` at `--size-data-lg` (clamped to 2rem from 1024 up) has NO
 * break opportunity, so its min-content is 302.14px, and with the card's 2 x 32px padding the
 * card's min-content is 366.14px. Measured at 768-1200 the card's content box and the price's
 * text width are the same number to two decimals — the price was sizing the card, not the other
 * way round.
 *
 * THAT IS NOT A 1024 PROBLEM, IT IS A CONTAINER-TIER PROBLEM. The 1024 tier caps `.container` at
 * `--container-lg` + gutters, so its inner width can never exceed 960px, and three 366.14px cards
 * plus two gaps need 1146.4px. The 1024 tier cannot hold this row AT ANY viewport width inside
 * it. It only became visible scrolling at 1024-1033, where the 40px right gutter ran out of room
 * to absorb the spill; from 1034 up the same broken 366/366/209 row was hiding inside the gutter.
 * The `--container-xl` tier starts at 1280 with an inner width of 1160px, a share of 370.66px,
 * which clears the 366.14px floor — so the row is moved to the tier that can hold it, and above
 * 1280 not one pixel changes (tracks measured 370.656/370.672/370.656 before and after).
 *
 * `minmax(0, 1fr)` states the contract the design actually makes: three EQUAL columns. A future
 * price string long enough to break the floor again must show up as text that spills its card,
 * which is visible and gets reported, instead of as a silently unequal row.
 *
 * NOT FIXED WITH `overflow-x: hidden`. That hides the symptom and takes the detector with it.
 */
@media (min-width: 1280px) {
  .level-cards {
    grid-template-columns: repeat(3, minmax(0, 1fr));
    grid-template-rows: auto auto auto auto auto;
  }
  .level-card {
    grid-row: span 5;
    display: grid;
    grid-template-rows: subgrid;
    row-gap: 0;
  }
}

/* ── Pull-quote (a Section recipe, not a component) ────────────────────── */

.pull-quote__body {
  margin: var(--space-4) 0 0;
  max-width: 46ch;
  color: var(--text-primary);
}

.section--wash .pull-quote__body {
  border-top: 3px solid var(--text-accent);
  padding-top: var(--space-6);
}

/* ── GroupedTable ──────────────────────────────────────────────────────── */

/*
 * The small form switches at 1024, not at `DataTable`'s 768. At 768px the prose column is about
 * 34ch and every row runs to three and a half lines — taller than the stacked form and harder to
 * read. Scoped to this component; `.table-wide` and `.table-transposed` do not move.
 */
.grouped-table__wide {
  display: none;
}

.grouped-table__small {
  display: block;
}

@media (min-width: 1024px) {
  .grouped-table__wide {
    display: block;
  }
  .grouped-table__small {
    display: none;
  }
}

/*
 * The documented density departure. `.data-table th/td` is 12px vertical; the grouped tables run
 * 8px. Twenty-three rendered rows at 12px cost 184px of pure padding and 122px at 8px, and the
 * row rhythm is still larger than the 15px type's own leading, so rows stay separated by the
 * hairline rather than by air. Scoped to `.grouped-table`, so no existing table changes.
 */
.grouped-table th,
.grouped-table td {
  padding: var(--space-2) var(--space-4);
  vertical-align: baseline;
}

.grouped-table .c-name {
  width: 20ch;
  min-width: 14ch;
}

.grouped-table .c-price {
  width: 14ch;
}

.grouped-table .c-what {
  font-size: var(--size-small);
  color: var(--text-primary);
  max-width: 80ch;
}

/* The merged price cell (CEO decision D2): amount over unit, one cell, never right-aligned —
 * "Enterprise" and "one-time" share this column with "$29". */
.c-price__amount {
  display: block;
  color: var(--text-primary);
}

.c-price__unit {
  display: block;
  font-size: var(--size-small);
  color: var(--text-secondary);
}

/*
 * The group heading gets its own `<tbody>` and a `th[scope="colgroup"]`, so a screen reader and
 * the hairline agree about which rows it governs. `--surface-sunken` is a value step down from
 * the band, deliberately subtle and never the only signal: the group's own words are in the row.
 * No accent colour — five accent bands in one table would spend the ember budget on furniture.
 */
.grouped-table__grouprow th {
  background: var(--surface-sunken);
  color: var(--text-primary);
  font-family: var(--font-body);
  font-size: var(--size-label);
  font-weight: 600;
  letter-spacing: 0.02em;
  border-bottom: 1px solid var(--rule-structural);
}

.grouped-table__smallgroup {
  margin-top: var(--space-8);
}

/* Sticky is LAYOUT, not motion, so it stays under `prefers-reduced-motion`. A reader three
 * thousand pixels into a price list always knows which group they are in. */
.grouped-table__smallheading {
  position: sticky;
  top: var(--header-height);
  z-index: 1;
  margin: 0;
  padding-block: var(--space-2);
  background: var(--surface-page);
  border-bottom: 1px solid var(--rule-structural);
}

.section--bleed .grouped-table__smallheading {
  background: var(--surface-raised);
}

.grouped-row {
  padding: var(--space-4) 0;
  border-bottom: 1px solid var(--rule-decorative);
  /*
   * The sticky heading must never cover a row someone just jumped to or tabbed into.
   *
   * ⛔ `--pinoff`, NOT `--header-height`. This rule was authored on a branch that predates lane 1's
   * two-tier header, where a header-only token was the whole offset. Below 1041px the rail lies
   * down UNDER the header, so an offset that clears only the header lands the heading under the
   * rail — `MR-R-40146` reproduces it. `--pinoff` is header + lying-down rail below 1041 and the
   * header alone above it, which is the one token that is correct at both widths.
   */
  scroll-margin-top: calc(var(--pinoff, 64px) + var(--space-12));
}

.grouped-row h4 {
  margin: 0;
}

.grouped-row__meta {
  margin: var(--space-2) 0 0;
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(0, 1fr));
  gap: var(--space-4);
}

.grouped-row__meta dt {
  font-size: var(--size-label);
  color: var(--text-secondary);
}

.grouped-row__meta dd {
  margin: 0;
  font-size: var(--size-small);
}

.grouped-row__what {
  margin: var(--space-2) 0 0;
}

.grouped-row__what dd {
  margin: 0;
  font-size: var(--size-small);
}

/*
 * PRINT. Not requested, and a page whose entire purpose is internal review will be printed. The
 * status banner and the closing note print, because they are the reason the page exists.
 */
@media print {
  .toc--rail,
  .grouped-table__small {
    display: none;
  }
  .grouped-table__wide {
    display: block;
  }
  .grouped-table__smallheading {
    position: static;
  }
}

/*
 * Interactive widget styling and the motion layer — CEO directive 2026-08-17, and
 * WEB-DESIGN-SPEC.md §7.
 *
 * EVERY transition and keyframe in this file is transform, opacity, or paint-only. That is not a
 * preference; `node build.mjs` runs src/checks/perf-guard.mjs over this file and refuses to emit
 * if it finds anything that can move a box. If you are here to add `transition: height`, the build
 * will stop you before the reviewer does.
 *
 * Reduced-motion handling lives at the bottom of this file, and it follows §7.4: collapse the
 * movement, KEEP the state change perceptible. A blanket `transition-duration: 0` is not compliance,
 * it is a silently degraded product.
 */

/* ── Placeholder image ────────────────────────────────────────────────────
 * It must never be mistakeable for a screenshot. The diagonal hatch is the load-bearing part: no
 * software looks like this, so nobody can read it as a picture of the product.
 */
.placeholder-image {
  margin: var(--space-8) 0;
}

.placeholder-image__frame {
  aspect-ratio: var(--placeholder-ratio, 16 / 9);
  display: grid;
  place-items: center;
  border: 1px dashed var(--rule-structural);
  border-radius: var(--radius-lg);
  background-color: var(--surface-sunken);
  background-image: repeating-linear-gradient(
    135deg,
    transparent 0,
    transparent 10px,
    rgb(100 121 143 / 12%) 10px,
    rgb(100 121 143 / 12%) 20px
  );
}

.placeholder-image__tag {
  font-family: var(--font-data);
  font-size: var(--size-label);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-secondary);
  background: var(--surface-card);
  border: 1px solid var(--rule-structural);
  border-radius: var(--radius-pill);
  padding: var(--space-2) var(--space-4);
}

.placeholder-image__caption {
  margin-top: var(--space-3);
  font-size: var(--size-small);
  color: var(--text-secondary);
  max-width: 60ch;
}

/* ── Live demo wrapper ────────────────────────────────────────────────────
 * The alternative to a placeholder: a real working thing, labelled as operable so a visitor knows
 * to touch it.
 */
.live-demo {
  border: 1px solid var(--rule-structural);
  border-radius: var(--radius-lg);
  padding: var(--space-4);
  background: var(--surface-raised);
  margin: var(--space-8) 0;
}

.live-demo__bar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-3);
  margin-bottom: var(--space-4);
}

.live-demo__badge {
  font-family: var(--font-data);
  font-size: var(--size-label);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--action-label);
  background: var(--action-fill);
  border-radius: var(--radius-pill);
  padding: var(--space-1) var(--space-3);
}

.live-demo__title {
  font-family: var(--font-display);
  font-weight: 700;
  color: var(--text-primary);
}

.live-demo__hint {
  margin: var(--space-4) 0 0;
  font-size: var(--size-small);
  color: var(--text-secondary);
  max-width: 62ch;
}

.live-demo__keys {
  display: inline-block;
  font-family: var(--font-data);
  font-size: var(--size-label);
  color: var(--text-secondary);
  border: 1px solid var(--rule-structural);
  border-radius: var(--radius-sm);
  padding: 0 var(--space-2);
}

/* ── Strike-out ───────────────────────────────────────────────────────────
 * The rule is drawn with scaleX on a pseudo-element. Animating `width` here is the obvious
 * implementation and it is rejected by the build.
 */
button.ticket-card__item {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  width: 100%;
  min-height: 44px;
  text-align: start;
  background: none;
  border: 0;
  border-radius: var(--radius-sm);
  padding: var(--space-2) var(--space-2);
  cursor: pointer;
  font: inherit;
  color: inherit;
  position: relative;
  transition: background-color var(--dur-fast) var(--ease-standard);
}

button.ticket-card__item:hover {
  background-color: var(--steel-200); /* raw: the card is always light. See the note below. */
}

.ticket-card__item-text {
  position: relative;
}

/*
 * ⚠ EVERYTHING INSIDE A TICKET CARD USES RAW RAMP TOKENS, NEVER SEMANTIC ONES.
 *
 * `.ticket-rail__card` is pinned to `--paper-card` with `--steel-950` ink in BOTH themes
 * (components.css:828-838). That is deliberate: the card is a replica of the product's OrderCard,
 * so it looks like the product's card whatever theme the surrounding site is in.
 *
 * A semantic token inside it therefore breaks in dark mode — `--text-secondary` resolves to the
 * dark theme's #9fb0c2 while the card underneath stays light #fbfcfd. Measured by axe-core at
 * 1.95:1 across 20 nodes on `/`, 9 on `/reliability`, 8 on `/es`. Light mode was clean, which is
 * why reading the CSS would not have found it.
 *
 * Rule for anyone adding to this block: inside the card use `--steel-*` / `--ember-600` directly;
 * outside the card use the semantic roles. The card does not follow the theme, so its contents
 * must not either.
 */
.ticket-card__item-text::after {
  content: "";
  position: absolute;
  inset-inline-start: 0;
  top: 50%;
  width: 100%;
  height: 2px;
  background: var(--ember-600); /* 5.62:1 on the card. Non-text, 1.4.11 needs 3:1. */
  transform: scaleX(0);
  transform-origin: left center;
  transition: transform var(--dur-base) var(--ease-out);
}

button.ticket-card__item[data-struck="true"] .ticket-card__item-text::after {
  transform: scaleX(1);
}

/* Never colour alone (1.4.1): the row also dims and gains a made marker. */
button.ticket-card__item[data-struck="true"] {
  color: var(--steel-600); /* 6.26:1 on the card */
}

button.ticket-card__item[data-struck="true"] .ticket-card__made {
  opacity: 1;
}

.ticket-card__made {
  margin-inline-start: auto;
  font-family: var(--font-data);
  font-size: var(--size-label);
  color: var(--ember-600); /* 5.62:1 on the card */
  opacity: 0;
  transition: opacity var(--dur-fast) var(--ease-standard);
}

.ticket-card__progress {
  font-family: var(--font-data);
  font-size: var(--size-label);
  color: var(--steel-600); /* 6.26:1 on the card */
}

/*
 * ── VOID: THE LINE THE KITCHEN CANNOT MAKE ───────────────────────────────────────────────────
 *
 * ⛔ THE DEFECT THIS CLOSES. Until now a line struck because the cook plated it and a line struck
 * because the kitchen ran out drew the IDENTICAL picture: same rule, same dim, same "made" marker,
 * same `aria-pressed="true"`. On a real pass those are opposite facts — one sends the table its
 * food, the other sends the server to apologise — and rendering them the same way is worse than
 * rendering neither, because it is confidently wrong. The CEO's reference board prints a red
 * CANCELED! for exactly this reason; ours says 86, which is the word an American kitchen uses.
 *
 * Three carriers, none of them alone (WCAG 1.4.1):
 *   - the rule turns from ember to the late red, and the row's text goes with it;
 *   - the "made" marker is replaced by an "86" chip, in text, built by `strike-out.js`;
 *   - the accessible name gains "eighty-sixed, not coming", because `aria-pressed="true"` with no
 *     other signal reads as "done" to a screen-reader user and means the opposite here.
 *
 * #9a1c12 measures 7.06:1 on `--paper-card` — the same value the late timer already uses, so the
 * board has one red and it means one thing.
 *
 * Colour only. Nothing here transitions a layout property, so the perf guard has nothing to refuse
 * and there is no travel for `prefers-reduced-motion` to collapse.
 */
button.ticket-card__item[data-void="true"] .ticket-card__item-text::after {
  background: #9a1c12;
}

button.ticket-card__item[data-void="true"] {
  color: #9a1c12;
}

/* The made marker and the 86 chip are never both present: one replaces the other. */
button.ticket-card__item[data-void="true"] .ticket-card__made {
  display: none;
}

.ticket-card__void {
  margin-inline-start: auto;
  font-family: var(--font-data);
  font-size: var(--size-label);
  font-weight: 700;
  letter-spacing: 0.06em;
  color: #9a1c12; /* 7.06:1 on the card */
}

/*
 * A ticket is not "complete" because everything on it is off. `[data-all-struck]` draws the ember
 * ring that means the cook finished the ticket, and `strike-out.js` only sets it from the MAKEABLE
 * lines — but a card carrying a void line gets the red edge as well, so the pass can see at a
 * glance which tickets have a problem on them rather than reading every line.
 */
.ticket-rail__card[data-has-void="true"] {
  /*
   * The SAME inset ring `[data-all-struck]` already uses, in the red rather than the ember. A
   * coloured strip down one edge would have been the decorative tell the anti-slop rulebook bans;
   * reusing the ring the board already speaks means the card has one way of saying "look at me"
   * and only the colour says which kind.
   */
  box-shadow: inset 0 0 0 2px #9a1c12;
}

.ticket-rail__card[data-all-struck="true"] {
  box-shadow: inset 0 0 0 2px var(--ember-600);
  transition: box-shadow var(--dur-base) var(--ease-standard);
}

/* ── The timer turning ────────────────────────────────────────────────────
 * Three states, each carrying a WORD as well as a hue (1.4.1). The dot is a second, non-colour
 * signal: filled, half, hollow.
 */
.ticket-card__timer {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  font-family: var(--font-data);
  font-variant-numeric: tabular-nums;
  transition: color var(--dur-base) var(--ease-standard);
}

/*
 * Raw ramp values, and NO dark-theme override. The card never goes dark, so an override for dark
 * mode would have applied a dark-surface colour to a light card — which is exactly the defect this
 * block used to have. All three measured against `--paper-card` #fbfcfd:
 *   fresh #4a5d71 → 6.26:1 · warn #a8420a → 5.62:1 · late #9a1c12 → 7.06:1
 */
.ticket-card__timer[data-timer-state="fresh"] {
  color: var(--steel-600);
}

.ticket-card__timer[data-timer-state="warn"] {
  color: var(--ember-600);
}

.ticket-card__timer[data-timer-state="late"] {
  color: #9a1c12;
}

.timer-dot {
  width: 10px;
  height: 10px;
  border-radius: var(--radius-pill);
  border: 2px solid currentcolor;
  transition: background-color var(--dur-base) var(--ease-standard);
}

.ticket-card__timer[data-timer-state="fresh"] .timer-dot {
  background-color: currentcolor;
}

.ticket-card__timer[data-timer-state="warn"] .timer-dot {
  background-color: transparent;
}

.ticket-card__timer[data-timer-state="late"] .timer-dot {
  background-color: transparent;
  border-style: dashed;
}

.timer-word {
  font-size: var(--size-label);
  letter-spacing: 0.04em;
  text-transform: uppercase;
}

/* The attention pulse, and the only part of the timer removed under reduced motion. */
.ticket-card__timer[data-timer-pulse="true"] {
  animation: timer-pulse 600ms var(--ease-out) 1;
}

@keyframes timer-pulse {
  0% {
    transform: scale(1);
  }

  40% {
    transform: scale(1.12);
  }

  100% {
    transform: scale(1);
  }
}

/* ── Routing board ────────────────────────────────────────────────────────
 * A tap moves an item to the next station. Drag is additional, never required (WCAG 2.5.7).
 */
.routing-board {
  display: grid;
  gap: var(--space-4);
  margin-top: var(--space-4);
}

@media (min-width: 640px) {
  .routing-board {
    grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
  }
}

.routing-station {
  border: 1px solid var(--rule-structural);
  border-radius: var(--radius-md);
  background: var(--surface-card);
  padding: var(--space-3);
  min-height: 132px;
  transition:
    background-color var(--dur-base) var(--ease-standard),
    box-shadow var(--dur-base) var(--ease-standard);
}

.routing-station[data-drop-target="true"],
.routing-station[data-received="true"] {
  background-color: var(--surface-accent-wash);
  box-shadow: inset 0 0 0 2px var(--action-fill);
}

.routing-station__name {
  font-family: var(--font-data);
  font-size: var(--size-label);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-secondary);
  margin: 0 0 var(--space-2);
}

.routing-station__items {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: var(--space-2);
}

.routing-item {
  display: block;
  width: 100%;
  min-height: 44px;
  text-align: start;
  font: inherit;
  color: var(--text-primary);
  background: var(--surface-raised);
  border: 1px solid var(--rule-structural);
  border-radius: var(--radius-sm);
  padding: var(--space-2) var(--space-3);
  cursor: pointer;
  transition:
    background-color var(--dur-fast) var(--ease-standard),
    transform var(--dur-fast) var(--ease-standard);
}

.routing-item:hover {
  background-color: var(--surface-accent-wash);
}

.routing-item:active {
  transform: scale(0.985);
}

.routing-item[data-dragging="true"] {
  opacity: 0.55;
}

.routing-status {
  font-family: var(--font-data);
  font-size: var(--size-label);
  color: var(--text-secondary);
  min-height: 22px;
}

/* ── The offline moment ───────────────────────────────────────────────────── */
.offline-demo__controls {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-3);
  margin-bottom: var(--space-4);
}

.offline-demo[data-network="offline"] {
  box-shadow: inset 0 0 0 2px var(--rule-structural);
  transition: box-shadow var(--dur-base) var(--ease-standard);
}

/*
 * 44x44 floor on the controls added in this pass.
 *
 * `button--sm` renders at 40px high. WCAG 2.5.8 AA only asks for 24x24, so 40px is not a criterion
 * failure — but WEB-DESIGN-SPEC.md §9.2 sets the house floor at 44px on every target, and the undo
 * has to be hittable with a wet thumb inside a four-second window. The proof harness measured 128x40
 * and failed it.
 *
 * Scoped to the controls this pass introduced rather than raised on `button--sm` globally: that
 * class is used across pages other lanes own, and a site-wide 4px height change is not this
 * branch's to make. Reported as a finding instead.
 */
.rail-undo [data-rail-undo-button],
.offline-demo__controls [data-network-toggle] {
  min-height: 44px;
}

/*
 * `[hidden]` must actually hide.
 *
 * The UA sheet's `[hidden] { display: none }` is a type-selector-strength rule, so ANY class that
 * sets `display` beats it — and `.button` sets `display: inline-flex`. The undo button therefore
 * rendered visible on page load with its `hidden` attribute present and correct, which is worse
 * than a styling slip: it advertised an undo for a bump that had not happened.
 *
 * Caught by looking at a 390px screenshot, not by any assertion. The harness now asserts it too.
 */
[hidden] {
  display: none !important;
}

.rail-undo {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-3);
  min-height: 44px;
}

/* The card header holds a number, a channel and a three-part timer. At 390px it must wrap rather
 * than squeeze the timer, because the timer is the thing the section is about. */
.ticket-card__header {
  flex-wrap: wrap;
  gap: var(--space-2);
}

.rail-undo__status {
  font-family: var(--font-data);
  font-size: var(--size-label);
  color: var(--text-secondary);
}

/* ── M1: focus indicators must survive forced-colors ──────────────────────
 * `/contact`'s four fields use `outline: none` plus a `box-shadow` ring
 * (components.css:653-657). In normal mode that ring is fine — 5.93:1, comfortably over 1.4.11.
 * In Windows High Contrast / forced-colors, `box-shadow` IS NOT RENDERED AT ALL, so all four
 * fields lose every focus indicator and a keyboard user loses their place entirely.
 *
 * The fix is additive and cannot regress the normal-mode ring: it only applies where the shadow
 * has already stopped existing. `Highlight` is a system colour, so it is correct by definition in
 * whatever theme the user has chosen.
 */
@media (forced-colors: active) {
  .form-field input:focus,
  .form-field textarea:focus,
  .form-field select:focus {
    outline: 2px solid Highlight;
    outline-offset: 2px;
  }

  /* Same class of defect anywhere else this pass introduced a shadow-only state. */
  .ticket-rail__card[data-all-struck="true"],
  .routing-station[data-drop-target="true"],
  .routing-station[data-received="true"] {
    outline: 2px solid Highlight;
    outline-offset: -2px;
  }
}

/* ── M2: the header row must fit at 390px under 1.4.12 text-spacing ───────
 * Measured on `/es` at 390px with word-spacing .16em and letter-spacing .12em applied: the header
 * row was 424px wide in a 390px viewport — wordmark 176 + switcher 69 + CTA 82 + toggle 44 + gaps.
 * The nav toggle was the visible offender only because it is last in the row.
 *
 * Two independent fixes, because there were two independent causes:
 *   1. The toggle sized itself from its own translated label. It is now a fixed 44x44 square.
 *   2. The row itself did not fit. The accessory moves into the drawer at drawer width.
 */
.site-header[data-nav="drawer"] .nav-toggle {
  flex-shrink: 0;
  width: 44px;
  min-width: 44px;
  height: 44px;
  padding: 0;
  gap: 0;
  overflow: hidden;
}

.site-header[data-nav="drawer"] .nav-toggle__label {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/*
 * At drawer width the header carries the wordmark, the CTA and the toggle, and nothing else. The
 * accessory moves into the drawer, where it has room and where the nav links already live.
 *
 * This is what actually fixes M2. Squaring the toggle stopped it growing with its own label, but
 * the row was still 424px wide because every sibling kept its full width.
 */
.site-header[data-nav="drawer"] .site-header__accessory {
  display: none;
}

.nav-drawer__accessory {
  margin-top: var(--space-6);
  padding-top: var(--space-6);
  border-top: 1px solid var(--rule-decorative);
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-4);
}

.site-header[data-nav="drawer"] .wordmark {
  min-width: 0;
}

/*
 * The hamburger. Three 2px rules, drawn in CSS, never an emoji and never an icon font.
 *
 * The two outer bars are pseudo-elements with a `background`, NOT `box-shadow`. The first version
 * of this rule used `box-shadow: 0 -6px 0, 0 6px 0` — which is the exact property the M1 fix above
 * exists because of. `box-shadow` is not rendered under `forced-colors: active`, so the icon would
 * have degraded to a single bar: a fix for a forced-colors defect shipping a new forced-colors
 * defect in the same file.
 */
.nav-toggle__icon {
  display: block;
  position: relative;
  width: 18px;
  height: 2px;
  background: currentcolor;
}

.nav-toggle__icon::before,
.nav-toggle__icon::after {
  content: "";
  position: absolute;
  left: 0;
  width: 18px;
  height: 2px;
  background: currentcolor;
}

.nav-toggle__icon::before {
  top: -6px;
}

.nav-toggle__icon::after {
  top: 6px;
}

.site-header[data-nav="inline"] .nav-toggle__icon {
  display: none;
}

/* ── B1: the pause control (WCAG 2.2.2) ───────────────────────────────────── */
.timer-controls {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-3);
  margin-bottom: var(--space-4);
}

.timer-controls__note {
  font-family: var(--font-data);
  font-size: var(--size-label);
  color: var(--text-secondary);
}

.timer-controls [data-timer-pause-button] {
  min-height: 44px;
}

/* ── Strikeable checklist (W3 / F4 / S1) ──────────────────────────────────
 * The same gesture as a ticket line, applied to the visitor's own list.
 */
.strike-list {
  margin-top: var(--space-8);
}

.strike-list__items {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: var(--space-2);
}

.strike-list__row {
  display: flex;
  align-items: flex-start;
  gap: var(--space-3);
  width: 100%;
  min-height: 44px;
  text-align: start;
  font: inherit;
  font-size: var(--size-body);
  color: var(--text-primary);
  background: none;
  border: 0;
  border-radius: var(--radius-sm);
  padding: var(--space-2) var(--space-3);
  cursor: pointer;
  transition: background-color var(--dur-fast) var(--ease-standard);
}

.strike-list__row:hover {
  background-color: var(--surface-raised);
}

/* The checkbox mark. A square that fills — never colour alone, and never an emoji. */
.strike-list__mark {
  flex-shrink: 0;
  width: 20px;
  height: 20px;
  margin-top: 3px;
  border: 2px solid var(--rule-structural);
  border-radius: var(--radius-sm);
  background: transparent;
  transition: background-color var(--dur-fast) var(--ease-standard);
}

.strike-list__row[data-struck="true"] .strike-list__mark {
  background-color: var(--action-fill);
  border-color: var(--action-fill);
}

.strike-list__row[data-struck="true"] {
  color: var(--text-secondary);
}

.strike-list__progress {
  margin-top: var(--space-4);
  font-family: var(--font-data);
  font-size: var(--size-label);
  color: var(--text-secondary);
}

.queue-count {
  font-family: var(--font-data);
  font-size: var(--size-small);
  color: var(--text-secondary);
}

.queue-list {
  list-style: none;
  margin: var(--space-3) 0 0;
  padding: 0;
  display: grid;
  gap: var(--space-2);
}

.queue-list__item {
  font-family: var(--font-data);
  font-size: var(--size-small);
  color: var(--text-secondary);
  border-inline-start: 2px solid var(--rule-structural);
  padding-inline-start: var(--space-3);
  transition:
    opacity var(--dur-base) var(--ease-in),
    transform var(--dur-base) var(--ease-in);
}

.queue-list__item[data-syncing="true"] {
  opacity: 0;
  transform: translateX(24px);
}

/* ── Replacement ticket arriving, and the bump leaving ────────────────────── */
.ticket-rail__card[data-replacement="true"] {
  will-change: transform;
}

/* ── Disclosure panels: contents lift, the box does not tween its height ──── */
[data-disclosure-panel][data-entered="true"] {
  animation: disclosure-in var(--dur-base) var(--ease-out) 1;
}

@keyframes disclosure-in {
  from {
    opacity: 0;
    transform: translateY(-6px);
  }

  to {
    opacity: 1;
    transform: translateY(0);
  }
}

/* ── The /about "will not do" markers, wired to hover and focus-within ────── */
.marker-list li:hover::before,
.marker-list li:focus-within::before {
  transform: scaleX(1);
}

/* ── Scroll-reveal ─────────────────────────────────────────────────────────
 * There is NO generic `[data-reveal] { opacity: 0 }` rule here, on purpose. The hidden state is
 * applied by JavaScript, only after it has confirmed it can remove it again. A CSS-hidden reveal
 * is how a page ships blank to anyone whose observer never fires.
 */

/* ── The pass band: two photographs counter-drifting inside fixed frames ───
 * `/`, directly below the hero rail. See src/site/home-photo-band.mjs.
 *
 * ⚠️ THIS IS NOT A SCROLL FADE-IN, AND IT IS NOT ONE BY DESIGN RATHER THAN BY COINCIDENCE.
 * Opacity is never touched here. Nothing is hidden and then shown, so there is no state in which
 * this band is absent: a browser with no support for scroll-driven animation, a browser with
 * JavaScript off, and a visitor who has asked for reduced motion all get the finished band
 * immediately, at the resting crop. The fade-in-everything reveal was refused for this page, and
 * this is what replaced it — continuous motion the visitor drives with the scrollbar, not a
 * one-shot entrance that plays whether they are looking or not.
 *
 * WHAT IT DOES. The two frames drift in OPPOSITE directions as the band crosses the viewport: the
 * wide frame's photograph rises through its crop, the short frame's settles. A pass runs in two
 * directions at once — food out, orders in — and that is the whole idea being drawn. The two
 * frames are already offset vertically in the layout, so the counter-motion reads as the pair
 * working against each other rather than as one block sliding.
 *
 * WHY IT COSTS NOTHING. The travel is 6% of the frame height, which is exactly the vertical
 * headroom `object-fit: cover` was already discarding (a 1264x848 source in a 16/9 box). The
 * transform is composited, `will-change` is deliberately absent — a scroll-driven animation is
 * already promoted by the compositor and a standing `will-change` on two large images pins two
 * layers for the life of the page — and no layout property is touched, so perf-guard is satisfied
 * by construction rather than by exemption.
 *
 * Desktop only. Below 768px the frames do not crop, so there is no headroom to spend and moving
 * the image would drag its own edge into view.
 */
@media (min-width: 768px) {
  /* The resting position: the 6% of headroom, split evenly top and bottom. This is what every
   * browser paints, and it is what the animation returns to at the middle of its range. */
  .pass-band__frame .photo__img {
    transform: translate3d(0, -3%, 0);
  }

  @supports (animation-timeline: view()) {
    .pass-band__frame--lead .photo__img {
      animation: pass-drift-rise linear both;
      animation-timeline: view();
      animation-range: entry 20% exit 80%;
    }

    .pass-band__frame--trail .photo__img {
      animation: pass-drift-settle linear both;
      animation-timeline: view();
      animation-range: entry 20% exit 80%;
    }
  }
}

@keyframes pass-drift-rise {
  from {
    transform: translate3d(0, 0, 0);
  }
  to {
    transform: translate3d(0, -6%, 0);
  }
}

@keyframes pass-drift-settle {
  from {
    transform: translate3d(0, -6%, 0);
  }
  to {
    transform: translate3d(0, 0, 0);
  }
}

/* ── Reduced motion (§7.4) ─────────────────────────────────────────────────
 * base.css already collapses durations globally. These are the widget-level exceptions: the state
 * changes that must SURVIVE the collapse, because they are information rather than decoration.
 */
@media (prefers-reduced-motion: reduce) {
  /* The strike-through still appears — it just does not draw. */
  button.ticket-card__item[data-struck="true"] .ticket-card__item-text::after {
    transform: scaleX(1) !important;
  }

  /* The made marker still appears. */
  button.ticket-card__item[data-struck="true"] .ticket-card__made {
    opacity: 1 !important;
  }

  /* The timer still changes COLOUR AND WORD. Only the pulse is removed. */
  .ticket-card__timer[data-timer-pulse="true"] {
    animation: none !important;
  }

  /* The routed item still lands in its new station; it simply does not travel. */
  .routing-station[data-received="true"] {
    box-shadow: inset 0 0 0 2px var(--action-fill) !important;
  }

  /* The queue still drains; the items simply vanish rather than sliding out. */
  .queue-list__item[data-syncing="true"] {
    opacity: 0 !important;
  }

  /* The disclosure panel still opens instantly with its content in place. */
  [data-disclosure-panel][data-entered="true"] {
    animation: none !important;
    opacity: 1 !important;
  }

  /* The "will not do" marker still marks. */
  .marker-list li:hover::before,
  .marker-list li:focus-within::before {
    transform: scaleX(1) !important;
  }

  /*
   * The pass band is STILL THERE, still cropped, still composed — it simply does not drift. The
   * drift is decoration and carries no information, so unlike the strike-through and the timer
   * above there is nothing here that has to survive the collapse. Pinning the transform back to
   * the resting position is what makes that true: without it the animation's `both` fill would
   * leave whichever frame the scroll last touched parked at one end of its travel.
   */
  .pass-band__frame .photo__img {
    animation: none !important;
    transform: translate3d(0, -3%, 0) !important;
  }
}

/* ══════════════════════════════════════════════════════════════════════════════════════════════
 * THE HOMEPAGE BOARD RUN — `src/site/kds-band.mjs`. SITE-LOCAL, `/` ONLY.
 *
 * ## Why this is its own file rather than more of `components.css`
 *
 * Four lanes are rebuilding this homepage at once and `components.css` belongs to one of them. A
 * shared 4,000-line stylesheet is where two lanes discover each other by conflict at merge time.
 * Everything here is namespaced under `.kdsrun` / `.kdsboard` / `.kdsband` / `.eightysix` /
 * `.timer-legend`, none of which existed before, so this file can only affect the run it styles.
 *
 * It is registered in `buildStyles()` AFTER `motion.css` — the two rules below that override
 * `.ticket-rail`'s bleed need to win on cascade order, and raising specificity to beat a stylesheet
 * loaded later would be the wrong instrument for a file that is simply newer.
 *
 * ## The one structural idea
 *
 * ONE board, FIVE stops — one board ELEMENT, shared by all five, so a ticket bumped at 18:10 is
 * still gone at 20:15. That is a fact about the DOM, not about the layout, and it holds at every
 * width. The board is full width of the content track and the five stops run beneath it; it does
 * not stick at any width, for the measured reasons recorded further down (too tall at 390, and
 * still too tall at 1440 once it is wide enough to show whole tickets). Each stop carries a real
 * anchor back up to it. Nothing here is scroll-jacking: the page scrolls at exactly its own speed
 * and nothing is pinned or intercepted.
 *
 * ## Motion floor
 *
 * Transform and opacity only. No `stroke-dashoffset` (spent elsewhere on this page), no width,
 * height, top or margin transitions — `findLayoutAnimations()` in build.mjs rejects those and it is
 * right to. Micro 100-200ms, standard 200-400ms, stagger 40-80ms.
 * ══════════════════════════════════════════════════════════════════════════════════════════════ */

/* The run takes its top rhythm from the band above it, the same clamp `.stopsec + .stopsec` uses.
 * `.kdsrun` is not a `.stopsec` — it is a container OF them — so the adjacent-sibling rule cannot
 * reach it, and this is the honest way to say "it joins the rhythm" without a magic number. */
.stopsec + .kdsrun,
.kdsrun + .stopsec {
  margin-top: clamp(3rem, 6vw, 5.5rem);
}

.kdsrun {
  position: relative;
  min-width: 0;
}

.kdsrun__stops,
.kdsrun__board {
  min-width: 0;
}

/* ── Phone and tablet: one column, and the board DOES NOT STICK ─────────────────────────────── */

/*
 * ⛔ NO STICKY BOARD BELOW 1041px, AND THE NUMBER IS MEASURED RATHER THAN GUESSED.
 *
 * At 390x844 the board renders 772px tall — 91% of the viewport. Its parts, measured in Chrome on
 * the built page: bar 49, timer controls 78, rail 377 (the card alone is 345), undo 44, foot 86,
 * disclosure 68. Stuck to the top of a phone that leaves roughly 70px for the stop the visitor is
 * trying to read, and every control below it is under the board: Playwright could not click "86 the
 * mussels" at all, reporting `<span class="board__label"> from <div class="kdsrun__board"> subtree
 * intercepts pointer events`. That is not a tuning problem. Trimming every part listed above still
 * leaves ~626px, so there is no version of this that fits.
 *
 * A capped box with `overflow-y: auto` was the other candidate and is worse: a nested vertical
 * scroller inside a vertically scrolling page is a scroll trap on touch, and it would hide the
 * cards it exists to show.
 *
 * So below 1041 the board scrolls like everything else and each stop carries a real link back up to
 * it. 1041 is not a new breakpoint invented here — it is the exact width at which `.railpage` stops
 * being a sticky two-column layout and the door rail stops being a sticky column. The page already
 * says "no sticky furniture below this width"; the board agrees with it.
 */
.kdsrun__board {
  margin-bottom: var(--space-8);
}

/*
 * The anchor target. A jump to the board must not land under the sticky furniture, and this is the
 * same `--pinoff` measurement `.stopsec` uses — not a second guess at the header's height.
 *
 * ⚠️ IT READ `--hdrh` UNTIL 2026-08-24 AND THAT WAS A HEADER, NOT THE FURNITURE. Below 1041 the
 * rail lies down under the header, so "the way back to the board" landed the board's own top edge
 * 186px under the rail — on the one link whose entire job is to show you the board.
 */
.kdsboard {
  scroll-margin-top: calc(var(--pinoff, 64px) + 1rem);
}

/*
 * THE WAY BACK. Below 1041 the board is above you; a stop that asks you to press something and
 * watch the board is useless without a route to it. A real `<a href="#home-board">`, so it works
 * with the keyboard, with a screen reader, and with JavaScript off.
 */
.kdsband__back {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  min-height: 44px;
  margin-top: var(--space-6);
  /*
   * ── 1.4.3 CONTRAST. THIS LINK DECLARED NO COLOUR AT ALL, IN EITHER THEME ──────────────────
   *
   * The rules above style it for LAYOUT and there is no global `a { color }` anywhere in
   * `src/styles`, so it fell all the way through to the UA defaults: #0000ee unvisited and
   * #551a8b visited. On the dark page #551a8b on `--ground` (#0f1720) measures 1.64:1 against a
   * 4.5:1 floor — a link the visitor has already followed once becomes very close to invisible,
   * and it is the link whose entire job is getting them back to the board.
   *
   * This is the SECOND instance of exactly this defect. `.language-switcher` was the first,
   * measured at 1.92:1 in dark mode and repaired on the same reasoning
   * (`components.css:1251`); this link was left behind in that pass. Both had the same cause: a
   * rule that positions an anchor reads as a rule that styles it.
   *
   * `.text-link`'s treatment rather than a local invention — `--text-accent` with a 1px underline
   * that thickens on hover and focus — because this IS an inline text link and the site already
   * has one answer for those.
   *
   * ⛔ `:visited` IS DECLARED EXPLICITLY EVEN THOUGH IT DOES NOT HAVE TO BE. An author `color` on
   * the element already beats the UA's `a:visited` on cascade origin, so this line changes no
   * pixel today. It is here because the failure it guards against is silent: if some later rule
   * sets colour on a `:link` or `:hover` selector, the UA purple returns for visited links ALONE,
   * on a state most testing never enters. Making it explicit costs one declaration and removes
   * the whole class of regression. Both selectors carry the SAME token, so the two states cannot
   * disagree — which is the property MR-R-40103 asserts.
   *
   * ── THE FOUR NUMBERS, AND WHICH TWO OF THEM ARE MEASURED ─────────────────────────────────
   *
   *   light  default  6.09:1   MEASURED — computed colour, opacity chain folded, glyph #a84109
   *   dark   default  6.01:1   MEASURED — same method, glyph #e4780a
   *   light  visited  6.09:1   NOT MEASURED — identical by construction, see below
   *   dark   visited  6.01:1   NOT MEASURED — identical by construction, see below
   *
   * ⚠️ THE VISITED PAIR IS AN ARGUMENT, NOT A READING, AND SAYING SO IS THE POINT.
   * `getComputedStyle` lies about `:visited` on purpose — every browser returns the unvisited
   * colour so a page cannot sniff history — so no computed-style checker can see a `:visited`
   * defect at all. Reading it off rendered pixels was tried four ways (ephemeral context,
   * persistent profile, synthetic carrier, headed Chrome) and the POSITIVE CONTROL settles it:
   * a bare `<a>` carrying no author colour renders #0000ee before AND after its target is
   * visited, in this harness, at every attempt. The harness never reaches the visited state, so
   * a "SAME" from it is worth nothing and is not quoted as if it were.
   *
   * What IS load-bearing is that both selectors above resolve to one token. Two states painted
   * from the same custom property cannot differ, whatever the harness can or cannot see.
   */
  color: var(--text-accent);
  text-decoration: underline;
  text-underline-offset: 3px;
  text-decoration-thickness: 1px;
  transition: text-decoration-thickness var(--dur-fast) var(--ease-standard);
}

.kdsband__back:visited {
  color: var(--text-accent);
}

.kdsband__back:hover,
.kdsband__back:focus-visible {
  text-decoration-thickness: 2px;
}

/* The link is shown at EVERY width. It used to be hidden above 1041px, where a sticky board sat
 * beside the stops and the link would have pointed at something already on screen. The board is no
 * longer sticky at any width — see the memo below — so the way back is needed everywhere. */

/* ── Desktop: the board is FULL WIDTH of the content track, and the stops run beneath it ─────── */

/*
 * ⛔ THE BOARD IS NOT A HALF-WIDTH COLUMN, AND THE REASON IS A COUNT OF WHOLE TICKETS.
 *
 * Until 2026-08-24 `.kdsrun` was a two-column grid above 1041px — `minmax(0, 1.05fr) minmax(0, 1fr)`
 * — with the board sticky in the left track. The CEO's report was that the kitchen system "looks
 * good on mobile but terrible on desktop", and the measurement says exactly why. Measured on the
 * built page in Chrome, board column vs the rail's own client width vs how many of the four 320px
 * ticket cards land WHOLE inside it:
 *
 *     1440  board 561  rail 527  →  1 whole ticket
 *     1280  board 488  rail 454  →  1 whole ticket
 *     1024  board 992  rail 958  →  2 whole tickets   (already full width; the grid starts at 1041)
 *      390  board 358  rail 324  →  1 whole ticket
 *
 * A 1440px desktop was showing the SAME number of whole tickets as a 390px phone — one — and
 * spending the other 900px on a text column. The second card was cut mid-word ("Chicken sandwi")
 * and the third and fourth were off the end. That is what read as broken: not a scroller, which
 * `.ticket-rail` legitimately is, but a board that appears to hold one and a half tickets.
 *
 * ⛔ THE DEFECT WAS NEVER "OVERFLOW IS CLIPPED". `.ticket-rail` is `overflow-x: auto` with
 * `tabindex="0"`, so it scrolls, it is keyboard-reachable, and the part-card at its right edge is
 * the DELIBERATE overrun signal documented in components.css — present and correct at 390 too,
 * which is the width the CEO says looks good. Anything measuring "is the last card partly outside
 * the rail box" reports true at every width including the good one and is measuring the
 * affordance, not the bug. The quantity that separates good from terrible is WHOLE TICKETS
 * VISIBLE, and it is the one asserted in the tests.
 *
 * Full width of the content track buys three whole tickets at 1440 (rail 1117) and two at 1280
 * (rail 970), with the fourth still peeking — the overrun signal survives. A board is a wide
 * instrument, the page's own door rail already takes 206px on the left, and the prose it used to
 * sit beside is capped at 75ch by `.body` regardless of the track it flows in, so nothing is lost
 * by putting the copy underneath.
 *
 * ## Why the board no longer sticks, and what replaces it
 *
 * A full-width board cannot also be sticky: it measures 679px tall at 1440 and a 900px viewport
 * would keep 221px for the stop the visitor is reading, which is the same failure the phone memo
 * above records at 390. So the board scrolls, and the `.kdsband__back` anchor in every stop — the
 * mechanism the sub-1041 layout already relies on — is now shown at all widths. One shape at every
 * width, and it is the shape that was already approved on the phone.
 *
 * The alternative that keeps sticky (widening the board track to 690px inside the grid) was built
 * and looked at: it yields two whole tickets at both 1440 and 1280, leaves the copy at 263px at
 * 1280 — too narrow to read — and the rail's trailing pad lands so that NO fourth card peeks, so
 * the board reads as if it holds exactly two tickets. It was worse on the thing being fixed.
 */

/* ── The board ──────────────────────────────────────────────────────────────────────────────── */

.kdsboard {
  min-width: 0;
}

/*
 * ⛔ THE RAIL'S BLEED IS CANCELLED INSIDE THE BOARD, AND THAT IS NOT A STYLE PREFERENCE.
 *
 * `.ticket-rail` carries `margin-inline: calc(-1 * var(--gutter))` and `padding-inline: var(--gutter)
 * 25vw` so it runs off the right edge of the PAGE — the correct behaviour on `/kds`, where it is
 * the full-width subject. Inside a sticky half-width column those negative margins escape the grid
 * track and put a horizontal scrollbar on the document, which is the exact defect
 * `.stopsec--bleed`'s memo in components.css records being measured at 1440 and reverted.
 *
 * The overrun signal is kept — there is still more board than fits, which is the truth about a
 * kitchen at 7pm — it is just expressed as trailing padding inside the column instead of as a
 * negative margin outside it.
 */
.kdsboard .ticket-rail {
  margin-inline: 0;
  padding-inline: 0 22%;
}

/*
 * ⛔ AT 320px THE CARD WAS WIDER THAN ITS OWN RAIL AND CUT A TABLE NUMBER IN HALF.
 *
 * Measured on the built homepage at 320x900: viewport 320 → board 288 (16px page gutter each
 * side) → rail client width 254 (17px board padding each side). The card is 280. So the FIRST
 * card overhung its own scroll window by 26px and ticket #141's header rendered `Dine-in 1` where
 * the order says `Dine-in 12`. Not a cosmetic truncation — a wrong table number, on a card whose
 * whole job is to be the card the product draws.
 *
 * ⛔ SCOPED TO `.kdsboard`, AND THE SCOPE IS MEASURED RATHER THAN CAUTIOUS. The same rule written
 * globally on `.ticket-rail__card` also hits `/kds`, where the rail is full-bleed
 * (`margin-inline: calc(-1 * var(--gutter))`) and so is 288 wide at a 320 viewport — the 280 card
 * already fits WHOLE there (measured: rail 288, card 280, 1 whole card). Globally it would have
 * shrunk a correct card by 23% to fix a page that was not broken. The board is narrower than the
 * bleeding rail by exactly the board's own padding, and that is the whole reason this is a
 * board-local rule.
 *
 * A percentage cannot express it: the rail's trailing `padding-inline-end` is part of the padding
 * box, so `width: 100%` on a flex item resolves against a content box already shrunk by it and
 * lands far too small. The viewport is the honest reference. 6.5rem = 104px is 32px of page
 * gutter plus 34px of board padding plus a 40px peek of the next card, so the overrun signal
 * survives at the narrowest width too.
 *
 * The window closes at 384px, where `100vw - 6.5rem` reaches 280 and the base rule takes over
 * again — continuous, and no other width and no other page is touched.
 */
@media (max-width: 383px) {
  .kdsboard .ticket-rail__card {
    width: calc(100vw - 6.5rem);
  }
}

/*
 * ⛔ THE CONTROLS INSIDE THE BOARD WERE INVISIBLE, AND ONLY LOOKING AT THE PAGE FOUND IT.
 *
 * `.board__body` sets a dark ground (`--steel-950`) and `--text-on-dark`. Three things inside it
 * do not inherit that and were rendering dark-on-dark: `.button--secondary` hard-codes
 * `color: var(--text-primary)` with a `--rule-structural` ring, and the timer note and the undo
 * status take their own muted-on-light greys. Screenshotted at 1440: the pause button and the undo
 * button were EMPTY DARK RECTANGLES with a faint outline, and "Timers run at 4x speed." and
 * "Order #142 bumped." were barely legible.
 *
 * Every test passed. `aria-pressed` was correct, the labels were in the DOM, the buttons were
 * focusable and clickable, the hit-test found them. There is no assertion that would have caught
 * it — which is the whole argument for looking at the render, and the reason the WCAG 2.2 AA
 * contrast floor is a default requirement rather than an audit item.
 *
 * `--text-on-dark` measures 15.44:1 on `--steel-950` and `--text-on-dark-secondary` 8.13:1; both
 * clear 4.5:1 with room. The ring moves to `--steel-800`-plus-currentcolor so the control still
 * reads as a control.
 */
.kdsboard .board__body .button--secondary {
  color: var(--text-on-dark);
  box-shadow: inset 0 0 0 1px currentcolor;
}

.kdsboard .board__body .timer-controls__note,
.kdsboard .board__body .rail-undo__status {
  color: var(--text-on-dark-secondary);
}

.kdsboard__note {
  margin-top: var(--space-3);
  opacity: 0.8;
}

/*
 * ═══════════════════════════════════════════════════════════════════════════════════════════════
 * THE PRODUCT TALLY — across the top, and the placement is arithmetic rather than taste.
 *
 * The CEO's reference board (`kds2-1.png`, RestApp) runs its product column down the LEFT. It can
 * afford to: its tickets wrap into a grid. Ours is a horizontal scroll-snap rail and
 * `test/a11y.kds-board.browser.mjs` pins the whole-ticket count at every width — 3 at 1440, 2 at
 * 1280/1024/900/768, 1 at 390/320. Those are the numbers of the desktop defect the CEO reported on
 * 2026-08-24, fixed the same day.
 *
 * MEASURED, at 1440, on the built page: the rail is 1117px and holds THREE whole 320px cards plus
 * the deliberate overrun. Take 200px for a product column and 32px for a gap and the rail is 885px
 * — two whole cards and most of a third. So a left column costs a whole ticket at exactly the
 * width the CEO looks at, in exchange for a feature whose entire purpose is making the board
 * easier to read. That is the trade refused here, and the number is `proofs/board-broad-design.mjs`
 * output rather than an estimate.
 *
 * Across the top it costs vertical space, which the page has by ruling (RULINGS.md amendment 6:
 * no height ceiling) and zero horizontal space, which the board does not have. Below 768px it
 * collapses to a single wrapping row of chips, because a station-grouped column on a phone is
 * taller than the board it summarises.
 * ═══════════════════════════════════════════════════════════════════════════════════════════════
 */

.tally {
  margin-bottom: var(--space-4);
  padding-bottom: var(--space-3);
  border-bottom: 1px solid var(--steel-800);
}

.tally__head {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: var(--space-2) var(--space-4);
  margin-bottom: var(--space-3);
}

.tally__title {
  margin: 0;
  font-family: var(--font-data);
  font-size: var(--size-label);
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-on-dark);
}

.tally__note {
  margin: 0;
  /* 8.13:1 on --steel-950. The strip sits on the board's dark ground, not on a card. */
  color: var(--text-on-dark-secondary);
}

.tally__groups {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-3) var(--space-8);
}

.tally__group {
  min-width: 0;
}

.tally__station {
  margin: 0 0 var(--space-1);
  font-family: var(--font-data);
  font-size: var(--size-label);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-on-dark-secondary);
}

.tally__list {
  margin: 0;
  padding: 0;
  list-style: none;
  display: grid;
  gap: 2px;
}

/*
 * The row is a three-column grid so every quantity in a station lines up in one column — which is
 * the entire reason a cook can read a tally faster than four cards. `1fr auto auto` rather than a
 * fixed name width: an item name is content and sets its own column, and the two right-hand cells
 * are the two things the eye is scanning for.
 */
.tally__row {
  display: grid;
  grid-template-columns: 1fr auto auto;
  align-items: baseline;
  gap: var(--space-2);
  /*
   * The row carries no control — nothing here is tappable, so no target-size floor applies to it.
   * The height is set for READING DISTANCE on a wall screen, which is the only reason a tally is
   * worth vertical space at all.
   */
  min-height: 28px;
  padding-block: var(--space-1);
  font-size: var(--size-small);
  color: var(--text-on-dark);
}

.tally__qty {
  font-family: var(--font-data);
  font-variant-numeric: tabular-nums;
  font-weight: 700;
  min-width: 2ch;
  text-align: end;
}

.tally__flag {
  font-family: var(--font-data);
  font-size: var(--size-label);
  letter-spacing: 0.06em;
  text-transform: uppercase;
}

/*
 * ⛔ NEVER COLOUR ALONE (1.4.1). Each state's colour is paired with the word `tally.js` writes into
 * `[data-tally-flag]` — "86" or "all made" — so the strip is fully readable in greyscale and to
 * anyone who cannot separate the red from the grey. The empty flag on a pending row is correct:
 * "pending" is what the count already says.
 */
.tally__row[data-state="clear"] {
  color: var(--text-on-dark-secondary);
}

.tally__row[data-state="void"] {
  /* #ff8a80 on --steel-950 measures 8.4:1. The card's #9a1c12 red is unreadable on this ground. */
  color: #ff8a80;
}

.tally__row[data-state="void"] .tally__qty {
  text-decoration: line-through;
}

@media (max-width: 767px) {
  /*
   * On a phone the station headings and the column grid cost more height than they return, so the
   * strip becomes one wrapping row. Every number is still here and still derived; what is dropped
   * is the grouping, which is the part that only pays off on a wall screen a cook is standing back
   * from.
   */
  .tally__groups {
    gap: var(--space-2) var(--space-4);
  }

  .tally__station {
    display: none;
  }

  .tally__list {
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-2) var(--space-4);
  }

  .tally__row {
    grid-template-columns: auto auto auto;
    /*
     * MEASURED, NOT TRIMMED BY EYE. At 320 the strip rendered 252px tall — within 60px of a whole
     * ticket card, above a board that only fits one card at that width. The wall-screen reading
     * height is the wrong trade on a phone held at arm's length, so the row height and the block
     * padding both go and the strip lands at roughly 150px. Every number is still present; what is
     * removed is air.
     */
    min-height: 0;
    padding-block: 0;
  }

  .tally {
    margin-bottom: var(--space-3);
    padding-bottom: var(--space-2);
  }

  .tally__head {
    gap: 0 var(--space-3);
    margin-bottom: var(--space-2);
  }
}

/* ── RECALL ─────────────────────────────────────────────────────────────────────────────────── */

.rail-recall {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-3);
  margin-top: var(--space-3);
}

.kdsboard .board__body .rail-recall__status {
  color: var(--text-on-dark-secondary);
}

/*
 * `aria-disabled` rather than `disabled` keeps the control in the tab order (see the memo in
 * `ticket-rail.mjs`), so it needs a visual "not yet" that is NOT the browser's disabled grey —
 * which would be a contrast failure and a lie about focusability. Reduced opacity on a control
 * that still focuses, still has a visible ring, and still says why in the readout beside it.
 */
.rail-recall [aria-disabled="true"] {
  opacity: 0.62;
}

/* ── THE HEADER CARRIES MORE ────────────────────────────────────────────────────────────────── */

/*
 * Server and table on every card, taken from the reference board. Two lines rather than one row:
 * MR-R-40136 exists because `Dine-in 12` rendered as `Dine-in 1` at 320px, and adding a third
 * thing to that single row is how that defect comes back. The server name takes its own line, so
 * the reference row is no more crowded than it was.
 */
.ticket-card__ref {
  display: grid;
  gap: 2px;
  min-width: 0;
}

/* The reference line is ONE grid child. See the memo in `ticket-card.mjs`: without this wrapper
 * the header renders `#141`, `·` and `Dine-in 12` on three separate rows. */
.ticket-card__line {
  display: block;
}

.ticket-card__server {
  font-family: var(--font-body);
  font-size: var(--size-label);
  font-weight: 500;
  color: var(--steel-600); /* 6.26:1 on --paper-card */
}

/*
 * ⛔ THE HEADER TINTS WITH AGE — the reference board's clearest idea and the cheapest to get wrong.
 *
 * Theirs paints the whole header red or green. Ours tints it, at roughly 8% of the ramp colour,
 * for a measured reason: the header holds the ticket number, the table, the server and the timer,
 * and a saturated ground under all four either fails 4.5:1 or forces every one of them to change
 * colour with the clock. A tint keeps `--steel-950` text on a near-`--steel-100` ground — the
 * contrast the header already passed — and still turns the whole card's top edge amber then red
 * from across a kitchen, which is the only distance the tint is for.
 *
 * It is NOT the only signal, and it never was: the timer's own colour, its dot, and the word
 * beside it ("On time" / "Pushing" / "Late") all still change. This adds reach, not information.
 *
 * `:has()` because `data-timer-state` belongs to the timer element and `timer.js`'s own memo is
 * explicit that nothing outside that module may write it. Mirroring the state onto the card would
 * mean two writers for one fact.
 *
 * Scoped to `.kdsboard` — the homepage board is the surface under review. `/kds` renders the same
 * component and is one selector away if the CEO wants it there too.
 */
.kdsboard .ticket-card__header:has([data-timer-state="warn"]) {
  background: color-mix(in srgb, var(--ember-600) 10%, var(--steel-100));
}

.kdsboard .ticket-card__header:has([data-timer-state="late"]) {
  background: color-mix(in srgb, #9a1c12 12%, var(--steel-100));
}

.kdsboard button.ticket-card__header:has([data-timer-state="warn"]):hover {
  background: color-mix(in srgb, var(--ember-600) 16%, var(--steel-200));
}

.kdsboard button.ticket-card__header:has([data-timer-state="late"]):hover {
  background: color-mix(in srgb, #9a1c12 18%, var(--steel-200));
}

/* The board's own frame reads as furniture rather than as a card competing with the stops. */
.board--home .board__body {
  padding-block-end: var(--space-2);
}

/* ── A stop ─────────────────────────────────────────────────────────────────────────────────── */

.kdsband__lede {
  margin-top: var(--space-6);
}

.kdsband__how {
  margin: var(--space-8) 0 0;
  padding: 0;
  list-style: none;
  display: grid;
  gap: var(--space-2);
}

.kdsband__how li {
  position: relative;
  padding-inline-start: var(--space-6);
}

.kdsband__how li::before {
  content: "";
  position: absolute;
  inset-inline-start: 0;
  top: 0.55em;
  width: 10px;
  height: 2px;
  background: currentcolor;
  opacity: 0.55;
}

.kdsband__controls {
  margin-top: var(--space-6);
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-4);
  align-items: center;
}

.kdsband__hint {
  margin-top: var(--space-6);
  opacity: 0.8;
}

/* ── The 86 controls ────────────────────────────────────────────────────────────────────────── */

.eightysix {
  display: grid;
  gap: var(--space-2);
}

/* 44x44 minimum tap target, stated rather than inherited and hoped for. */
.eightysix__btn {
  min-height: 44px;
}

/*
 * The pressed state is carried by a SHAPE as well as by a colour: the button takes a solid fill and
 * an inset ring. 1.4.1 — an owner with a red-green deficiency has to be able to see which items are
 * off, and this audience is heavily male.
 */
.eightysix__btn[aria-pressed="true"] {
  background: var(--steel-100);
  box-shadow: inset 0 0 0 2px currentcolor;
}

.eightysix__count,
/*
 * The rush stop's receipt joins the 86 readout's treatment rather than getting one of its own.
 * Two readouts sitting one stop apart, saying the same KIND of thing — what the board did when
 * you pressed the thing — that render differently is two idioms where the page has one.
 *
 * ⚠️ THE `0.8` IS AN OPACITY AND IT IS PART OF THE CONTRAST SUM. MEASURED on the built page with
 * the ancestor opacity chain folded in: the receipt paints #646e79 on white at 5.18:1, and #95a1ac
 * on `--ground` at 6.87:1. Both clear the 4.5:1 floor and neither has much room, which is the
 * reason for writing them down.
 *
 * ⛔ THE TOKEN'S OWN RATIO IS 9.07:1 AND QUOTING IT HERE WOULD BE A LIE BY OMISSION. That is the
 * number an opacity-blind checker reports, it is 75% higher than what the element actually paints,
 * and it is how the logo wall shipped at 3.27:1 while a checker said it passed. Every ratio in this
 * file is the composited one.
 */
.kdsband__receipt {
  opacity: 0.8;
}

/* ── The timer legend ───────────────────────────────────────────────────────────────────────── */

/*
 * The legend's dots reproduce `.ticket-card__timer`'s three states EXACTLY — filled, hollow, hollow
 * dashed — because a legend whose swatch does not match the thing it explains is worse than no
 * legend. The colours are the same three tokens, which are the ones measured at 6.26:1, 5.62:1 and
 * 7.06:1 against `--paper-card`.
 */
.timer-legend {
  margin: var(--space-6) 0 0;
  padding: 0;
  list-style: none;
  display: grid;
  gap: var(--space-3);
}

.timer-legend__row {
  display: flex;
  align-items: center;
  gap: var(--space-3);
}

.timer-legend .timer-dot[data-timer-state="fresh"] {
  color: var(--steel-600);
  background-color: currentcolor;
}

.timer-legend .timer-dot[data-timer-state="warn"] {
  color: var(--ember-600);
  background-color: transparent;
}

.timer-legend .timer-dot[data-timer-state="late"] {
  color: #9a1c12;
  background-color: transparent;
  border-style: dashed;
}

/* ── Reduced motion ─────────────────────────────────────────────────────────────────────────── */

/*
 * ⛔ NOTHING STRUCTURAL IS REMOVED HERE.
 *
 * `prefers-reduced-motion` is a request for less TRAVEL, not for less information. The things that
 * actually travel — the bump, the deal-in, the 60ms cascade, the queue flush — are each collapsed
 * to their end state in their own widget, where the state change survives and only the journey
 * goes. A visitor who presses "86 the mussels" still gets the three struck lines; they reach them
 * by the `.kdsband__back` anchor, which is a link rather than a movement and so is unaffected by
 * this query.
 *
 * (This memo used to argue that the sticky board must survive reduced motion. The board no longer
 * sticks at any width — see the full-width memo above — so the argument no longer has a subject.)
 *
 * What is removed here is the only decoration in this file: the dot's colour transition.
 */
@media (prefers-reduced-motion: reduce) {
  .timer-legend .timer-dot {
    transition: none;
  }
}

/*
 * ═══════════════════════════════════════════════════════════════════════════════════════════════
 * STOP MIRRORS — the consequence, brought to the control, at about an eighth of the scale.
 *
 * ## The measurement that ruled out the obvious fix
 *
 * Every control in this run acts on a rail roughly 800px above it on a phone. The obvious answer
 * is a sticky board and it is DEAD: the board is 772px tall on a 390x844 viewport (see the
 * full-width memo above), so a stuck board leaves ~70px for the stop you are reading and puts
 * every control underneath itself. That is not a taste disagreement to be reopened; it is a
 * measurement.
 *
 * ## ⛔ INDICATOR, NEVER INSTRUMENT
 *
 * Nothing below draws a ticket. There is no `.tk`, no `ticket-card__*`, no number, no `d:dd` age
 * and no bump control anywhere in this section, and `src/site/stop-mirror.mjs` is written to pass
 * `assertNoCargo` even on `/`, which holds the cargo licence and would have let it through. The
 * licence covers the board; it is not a budget to spend on decoration further down the page.
 *
 * ## ⛔ TRANSFORM AND OPACITY ONLY
 *
 * `src/checks/perf-guard.mjs` refuses an animated layout property and the build runs it over this
 * file. `stroke-dashoffset` is deliberately NOT spent here even though it would be allowed: these
 * mirrors sit inches from a running clock, and a second animated stroke in the same eyeline is two
 * things competing to be the thing that moved.
 * ═══════════════════════════════════════════════════════════════════════════════════════════════
 */

.stopmirror {
  /* --space-5 does not exist. The scale is 1,2,3,4,6,8,10,12,16,20,24,32,40 — it SKIPS 5,
   * and a var() that resolves to nothing is invalid at computed-value time, so this margin
   * was silently rendering as ZERO. --space-6 (24px) is the nearest step up from the 16px
   * the author reached past. Caught by the console lane's undefined-token check, not by
   * any marketing test. */
  margin: var(--space-6) 0 0;
}

/* ── The rail spine ─────────────────────────────────────────────────────────────────────────── */

.stopmirror--spine {
  display: flex;
  align-items: center;
  gap: var(--space-2);
}

/*
 * A LOZENGE IS A CARD AT ABOUT AN EIGHTH OF THE SCALE, and the proportion is taken from the thing
 * it mirrors rather than invented: the rail's card is 320px wide (`components.css`), so 40px is
 * the eighth. The height is the smallest bar that still reads as a shape rather than as a rule.
 */
.stopmirror__lozenge {
  inline-size: 40px;
  block-size: 10px;
  border-radius: 2px;
  background-color: var(--steel-600);
  opacity: 0.85;
  transition:
    opacity var(--dur-base) var(--ease-out),
    transform var(--dur-base) var(--ease-out);
}

/*
 * THE EMPTY SLOT IS DRAWN, NOT REMOVED. A strip that shortened as cards left would say "the rail
 * is smaller now", which is the opposite of what a bump means — the board still holds four slots
 * and one of them is clear. Outline rather than fill, which is the same device the timer legend
 * uses for a state that is not the resting one.
 */
.stopmirror__lozenge[data-filled="false"] {
  background-color: transparent;
  box-shadow: inset 0 0 0 1px var(--rule-structural);
  opacity: 1;
}

.stopmirror__read {
  font-family: var(--font-data);
  font-size: var(--size-small);
  color: var(--text-secondary);
  margin-inline-start: var(--space-2);
}

/*
 * THE MOTION SAYS WHICH DIRECTION, because a card LEAVING and a card ARRIVING must not draw the
 * same picture. `data-moved` is set by the widget for 400ms and removed again; it is about the
 * easing and never about the count.
 */
.stopmirror--spine[data-moved="out"] .stopmirror__lozenge {
  transform: translateX(-6px);
}

.stopmirror--spine[data-moved="in"] .stopmirror__lozenge {
  transform: translateX(6px);
}

/* ── The threshold ramp ─────────────────────────────────────────────────────────────────────── */

.stopmirror--ramp {
  max-inline-size: 420px;
}

/*
 * The marker moves on a group `transform`, written by the widget in the drawing's own units. The
 * transition is here rather than inline so `prefers-reduced-motion` can remove it in one place.
 */
.stopmirror--ramp [data-ramp-marker] {
  transition: transform var(--dur-base) var(--ease-out);
}

/*
 * THE THREE STATES ARE THE TIMER'S THREE TOKENS, and they are the same values `.timer-legend`
 * paints its dots with — 6.26:1, 5.62:1 and 7.06:1 measured against `--paper-card`. A mirror that
 * invented a fourth colour for "getting on" would be a vocabulary this board does not have.
 *
 * ⚠️ COLOUR IS NOT THE ONLY CARRIER AND IT MUST NEVER BECOME ONE. The ramp is `aria-hidden` and
 * the three states are written out in words in the `.timer-legend` immediately above it, which is
 * what carries SC 1.4.1 here. If the legend is ever removed from this band, this drawing goes with
 * it.
 */
.stopmirror--ramp[data-ramp-state="warn"] {
  --text-accent: var(--ember-600);
}

.stopmirror--ramp[data-ramp-state="late"] {
  --text-accent: #9a1c12;
}

/*
 * THE LEGEND ROW THAT MATCHES GOES LIVE. This is the whole of "the legend goes live": the row
 * naming the state the board is actually in gains weight and its own left rule. It is a READOUT of
 * `data-ramp-state`, written by the same painter that moves the marker, so the list and the
 * drawing can never disagree.
 */
.timer-legend__row[data-live="true"] {
  font-weight: 600;
}

.timer-legend__row[data-live="false"] {
  opacity: 0.62;
}

/* ── The offline queue, as an ember strip ───────────────────────────────────────────────────── */

/*
 * ⛔ ONE `<ul>`, ONE WIDGET, TWO SKINS — and this is the skin, not a second queue.
 *
 * `client/widgets/offline.js` appends one `<li class="queue-list__item">` per held change and
 * removes them oldest first on reconnect. It knows nothing about this class. So a bar can never
 * disagree with the counter beside it: there is one writer, and it is not the stylesheet.
 *
 * The item's text ("Bump #141") is clipped rather than deleted, because deleting it would mean
 * teaching the widget about the skin — which is the coupling this arrangement exists to avoid —
 * and because the string is still the accessible content of a list the counter announces.
 */
.queue-list--ember {
  gap: 3px;
  margin-top: var(--space-3);
}

.queue-list--ember .queue-list__item {
  block-size: 4px;
  inline-size: clamp(64px, 40%, 160px);
  border-radius: 2px;
  border-inline-start: 0;
  padding-inline-start: 0;
  background-color: var(--ember-600);
  overflow: hidden;
  text-indent: -200vw;
  white-space: nowrap;
}

/* ── Reduced motion ─────────────────────────────────────────────────────────────────────────── */

/*
 * The state survives; the travel goes. A lozenge still empties, the ramp's marker still sits at
 * the right place on the rule, the ember strip still gains and loses bars. What is removed is the
 * journey between two states, which is this site's motion policy stated once in `main.js:9-12`.
 */
@media (prefers-reduced-motion: reduce) {
  .stopmirror__lozenge,
  .stopmirror--ramp [data-ramp-marker] {
    transition: none;
  }

  .stopmirror--spine[data-moved="out"] .stopmirror__lozenge,
  .stopmirror--spine[data-moved="in"] .stopmirror__lozenge {
    transform: none;
  }
}

/*
 * THE COST PROJECTOR AND INLINE VALIDATION — CEO brief 2026-08-19.
 *
 * Its own file rather than another 200 lines on the end of motion.css, because it is a component
 * with a chart in it rather than a site-wide motion rule, and motion.css is already the longest
 * sheet here. Added to the cascade list in `build.mjs` LAST, so it can override `components.css`
 * for the one rule that needs to (`.form-field__error`).
 *
 * The reasoning behind the widget — why it is not an ROI calculator, why it never animates on
 * scroll, and why the axis is shared — lives in `src/site/cost-projector.mjs`. What matters HERE:
 *
 *   NOTHING IN THIS FILE TRANSITIONS A PROPERTY THAT CAN MOVE A BOX. The plot is a fixed-height
 *   grid, every bar is a full-height element scaled with `transform`, and the digits are
 *   tabular so a counting number does not resize its own container. `src/checks/perf-guard.mjs`
 *   rejects the build if that ever stops being true, so this is enforced rather than promised.
 *
 *   NO NEW COLOUR AND NO NEW FONT. Two series, two of the palette's existing three hues. There is
 *   no red in this design system, so the validation error uses `--text-accent` — the existing
 *   accent role — rather than a `--color-error` invented in a stylesheet.
 */

.projector {
  margin-top: var(--space-8);
  border: 1px solid var(--rule-structural);
  border-radius: var(--radius-lg);
  background: var(--surface-card);
  padding: var(--space-6);
}

.projector__controls {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-8);
  align-items: flex-start;
}

.projector__field {
  border: 0;
  margin: 0;
  padding: 0;
  min-width: 0;
}

.projector__label {
  display: block;
  font-family: var(--font-data);
  font-size: var(--size-label);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-secondary);
  padding: 0;
  margin-bottom: var(--space-3);
}

.projector__stepper {
  display: flex;
  align-items: stretch;
  gap: var(--space-2);
}

/* 44x44 floor (WEB-DESIGN-SPEC.md §9.2). The stepper is the most-tapped control on the page. */
.projector__step {
  min-width: 44px;
  min-height: 44px;
  font: inherit;
  font-size: var(--size-h4);
  line-height: 1;
  color: var(--text-primary);
  background: var(--surface-raised);
  border: 1px solid var(--rule-structural);
  border-radius: var(--radius-md);
  cursor: pointer;
  transition:
    background-color var(--dur-fast) var(--ease-standard),
    transform var(--dur-instant) var(--ease-standard);
}

.projector__step:hover {
  background-color: var(--surface-accent-wash);
}

/*
 * Press feedback, and it is the only thing on this widget that is purely tactile. It is `:active`
 * rather than `:hover` on purpose — the brief forbids relying on hover for anything critical, and
 * a phone has no hover at all, so the confirmation that a tap registered must come from the press.
 */
.projector__step:active {
  transform: scale(0.94);
}

.projector__input {
  width: 5rem;
  min-height: 44px;
  font-family: var(--font-data);
  font-size: var(--size-data-lg);
  font-variant-numeric: tabular-nums;
  text-align: center;
  color: var(--text-primary);
  background: var(--surface-page);
  border: 1px solid var(--rule-structural);
  border-radius: var(--radius-md);
}

.projector__radios {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-4);
}

.projector__radio {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  min-height: 44px;
}

.projector__radio label {
  font-size: var(--size-body);
  color: var(--text-primary);
}

.projector__figures {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-8);
  margin: var(--space-8) 0 0;
}

.projector__figure {
  min-width: 0;
}

.projector__term {
  font-family: var(--font-data);
  font-size: var(--size-label);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-secondary);
}

/*
 * `tabular-nums` is load-bearing, not typographic taste. The figure counts through intermediate
 * values, and proportional digits change the string's width on almost every frame — a box that
 * resizes sixty times a second inside a flex row. Fixed-width digits make the count-up inert.
 */
.projector__value {
  margin: var(--space-1) 0 0;
  font-variant-numeric: tabular-nums;
  color: var(--text-primary);
}

.projector__note {
  margin: var(--space-1) 0 0;
  font-size: var(--size-small);
  color: var(--text-secondary);
  max-width: 32ch;
}

.projector__chart-figure {
  margin: var(--space-8) 0 0;
}

/*
 * FIXED HEIGHT. This is the declaration that guarantees zero layout shift: whatever the numbers
 * do, the plot occupies exactly this much space, so nothing below the chart ever moves.
 */
.projector-chart {
  height: 180px;
  padding-bottom: var(--space-6);
}

.projector-chart__plot {
  display: grid;
  grid-template-columns: repeat(12, 1fr);
  gap: var(--space-1);
  height: 100%;
  align-items: end;
}

.projector-chart__col {
  display: grid;
  grid-template-rows: 1fr auto;
  height: 100%;
  min-width: 0;
}

.projector-chart__stack {
  position: relative;
  height: 100%;
  display: flex;
  align-items: flex-end;
  gap: 1px;
}

/*
 * Each bar is a FULL-HEIGHT box scaled down from its base. Animating `height` is the obvious
 * implementation and the build rejects it.
 */
.projector-chart__bar {
  display: block;
  flex: 1 1 0;
  min-width: 0;
  height: 100%;
  transform-origin: bottom center;
  border-radius: var(--radius-sm) var(--radius-sm) 0 0;
}

/*
 * Two series, two of the palette's three hues, nothing invented. Steel is the neutral majority
 * hue and ember the accent — the right pairing for "the ordinary case" against "the one you are
 * being asked to choose". They are distinguishable without colour too: the annual series is the
 * flat one, and both are named in the legend and spelled out in the caption.
 */
.projector-chart__bar--monthly {
  background-color: var(--steel-500);
}

.projector-chart__bar--annual {
  background-color: var(--ember-500);
}

.projector-chart__tick {
  font-family: var(--font-data);
  font-size: var(--size-label);
  color: var(--text-secondary);
  text-align: center;
  padding-top: var(--space-2);
}

.projector__legend {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-6);
  margin-top: var(--space-4);
}

/* The key is a swatch AND a word. Never colour alone (1.4.1). */
.projector__key {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  font-family: var(--font-data);
  font-size: var(--size-label);
  color: var(--text-secondary);
}

.projector__key::before {
  content: "";
  width: 14px;
  height: 14px;
  border-radius: var(--radius-sm);
}

.projector__key--monthly::before {
  background-color: var(--steel-500);
}

.projector__key--annual::before {
  background-color: var(--ember-500);
}

.projector__caption {
  margin-top: var(--space-4);
  font-size: var(--size-small);
  color: var(--text-secondary);
  max-width: 68ch;
}

/*
 * TRANSITIONS ARE OPT-IN, and the widget only opts in after its first render and never under
 * reduced motion. Without this attribute the bars have no transition at all, which is what stops
 * twenty-four of them animating up from zero on page load — motion `/pricing` is not allowed to
 * have, and a count-up nobody asked for.
 */
.projector[data-animate="true"] .projector-chart__bar {
  transition: transform var(--dur-base) var(--ease-out);
}

/* ── Inline validation (`/contact`) ─────────────────────────────────────── */

/*
 * The message node is rendered EMPTY and always present so `aria-describedby` is associated before
 * anything is wrong. An empty paragraph would still occupy a line box and push the next field
 * down when it filled, so it is collapsed until it has something to say — and because it collapses
 * on `:empty` rather than being inserted by script, the reflow happens at the moment the visitor
 * caused it and never during page load.
 */
.form-field__error:empty {
  display: none;
}

.form-field__error {
  margin: var(--space-2) 0 0;
  font-size: var(--size-small);
  color: var(--text-accent);
}

/* The border is a SECOND signal behind the sentence, never the only one (1.4.1). */
.form-field input[aria-invalid="true"],
.form-field textarea[aria-invalid="true"] {
  /* ⛔ --late, NOT --text-accent. Fixed 2026-08-25.
   *
   * This rule sits in a LATER stylesheet than the identical one in components.css, so it wins
   * on source order and it is the one that ships. components.css was corrected to --late on
   * 08-24 and that fix has been DEAD CODE ever since — the invalid field really rendered
   * rgb(168,66,10), which is the ember focus ring. An invalid field was drawing itself as a
   * focused field, which is the defect the components.css comment says it fixed. Two copies of
   * one rule is how that hid. */
  border-color: var(--late);
  transition: border-color var(--dur-fast) var(--ease-standard);
}

@media (prefers-reduced-motion: reduce) {
  /*
   * ONE rule here, and the absence of the other two is deliberate.
   *
   * `base.css` already collapses `transition-duration` and `animation-duration` to 0.01ms on
   * `*, *::before, *::after` with `!important`. Every TRANSITION in this file is therefore already
   * dead under reduced motion — the bar tween and the invalid-border fade both measured 0.01ms in
   * a real reduced-motion browser context (`MR-R-30271`). Re-declaring `transition: none` for them
   * here would be a rule that cannot be observed doing anything: deleting it changes no measurable
   * behaviour, which is the definition of code that has not earned its place. It was written, it
   * was measured, and it was removed.
   *
   * The press scale is a DIFFERENT KIND OF THING and the global collapse does not touch it.
   * `:active { transform: scale(0.94) }` is a state, not a transition — collapsing its duration
   * only makes it snap to 0.94 instantly, which is a jolt rather than a fade. It has to be
   * cancelled by value, so it is, and it is the only thing in this block.
   *
   * WHAT SURVIVES under reduced motion, in full: every number, the caption rewrite, the
   * live-region announcement, all twenty-four bar heights, the error text and the invalid border.
   * §7.4 — collapse the movement, keep the state change perceptible.
   */
  .projector__step:active {
    transform: none;
  }
}

/* BUILD IDENTITY — see src/build-identity.mjs. Hashed WITH this file, deliberately. */
:root { --build: "0781a5b25ae8ff25926aab4e4c1b9ca0e8997a23" }
