/* ===========================================================================
   [BRAND] — CORE. Tokens, reset, typography, focus, motion, utilities and
   every primitive rendered by src/views/components.tsx. Loaded by EVERY page.
   Surface bundles (scan/app/marketing/admin.css) add composition only and
   never redefine a token. Source: docs/06-ui-ux.md 2.1-2.8 and 9.

   USING THE TOKENS
   1. Never write a raw colour, size, radius, shadow or duration downstream.
      Missing value? Add a token here. No magic numbers below this file.
   2. Colour is chosen by ROLE, not lightness:
        --c-bg  page   --c-surface  card   --c-surface-2  panel inside a card
        --c-surface-3  deepest inset
        --c-ink  text   --c-ink-2  helper/timestamps   --c-ink-3  placeholder
        --c-border        decorative hairline — NEVER a control's only edge
        --c-border-strong a control's edge (>= 3:1 in both themes)
      Each --c-*-soft background has a matching --c-* foreground >= 4.5:1 on
      it, and --c-ink is legible on it too. Each fill has an --c-on-* partner;
      use the partner or the pair is unproven.
   3. BRAND vs STATE. --c-primary (neel indigo) is identity: primary actions,
      links, active nav. --c-accent (peacock teal) is a decorative second
      voice for illustration and section marks. NEITHER ever carries state.
      State is only success/warning/danger/emergency, and never colour alone:
      every badge and callout also carries an icon and words (SC 1.4.1).
   4. SPACING --s-1 (4px) .. --s-16 (64px). Use the .u-* utilities for one-off
      margins: the CSP is `style-src 'self'`, so an inline style= attribute is
      silently DROPPED by the browser. It is not a shortcut, it is a no-op.
   5. TYPE tokens are size/line-height pairs that grow at 768px on their own.
      --t-hero is the landing hero; --t-display is a screen's one hero line.
   6. ELEVATION differs by theme on purpose: light lifts with shadow, dark
      lifts with a LIGHTER SURFACE plus a hairline highlight (--elev-lift),
      because shadow on near-black reads as dirt. Use --elev-1|2|3 only.
   7. MOTION: --motion-instant|fast|base|slow|deliberate with --ease-standard|
      decelerate|accelerate|spring, and --stagger-step. Nothing animates on its
      own and nothing loops in the periphery; the whole library is defined
      inside prefers-reduced-motion: no-preference, so it cannot be bypassed.
      Read "THE MOTION LAYER" below before adding an animation anywhere.

   Contrast is not a matter of taste: scripts/contrast.mjs parses THIS file,
   recomputes every documented pairing in both themes, and exits non-zero
   below 4.5:1 for text or 3:1 for a boundary. Change a hex, run it.
   =========================================================================== */

:root {
  color-scheme: light dark;

  /* Surfaces */
  --c-bg: #fdf8f1;
  --c-surface: #f7efe2;
  --c-surface-2: #f0e5d4;
  --c-surface-3: #e8dac6;
  /* Ink */
  --c-ink: #2a211a;
  --c-ink-2: #5a4a3c;
  --c-ink-3: #6f5a49;
  /* Boundaries */
  --c-border: #e6d8c6;
  --c-border-strong: #8f7350;
  /* Brand: neel indigo */
  --c-primary: #b0421c;
  --c-primary-hover: #8f3414;
  --c-primary-soft: #f8e2d2;
  --c-on-primary: #ffffff;
  /* Accent: peacock teal. Decorative only, never a state. */
  --c-accent: #1c6f52;
  --c-accent-hover: #14543e;
  --c-accent-soft: #d9efe4;
  --c-on-accent: #ffffff;
  /* Semantic */
  --c-success: #15653a;
  --c-success-soft: #dcf5e6;
  --c-on-success: #ffffff;
  --c-warning: #8a4b0b;
  --c-warning-soft: #fdf0d5;
  --c-on-warning: #ffffff;
  --c-danger: #b3241c;
  --c-danger-soft: #fde6e4;
  --c-on-danger: #ffffff;
  --c-emergency: #7a1416;
  --c-on-emergency: #ffffff;
  /* Drawn on --c-emergency, which never themes, so nor does this. The default
     ring is #b0421c in light, which is 1.88:1 on #7a1416 — invisible. */
  --c-focus-on-emergency: #ffb300;
  /* Focus: --c-focus is drawn over bg/surface, --c-focus-on-fill over a fill */
  --c-focus: #b0421c;
  --c-focus-on-fill: #ffb300;
  /* Legacy aliases: older markup names these; they resolve into the ramp. */
  --c-muted: var(--c-ink-2);
  --c-placeholder: var(--c-ink-3);
  --c-divider: var(--c-border);
  --c-focus-on-dark: var(--c-focus-on-fill);
  --c-qr-frame-dark: #241a12;

  /* Type. System stack only: PERF-001 forbids a web font on /c/*, DEC-170
     applies that everywhere — zero requests, zero layout shift, zero cost.
     Devanagari faces are named ahead of the generic for the planned hi-IN. */
  --font-sans: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans",
    "Noto Sans Devanagari", "Nirmala UI", "Helvetica Neue", Arial, sans-serif;
  --font-mono: ui-monospace, "SF Mono", Menlo, Consolas, "Roboto Mono", monospace;
  /* Type in rem, so a reader who enlarges text in their browser or OS actually
     gets larger text. Every one of these was a px literal, which pins the glyph
     against the reader's own setting while the layout tokens (--measure and the
     content widths) are already rem and scale around it — so raising the base
     used to move the columns and leave the words behind.

     The line heights convert with the sizes and MUST stay lengths rather than
     unitless ratios: `p` uses --t-body-lh as its bottom margin. Converting a
     size without its partner would put larger text into an unchanged line box
     and clip the descenders, which is worse than not scaling at all.

     Values are the previous pixels over a 16px base, so rendering at default
     settings is unchanged to the pixel. */
  --t-hero-size: 2.125rem;   --t-hero-lh: 2.5rem;
  --t-display-size: 1.75rem; --t-display-lh: 2.125rem;
  --t-h1-size: 1.5rem;       --t-h1-lh: 1.875rem;
  --t-h2-size: 1.25rem;      --t-h2-lh: 1.625rem;
  --t-h3-size: 1.0625rem;    --t-h3-lh: 1.5rem;
  --t-body-size: 1rem;       --t-body-lh: 1.5rem;
  --t-small-size: 0.875rem;  --t-small-lh: 1.25rem;
  --t-micro-size: 0.75rem;   --t-micro-lh: 1rem;
  --t-code-size: 1.375rem;   --t-code-lh: 1.75rem;
  /* PARAGRAPH RHYTHM, which is not the same thing as line height even though
     `p` used --t-body-lh for both. One value welded the gap BETWEEN paragraphs
     to the leading INSIDE them, so a 14px `.small` paragraph carried the 26px
     gap belonging to 16px body text, and the only way to tighten a page was to
     tighten its leading. Default is byte-identical to what it replaces.

     IT IS RE-DECLARED BESIDE EVERY --t-body-lh, and has to be: a custom
     property is substituted where it is DECLARED, not where it is used, so a
     single `--t-flow: var(--t-body-lh)` here would resolve once against the
     :root line-height and then inherit that fixed length into scopes whose own
     line-height is different — leaving a 26px gap around a 24px line box. */
  --t-flow: var(--t-body-lh);
  --tracking-hero: -0.022em;
  --tracking-tight: -0.012em;
  --tracking-wide: 0.04em;

  /* Space, radius */
  --s-1: 4px;  --s-2: 8px;  --s-3: 12px; --s-4: 16px; --s-5: 20px;
  --s-6: 24px; --s-8: 32px; --s-10: 40px; --s-12: 48px; --s-16: 64px;
  --r-1: 6px; --r-2: 10px; --r-3: 16px; --r-pill: 999px;

  /* Elevation (light lifts with shadow; --elev-lift is a no-op here) */
  --elev-1: 0 1px 2px rgba(22, 24, 43, 0.06);
  --elev-2: 0 1px 2px rgba(22, 24, 43, 0.07), 0 4px 12px rgba(22, 24, 43, 0.06);
  --elev-3: 0 2px 4px rgba(22, 24, 43, 0.08), 0 12px 28px rgba(22, 24, 43, 0.1);
  --elev-lift: transparent;
  --elevation: var(--elev-2);

  /* Motion. The full rationale is in "THE MOTION LAYER" below; this is the
     scale only. Reach for the SHORTEST one that still reads as a change. */
  --motion-instant: 80ms;     /* a state flip that must not look animated */
  --motion-fast: 120ms;       /* hover, press, colour, a 2px nudge */
  --motion-base: 200ms;       /* the default: entrances, popovers, panels */
  --motion-slow: 320ms;       /* a large surface, or a stroke drawing itself */
  --motion-deliberate: 420ms; /* the ONE success moment. Nothing else. */
  --motion-loop: 1400ms;      /* shimmer and sweep — the only repeats allowed */
  --motion-spin: 700ms;       /* the busy spinner */
  /* How far an entrance travels. A hint of origin, never a slide. */
  --motion-rise: 8px;
  /* Easing. Entrances decelerate (arrive fast, settle), exits accelerate
     (leave promptly). --ease-spring overshoots and is for success only. */
  --ease-standard: cubic-bezier(0.2, 0, 0, 1);
  --ease-decelerate: cubic-bezier(0, 0, 0.2, 1);
  --ease-accelerate: cubic-bezier(0.4, 0, 1, 1);
  --ease-spring: cubic-bezier(0.22, 1.28, 0.44, 1);
  /* Stagger. Capped at six steps by .m-stagger, so a long list never crawls. */
  --stagger-step: 40ms;    /* dense rows */
  --stagger-step-lg: 70ms; /* three or four big cards */

  /* Layout */
  --gutter: var(--s-4);
  --measure: 40rem;          /* ~68 characters */
  --content-narrow: 640px;   /* forms, public page */
  --content-wide: 1040px;    /* dashboards, marketing */
  --content-console: 1440px; /* admin tables */
  --tap: 44px;               /* touch floor; the spec's absolute floor is 24 */
}

/* Dark (2.7). Surfaces step UP in lightness with elevation. Both dark blocks
   are kept identical by scripts/contrast.mjs, so the media query and the
   explicit toggle can never drift apart. */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --c-bg: #1a140f;
    --c-surface: #241c15;
    --c-surface-2: #2f251b;
    --c-surface-3: #3a2e22;
    --c-ink: #f6ede2;
    --c-ink-2: #cdb9a6;
    --c-ink-3: #ab9682;
    --c-border: #40342a;
    --c-border-strong: #8f7358;
    --c-primary: #ff9d6b;
    --c-primary-hover: #ffb187;
    --c-primary-soft: #3a2114;
    --c-on-primary: #1a140f;
    --c-accent: #57c79a;
    --c-accent-hover: #74d6ae;
    --c-accent-soft: #103a2a;
    --c-on-accent: #1a140f;
    --c-success: #5fd894;
    --c-success-soft: #0d3a24;
    --c-on-success: #1a140f;
    --c-warning: #f5b93d;
    --c-warning-soft: #3a2a08;
    --c-on-warning: #1a140f;
    --c-danger: #fa8078;
    --c-danger-soft: #3f1a18;
    --c-on-danger: #1a140f;
    --c-emergency: #7a1416;
    --c-on-emergency: #ffffff;
  /* Drawn on --c-emergency, which never themes, so nor does this. The default
     ring is #b0421c in light, which is 1.88:1 on #7a1416 — invisible. */
  --c-focus-on-emergency: #ffb300;
    --c-focus: #ffb300;
    --c-focus-on-fill: #1a140f;
    --elev-1: 0 1px 2px rgba(0, 0, 0, 0.5);
    --elev-2: 0 1px 2px rgba(0, 0, 0, 0.55), 0 6px 16px rgba(0, 0, 0, 0.4);
    --elev-3: 0 2px 4px rgba(0, 0, 0, 0.6), 0 16px 36px rgba(0, 0, 0, 0.5);
    --elev-lift: rgba(255, 255, 255, 0.06);
  }
}

/* A PINNED theme must tell the browser what it pinned. `:root` above declares
   `color-scheme: light dark`, which follows the OS — so a reader on a light OS
   who pins dark gets our dark palette with the browser's LIGHT form controls
   painted into it: an autofilled field arrives as a pale yellow block with the
   reveal icon sitting on top of it, and the caret and the scrollbar are drawn
   for the wrong background. The pin has to move both. */
:root[data-theme="light"] { color-scheme: light; }

:root[data-theme="dark"] {
  color-scheme: dark;
  --c-bg: #1a140f;
  --c-surface: #241c15;
  --c-surface-2: #2f251b;
  --c-surface-3: #3a2e22;
  --c-ink: #f6ede2;
  --c-ink-2: #cdb9a6;
  --c-ink-3: #ab9682;
  --c-border: #40342a;
  --c-border-strong: #8f7358;
  --c-primary: #ff9d6b;
  --c-primary-hover: #ffb187;
  --c-primary-soft: #3a2114;
  --c-on-primary: #1a140f;
  --c-accent: #57c79a;
  --c-accent-hover: #74d6ae;
  --c-accent-soft: #103a2a;
  --c-on-accent: #1a140f;
  --c-success: #5fd894;
  --c-success-soft: #0d3a24;
  --c-on-success: #1a140f;
  --c-warning: #f5b93d;
  --c-warning-soft: #3a2a08;
  --c-on-warning: #1a140f;
  --c-danger: #fa8078;
  --c-danger-soft: #3f1a18;
  --c-on-danger: #1a140f;
  --c-emergency: #7a1416;
  --c-on-emergency: #ffffff;
  /* Drawn on --c-emergency, which never themes, so nor does this. The default
     ring is #b0421c in light, which is 1.88:1 on #7a1416 — invisible. */
  --c-focus-on-emergency: #ffb300;
  --c-focus: #ffb300;
  --c-focus-on-fill: #1a140f;
  --elev-1: 0 1px 2px rgba(0, 0, 0, 0.5);
  --elev-2: 0 1px 2px rgba(0, 0, 0, 0.55), 0 6px 16px rgba(0, 0, 0, 0.4);
  --elev-3: 0 2px 4px rgba(0, 0, 0, 0.6), 0 16px 36px rgba(0, 0, 0, 0.5);
  --elev-lift: rgba(255, 255, 255, 0.06);
}

@media (min-width: 480px) { :root { --gutter: var(--s-6); } }
@media (min-width: 768px) {
  :root {
    --gutter: var(--s-8);
    --t-hero-size: 3.25rem;    --t-hero-lh: 3.5rem;
    --t-display-size: 2.25rem; --t-display-lh: 2.75rem;
    --t-h1-size: 1.875rem;     --t-h1-lh: 2.375rem;
    --t-h2-size: 1.375rem;     --t-h2-lh: 1.875rem;
    --t-h3-size: 1.125rem;     --t-h3-lh: 1.625rem;
    --t-body-lh: 1.625rem;      --t-flow: var(--t-body-lh);
  }
}

/* ---------------------------------------------------------------------------
   DENSITY: the application scope.

   `body.dense` is set by the shells that render SIGNED-IN surfaces — the owner
   app and the admin console — and by nothing else. Those screens are worked in
   rather than read: somebody comes to them knowing what they want, does it, and
   leaves, and the measure of a good one is how much of the answer is on screen
   at once. The marketing pages are read, and keep the roomier scale that suits
   a first visit.

   TWO THINGS ARE ADJUSTED AND NOTHING ELSE — and paragraph rhythm is not one
   of them; see the note under the block. First, the 768px type bump above:
   an app that is already dense at 375px does not need every heading a step
   larger the moment the window widens, and that one block was most of why a
   dashboard felt oversized on a laptop. Second, the upper half of the spacing
   scale — the page air between sections and cards.

   --s-1 .. --s-4 ARE DELIBERATELY ABSENT. They are what a control's own padding
   and the 8px between two adjacent targets are built from (docs/06-ui-ux.md
   2.4); shrinking them shrinks tap targets, which is the one thing density must
   never buy. Every value below is still a multiple of the 4px base unit (2.3),
   and every type value below is a row that already exists in `:root`, so the
   contrast and leading are the ones already proven — this scope chooses among
   existing steps, it does not invent any.

   Below 768px the type half is a measured no-op: the base scale is already the
   smaller one there. -------------------------------------------------------- */
body.dense {
  --s-5: 16px;   /* was 20px */
  --s-6: 20px;   /* was 24px */
  --s-8: 24px;   /* was 32px */
  --s-10: 32px;  /* was 40px */
  --s-12: 36px;  /* was 48px */
  --s-16: 48px;  /* was 64px */
}
/* --t-flow IS NOT PINNED TO A SPACING STEP HERE. The rule is one line-height
   per paragraph (docs 2.2), and density reaches it through the line-height it
   already tightens: the >=768 block below re-declares --t-body-lh at 1.5rem and
   --t-flow with it. Setting --t-flow to var(--s-3) instead put a 12px gap under
   a 16px paragraph while `p:where(.small)` in app.css correctly gave a 14px
   paragraph its own 20px — the smaller text ending up further apart than the
   larger text above it, which is the rhythm the token was introduced to fix,
   inverted. */

@media (min-width: 768px) {
  body.dense {
    --t-display-size: 1.75rem;  --t-display-lh: 2.125rem;
    --t-h1-size: 1.5rem;        --t-h1-lh: 1.875rem;
    --t-h2-size: 1.25rem;       --t-h2-lh: 1.625rem;
    --t-h3-size: 1.0625rem;     --t-h3-lh: 1.5rem;
    --t-body-lh: 1.5rem;        --t-flow: var(--t-body-lh);
  }
}

/* The wordmark's height is the body line box plus its own padding, so the
   tighter line box takes it from 42px to 40px — under the 44px floor, on the
   one control that is the way home from every screen. The header row is
   already min-height 56px, so giving it the floor outright grows nothing. */
body.dense .brand { min-height: var(--tap); }

/* --- Reset and base ------------------------------------------------------ */

*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
  margin: 0; background: var(--c-bg); color: var(--c-ink);
  font-family: var(--font-sans); font-size: var(--t-body-size); line-height: var(--t-body-lh);
  font-synthesis-weight: none; -webkit-font-smoothing: antialiased;
  min-height: 100dvh; display: flex; flex-direction: column;
}
img, svg, video { max-width: 100%; height: auto; }
svg { flex: none; }
h1, h2, h3, h4 { margin: 0 0 var(--s-3); text-wrap: balance; letter-spacing: var(--tracking-tight); }
h1 { font-size: var(--t-h1-size); line-height: var(--t-h1-lh); font-weight: 700; }
h2 { font-size: var(--t-h2-size); line-height: var(--t-h2-lh); font-weight: 650; }
h3 { font-size: var(--t-h3-size); line-height: var(--t-h3-lh); font-weight: 650; }
h4 { font-size: var(--t-body-size); line-height: var(--t-body-lh); font-weight: 650; }
p { margin: 0 0 var(--t-flow); max-width: var(--measure); text-wrap: pretty; }
p:last-child { margin-bottom: 0; }
ul, ol { max-width: var(--measure); }
blockquote { margin: 0; }
hr { border: 0; border-top: 1px solid var(--c-border); margin: var(--s-6) 0; }
table, .tabular, time, .stat__value { font-variant-numeric: tabular-nums; }

/* Inline links are underlined always, not on hover only (2.4). */
a { color: var(--c-primary); text-decoration: underline; text-underline-offset: 2px; text-decoration-thickness: 1px; }
a:hover { color: var(--c-primary-hover); text-decoration-thickness: 2px; }

/* Focus is NEVER removed. 3px ring + 2px offset (SC 2.4.7, 2.4.11). */
:focus-visible { outline: 3px solid var(--c-focus); outline-offset: 2px; border-radius: var(--r-1); }

/* ===========================================================================
   THE MOTION LAYER (docs/06-ui-ux.md 9.9, SC 2.2.2 / 2.3.1 / 2.3.3)
   The reader is annoyed, beside a stranger's car, on a mid-range Android.
   Fast and confident beats elaborate. Five rules, none of them tradeable:

   1. IT EXPLAINS. Every animation answers one question — where did this come
      from, what changed, is it working, did it succeed. If none, delete it.
   2. IT NEVER BLOCKS. Nothing gates a submit, delays a tap or makes anyone
      wait to read. Entrances fill `both`, so the element is already in place.
   3. NOTHING LOOPS IN THE PERIPHERY. The only repeats are the shimmer, the
      sweep and the spinner; each means "still working" and is removed when the
      work ends. .m-attention runs twice and stops. No carousel, no idle
      bounce, no ambient pulse — adding one is a bug.
   4. TRANSFORM AND OPACITY ONLY. Never width/height/top/left/margin, never
      anything layout-triggering on scroll, and an entrance never changes the
      space an element occupies. Two paint-only exceptions, neither on a hot
      path: the box-shadow crossfade on .btn / .card--interactive hover, and
      stroke-dashoffset on .m-draw, which is how a stroke draws at all.
   5. SHORTEST THING THAT READS AS A CHANGE. Most motion is 120-260 ms; only
      --motion-deliberate passes 400 ms, once per screen, for success.

   THE GUARD is section 1 AND the shape of section 3: the library is defined
   inside @media (prefers-reduced-motion: no-preference), so under reduce those
   rules do not exist and no downstream !important can un-hide what was never
   hidden. State still changes under reduce, instantly. So NEVER make
   `opacity: 0` or an off-screen transform an element's BASE state — write it
   as a keyframe `from` and leave the resting state visible.
   =========================================================================== */

/* --- 1. The guard. One authoritative block. ------------------------------- */

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-delay: 0s !important;
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-delay: 0s !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
  /* The one place where "instant" is not enough, because the resting state of
     a sweeping bar is off-screen: under reduce it is a full, static bar. The
     busy spinner needs nothing — it stops as the static ring it already is. */
  .m-progress__bar { transform: none !important; width: 100% !important; }
}

/* --- 2. Structure that must exist with or without motion ------------------
   Geometry lives out here; only the `animation` lives in the guarded block
   below. A loading bar with no motion is still a bar. */

/* `display: block` so it is the same 3px rail whether a surface renders it as
   a <div> (ProgressSweep) or inline as a <span> (the owner surface's rail). */
.m-progress {
  display: block; overflow: hidden; height: 3px; border-radius: var(--r-pill);
  background: var(--c-surface-3);
}
.m-progress__bar {
  display: block; height: 100%; width: 40%;
  border-radius: inherit; background: var(--c-primary);
}
.m-attention { position: relative; }
/* A stroke that draws itself. The path MUST carry `pathLength="100"` — a
   presentation attribute, so no `style=` and no CSP problem — and its resting
   state here is FULLY DRAWN, so it is correct with no animation at all. */
.m-draw { stroke-dasharray: 100; stroke-dashoffset: 0; }

/* --- 3. The library. Absent under reduced motion, by construction. -------- */

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

  /* 3.1 Interaction states. These fire on every tap and hover, so they are
     the cheapest and shortest thing in the file. */

  a, .tab, .badge, .chip, .field__control, .check__input, .choice__input {
    transition:
      background-color var(--motion-fast) var(--ease-standard),
      border-color var(--motion-fast) var(--ease-standard),
      color var(--motion-fast) var(--ease-standard),
      transform var(--motion-instant) var(--ease-standard);
  }
  .btn, .card--interactive, .choice {
    transition:
      background-color var(--motion-fast) var(--ease-standard),
      border-color var(--motion-fast) var(--ease-standard),
      color var(--motion-fast) var(--ease-standard),
      transform var(--motion-fast) var(--ease-standard),
      box-shadow var(--motion-base) var(--ease-standard);
  }
  /* Press. The control moves under the thumb, then returns. */
  .btn:active { transform: translateY(1px) scale(0.985); }
  .m-press:active { transform: scale(0.97); }
  .check__input:active, .choice__input:active { transform: scale(0.9); }
  /* Hover lift, fine pointers only: on touch, :hover sticks after the tap. */
  @media (hover: hover) and (pointer: fine) {
    .card--interactive:hover { transform: translateY(-2px); }
  }
  /* Opt-in smooth scrolling for an in-page anchor. NOT global: the skip link
     must land instantly, and nobody should wait to start reading. */
  .m-smooth { scroll-behavior: smooth; }

  /* 3.2 Entrances. Rise is the default; scale-in is for something that grew
     out of a control (popover, menu, dialog); fade is for a panel swapped in
     place. All end at the element's own position, so none shifts layout. */

  .m-rise { animation: m-rise var(--motion-base) var(--ease-decelerate) var(--m-delay, 0s) both; }
  @keyframes m-rise { from { opacity: 0; transform: translateY(var(--motion-rise)); } to { opacity: 1; transform: none; } }
  .m-scale-in { animation: m-scale-in var(--motion-base) var(--ease-decelerate) var(--m-delay, 0s) both; }
  @keyframes m-scale-in { from { opacity: 0; transform: scale(0.96); } to { opacity: 1; transform: none; } }
  /* Anchor a popover to the control it came from, so it grows the right way. */
  .m-origin-top { transform-origin: top center; }
  .m-origin-bottom { transform-origin: bottom center; }
  .m-fade { animation: m-fade var(--motion-fast) var(--ease-decelerate) var(--m-delay, 0s) both; }
  @keyframes m-fade { from { opacity: 0; } to { opacity: 1; } }
  /* Exit. Shorter and accelerating: leaving should not be dwelt on. */
  .m-exit { animation: m-exit var(--motion-fast) var(--ease-accelerate) var(--m-delay, 0s) both; }
  @keyframes m-exit { from { opacity: 1; transform: none; } to { opacity: 0; transform: scale(0.98); } }

  /* 3.3 Stagger, without a `style=` attribute — the CSP drops those silently.
     `.m-stagger` on the PARENT delays its children; `.m-delay-N` for items
     that are not siblings. Capped at six steps, so row twenty is not slower
     to arrive than row six.

     These set --m-delay, NOT animation-delay, and every animation above reads
     `var(--m-delay, 0s)` out of its shorthand. That is the whole trick: an
     `animation:` shorthand RESETS animation-delay, so a delay class would be
     silently wiped by whichever rule happened to come later in the file. A
     custom property is resolved at used-value time and cannot be. The guard's
     `animation-delay: 0s !important` still beats it, so a delay never survives
     into reduced motion. --m-delay inherits, so a delayed element's own
     animated children arrive with it; set --m-delay: 0s to opt a subtree out. */

  .m-stagger { --m-step: var(--stagger-step); }
  .m-stagger--slow { --m-step: var(--stagger-step-lg); }
  .m-stagger > :nth-child(2) { --m-delay: var(--m-step); }
  .m-stagger > :nth-child(3) { --m-delay: calc(var(--m-step) * 2); }
  .m-stagger > :nth-child(4) { --m-delay: calc(var(--m-step) * 3); }
  .m-stagger > :nth-child(5) { --m-delay: calc(var(--m-step) * 4); }
  .m-stagger > :nth-child(6) { --m-delay: calc(var(--m-step) * 5); }
  .m-stagger > :nth-child(n + 7) { --m-delay: calc(var(--m-step) * 6); }
  .m-delay-1 { --m-delay: var(--stagger-step); }
  .m-delay-2 { --m-delay: calc(var(--stagger-step) * 2); }
  .m-delay-3 { --m-delay: calc(var(--stagger-step) * 3); }
  .m-delay-4 { --m-delay: calc(var(--stagger-step) * 4); }

  /* 3.4 Pending. All three mean "still working" and all three are REMOVED
     when it ends — a loop that outlives its cause is periphery noise. */

  .skeleton::after { animation: m-shimmer var(--motion-loop) linear infinite; }
  @keyframes m-shimmer { from { transform: translateX(-100%); } to { transform: translateX(100%); } }
  .m-progress__bar { transform: translateX(-120%); animation: m-sweep var(--motion-loop) var(--ease-standard) infinite; }
  @keyframes m-sweep { from { transform: translateX(-120%); } to { transform: translateX(300%); } }
  .btn__spinner { animation: m-spin var(--motion-spin) linear infinite; }
  @keyframes m-spin { to { transform: rotate(360deg); } }

  /* 3.5 Outcomes. Success is the one place this product takes a moment: the
     stroke draws, the mark springs in (the only use of --ease-spring and
     --motion-deliberate). Rejection shakes once and stops. */

  .m-draw { animation: m-draw var(--motion-slow) var(--ease-decelerate) var(--m-delay, 0s) both; }
  @keyframes m-draw { from { stroke-dashoffset: 100; } to { stroke-dashoffset: 0; } }
  .m-success { animation: m-success var(--motion-deliberate) var(--ease-spring) var(--m-delay, 0s) both; }
  @keyframes m-success { from { opacity: 0; transform: scale(0.72); } to { opacity: 1; transform: none; } }
  /* Rejected input. Added by an island when a value comes back wrong — NEVER
     bound to [aria-invalid], or a server-rendered error page shakes on load.
     Ends where it started, so nothing is left displaced. */
  .m-shake { animation: m-shake var(--motion-base) var(--ease-standard) var(--m-delay, 0s) both; }
  @keyframes m-shake {
    0%, 100% { transform: none; }
    20% { transform: translateX(-4px); } 40% { transform: translateX(4px); }
    60% { transform: translateX(-3px); } 80% { transform: translateX(2px); }
  }
  /* Attention. ONLY for content genuinely new to this reader — an alert that
     arrived while they were on the page. Two passes and it is gone for good,
     ending at opacity 0, so nothing lingers on a page someone is reading. */
  .m-attention::after {
    content: ""; position: absolute; inset: -2px; border-radius: inherit;
    border: 2px solid var(--c-primary); pointer-events: none; opacity: 0;
    animation: m-attention var(--motion-deliberate) var(--ease-decelerate) var(--m-delay, 0s) 2 both;
  }
  @keyframes m-attention { from { opacity: 0.65; transform: scale(1); } to { opacity: 0; transform: scale(1.03); } }

  /* 3.6 Toast. A toast in the DOM is by definition new, so it enters on its
     own. To dismiss, an island adds `toast--leaving` and removes the node one
     --motion-fast later. */

  .toast { animation: m-toast-in var(--motion-base) var(--ease-decelerate) var(--m-delay, 0s) both; }
  @keyframes m-toast-in { from { opacity: 0; transform: translateY(var(--motion-rise)) scale(0.98); } to { opacity: 1; transform: none; } }
  .toast--leaving { animation: m-exit var(--motion-fast) var(--ease-accelerate) var(--m-delay, 0s) both; }

  /* 3.7 Disclosure. The height snaps and the CONTENT fades in: animating a
     height is a layout animation on every frame, which rule 4 forbids and a
     low-end Android cannot afford. Put `.m-disclosure` on the <details>. */

  .m-disclosure[open] > :not(summary) { animation: m-rise var(--motion-fast) var(--ease-decelerate) var(--m-delay, 0s) both; }

  /* 3.8 Scroll reveal, driven by /static/reveal.js. CONTENT IS VISIBLE BY
     DEFAULT: nothing here hides an element until that script has armed it, it
     only ever arms what is already below the fold, it puts everything back if
     its observer stays silent, and these rules do not exist under reduced
     motion. A missing, blocked or broken script leaves the page readable. */

  .m-reveal-armed { opacity: 0; transform: translateY(var(--motion-rise)); }
  .m-reveal-armed.m-reveal-in {
    opacity: 1; transform: none;
    transition:
      opacity var(--motion-base) var(--ease-decelerate),
      transform var(--motion-base) var(--ease-decelerate);
  }
}

/* --- 4. Print. Paper does not animate. ------------------------------------
   The printable QR kit is a standalone document that does not load this file
   at all (see src/modules/qr/kit-assets.ts), so it is untouched either way.
   This is for the help and legal pages, which people do print. */

@media print {
  *, *::before, *::after { animation: none !important; transition: none !important; }
  /* Whatever the reveal script armed must print as ink, not as blank paper. */
  .m-reveal-armed { opacity: 1 !important; transform: none !important; }
  .m-progress__bar { transform: none !important; width: 100% !important; }
}

/* --- Page shell ---------------------------------------------------------- */

.skip-link {
  position: absolute; left: -9999px; top: var(--s-2); z-index: 100;
  background: var(--c-primary); color: var(--c-on-primary);
  padding: var(--s-3) var(--s-4); border-radius: var(--r-2);
  text-decoration: none; box-shadow: var(--elev-3);
}
.skip-link:focus { left: var(--s-4); }

.site-header { border-bottom: 1px solid var(--c-border); background: var(--c-bg); }
/* Both shells set `viewport-fit=cover`, which puts the page under the notch and
   the rounded corners — and nothing compensated, so on a landscape iPhone the
   first characters of every line sat in the cutout. `max()` keeps the ordinary
   gutter everywhere it is already big enough and only grows where a real inset
   exists, so nothing moves on a device without one. */
.site-header__inner, .site-footer__inner, .page {
  width: 100%; margin-inline: auto;
  padding-left: max(var(--gutter), env(safe-area-inset-left));
  padding-right: max(var(--gutter), env(safe-area-inset-right));
}
.site-header__inner, .site-footer__inner { max-width: var(--content-wide); }
.site-header__inner { display: flex; align-items: center; justify-content: space-between; gap: var(--s-4); min-height: 56px; }

/* The wordmark. A real logotype drops into .brand__mark; nothing else moves. */
.brand {
  display: inline-flex; align-items: center; gap: var(--s-2);
  font-weight: 700; font-size: var(--t-h3-size); letter-spacing: var(--tracking-tight);
  color: var(--c-ink); text-decoration: none; padding: var(--s-2) 0;
}
.brand:hover { color: var(--c-ink); }
.brand__mark { color: var(--c-ink); }

/* ---------------------------------------------------------------------------
   The wordmark: `scan` heavy and dark, `blip` light.

   This is how the physical sticker is set, and it is what makes the name
   recognisable at arm's length on a windscreen. All LOWERCASE — the sticker is
   not set in capitals; the weight and the ink carry the emphasis instead. The
   screen used to render it as one uniform capitalised word, which is not the
   brand.

   TWO SIGNALS, NOT ONE. Weight alone is easy to lose to a synthesised bold or a
   fallback face, so the halves also differ in ink: `scan` takes the darkest
   text colour, `blip` the secondary one. Both are contrast-proven tokens in
   both themes, so the lighter half is never the reason text fails AA.

   The seam has no space in it — they are set as a single word — and the heavy
   half is tracked in slightly, because at 800 it otherwise reads wider than the
   lowercase tail and the two stop looking like one word.
--------------------------------------------------------------------------- */
.wordmark {
  white-space: nowrap;
  text-transform: none;
}
.wordmark__scan {
  font-weight: 800;
  letter-spacing: -0.015em;
  color: var(--c-ink);
}
.wordmark__blip {
  font-weight: 300;
  letter-spacing: 0;
  color: var(--c-ink-2);
}

/* Synthesised bold is uneven at this size; where the stack has real weights it
   uses them, and where it does not the weight contrast still reads. */
@supports (font-variation-settings: normal) {
  .wordmark__scan { font-variation-settings: 'wght' 800; }
  .wordmark__blip { font-variation-settings: 'wght' 300; }
}

.site-nav { display: flex; gap: var(--s-4); align-items: center; flex-wrap: wrap; }
.site-nav a { font-size: var(--t-small-size); padding: var(--s-2) 0; }

/* Header menu — a <details> disclosure, so it opens with no JavaScript, at
   EVERY width. It used to dissolve into an inline row above 768px, which is
   how all eleven owner links ended up competing in one bar; now the bar
   carries the four primary destinations and this holds the rest, grouped.
   The button is also the account surface, so it, not the theme control, keeps
   the right-hand corner. */
/* Brand on the left; menu + theme toggle clustered on the right, so the menu
   panel opens against the screen edge rather than out of the middle. */
.site-header__actions { display: flex; align-items: center; gap: var(--s-3); }
@media (min-width: 768px) { .site-header__actions { gap: var(--s-5); } }

.site-menu { position: relative; }
.site-menu__toggle {
  display: inline-flex; align-items: center; gap: var(--s-2);
  min-height: var(--tap); padding: var(--s-2) var(--s-3);
  border: 1px solid var(--c-border-strong); border-radius: var(--r-2);
  font-size: var(--t-small-size); font-weight: 600; color: var(--c-ink);
  cursor: pointer; list-style: none; white-space: nowrap;
}
.site-menu__toggle::-webkit-details-marker { display: none; }
.site-menu[open] > .site-menu__toggle { border-color: var(--c-primary); color: var(--c-primary); }
.site-menu > .site-nav {
  display: none; position: absolute; right: 0; top: calc(100% + var(--s-2)); z-index: 50;
  flex-direction: column; align-items: stretch; gap: 0; min-width: 13rem;
  padding: var(--s-2); background: var(--c-surface);
  border: 1px solid var(--c-border); border-radius: var(--r-2); box-shadow: var(--elev-3);
}
.site-menu[open] > .site-nav { display: flex; }
.site-menu > .site-nav a { padding: var(--s-3); border-radius: var(--r-1); }
.site-menu > .site-nav a:hover { background: var(--c-surface-2); }

/* The bar: the four primary destinations, and only above 768px. Below that the
   menu carries everything and the bar would just be a scrolling row. */
.site-nav--bar { display: none; }
@media (min-width: 768px) {
  .site-nav--bar { display: flex; flex-wrap: nowrap; gap: var(--s-4); }
  .site-nav--bar a { white-space: nowrap; }
}
/* The panel's groups: a hairline between what you have bought, your account,
   and help-and-the-way-out. A rule rather than a heading, because three
   two-item groups with headings is more furniture than content. */
.site-nav__primary,
.site-nav__group { display: flex; flex-direction: column; }
.site-nav__group + .site-nav__group,
.site-nav__primary + .site-nav__group {
  margin-top: var(--s-2);
  padding-top: var(--s-2);
  border-top: 1px solid var(--c-border);
}

/* The panel repeats the primary links for the phone; above 768px the bar has
   them and the copy in the panel goes away. This rule has to sit AFTER the
   `display: flex` above: same specificity, so source order decides, and while
   it lived in the earlier media block the panel showed all four twice. With
   the copy gone the first group is the panel's first row, so it must not draw
   the rule that separated it from the copy either. */
@media (min-width: 768px) {
  .site-nav__primary { display: none; }
  .site-nav__primary + .site-nav__group {
    margin-top: 0;
    padding-top: 0;
    border-top: 0;
  }
}

/* The bar shrinks before the controls do.

   Four links fit at every width the bar appears at, but `min-width: 0` on the
   cluster is what guarantees it. A flex item will not shrink below its content
   unless told it may, and while the bar still held all eleven owner links this
   was the bug: `.site-header__actions` sized itself to the whole row and grew
   past the end of the header, carrying the theme control and Sign out off the
   right edge. Between 768px and about 1000px there was no way to sign out. */
@media (min-width: 768px) {
  .site-header__actions { min-width: 0; }
  .site-header__actions > .site-nav--bar {
    flex: 0 1 auto; min-width: 0;
    overflow-x: auto; overflow-y: clip; scrollbar-width: thin;
  }
  .site-header__actions > .theme-toggle,
  .site-header__actions > .site-menu { flex: none; }
}

/* On a phone the button is its icon alone: the word costs the width the
   wordmark needs, and a hamburger beside a brand is not ambiguous. The
   accessible name comes from the icon's own title, so nothing is lost. */
.site-menu__label { display: none; }
@media (min-width: 480px) { .site-menu__label { display: inline; } }

main { flex: 1 0 auto; }
.page { max-width: var(--content-narrow); padding-block: var(--s-8); }
.page--wide { max-width: var(--content-wide); }
.page--console { max-width: var(--content-console); }
@media (min-width: 768px) { .page { padding-block: var(--s-12); } }

.site-footer {
  border-top: 1px solid var(--c-border); background: var(--c-surface); color: var(--c-ink-2);
  font-size: var(--t-small-size); line-height: var(--t-small-lh);
  padding-top: var(--s-6);
  /* Clears the home indicator, which the footer runs under with viewport-fit=cover. */
  padding-bottom: calc(var(--s-6) + env(safe-area-inset-bottom));
  margin-top: var(--s-12);
}
/* The simple footer's single nav is a horizontal row. Scoped to the direct
   child so it never reaches the marketing footer's stacked column navs, whose
   title must sit above its links, not beside them. */
.site-footer__inner > nav { display: flex; flex-wrap: wrap; gap: var(--s-2) var(--s-4); margin-bottom: var(--s-3); }
/* Four legal links at 14px with no padding are a 20px-high target. SC 2.5.8
   wants 24px, and a thumb wants more; they get the full tap height without
   pushing the links apart, because the box grows around the text. */
.site-footer__inner > nav > a {
  display: inline-flex; align-items: center; min-height: var(--tap);
}

/* --- Utilities. Use these instead of a style= attribute (see note 4). ----- */

.stack > * + * { margin-top: var(--s-5); }
.stack-tight > * + * { margin-top: var(--s-3); }
.stack-loose > * + * { margin-top: var(--s-8); }
.row { display: flex; flex-wrap: wrap; gap: var(--s-3); align-items: center; }
.row--between { justify-content: space-between; }
.muted { color: var(--c-ink-2); }
.subtle { color: var(--c-ink-3); }
.small { font-size: var(--t-small-size); line-height: var(--t-small-lh); }
.micro { font-size: var(--t-micro-size); line-height: var(--t-micro-lh); font-weight: 500; }
.mono { font-family: var(--font-mono); }
.center { text-align: center; }
.measure { max-width: var(--measure); }
.strong { font-weight: 650; }
.t-hero { font-size: var(--t-hero-size); line-height: var(--t-hero-lh); font-weight: 700; letter-spacing: var(--tracking-hero); text-wrap: balance; }
.t-display { font-size: var(--t-display-size); line-height: var(--t-display-lh); font-weight: 700; letter-spacing: var(--tracking-hero); text-wrap: balance; }
.eyebrow {
  font-size: var(--t-micro-size); line-height: var(--t-micro-lh); font-weight: 700;
  letter-spacing: var(--tracking-wide); text-transform: uppercase; color: var(--c-accent);
}
.u-m-0 { margin: 0; }
.u-mt-2 { margin-top: var(--s-2); }
.u-mt-3 { margin-top: var(--s-3); }
.u-mt-4 { margin-top: var(--s-4); }
.u-mt-5 { margin-top: var(--s-5); }
.u-mt-6 { margin-top: var(--s-6); }
.u-mt-8 { margin-top: var(--s-8); }
.u-mb-0 { margin-bottom: 0; }
.u-mb-2 { margin-bottom: var(--s-2); }
.u-mb-3 { margin-bottom: var(--s-3); }
.u-mb-4 { margin-bottom: var(--s-4); }
.u-mr-2 { margin-right: var(--s-2); }
.u-flat { border-radius: 0; }
.u-list-plain { list-style: none; padding: 0; margin: 0; max-width: none; }
/* .u-scroll-x removed: a bare overflow-x container is a scroll region a
   keyboard cannot reach and a screen reader cannot name. Use the `Table`
   primitive, which carries role=region, an accessible name and tabindex. */
.u-normal { font-weight: 400; text-align: left; }
.visually-hidden {
  position: absolute !important; width: 1px; height: 1px;
  padding: 0; margin: -1px; overflow: hidden;
  clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}

/* --- Button -------------------------------------------------------------- */

.btn {
  display: inline-flex; align-items: center; justify-content: center; gap: var(--s-2);
  min-height: var(--tap); min-width: var(--tap); padding: var(--s-3) var(--s-5);
  border: 1px solid transparent; border-radius: var(--r-2);
  font: inherit; font-weight: 650; line-height: 1.2;
  text-decoration: none; text-align: center; cursor: pointer;
  background: var(--c-primary); color: var(--c-on-primary); box-shadow: var(--elev-1);
}
.btn:hover { background: var(--c-primary-hover); color: var(--c-on-primary); box-shadow: var(--elev-2); }
.btn:active { box-shadow: none; }
/* The ring lands in the 2px offset gap; the halo keeps it off the fill. */
.btn:focus-visible { box-shadow: 0 0 0 2px var(--c-bg); }
/* `aria-disabled` rather than `disabled` is deliberate: the button stays in the
   tab order so the reason on `aria-describedby` can be announced. It must still
   not be clickable, or a "disabled" Verify submits an empty code. `cursor` alone
   only changed the pointer's picture. The keyboard route is closed on the
   server, which is where it has to be anyway on a page with no script. */
.btn[aria-disabled="true"] { pointer-events: none; }
.btn[aria-disabled="true"], .btn:disabled { opacity: 0.55; cursor: not-allowed; box-shadow: none; }
.btn[aria-disabled="true"]:hover, .btn:disabled:hover { background: var(--c-primary); }
.btn[aria-busy="true"] { cursor: progress; }
.btn--secondary { background: var(--c-bg); color: var(--c-primary); border-color: var(--c-border-strong); }
.btn--secondary:hover { background: var(--c-primary-soft); color: var(--c-primary-hover); border-color: var(--c-primary); }
.btn--secondary[aria-disabled="true"]:hover, .btn--secondary:disabled:hover { background: var(--c-bg); }
.btn--danger { background: var(--c-danger); color: var(--c-on-danger); }
.btn--danger:hover { background: var(--c-danger); filter: brightness(0.88); color: var(--c-on-danger); }
.btn--danger[aria-disabled="true"]:hover, .btn--danger:disabled:hover { background: var(--c-danger); filter: none; }
.btn--ghost { background: transparent; color: var(--c-ink); box-shadow: none; }
.btn--ghost:hover { background: var(--c-surface-2); color: var(--c-ink); box-shadow: none; }
.btn--ghost[aria-disabled="true"]:hover, .btn--ghost:disabled:hover { background: transparent; }
.btn--sm { padding: var(--s-2) var(--s-3); font-size: var(--t-small-size); }
.btn--lg { padding: var(--s-4) var(--s-6); font-size: var(--t-h3-size); min-height: 52px; }
/* Only a fine pointer may go below the 44px touch floor. */
@media (pointer: fine) { .btn--sm { min-height: 32px; min-width: 32px; } }
.btn--block { width: 100%; }
@media (max-width: 479px) { .btn--block-mobile { width: 100%; } }
/* Loading: the label stays, the spinner takes the icon slot. */
.btn__spinner {
  width: 16px; height: 16px; border: 2px solid currentColor;
  border-top-color: transparent; border-radius: 50%; opacity: 0.9;
}

/* --- Fields -------------------------------------------------------------- */

.field { display: block; }
.field + .field { margin-top: var(--s-5); }
.field__label { display: block; font-size: var(--t-body-size); font-weight: 650; margin-bottom: var(--s-2); }
.field__control {
  display: block; width: 100%; min-height: var(--tap); padding: var(--s-3);
  font: inherit; font-size: var(--t-body-size); /* 16px stops iOS zooming on focus */
  color: var(--c-ink); background: var(--c-bg);
  border: 1px solid var(--c-border-strong); border-radius: var(--r-1);
}
.field__control:hover { border-color: var(--c-ink-3); }
.field__control:focus { border-color: var(--c-primary); }
.field__control::placeholder { color: var(--c-ink-3); opacity: 1; }
.field__control:disabled { background: var(--c-surface-2); opacity: 0.75; cursor: not-allowed; }
.field__control[readonly] { background: var(--c-surface); }
textarea.field__control { min-height: 96px; resize: vertical; }

/*
   THE PASSWORD REVEAL.

   Every rule here styles something `/static/password.js` CREATED — the button
   does not exist in the HTML the server sends, because a control that cannot
   work without JavaScript must not be offered to somebody who has none.

   The wrapper exists so the button positions against the INPUT and not against
   `.field`, whose height changes as help text and errors appear beneath it.
*/
.field__reveal-wrap { position: relative; display: block; }

/* Room for the button, so a long password never runs underneath it. */
.field__control--revealable { padding-right: calc(var(--tap) + var(--s-1)); }

/* Edge ships its own reveal control inside the field and draws it in the same
   corner as ours, so the field ends up with two eyes that disagree: pressing
   one leaves the other showing the opposite glyph. Ours stays, because it is
   the one that is keyboard reachable, labelled, and announced. Scoped to the
   fields we actually took over — a password field we never reached keeps the
   browser's own control rather than losing it for nothing. */
.field__control--revealable::-ms-reveal { display: none; }

.field__reveal {
  position: absolute;
  inset-block: 0;
  inset-inline-end: 0;
  /* The tap-target floor (2.4), even though the icon inside is 20px. */
  width: var(--tap);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  border: 0;
  background: none;
  color: var(--c-ink-2);
  cursor: pointer;
}
.field__reveal:hover { color: var(--c-ink); }
/* Inset, because the ring would otherwise be clipped by the input's border. */
.field__reveal:focus-visible { outline: 2px solid var(--c-focus); outline-offset: -2px; border-radius: var(--r-1); }
.field__control[aria-invalid="true"] { border: 2px solid var(--c-danger); }
/* Native select, chevron drawn in CSS so there is no image request. */
.field__control--select {
  appearance: none; padding-right: var(--s-8);
  background-image:
    linear-gradient(45deg, transparent 50%, currentColor 50%),
    linear-gradient(135deg, currentColor 50%, transparent 50%);
  background-position: calc(100% - 20px) calc(50% + 1px), calc(100% - 14px) calc(50% + 1px);
  background-size: 6px 6px; background-repeat: no-repeat;
}
.field__help { display: block; margin-top: var(--s-2); font-size: var(--t-small-size); line-height: var(--t-small-lh); color: var(--c-ink-2); }
.field__error {
  display: flex; align-items: flex-start; gap: var(--s-2); margin-top: var(--s-2);
  font-size: var(--t-small-size); line-height: var(--t-small-lh);
  color: var(--c-danger); font-weight: 650;
}
/* Sits under the control, not inside the label — it is a description, not part
   of the field's accessible name. Right-aligned against the field's own edge. */
.field__counter {
  display: block; margin-top: var(--s-1);
  text-align: right; font-weight: 400; color: var(--c-ink-2);
  font-variant-numeric: tabular-nums;
}

/* Checkbox and radio: the whole row is the target (>= 44px). */
.check { display: flex; align-items: flex-start; gap: var(--s-3); min-height: var(--tap); padding-block: var(--s-2); cursor: pointer; }
.check__input { flex: none; width: 22px; height: 22px; margin: 1px 0 0; accent-color: var(--c-primary); cursor: pointer; }
.check__text { min-width: 0; }
.check__title { display: block; font-weight: 500; }
.check__desc { display: block; font-size: var(--t-small-size); line-height: var(--t-small-lh); color: var(--c-ink-2); }
.check__input:disabled, .check__input:disabled ~ .check__text { cursor: not-allowed; opacity: 0.6; }
fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
legend { padding: 0; font-weight: 650; margin-bottom: var(--s-2); }

/* Choice card: a radio the size of a thumb (>= 56px, docs 2.5). */
.choice {
  display: flex; align-items: flex-start; gap: var(--s-3); min-height: 56px; padding: var(--s-4);
  border: 1px solid var(--c-border-strong); border-radius: var(--r-3);
  background: var(--c-bg); cursor: pointer;
}
.choice:hover { background: var(--c-surface); border-color: var(--c-primary); }
.choice__input { flex: none; width: 22px; height: 22px; margin: 2px 0 0; accent-color: var(--c-primary); }
.choice__icon { flex: none; color: var(--c-primary); }
.choice__title { display: block; font-weight: 650; }
.choice__desc { display: block; font-size: var(--t-small-size); line-height: var(--t-small-lh); color: var(--c-ink-2); }
.choice:has(.choice__input:checked) {
  background: var(--c-primary-soft); border-color: var(--c-primary);
  border-width: 2px; padding: calc(var(--s-4) - 1px);
}
.choice:has(.choice__input:focus-visible) { outline: 3px solid var(--c-focus); outline-offset: 2px; }

/* OTP: ONE input with SIX CELLS DRAWN AS A BACKGROUND, never six inputs — so
   paste works, `autocomplete="one-time-code"` works, and a screen reader
   announces one labelled field (docs/06-ui-ux.md 2.5).

   The geometry is in `ch`, not `em`, so it is exact on any monospace face:
   tracking of 1ch makes every digit advance 2ch, the cell rule under it is
   1ch wide on a 2ch step, and six digits span exactly 12ch. `content-box`
   sizing makes the content box exactly 12ch, and `background-origin:
   content-box` paints the rules onto it — so the digits sit on their cells
   whatever font the device resolves. Left-aligned, so the cells fill left to
   right as the code is typed. */
.otp {
  box-sizing: content-box;
  width: 12ch;
  max-width: 100%;
  font-family: var(--font-mono); font-size: var(--t-code-size); line-height: var(--t-code-lh);
  font-weight: 650; letter-spacing: 1ch; text-align: left;
  min-height: 56px; padding-inline: var(--s-2);
  background-image: repeating-linear-gradient(to right, var(--c-border-strong) 0 1ch, transparent 1ch 2ch);
  background-size: 12ch 2px;
  background-position: left bottom var(--s-3);
  background-repeat: no-repeat;
  background-origin: content-box;
}
.otp:focus { background-image: repeating-linear-gradient(to right, var(--c-primary) 0 1ch, transparent 1ch 2ch); }
.otp[aria-invalid="true"] { background-image: repeating-linear-gradient(to right, var(--c-danger) 0 1ch, transparent 1ch 2ch); }

/* --- Card ---------------------------------------------------------------- */

.card {
  position: relative; background: var(--c-surface);
  border: 1px solid var(--c-border); border-radius: var(--r-2);
  padding: var(--s-4); box-shadow: var(--elev-1);
}
/* Dark lifts with a hairline highlight, not a heavier shadow. */
.card::before {
  content: ""; position: absolute; inset: 0 0 auto; height: 1px;
  border-radius: var(--r-2) var(--r-2) 0 0; background: var(--elev-lift); pointer-events: none;
}
@media (min-width: 768px) { .card { padding: var(--s-6); } }
.card--raised { box-shadow: var(--elev-2); background: var(--c-bg); }
.card--interactive:hover { border-color: var(--c-border-strong); box-shadow: var(--elev-2); }
.card--interactive:focus-within { border-color: var(--c-primary); }
.card__header { display: flex; align-items: center; justify-content: space-between; gap: var(--s-3); margin-bottom: var(--s-3); }
.card__title { margin: 0; font-size: var(--t-h3-size); line-height: var(--t-h3-lh); }
.card__footer { margin-top: var(--s-4); display: flex; gap: var(--s-3); flex-wrap: wrap; }
/* A whole card that is one link: one focus ring, no nested interactives. */
.card__link { text-decoration: none; color: inherit; display: block; }
.card__link:hover { color: inherit; }
.card__link::after { content: ""; position: absolute; inset: 0; border-radius: inherit; }

/* --- Alert / callout. Icon + heading + left rule; never colour alone. ----- */

.alert {
  display: flex; gap: var(--s-3); padding: var(--s-4); border-radius: var(--r-2);
  border-left: 3px solid var(--c-primary); background: var(--c-primary-soft); color: var(--c-ink);
}
.alert__icon { flex: 0 0 auto; color: var(--c-primary); line-height: 1.2; font-size: 20px; }
.alert__body { min-width: 0; }
.alert__heading { margin: 0 0 var(--s-1); font-size: var(--t-body-size); font-weight: 650; }
.alert__text { margin: 0; font-size: var(--t-small-size); line-height: var(--t-small-lh); }
.alert__text > :last-child { margin-bottom: 0; }
.alert--success { background: var(--c-success-soft); border-left-color: var(--c-success); }
.alert--success .alert__icon { color: var(--c-success); }
.alert--warning { background: var(--c-warning-soft); border-left-color: var(--c-warning); }
.alert--warning .alert__icon { color: var(--c-warning); }
.alert--error { background: var(--c-danger-soft); border-left-color: var(--c-danger); }
.alert--error .alert__icon { color: var(--c-danger); }
/* Site-wide banner: same anatomy, full width, square. */
.banner {
  border-radius: 0; border-left: 0; border-bottom: 1px solid var(--c-border);
  padding-left: max(var(--gutter), env(safe-area-inset-left));
  padding-right: max(var(--gutter), env(safe-area-inset-right));
}

/* --- Badge, pill, identity chip ------------------------------------------ */

.badge {
  display: inline-flex; align-items: center; gap: var(--s-1);
  padding: 3px var(--s-2); border-radius: var(--r-pill);
  font-size: var(--t-micro-size); line-height: var(--t-micro-lh); font-weight: 650;
  background: var(--c-surface-2); color: var(--c-ink);
  border: 1px solid var(--c-border-strong); white-space: nowrap;
}
.badge--success { background: var(--c-success-soft); color: var(--c-success); border-color: var(--c-success); }
.badge--warning { background: var(--c-warning-soft); color: var(--c-warning); border-color: var(--c-warning); }
.badge--danger { background: var(--c-danger-soft); color: var(--c-danger); border-color: var(--c-danger); }
.badge--info { background: var(--c-primary-soft); color: var(--c-primary); border-color: var(--c-primary); }

/* Identity chip. There is no avatar anywhere in this product: we hold no
   name and no photograph, so identity is a glyph and a public id. */
.chip {
  display: inline-flex; align-items: center; gap: var(--s-2);
  padding: var(--s-1) var(--s-3) var(--s-1) var(--s-1); border-radius: var(--r-pill);
  background: var(--c-surface-2); border: 1px solid var(--c-border);
  font-size: var(--t-small-size); line-height: var(--t-small-lh); max-width: 100%;
}
.chip__glyph {
  flex: none; display: inline-flex; align-items: center; justify-content: center;
  width: 28px; height: 28px; border-radius: 50%;
  background: var(--c-primary); color: var(--c-on-primary);
  font-size: var(--t-micro-size); font-weight: 700;
}
.chip__text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* --- Table --------------------------------------------------------------- */

.table-wrap { overflow-x: auto; border: 1px solid var(--c-border); border-radius: var(--r-2); }
.table { border-collapse: collapse; width: 100%; font-size: var(--t-small-size); line-height: var(--t-small-lh); }
.table caption { text-align: left; padding: var(--s-3) var(--s-4); color: var(--c-ink-2); }
.table th, .table td { padding: var(--s-3) var(--s-4); text-align: left; vertical-align: top; }
.table thead th {
  font-size: var(--t-micro-size); letter-spacing: var(--tracking-wide); text-transform: uppercase;
  color: var(--c-ink-2); background: var(--c-surface-2);
  border-bottom: 1px solid var(--c-border); white-space: nowrap;
}
.table tbody tr + tr { border-top: 1px solid var(--c-border); }
.table tbody th { font-weight: 500; }
.table tbody tr:hover { background: var(--c-surface); }
.table .num { text-align: right; font-variant-numeric: tabular-nums; }

/* --- Empty state, skeleton, toast ---------------------------------------- */

.empty-state {
  text-align: center; padding: var(--s-10) var(--s-4); color: var(--c-ink-2);
  border: 1px dashed var(--c-border-strong); border-radius: var(--r-3); background: var(--c-surface);
}
.empty-state__illustration { color: var(--c-primary); margin-bottom: var(--s-4); }
.empty-state h2 { color: var(--c-ink); }
.empty-state p { margin-inline: auto; }
.empty-state .btn { margin-top: var(--s-5); }

.skeleton { position: relative; overflow: hidden; background: var(--c-surface-2); border-radius: var(--r-1); height: 1em; }
.skeleton + .skeleton { margin-top: var(--s-3); }
.skeleton--title { height: 1.6em; width: 55%; }
.skeleton--short { width: 70%; }
.skeleton::after { content: ""; position: absolute; inset: 0; background: linear-gradient(90deg, transparent, var(--c-surface-3), transparent); }

.toast-area {
  position: fixed; inset: auto var(--s-4) var(--s-4); z-index: 90;
  display: flex; flex-direction: column; gap: var(--s-2); pointer-events: none;
}
@media (min-width: 768px) { .toast-area { inset: auto var(--s-6) var(--s-6) auto; max-width: 22rem; } }
.toast {
  pointer-events: auto; display: flex; gap: var(--s-2); align-items: center;
  padding: var(--s-3) var(--s-4); border-radius: var(--r-2);
  background: var(--c-ink); color: var(--c-bg); box-shadow: var(--elev-3); font-size: var(--t-small-size);
}

/* --- Page header, section, stat tile, tabs, breadcrumb ------------------- */

.page-header { margin-bottom: var(--s-6); }
.page-header__row { display: flex; flex-wrap: wrap; gap: var(--s-3); align-items: flex-start; justify-content: space-between; }
.page-header__title { margin: 0; }
.page-header__lead { margin: var(--s-3) 0 0; color: var(--c-ink-2); max-width: var(--measure); }
.page-header__actions { display: flex; gap: var(--s-3); flex-wrap: wrap; }

.section { margin-top: var(--s-8); }
.section__head { display: flex; flex-wrap: wrap; gap: var(--s-3); align-items: baseline; justify-content: space-between; margin-bottom: var(--s-4); }
.section__title { margin: 0; }
@media (min-width: 768px) { .section { margin-top: var(--s-12); } }

.stat { padding: var(--s-4); border: 1px solid var(--c-border); border-radius: var(--r-2); background: var(--c-surface); }
.stat__label {
  margin: 0 0 var(--s-1); font-size: var(--t-micro-size); line-height: var(--t-micro-lh);
  letter-spacing: var(--tracking-wide); text-transform: uppercase; color: var(--c-ink-2);
}
.stat__value { display: block; font-size: var(--t-display-size); line-height: var(--t-display-lh); font-weight: 700; letter-spacing: var(--tracking-hero); }
.stat__hint { margin: var(--s-1) 0 0; font-size: var(--t-small-size); line-height: var(--t-small-lh); color: var(--c-ink-2); }
.stat-grid { display: grid; gap: var(--s-4); grid-template-columns: repeat(auto-fit, minmax(min(100%, 11rem), 1fr)); }

/* The strip WRAPS (V#12). It used to scroll sideways with no scrollbar, partial
   tab or other cue, so on a 390px phone the last tab (a business's read-only
   overview, an inbox's "Over limit") lay wholly past the edge and looked
   unreachable. A second row is always visible; `overflow-x` stays only for a
   single label wider than the screen. */
.tabs { display: flex; flex-wrap: wrap; gap: var(--s-1); overflow-x: auto; border-bottom: 1px solid var(--c-border); margin-bottom: var(--s-5); }
.tab {
  display: inline-flex; align-items: center; min-height: var(--tap); padding: var(--s-2) var(--s-4);
  white-space: nowrap; text-decoration: none; color: var(--c-ink-2);
  border-bottom: 2px solid transparent; font-weight: 650; font-size: var(--t-small-size);
}
.tab:hover { color: var(--c-ink); background: var(--c-surface); }
.tab[aria-selected="true"], .tab[aria-current="page"] { color: var(--c-primary); border-bottom-color: var(--c-primary); }

.crumbs { margin: 0 0 var(--s-5); font-size: var(--t-small-size); line-height: var(--t-small-lh); }
.crumbs ol { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: var(--s-2); max-width: none; }
.crumbs li { display: flex; gap: var(--s-2); align-items: center; color: var(--c-ink-2); }
.crumbs li + li::before { content: "/"; color: var(--c-border-strong); }
.crumbs a { min-height: 24px; display: inline-flex; align-items: center; }

/* --- Surfaces that never theme ------------------------------------------- */

/* The 112 band: identical in both themes, never dismissible (DEC-057). */
.emergency-band {
  background: var(--c-emergency); color: var(--c-on-emergency); font-weight: 650;
  padding-block: var(--s-3);
  padding-left: max(var(--gutter), env(safe-area-inset-left));
  padding-right: max(var(--gutter), env(safe-area-inset-right));
}
.emergency-band a, .emergency-band a:hover { color: var(--c-on-emergency); }
/* The band is a dark red fill and its link fills the band's own padding box, so
   the global ring is both low-contrast against it and drawn past its edge. This
   one is amber (6.03:1 on the band) and drawn inside the link. */
.emergency-band a:focus-visible {
  outline: 3px solid var(--c-focus-on-emergency);
  outline-offset: -3px;
}

/* Never dismissible, and identical in both themes: the one thing on the page
   that must look the same everywhere. */
.emergency-band--inset {
  display: flex; gap: var(--s-3); align-items: center;
  margin-top: var(--s-6); border-radius: var(--r-2);
  font-size: var(--t-small-size); line-height: var(--t-small-lh);
}
.emergency-band__icon { flex: none; }
.emergency-band--inset a { display: inline-flex; align-items: center; min-height: var(--tap); }

/* The QR symbol is ALWAYS pure black modules on pure white (spine 8). */
.qr-canvas { background: #ffffff; padding: var(--s-4); border-radius: var(--r-2); border: 1px solid var(--c-border); display: inline-block; }

/* --- Icons (src/views/icons.tsx) -----------------------------------------
   One rule for the whole set, so an inline icon carries no repeated
   presentation attributes and the weight stays in the cached stylesheet. */

.icon { stroke: currentColor; stroke-width: 1.75; stroke-linecap: round; stroke-linejoin: round; }
.icon--bold { stroke-width: 2; }

/* --- Illustration colour slots (src/views/illustrations.tsx) -------------- */

.il-ink { color: var(--c-ink-2); }
.il-brand { color: var(--c-primary); }
.il-accent { color: var(--c-accent); }
.il-bg-fill { fill: var(--c-bg); }
.il-soft-fill { fill: var(--c-surface-2); }
.il-accent-fill { fill: var(--c-accent-soft); }
.il-slot { position: relative; display: inline-flex; align-items: center; justify-content: center; width: 72px; height: 72px; color: var(--c-primary); }
.il-slot__frame { position: absolute; inset: 0; width: 72px; height: 72px; }
.il-slot__icon { position: relative; }

/* --- Colour-theme toggle --------------------------------------------------
   An icon button in the header. It shows the moon while a light theme is in
   effect (a tap goes dark) and the sun while a dark theme is in effect (a tap
   goes light), resolving system default the same way the palette does above.
   The swap is pure CSS; /static/theme.js only sets data-theme and the label. */
/* `display: inline-flex` would beat the `hidden` attribute, and the control
   ships hidden until theme.js reveals it. */
.theme-toggle[hidden] { display: none; }
.theme-toggle {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: var(--tap);
  height: var(--tap);
  padding: 0;
  border-radius: var(--r-2);
  color: var(--c-ink-2);
  background: transparent;
  border: 1px solid var(--c-border);
  cursor: pointer;
}
.theme-toggle:hover {
  color: var(--c-primary);
  border-color: var(--c-border-strong);
  background: var(--c-surface-2);
}
.theme-toggle:focus-visible { outline: 2px solid var(--c-focus); outline-offset: 2px; }
.theme-toggle__sun { display: none; }
.theme-toggle__moon { display: inline-flex; }
:root[data-theme="dark"] .theme-toggle__sun { display: inline-flex; }
:root[data-theme="dark"] .theme-toggle__moon { display: none; }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .theme-toggle__sun { display: inline-flex; }
  :root:not([data-theme="light"]) .theme-toggle__moon { display: none; }
}
