/* @tug-work/workbench — workbench (tokens + components)
   GENERATED FILE — do not edit. Source: packages/<pkg>/src/ */

:root {
    /* ═══════════════════════════════════════════════════════════════════
       TIER 1 — PRIMITIVES
       Raw values with no role. Components must NEVER reference these directly;
       they exist only to feed the Tier-2 semantic layer below. The palette
       swatches double as the data-viz / diagram / accent spectrum.
       ═══════════════════════════════════════════════════════════════════ */
    /* Palette — core neutrals */
    --ink:#1b1d18;--forest:#3d4a41;--mist:#8c9b98;--paper:#f3f3f3;--white:#ffffff;
    /* Palette — signal */
    --highlight:#e71e10;
    /* Palette — plum family */
    --plum-deep:#633162;--plum:#7c3f7a;--plum-light:#a177a0;
    /* Palette — sky family */
    --sky-light:#9acfef;--sky:#469dc6;--sky-deep:#327492;
    /* Palette — accent spectrum */
    --lime:#a6b228;--gold:#d7b728;--tangerine:#e87b26;--coral:#d25c34;
    --wine:#5b2233;--lavender:#8d7dbc;--azure:#3b88de;
    /* Retuned surfaces — AAA-capable variants for section backgrounds that
       carry body text (the raw --sky-deep / --plum-light swatches stay above
       for accent/data-viz use, where they do not sit behind reading copy). */
    --surface-sky-deep:#1c4a63;   /* deepened teal: white body text clears 7:1 */
    --surface-plum-light:#ece1eb; /* pale plum tint: ink body text clears 7:1  */
    /* Text-color primitives, contrast-tuned (see mark-and-tokens.md audit) */
    --fg-dark:#1b1d18;              /* primary text on light surfaces        */
    --fg-light:#ffffff;             /* primary text on dark surfaces          */
    --fg-muted-on-light:#3f4946;    /* secondary/caption/body on light (AAA)  */
    --fg-muted-on-dark:#e0e5e3;     /* secondary/caption on dark (AAA)        */
    --fg-body-on-dark:#f2f5f4;      /* card body copy on dark (AAA — retuned  */
                                     /* from #eef1f0: that value cleared 7:1   */
                                     /* against --wash-1 (0.04 alpha) but fell */
                                     /* to 6.89:1 on bg-forest's real ground,  */
                                     /* --wash-2 (0.06 alpha) — the actual fill*/
                                     /* .intro-panel p sits on */
    --fg-num-on-light:#58625c;      /* subtle numerals on light (AAA large —  */
                                     /* retuned from #5f6a63, which fell to    */
                                     /* 4.44:1 on bg-plum-light against the    */
                                     /* 4.5:1 large-text bar) */
    --signal-on-light:#c21707;      /* signal TEXT on light (AA — brand red)  */
    --signal-on-dark:#ff9086;       /* signal TEXT on dark (AA — brand red)   */
    /* Link/accent text — backs the Tier-2 --text-link role below. Introduced to
       replace two Tier-1 violations (.arch-label, .vision-teaser a hardcoded
       --sky-light directly, in breach of the "components never reference Tier-1"
       rule). --sky-light itself clears 7:1 only on bg-ink/bg-wine; a light-family
       value (dark, legible on white/paper/plum-light) and its own dark-family
       re-point (a much paler blue, legible on forest/plum-deep/sky-deep/ink/wine)
       are both needed since one flat value cannot clear 7:1 across all 8 grounds. */
    --link-on-light:#144a71;        /* text-link on light surfaces (AAA)      */
    --link-on-dark:#cfe5f5;         /* text-link on dark surfaces (AAA)       */
    /* On-color text roles (compat aliases — retuned to the compliant values
       above so inline style="" usages in exemplars inherit AAA contrast). */
    --on-ink:var(--fg-light);--on-ink-secondary:var(--fg-muted-on-dark);
    --on-forest:var(--fg-light);--on-forest-secondary:var(--fg-muted-on-dark);
    --on-light:var(--fg-dark);--on-light-secondary:var(--fg-muted-on-light);
    /* Button fill primitives — chosen so white text clears 4.5:1 */
    --fill-primary:#6a2f68;--fill-secondary:#2c6580;--fill-action:#a83c1c;
    /* Status-pill primitives. --fg-done is retuned from the pre-fix #454b08 (which fell to
       6.57:1 on bg-plum-light) so the light family clears 7:1 on the toughest light ground;
       --fg-next is new for the same reason (the pre-fix wiring reused --card-text, which
       likewise fell to 6.30:1 on bg-plum-light). The dark family gets its own tint/text pair
       (--tint-done-on-dark lighter-alpha, --fg-done-on-dark near-white lime) instead of reusing
       --tint-done/--fg-done, because at the light family's 0.18 alpha no plausible lime text
       clears 7:1 against bg-forest's composite — only a lighter tint leaves room. */
    --tint-done:rgba(166,178,40,0.18);--fg-done:#3d4207;
    --tint-next:rgba(27,29,24,0.08);--fg-next:#363f3c;
    --tint-done-on-dark:rgba(166,178,40,0.10);--fg-done-on-dark:#f3f6da;
    /* Spacing scale (4px rhythm) */
    --space-1:4px;--space-2:8px;--space-3:12px;--space-4:16px;--space-5:20px;
    --space-6:24px;--space-7:28px;--space-8:32px;--space-9:36px;--space-12:48px;
    --space-15:60px;--space-20:80px;--space-24:96px;--space-30:120px;
    /* Radii */
    --radius-sm:2px;--radius-md:3px;--radius-lg:4px;
    /* Border widths */
    --bw-hair:1px;--bw-accent:3px;--bw-bar:4px;
    /* Motion. Named by ROLE, not by duration, so a component asks for "a control responding"
       rather than "150ms" — the same reason the colour roles are named for meaning. The four
       values are the ones the system already used before they were tokens (.1/.15/.3/.7s), so
       this codifies existing practice rather than redesigning the feel.
         --dur-fast     a value ticking: progress bars, tooltips. Barely perceptible.
         --dur-control  a control answering a pointer or key: hover, focus, check, toggle.
         --dur-surface  something arriving or leaving: menus, dialogs, toasts, accordions.
         --dur-reveal   editorial page reveal only. Never for application UI, where a control
                        that takes 700ms to answer feels broken.
       Easings: --ease-standard for UI, which starts fast and settles (an interface should feel
       like it responded immediately); --ease-reveal is the existing page-reveal curve, kept
       byte-identical so the fadeUp keyframe is unchanged. */
    --dur-fast:100ms;--dur-control:150ms;--dur-surface:300ms;--dur-reveal:700ms;
    --ease-standard:cubic-bezier(.2,0,0,1);--ease-reveal:cubic-bezier(.22,1,.36,1);
    /* Type-size scale (feeds the .t-* roles) */
    --fs-display:clamp(2.6rem,5.5vw,4rem);
    --fs-heading:clamp(1.9rem,4vw,3rem);
    --fs-subheading:clamp(1.1rem,2vw,1.4rem);
    --fs-body:clamp(1rem,1.4vw,1.18rem);
    --fs-quote:clamp(1.05rem,1.8vw,1.25rem);
    --fs-small:.88rem;--fs-cite:.9rem;--fs-caption:.76rem;--fs-mono:.73rem;
    /* Font-family primitives */
    --font-h1:'Helvetica Now Display','Helvetica Neue',Helvetica,'Inter',Arial,sans-serif;
    --font-h2:'Helvetica Now Display','Helvetica Neue',Helvetica,'Inter',Arial,sans-serif;
    --font-h3:'Helvetica Now Display','Helvetica Neue',Helvetica,'Inter',Arial,sans-serif;
    --font-quote:'Helvetica Now Text','Helvetica Neue',Helvetica,'Inter',Arial,sans-serif;
    --font-cite:'Helvetica Now Text','Helvetica Neue',Helvetica,'Inter',Arial,sans-serif;
    --font-caption:'Helvetica Now Micro','Helvetica Now Text','Helvetica Neue',Helvetica,'Inter',Arial,sans-serif;
    --font-body:'Source Serif 4','Source Serif Pro',Georgia,serif;

    /* ═══════════════════════════════════════════════════════════════════
       TIER 2 — SEMANTIC ROLES (light-surface defaults)
       Everything a component references. Each .bg-* class re-points these,
       so a component is correct on any background by construction. Values
       are tuned to WCAG 2.1 AAA (7:1 body / 4.5:1 large); the vermilion
       signal role is the one documented AA exception (brand colour).
       ═══════════════════════════════════════════════════════════════════ */
    --text-1:var(--fg-dark);            /* primary text            */
    --text-2:var(--fg-muted-on-light);  /* muted secondary / ledes */
    --text-caption:var(--fg-muted-on-light); /* labels, cites, meta, nav */
    --card-text:var(--fg-muted-on-light);    /* copy inside card chrome  */
    --text-signal:var(--signal-on-light);    /* eyebrows, inline emphasis */
    --text-num:var(--fg-num-on-light);       /* subtle step numerals     */
    --text-link:var(--link-on-light);        /* inline links / labels    */
    --wash-1:var(--white);              /* card fill               */
    --wash-solid:var(--white);          /* opaque card fill (same look on light) */
    --wash-2:var(--white);              /* pill / callout fill     */
    --line-1:rgba(27,29,24,0.08);       /* card hairline           */
    --line-2:rgba(27,29,24,0.16);       /* pill / callout hairline */
    --hairline:rgba(27,29,24,0.1);      /* list / step dividers    */
    --rule-c:rgba(140,155,152,0.4);     /* the short section rule  */
    --focus-ring:var(--text-1);         /* keyboard focus outline  */
    --btn-fill:var(--fill-primary);--btn-text:var(--white);
    --pill-done-bg:var(--tint-done);--pill-done-text:var(--fg-done);
    --pill-next-bg:var(--tint-next);--pill-next-text:var(--fg-next);
    /* Spacing roles — the system's structural rhythm */
    --section-pad-y:var(--space-30);--section-pad-x:var(--space-15);
    --section-pad-y-compact:var(--space-24);
    --card-pad-y:var(--space-6);--card-pad-x:var(--space-7);
    --grid-gap:18px;--stack-gap:var(--space-4);--rule-margin-y:var(--space-7);
    /* Application control roles — app UI only; page tiers never reference these */
    --control-bg:var(--white);--control-text:var(--fg-dark);
    --control-border:var(--fg-muted-on-light);--control-border-hover:var(--fg-dark);
    --control-placeholder:var(--fg-muted-on-light);
    --control-disabled-bg:var(--paper);--control-disabled-text:var(--mist);
    --overlay-scrim:rgba(27,29,24,0.55);
    --elevation-line:rgba(27,29,24,0.16);
    /* Affordance icon glyphs — masked SVG, sized in text-relative units so an
       icon always matches the type it sits beside. */
    --icon-size:1em;
  }

  /* ── Background / theme classes ────────────────────────────────────
     Each class re-points the Tier-2 semantic roles. Dark family shares one
     block; the signal role falls back to primary text on the three mid-tone
     surfaces where vermilion text cannot clear AA. ───────────────────── */
  .bg-ink,.bg-forest,.bg-plum-deep,.bg-sky-deep,.bg-wine{
    color:var(--text-1);
    --text-1:var(--fg-light);--text-2:var(--fg-muted-on-dark);
    --text-caption:var(--fg-muted-on-dark);--card-text:var(--fg-body-on-dark);
    --text-signal:var(--signal-on-dark);--text-num:var(--fg-muted-on-dark);
    --text-link:var(--link-on-dark);
    --wash-1:rgba(255,255,255,0.04);--wash-2:rgba(255,255,255,0.06);
    /* Opaque twin of --wash-1: the same 4% lift, pre-composited over the
       section's own ground (--surface-c, set per bg-* class below) so a card
       can sit OVER page furniture — a spine rail, a hero field — without it
       showing through. */
    --wash-solid:color-mix(in srgb, var(--white) 4%, var(--surface-c, var(--ink)));
    --line-1:rgba(255,255,255,0.08);--line-2:rgba(255,255,255,0.16);
    --hairline:rgba(255,255,255,0.1);--rule-c:rgba(255,255,255,0.15);
    --pill-done-bg:var(--tint-done-on-dark);--pill-done-text:var(--fg-done-on-dark);
    --pill-next-bg:var(--wash-2);--pill-next-text:var(--fg-light)}
  .bg-ink{background:var(--ink);--surface-c:var(--ink)}
  .bg-forest{background:var(--forest);--surface-c:var(--forest);--text-signal:var(--fg-light)}     /* vermilion < AA here → primary text */
  .bg-plum-deep{background:var(--plum-deep);--surface-c:var(--plum-deep);--text-signal:var(--fg-light)}
  .bg-sky-deep{background:var(--surface-sky-deep);--surface-c:var(--surface-sky-deep);--text-signal:var(--fg-light)}
  .bg-wine{background:var(--wine);--surface-c:var(--wine)}
  /* Light family: root defaults already correct; set surface + colour.
     plum-light is now a light-family surface (retuned tint) carrying dark text. */
  .bg-plum-light{background:var(--surface-plum-light);color:var(--text-1)}
  .bg-paper{background:var(--paper);color:var(--text-1)}
  .bg-white{background:var(--white);color:var(--text-1)}

  /* ── Application surfaces ──────────────────────────────────────────
     The application archetype's grounds. Each re-points the Tier-2 roles,
     so an app component is correct on any surface by construction — the
     same contract the .bg-* classes above provide for page components.
     .app-raised is deliberately its own surface: floating layers (modal,
     dropdown, popover, tooltip, toast) do not inherit the ground beneath
     them, so they need their own contrast guarantees rather than
     borrowing whatever happens to be underneath.
     Depth model (light family): .app-chrome/.app-canvas both sit on
     --paper (the recessed structural ground — persistent chrome and the
     working backdrop behind it read as one recessed layer); .app-card/
     .app-raised both sit on --white (the popped content ground) — this
     mirrors the .bg-paper -> wash-1 (white card fill) relationship
     already established above. The card/raised distinction is an
     elevation (shadow, z-index) concern for component CSS, not a
     token-level colour difference; both need the SAME contrast
     guarantees, hence identical role values.
     Every surface restates the full role set explicitly rather than
     inheriting from a sibling: nesting .app-card inside .app-canvas is
     the normal case, and inheritance breaks the moment that happens.
     Each surface also sets --app-surface-bg to its own background — the
     resolvable ground Task 3's non-text contrast gate composites
     translucent roles (like a focus ring) against. ─────────────────────── */
  .app-chrome{color-scheme:light;background:var(--paper);color:var(--text-1);
    --app-surface-bg:var(--paper);
    --text-1:var(--fg-dark);--text-2:var(--fg-muted-on-light);
    --text-caption:var(--fg-muted-on-light);--card-text:var(--fg-muted-on-light);
    --wash-1:var(--white);--wash-2:var(--white);
    --line-1:rgba(27,29,24,0.08);--line-2:rgba(27,29,24,0.16);
    --hairline:rgba(27,29,24,0.1);--focus-ring:var(--text-1);
    --control-bg:var(--white);--control-text:var(--fg-dark);
    --control-border:var(--fg-muted-on-light);--control-border-hover:var(--fg-dark);
    --control-placeholder:var(--fg-muted-on-light);
    --control-disabled-bg:var(--paper);--control-disabled-text:var(--mist)}
  .app-canvas{color-scheme:light;background:var(--paper);color:var(--text-1);
    --app-surface-bg:var(--paper);
    --text-1:var(--fg-dark);--text-2:var(--fg-muted-on-light);
    --text-caption:var(--fg-muted-on-light);--card-text:var(--fg-muted-on-light);
    --wash-1:var(--white);--wash-2:var(--white);
    --line-1:rgba(27,29,24,0.08);--line-2:rgba(27,29,24,0.16);
    --hairline:rgba(27,29,24,0.1);--focus-ring:var(--text-1);
    --control-bg:var(--white);--control-text:var(--fg-dark);
    --control-border:var(--fg-muted-on-light);--control-border-hover:var(--fg-dark);
    --control-placeholder:var(--fg-muted-on-light);
    --control-disabled-bg:var(--paper);--control-disabled-text:var(--mist)}
  .app-card{color-scheme:light;background:var(--white);color:var(--text-1);
    --app-surface-bg:var(--white);
    --text-1:var(--fg-dark);--text-2:var(--fg-muted-on-light);
    --text-caption:var(--fg-muted-on-light);--card-text:var(--fg-muted-on-light);
    --wash-1:var(--white);--wash-2:var(--white);
    --line-1:rgba(27,29,24,0.08);--line-2:rgba(27,29,24,0.16);
    --hairline:rgba(27,29,24,0.1);--focus-ring:var(--text-1);
    --control-bg:var(--white);--control-text:var(--fg-dark);
    --control-border:var(--fg-muted-on-light);--control-border-hover:var(--fg-dark);
    --control-placeholder:var(--fg-muted-on-light);
    --control-disabled-bg:var(--paper);--control-disabled-text:var(--mist)}
  .app-raised{color-scheme:light;background:var(--white);color:var(--text-1);
    --app-surface-bg:var(--white);
    --text-1:var(--fg-dark);--text-2:var(--fg-muted-on-light);
    --text-caption:var(--fg-muted-on-light);--card-text:var(--fg-muted-on-light);
    --wash-1:var(--white);--wash-2:var(--white);
    --line-1:rgba(27,29,24,0.08);--line-2:rgba(27,29,24,0.16);
    --hairline:rgba(27,29,24,0.1);--focus-ring:var(--text-1);
    --control-bg:var(--white);--control-text:var(--fg-dark);
    --control-border:var(--fg-muted-on-light);--control-border-hover:var(--fg-dark);
    --control-placeholder:var(--fg-muted-on-light);
    --control-disabled-bg:var(--paper);--control-disabled-text:var(--mist)}

  /* ── .app-dark shell modifier ──────────────────────────────────────
     Re-points all four application surfaces to the dark family. Applied
     to a shell ancestor (e.g. <body class="app-dark">), the same pattern
     .bg-ink etc. use for page sections.
     Depth model mirrors the light family: .app-chrome/.app-canvas sit on
     --ink (the recessed structural ground); .app-card/.app-raised sit on
     --forest (the popped content ground — one step lighter than ink, the
     same "next shade up" role --forest already plays for .bg-forest on
     the page tier).
     Two declaration shapes, chosen deliberately and different on
     purpose: the per-surface role re-points (text/wash/line/control) are
     written on EACH surface's own `.app-dark .app-X` selector, not
     shared — the architecture note above requires every surface to be
     independently correct, so nesting one inside another (a card inside
     a canvas) can never silently borrow a sibling's re-point. By
     contrast, --elevation-line and --overlay-scrim are declared ONCE on
     `.app-dark` itself for all nested surfaces to inherit: they are
     cross-surface overlay concepts (a modal's backdrop scrim, the
     hairline between a raised layer and whatever sits behind it), not
     per-surface fills — nothing ever nests one differently-scrimmed
     .app-dark context inside another, so a single shared re-point is
     safe here in a way it structurally isn't for the surface roles.
     --overlay-scrim keeps its light-family dark-tinted value: a scrim
     dims whatever is behind it and reads correctly against either
     family, so it is not re-pointed.
     --app-overlay-1 / --app-overlay-2 hold the dark family's translucent
     wash values (rgba(255,255,255,0.04) / 0.06 — the same pair .bg-ink's
     shared block already uses for --wash-1/--wash-2) as a SINGLE literal
     declaration on `.app-dark`, referenced by every surface's --wash-1/
     --wash-2 below via var() rather than restating the rgba() literal
     four more times. This isn't a style preference: scripts/contrast-
     core.mjs's parseRGBA() (used by pillReport()/extendedReport() for
     the page-tier .step-done/.step-next pills) does a file-wide search
     for a literal rgba() assignment on the --wash-2 custom property and
     asserts there is EXACTLY ONE — by design, per its own header
     comment, so a second re-point can never be silently picked up as
     the wrong ground for that unrelated page-tier math. A fifth literal
     rgba() assignment on --wash-2 (one .bg-ink block + four app-dark
     surfaces) would trip that assertion and crash check:contrast;
     routing every surface through one shared var() keeps the literal
     singular while still giving each surface its own independently-
     correct computed value. ─────────────────────────────────────────── */
  /* `color-scheme` is what tells the BROWSER to render its own native chrome dark: the date input's
     calendar indicator, the select popup, scrollbars, spinners, and the focus rings the UA draws on
     controls we do not fully restyle. Without it those render light-mode on a dark surface — most
     visibly the date field's calendar glyph, a dark icon on a dark control. Declared on the shell
     modifier so every nested `.app-*` surface inherits one answer; the four light surfaces declare
     `light` explicitly so an OS dark preference cannot flip native chrome underneath them. */
  .app-dark{color-scheme:dark;--elevation-line:rgba(255,255,255,0.16);
    --app-overlay-1:rgba(255,255,255,0.04);--app-overlay-2:rgba(255,255,255,0.06)}
  .app-dark .app-chrome{background:var(--ink);color:var(--text-1);
    --app-surface-bg:var(--ink);
    --text-1:var(--fg-light);--text-2:var(--fg-muted-on-dark);
    --text-caption:var(--fg-muted-on-dark);--card-text:var(--fg-body-on-dark);
    --text-signal:var(--signal-on-dark);
    --wash-1:var(--app-overlay-1);--wash-2:var(--app-overlay-2);
    --line-1:rgba(255,255,255,0.08);--line-2:rgba(255,255,255,0.16);
    --hairline:rgba(255,255,255,0.1);--focus-ring:var(--text-1);
    --control-bg:var(--wash-2);--control-text:var(--fg-light);
    --control-border:var(--fg-muted-on-dark);--control-border-hover:var(--fg-light);
    --control-placeholder:var(--fg-muted-on-dark);
    --control-disabled-bg:var(--wash-1);--control-disabled-text:var(--mist)}
  .app-dark .app-canvas{background:var(--ink);color:var(--text-1);
    --app-surface-bg:var(--ink);
    --text-1:var(--fg-light);--text-2:var(--fg-muted-on-dark);
    --text-caption:var(--fg-muted-on-dark);--card-text:var(--fg-body-on-dark);
    --text-signal:var(--signal-on-dark);
    --wash-1:var(--app-overlay-1);--wash-2:var(--app-overlay-2);
    --line-1:rgba(255,255,255,0.08);--line-2:rgba(255,255,255,0.16);
    --hairline:rgba(255,255,255,0.1);--focus-ring:var(--text-1);
    --control-bg:var(--wash-2);--control-text:var(--fg-light);
    --control-border:var(--fg-muted-on-dark);--control-border-hover:var(--fg-light);
    --control-placeholder:var(--fg-muted-on-dark);
    --control-disabled-bg:var(--wash-1);--control-disabled-text:var(--mist)}
  /* --text-signal falls back to --fg-light here, not --signal-on-dark: same "vermilion < AA"
     reasoning as .bg-forest above (1.52:1 unpointed / would be 4.25:1 even with signal-on-dark,
     still short of the 4.5:1 AA bar) — --forest is the ground for BOTH .app-card and .app-raised
     in the dark family, so both need the same fallback .bg-forest already established. */
  .app-dark .app-card{background:var(--forest);color:var(--text-1);
    --app-surface-bg:var(--forest);
    --text-1:var(--fg-light);--text-2:var(--fg-muted-on-dark);
    --text-caption:var(--fg-muted-on-dark);--card-text:var(--fg-body-on-dark);
    --text-signal:var(--fg-light);
    --wash-1:var(--app-overlay-1);--wash-2:var(--app-overlay-2);
    --line-1:rgba(255,255,255,0.08);--line-2:rgba(255,255,255,0.16);
    --hairline:rgba(255,255,255,0.1);--focus-ring:var(--text-1);
    --control-bg:var(--wash-2);--control-text:var(--fg-light);
    --control-border:var(--fg-muted-on-dark);--control-border-hover:var(--fg-light);
    --control-placeholder:var(--fg-muted-on-dark);
    --control-disabled-bg:var(--wash-1);--control-disabled-text:var(--mist)}
  .app-dark .app-raised{background:var(--forest);color:var(--text-1);
    --app-surface-bg:var(--forest);
    --text-1:var(--fg-light);--text-2:var(--fg-muted-on-dark);
    --text-caption:var(--fg-muted-on-dark);--card-text:var(--fg-body-on-dark);
    --text-signal:var(--fg-light);
    --wash-1:var(--app-overlay-1);--wash-2:var(--app-overlay-2);
    --line-1:rgba(255,255,255,0.08);--line-2:rgba(255,255,255,0.16);
    --hairline:rgba(255,255,255,0.1);--focus-ring:var(--text-1);
    --control-bg:var(--wash-2);--control-text:var(--fg-light);
    --control-border:var(--fg-muted-on-dark);--control-border-hover:var(--fg-light);
    --control-placeholder:var(--fg-muted-on-dark);
    --control-disabled-bg:var(--wash-1);--control-disabled-text:var(--mist)}


  /* ── Affordance icons ──────────────────────────────────────────────
     A minimal set for component affordances — NOT a general icon library.
     Masked SVG so each glyph paints with currentColor and inherits the
     surrounding Tier-2 text role automatically. ──────────────────────── */
  .wb-icon{display:inline-block;inline-size:var(--icon-size);block-size:var(--icon-size);
    background-color:currentColor;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;
    -webkit-mask-position:center;mask-position:center;-webkit-mask-size:contain;mask-size:contain;
    vertical-align:middle;flex:none}

  .wb-icon--close{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 6L18 18M18 6L6 18'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 6L18 18M18 6L6 18'/%3E%3C/svg%3E")}

  .wb-icon--chevron-down{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9L12 15L18 9'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9L12 15L18 9'/%3E%3C/svg%3E")}

  .wb-icon--chevron-right{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M9 6L15 12L9 18'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M9 6L15 12L9 18'/%3E%3C/svg%3E")}

  .wb-icon--check{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M5 13L10 18L19 7'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M5 13L10 18L19 7'/%3E%3C/svg%3E")}

  .wb-icon--search{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='10' cy='10' r='6'/%3E%3Cline x1='20' y1='20' x2='14.5' y2='14.5'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='10' cy='10' r='6'/%3E%3Cline x1='20' y1='20' x2='14.5' y2='14.5'/%3E%3C/svg%3E")}

  .wb-icon--more{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='5' cy='12' r='1'/%3E%3Ccircle cx='12' cy='12' r='1'/%3E%3Ccircle cx='19' cy='12' r='1'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='5' cy='12' r='1'/%3E%3Ccircle cx='12' cy='12' r='1'/%3E%3Ccircle cx='19' cy='12' r='1'/%3E%3C/svg%3E")}
  .wb-icon--menu{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 6h16M4 12h16M4 18h16'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 6h16M4 12h16M4 18h16'/%3E%3C/svg%3E")}

  .wb-icon--info{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='9'/%3E%3Cline x1='12' y1='11' x2='12' y2='16'/%3E%3Ccircle cx='12' cy='7.5' r='1'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='9'/%3E%3Cline x1='12' y1='11' x2='12' y2='16'/%3E%3Ccircle cx='12' cy='7.5' r='1'/%3E%3C/svg%3E")}

  .wb-icon--warn{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 3L22 20H2Z'/%3E%3Cline x1='12' y1='9' x2='12' y2='14'/%3E%3Ccircle cx='12' cy='17.3' r='1'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 3L22 20H2Z'/%3E%3Cline x1='12' y1='9' x2='12' y2='14'/%3E%3Ccircle cx='12' cy='17.3' r='1'/%3E%3C/svg%3E")}


  *,*::before,*::after{margin:0;padding:0;box-sizing:border-box}
  html{scroll-behavior:smooth}
  body{font-family:var(--font-body);color:var(--text-1);background:var(--paper);-webkit-font-smoothing:antialiased}
  a{color:inherit}

  /* ── Reduced motion ─────────────────────────────────────────────────
     WCAG 2.3.3 (Animation from Interactions). This system had NO reduced-motion guard at all
     through Phase 4a-iv — every transition and the 700ms page reveal ran regardless of the user's
     stated preference. For someone with a vestibular disorder that is not a matter of taste.

     Placed here, beside the reset, because it must also override `html{scroll-behavior:smooth}`
     above — a smooth-scrolled anchor jump is exactly the kind of unrequested motion this
     preference is about, and it is the one people most often forget.

     `!important` and the `*` selector are deliberate: this must win over every later tier and
     every component, and the cascade order between the five tiers should not be load-bearing for
     an accessibility guarantee.

     `.01ms`, not `0s`: a zero duration can skip `transitionend`/`animationend` entirely in some
     engines, and any script waiting on one would hang. This is imperceptible while still firing
     the events. `animation-iteration-count:1` stops anything looping forever at high speed.

     What this does NOT do is remove state changes — a switch still moves, a menu still opens, an
     accordion still expands. Only the interpolation between states is removed, so nothing becomes
     unusable or invisible. That is why the `.anim` reveal is safe to neutralise here: no rule in
     this file hides `.anim` content by default (the lint gate and a css-tiers test both enforce
     that), so content is already visible without the animation ever running.

     Incidentally, an earlier draft of this comment spelled that forbidden rule out literally and
     failed the css-tiers test — which reads the raw source and strips only whitespace, unlike
     check-lint.mjs, which strips comments first. The stricter reading is the right one for a
     safety net, so the prose changed rather than the gate. */
  @media (prefers-reduced-motion: reduce) {
    *,*::before,*::after{
      animation-duration:.01ms !important;
      animation-iteration-count:1 !important;
      transition-duration:.01ms !important;
      scroll-behavior:auto !important}
    html{scroll-behavior:auto !important}
  }
  code{font-family:var(--font-caption);font-size:.82em;background:rgba(27,29,24,0.08);padding:1px 4px;border-radius:var(--radius-sm)}

  .accent-bar{position:absolute;top:0;left:0;right:0;height:var(--bw-bar);background:var(--highlight);z-index:10}

  /* ── Type scale ────────────────────────────────────────────────── */
  .t-display{font-family:var(--font-h1);font-size:var(--fs-display);font-weight:900;line-height:1.02;letter-spacing:-.03em}
  .t-heading{font-family:var(--font-h2);font-size:var(--fs-heading);font-weight:700;line-height:1.1;letter-spacing:-.02em}
  .t-subheading{font-family:var(--font-h3);font-size:var(--fs-subheading);font-weight:300;line-height:1.3}
  .t-body{font-family:var(--font-body);font-size:var(--fs-body);line-height:1.7;font-weight:400}
  .t-small{font-family:var(--font-cite);font-size:var(--fs-small);line-height:1.55;font-weight:300}
  .t-mono{font-family:var(--font-caption);font-size:var(--fs-mono);letter-spacing:.09em;text-transform:uppercase}
  .t-quote{font-family:var(--font-quote);font-weight:700;font-style:italic;font-size:var(--fs-quote);line-height:1.5}
  .t-cite{font-family:var(--font-cite);font-weight:300;font-size:var(--fs-cite);line-height:1.55;color:var(--text-caption)}
  .bold{font-weight:700}

  /* ── Text-color utilities (auto-adapt to dark sections) ────────── */
  .c-highlight{color:var(--text-signal)}
  .c-mist{color:var(--text-caption)}
  .c-secondary{color:var(--text-2)}

  /* ── Rules & labels ────────────────────────────────────────────── */
  .rule{width:44px;height:var(--bw-accent);background:var(--rule-c);border:none;margin:var(--rule-margin-y) 0}
  .eyebrow{font-family:var(--font-caption);font-size:var(--fs-caption);font-weight:700;letter-spacing:.09em;text-transform:uppercase;color:var(--text-signal);margin-bottom:18px}

  /* ── Buttons ───────────────────────────────────────────────────── */
  .btn-row{display:flex;gap:var(--stack-gap);margin-top:var(--space-9);flex-wrap:wrap}
  .btn{font-family:var(--font-caption);font-size:.78rem;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--btn-text);background:var(--btn-fill);text-decoration:none;padding:var(--space-3) var(--space-6);border-radius:var(--radius-sm);display:inline-block;transition:opacity var(--dur-control) var(--ease-standard)}
  .btn:hover{opacity:.85}
  .btn--secondary{--btn-fill:var(--fill-secondary)}
  .btn--action{--btn-fill:var(--fill-action)}

  /* ── Status pills & strip ──────────────────────────────────────── */
  .status-strip{display:flex;gap:18px;flex-wrap:wrap;margin-top:42px}
  .status-pill{font-family:var(--font-caption);font-size:.72rem;font-weight:700;letter-spacing:.07em;text-transform:uppercase;padding:var(--space-2) var(--space-3);border:var(--bw-hair) solid var(--line-2);border-left:var(--bw-accent) solid var(--coral);border-radius:var(--radius-sm);background:var(--wash-2)}

  /* ── Tag pill ──────────────────────────────────────────────────── */
  /* A classification label — a topic, a format, a series — as a link or a span. Deliberately NOT
     `.status-pill`: that one carries a state (the coral accent edge says "attention"), and a topic
     is not a state. Caption voice, hairline border, the quiet `--wash-2` fill so a row of them reads
     as metadata under a title rather than as buttons; `color:inherit` keeps it legible on all eight
     grounds without variants. 32px minimum height clears the AA target floor for inline links; a
     standalone action still uses `.btn`. `--format` drops the fill so the one format pill in a row
     reads as a different kind of tag than the topics beside it. */
  .tag-pill{display:inline-flex;align-items:center;min-block-size:32px;padding:var(--space-2) var(--space-3);font-family:var(--font-caption);font-size:.72rem;font-weight:700;letter-spacing:.07em;text-transform:uppercase;line-height:1;color:inherit;text-decoration:none;border:var(--bw-hair) solid var(--line-2);border-radius:var(--radius-sm);background:var(--wash-2)}
  a.tag-pill:hover{text-decoration:underline}
  a.tag-pill:focus-visible{outline:2px solid var(--focus-ring);outline-offset:2px}
  .tag-pill--format{background:none}

  .attribution{font-family:var(--font-caption);font-size:var(--fs-mono);letter-spacing:.06em;margin-top:var(--space-12)}

  /* ── Reveal animation ──────────────────────────────────────────── */
  @keyframes fadeUp{from{opacity:0;transform:translateY(28px)}to{opacity:1;transform:translateY(0)}}
  .anim.visible{animation:fadeUp var(--dur-reveal) var(--ease-reveal) forwards}
  .anim-d1.visible{animation-delay:.08s}.anim-d2.visible{animation-delay:.16s}
  .anim-d3.visible{animation-delay:.24s}.anim-d4.visible{animation-delay:.32s}

  /* ── Connector draw ────────────────────────────────────────────────
     Generalises the one-off `#closeRule` draw — behavior.js used to animate a single HARDCODED
     id's width to a hardcoded 64px on intersection — into a class the connector patterns can all
     use: `.f-conn`/`.f-bl`, `.v-conn`/`.v-conn-line`, `.migration-step`'s rules, `.arch-flow`.
     Trigger is the same one `.anim` uses (behavior.js adds `.visible` from its single
     IntersectionObserver), and the timing is the same `--dur-reveal`/`--ease-reveal` pair, because
     a connector drawing itself open IS the page reveal — it should not be its own tempo.

     The keyframes deliberately have NO `to` frame. The implicit end frame is the element's own
     computed size, so a 64px rule draws to 64px and a 100%-wide one to 100%, with no per-instance
     override and no token. It also means `forwards` is unnecessary: the resting state already IS
     the end state. That is what keeps the reduced-motion guard above (`.01ms`, one iteration,
     no fill) landing on a fully drawn connector rather than a collapsed, invisible one.

     `.draw--v` is a MODIFIER (`class="draw draw--v"`), not a standalone class — it swaps only the
     animated axis to `block-size`, which is what a vertical spine needs; the duration and easing
     still come from `.draw`.

     The element needs a DEFINITE size on the animated axis (a length, or a percentage of a
     definite containing block). A flex- or content-derived `auto` is not interpolable from 0 and
     will simply snap to full size. Bare `.draw` carries no rule at all, so — exactly like bare
     `.anim` — the connector is fully drawn with CSS alone and JS only adds the animation. */
  @keyframes wb-draw{from{inline-size:0}}
  @keyframes wb-draw-v{from{block-size:0}}
  .draw.visible{animation:wb-draw var(--dur-reveal) var(--ease-reveal)}
  .draw--v.visible{animation-name:wb-draw-v}

/* ── Wide-viewport end alignment ─────────────────────────────────────
     For mirrored compositions around a central axis (the marketing spine):
     copy on the leading side reads ragged-left toward the axis on wide
     viewports, then returns to natural start alignment when the layout
     stacks below the 768px breakpoint — which is why the utility is
     viewport-scoped rather than a bare text-align. */
@media(min-width:769px){
    .u-text-end-wide{text-align:end}
}

/* Centered text for a card or block whose composition is symmetric (the
   marketing spine's stage cards). Unscoped: centering reads correctly at
   every viewport, unlike the end alignment above. */
.u-text-center{text-align:center}

@media(max-width:768px){
    .btn-row{flex-direction:column}
}


  /* ── Callout panel ─────────────────────────────────────────────── */
  .intro-panel{margin-top:34px;padding:26px 30px;border-left:var(--bw-bar) solid var(--highlight);background:var(--wash-2);border-top:var(--bw-hair) solid var(--line-2);border-right:var(--bw-hair) solid var(--line-2);border-bottom:var(--bw-hair) solid var(--line-2)}
  .intro-panel p{color:var(--card-text)}

  /* ── Card primitives (shared title/copy) ───────────────────────── */
  .item-title,.sc-name{font-family:var(--font-h2);font-weight:700;font-size:1rem;letter-spacing:-.02em;margin-bottom:6px}
  .item-copy,.sc-desc{font-family:var(--font-body);font-size:.9rem;line-height:1.62;color:var(--card-text)}

  /* Generic card chrome — used by method / status / dispatch / portal */
  .method-item,.status-card,.dispatch-card,.portal-card{padding:var(--card-pad-y) var(--card-pad-x);border-radius:var(--radius-sm);background:var(--wash-1);border:var(--bw-hair) solid var(--line-1);border-left:var(--bw-accent) solid var(--sky)}
  /* Opaque variant: same chrome, --wash-solid fill — for cards that sit over
     page furniture (a spine rail) that must not show through. Identical to
     the translucent wash on light grounds, pre-composited on dark ones. */
  .card--solid{background:var(--wash-solid)}

  /* ── Grids ─────────────────────────────────────────────────────── */
  .section-grid{display:grid;grid-template-columns:1fr 1fr;gap:var(--grid-gap);margin-top:var(--space-9)}
  .method-list{display:grid;gap:var(--stack-gap);margin-top:34px}
  .status-grid{display:grid;grid-template-columns:1fr 1fr;gap:var(--space-4);margin-top:var(--space-8)}

  /* ── Status card ──────────────────────────────────────────────── */
  .sc-desc{font-size:.85rem;line-height:1.6}

  /* ── Dispatch grid (5-up capability strip) ─────────────────────── */
  .dispatch-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:var(--space-3);margin-top:34px}
  .dispatch-card{padding:18px var(--space-4);text-align:left;border-left-color:var(--coral)}
  .dispatch-card:nth-child(2){border-left-color:var(--sky)}
  .dispatch-card:nth-child(3){border-left-color:var(--plum)}
  .dispatch-card:nth-child(4){border-left-color:var(--lime)}
  .dispatch-card:nth-child(5){border-left-color:var(--tangerine)}
  .dispatch-card .item-title{font-size:.86rem}
  .dispatch-card .item-copy{font-size:.8rem}

  /* ── Portal grid (linked cards, hover lift) ────────────────────── */
  .portal-grid{display:grid;grid-template-columns:1fr 1fr;gap:var(--space-4);margin-top:38px}
  .portal-card{display:block;color:inherit;text-decoration:none;transition:transform var(--dur-control) var(--ease-standard),box-shadow var(--dur-control) var(--ease-standard),border-color var(--dur-control) var(--ease-standard)}
  .portal-card:hover{transform:translateY(-2px);box-shadow:0 12px 32px rgba(27,29,24,0.12);border-color:rgba(27,29,24,0.18)}
  .portal-card:nth-child(2){border-left-color:var(--plum)}
  .portal-card:nth-child(3){border-left-color:var(--sky)}
  .portal-card:nth-child(4){border-left-color:var(--lime)}
  .portal-meta{font-family:var(--font-caption);font-size:.68rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--text-caption);margin-top:18px}

  /* ── Charter grid (4 labelled blocks, color-coded) ─────────────── */
  .charter-grid{display:grid;grid-template-columns:1fr 1fr;gap:var(--space-5);margin-top:38px}
  .charter-block{padding:26px var(--card-pad-x);background:var(--wash-1);border:var(--bw-hair) solid var(--line-1);border-left:var(--bw-accent) solid var(--lavender);border-radius:var(--radius-sm)}
  .charter-block:nth-child(2){border-left-color:var(--sky)}
  .charter-block:nth-child(3){border-left-color:var(--tangerine)}
  .charter-block:nth-child(4){border-left-color:var(--lime)}
  .charter-block h3{font-family:var(--font-h2);font-size:1rem;font-weight:700;letter-spacing:-.01em;margin-bottom:10px}
  .charter-block p,.charter-block li{font-family:var(--font-body);font-size:.9rem;line-height:1.62;color:var(--card-text)}
  .charter-block ul{padding-left:18px}
  .charter-block li{margin-bottom:6px}

  /* ── Question cards ────────────────────────────────────────────── */
  .q-grid{display:flex;flex-direction:column;gap:var(--grid-gap);margin-top:var(--space-9)}
  .q-card{padding:var(--card-pad-y) var(--card-pad-x);border-radius:var(--radius-sm);background:var(--wash-1);border:var(--bw-hair) solid var(--line-1)}
  .q-card-q{font-family:var(--font-h3);font-weight:300;font-size:1.1rem;color:var(--text-1);line-height:1.35}
  .q-card-context{font-family:var(--font-body);font-size:.85rem;line-height:1.6;color:var(--card-text);margin-top:8px}

  /* ── Migration / process steps ─────────────────────────────────── */
  .migration{display:grid;gap:0;margin-top:38px;border-top:var(--bw-hair) solid var(--hairline)}
  .migration-step{display:grid;grid-template-columns:42px 1fr auto;gap:var(--grid-gap);align-items:start;padding:22px 0;border-bottom:var(--bw-hair) solid var(--hairline)}
  .step-num{font-family:var(--font-h1);font-size:1.8rem;font-weight:900;line-height:1;color:var(--text-num)}
  .step-title{font-family:var(--font-h2);font-size:1rem;font-weight:700;letter-spacing:-.01em;margin-bottom:4px}
  .step-desc{font-family:var(--font-body);font-size:.9rem;line-height:1.58;color:var(--card-text)}
  .step-status{font-family:var(--font-caption);font-size:.68rem;font-weight:700;letter-spacing:.07em;text-transform:uppercase;padding:5px 8px;border-radius:var(--radius-sm);white-space:nowrap}
  .step-done{background:var(--pill-done-bg);color:var(--pill-done-text)}
  .step-next{background:var(--pill-next-bg);color:var(--pill-next-text)}

  .cap-g{margin-top:10px;display:flex;flex-wrap:wrap;gap:6px}
  .cap-p{font-family:var(--font-h2);font-weight:700;font-size:.78rem;letter-spacing:-.01em;color:var(--text-1);background:rgba(255,255,255,0.12);border:var(--bw-hair) solid rgba(255,255,255,0.18);border-radius:var(--radius-md);padding:5px 12px}

  .q-panel{display:flex;flex-direction:column;justify-content:center;padding-right:var(--card-pad-x);flex-shrink:0;max-width:220px;position:relative}
  .q-panel::after{content:'';position:absolute;right:0;top:10%;bottom:10%;width:1px;background:linear-gradient(180deg,transparent,rgba(255,255,255,0.25) 25%,rgba(255,255,255,0.25) 75%,transparent)}
  .q-list{display:flex;flex-direction:column;gap:var(--stack-gap)}
  .q-item{font-family:var(--font-body);font-size:.88rem;font-style:italic;color:var(--text-1);line-height:1.5}

@media(max-width:900px){
    .dispatch-grid{grid-template-columns:1fr 1fr}
}
@media(max-width:768px){
    .section-grid,.charter-grid,.status-grid{grid-template-columns:1fr}
    .migration-step{grid-template-columns:32px 1fr;gap:var(--stack-gap)}
    .step-status{grid-column:2;justify-self:start}
    .dispatch-grid,.portal-grid{grid-template-columns:1fr}
    .q-panel{max-width:100%;padding-right:0;padding-bottom:var(--space-6)}
    .q-panel::after{width:100%;height:1px;top:auto;bottom:0;left:10%;right:10%;background:linear-gradient(90deg,transparent,rgba(231,30,16,0.6) 25%,rgba(231,30,16,0.6) 75%,transparent)}
}


  /* ── Scroll progress ───────────────────────────────────────────────
     TWO IMPLEMENTATIONS OF ONE BAR, and the CSS one is primary where it exists.

     0.14.1 made the bar a rAF-throttled `scaleX` written from behavior.js, and that measured at 0
     deviation from scrollY/(scrollHeight-innerHeight) in all three desktop engines. It still
     sputtered on iOS Safari — which is not a defect in that code and could not be fixed inside it.
     During iOS momentum scrolling the compositor thread scrolls the page while the main thread is
     throttled or stalled outright, so ANY main-thread scroll handler, however cheap, reports a
     stale position for as long as the stall lasts. The only fix is to stop asking the main thread.

     A scroll-driven animation (`animation-timeline: scroll(root)`) is evaluated on the compositor
     from the scroll offset itself, so the bar stays exact while the main thread is frozen. The
     animation's progress IS the scroll fraction: `both` fill keeps `scaleX(0)` before the timeline
     starts and `scaleX(1)` after it ends, and `linear` because any easing would make the bar
     disagree with the page position it is reporting.

     `@supports` is doing real work, not decorating: behavior.js runs the SAME feature test and
     skips registering its scroll/resize listeners entirely when this block applies, so exactly one
     implementation is live at a time. Where the query fails (Firefox, through at least 153 —
     checked, not read off a support table), the JS path is registered and behaves exactly as it did
     in 0.14.1.

     The `@keyframes` sits INSIDE the `@supports` on purpose: an engine that cannot use it should
     not carry it, and nothing outside this block references `wb-progress`.

     CASCADE: an inline `transform` on the element cannot beat this. CSS animations are their own
     cascade origin above every normal author declaration, style attribute included — so even if a
     consumer's own script (or a build of behavior.js older than this one) wrote `scaleX(…)` inline
     before the feature test existed, the animation still wins. Verified in Chromium 151 and WebKit
     26.5 rather than asserted: with `style.transform="scaleX(0.02)"` written directly onto the bar,
     the COMPUTED transform stayed on the scroll fraction.

     REDUCED MOTION: the global guard in primitives.css forces `animation-duration:.01ms !important`
     onto everything, which for a time-driven animation means "finish instantly". It does NOT do
     that here, and that is the spec'd behaviour rather than luck: a progress-based timeline
     supplies the animation's progress directly, so `animation-duration` is ignored. Verified under
     `prefers-reduced-motion: reduce` in both engines — computed `animation-duration` reads
     `0.00001s` while the bar still tracks scroll at every sampled fraction with 0 deviation. That
     is also the RIGHT outcome for the preference: the bar is a position readout, not decoration,
     and it never moves unless the user is already moving the page. */
  .progress-track{position:fixed;top:0;left:0;right:0;height:3px;background:var(--paper);z-index:1000}
  .progress-bar{height:100%;background:var(--highlight);width:100%;transform-origin:0 50%;transform:scaleX(0)}
  /* MINIFIER-SAFETY: `animation` and `animation-timeline` are split across two rules with
     DIFFERENT selectors on purpose. esbuild and lightningcss both fold co-located
     `animation:…` + `animation-timeline:…` declarations on the same selector into the single
     CSS Animations Level 2 DRAFT shorthand (`animation: … wb-progress scroll(root)`) — grammar
     no shipping engine parses, so the merged declaration is dropped outright and the rule
     computes empty. Keeping `animation-timeline` in `.progress-track .progress-bar` instead of
     `.progress-bar` gives it a different declaration block, so no minifier has a single rule to
     merge into. `.progress-bar` is always a descendant of `.progress-track` in the canonical
     markup (Page Chrome story), so the selector always matches in practice. The higher
     specificity of `.progress-track .progress-bar` (0,2,0) over `.progress-bar` (0,1,0) also
     means the longhand `animation-timeline` here beats the shorthand `animation` declaration's
     implicit `animation-timeline: auto` reset regardless of which rule is declared first in the
     cascade — so this is not relying on source order, which a minifier is also free to change. */
  @supports (animation-timeline: scroll()){
    .progress-bar{animation:wb-progress linear both}
    .progress-track .progress-bar{animation-timeline:scroll(root)}
    @keyframes wb-progress{from{transform:scaleX(0)}to{transform:scaleX(1)}}
  }

  /* ── Header (fixed, scroll-aware, theme-aware) ─────────────────── */
  /* `top:0` — flush against the viewport (was `top:3px`, a designed gap for the progress track that
     read as a hairline on desktop but became the anchor for a much larger break on iOS Safari, where
     a fixed element under `backdrop-filter` can visibly detach from the viewport edge during scroll).
     `.progress-track` (`top:0;z-index:1000`) now overlays the header's top 3px instead of sitting
     above it — its z-index already beats the header's 900, so the bar stays fully visible whether
     the track is a sibling of `.site-header` (canonical markup, Page Chrome story) or nested inside
     it: `.site-header`'s own `transform` makes it the containing block for a nested fixed track, but
     the header's padding-box top/left already sit flush with the viewport's, so a nested track's
     `top:0;left:0` lands at the identical pixel as a sibling track's viewport-relative `top:0;left:0`.
     `transform:translateZ(0)` forces `.site-header` onto its own compositor layer — the standard
     mitigation for the WebKit/iOS bug where a fixed element with `backdrop-filter` (`.scrolled`,
     below) samples a stale backdrop region and lets page content ghost through above it during
     scroll. Headless engines (including desktop Safari/WebKit) don't reproduce iOS's dynamic
     toolbar, so this mitigation's real-world effect is not fully verifiable in CI — an on-device
     check is still required. */
  .site-header{position:fixed;top:0;left:0;right:0;z-index:900;padding:var(--space-4) var(--space-12);display:flex;align-items:center;justify-content:space-between;gap:var(--space-4);transition:background var(--dur-surface) var(--ease-standard),box-shadow var(--dur-surface) var(--ease-standard);transform:translateZ(0);-webkit-transform:translateZ(0)}
  .site-header.scrolled{background:rgba(243,243,243,0.95);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);box-shadow:0 1px 0 rgba(27,29,24,0.08)}
  .site-header.scrolled.on-dark{background:rgba(27,29,24,0.85);box-shadow:0 1px 0 rgba(255,255,255,0.05)}
  .site-header.on-dark{--text-caption:var(--fg-muted-on-dark);--text-1:var(--fg-light)}
  .brand-link{display:flex;align-items:center;gap:var(--space-4);text-decoration:none;color:var(--text-1)}
  .u-logo,.header-logo{height:32px;width:auto;flex-shrink:0;color:currentColor}
  .header-title{font-family:var(--font-h2);font-size:.92rem;font-weight:700;letter-spacing:-.01em}
  /* ── Collapsing brand (Phase 4c) ─────────────────────────────────────
     The header brand collapses from the full TUG lockup to the monogram on scroll, and expands
     again on the way back up. It reuses the `.scrolled` class behavior.js already toggles at 20px,
     so this adds NO JavaScript.

     IT IS NOT A MORPH. The brand link holds two separate elements — `.tug-monogram` and
     `.tug-wordmark` — and only the wordmark changes. The monogram never moves, never rescales and
     never redraws, so there is no landing to get right and no settling at the end of a scroll. That
     is possible because both marks are cut from the same source path at build time
     (scripts/build-brand.mjs), which makes them consistent by construction.

     BOTH DIRECTIONS USE THE SAME DURATION, and that is load-bearing rather than tidy. An earlier
     version faded the wordmark over --dur-fast while the width took --dur-surface; the fade
     outran the wipe 3:1, so collapsing read as the wordmark vanishing and only expanding read as
     an animation. Measured: opacity hit 0.01 at 94ms while the width was still at 37px. Keep both
     properties on one duration or the collapse stops being visible.

     These classes live in the OPT-IN brand.css, not in this file — a consumer without it simply has
     no marks to collapse, and these rules match nothing. That is the intended degradation. */
  /* `.brand-link` sets a flex gap for the older mark-plus-title layout. With the collapsing brand
     that gap would survive the collapse and leave the monogram floating off a stray space, so it is
     zeroed — but only where a wordmark is actually present, via `:has()` (supported in all three
     modern engines; verified, not assumed). The spacing that remains is the wordmark's own
     margin, which is what animates. */
  .site-header .brand-link:has(.tug-wordmark){gap:0}

  .site-header .tug-wordmark{margin-inline-start:var(--space-3);
    transition:inline-size var(--dur-surface) var(--ease-standard),
               opacity var(--dur-surface) var(--ease-standard),
               margin-inline-start var(--dur-surface) var(--ease-standard)}
  .site-header.scrolled .tug-wordmark{inline-size:0;opacity:0;margin-inline-start:0}

  .header-nav{display:flex;align-items:center;gap:18px}
  .header-nav a{font-family:var(--font-cite);font-size:.78rem;color:var(--text-caption);text-decoration:none}
  .header-nav a:hover{color:var(--text-1)}

  /* ── Header menu (mobile nav disclosure) ───────────────────────── */
  /* Below the same 768px breakpoint that hides .header-nav, the nav
     collapses into a right-justified hamburger. The trigger + panel
     reuse the wb-menu menu-button pattern, so behavior.js's
     [data-wb-menu] wiring owns open/close, keyboard, and ARIA; markup
     is a button pointing at a wb-menu list of role=menuitem links
     (Page Chrome story). Desktop hides the whole disclosure. The
     panel pins its ground and text tokens locally: --app-surface-bg
     and --control-disabled-bg exist only inside application-tier
     surfaces, which a marketing-page header is not, and
     .site-header.on-dark flips --text-1/--text-caption for the header
     row while the panel stays a light surface. */
  .header-menu{position:relative;display:none}
  /* 44x44 minimum target (WCAG 2.1 AAA, 2.5.5) with a 24px glyph — the
     trigger only renders below 768px, where every pointer is a finger. */
  .header-menu-trigger{display:inline-flex;align-items:center;justify-content:center;
    min-inline-size:44px;min-block-size:44px;--icon-size:24px;
    padding:var(--space-2);border:0;background:transparent;color:var(--text-caption);
    cursor:pointer;border-radius:var(--radius-sm)}
  .header-menu-trigger:hover{color:var(--text-1)}
  .header-menu-trigger:focus-visible{outline:2px solid var(--focus-ring);outline-offset:2px}
  .header-menu .wb-menu{inset-inline-end:0;top:calc(100% + var(--space-2));
    --app-surface-bg:var(--white);--control-disabled-bg:var(--paper);
    --elevation-line:var(--hairline);
    --text-1:var(--fg-dark);--text-caption:var(--fg-muted-on-light)}
  .header-menu .wb-menu .wb-menu__item{text-decoration:none}

  /* ── Section system ────────────────────────────────────────────── */
  .section{position:relative;overflow:hidden}
  .section-inner{max-width:860px;margin:0 auto;padding:var(--section-pad-y) var(--section-pad-x);position:relative;z-index:2}
  .section--compact .section-inner{padding:var(--section-pad-y-compact) var(--section-pad-x)}   /* denser rhythm (Guide pages) */
  .section--hero{min-height:100vh;display:flex;align-items:center;justify-content:center}
  .section--hero .section-inner{padding-top:var(--space-20);padding-bottom:var(--space-20)}
  .section--hero-compact{min-height:76vh;display:flex;align-items:center}                      /* Guide/portal archetype */
  .section--hero-compact .section-inner{padding-top:var(--section-pad-y);padding-bottom:var(--space-20)}
  .section--close{min-height:50vh;display:flex;align-items:center;justify-content:center;text-align:center}

  /* ── Texture & ornament ────────────────────────────────────────── */
  .noise::after{content:'';position:absolute;inset:0;background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.04'/%3E%3C/svg%3E");background-size:200px 200px;pointer-events:none;z-index:1}
  .convergence-field{position:absolute;inset:0;z-index:0;overflow:hidden;pointer-events:none}
  .convergence-field svg{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:140%;height:140%}

  /* ── Water field (ambient hero surface) ────────────────────────────
     A quiet water surface for a hero — the tugboat reading of the brand, at the volume of a
     texture. It is a `.section` ornament like `.convergence-field` above: absolutely positioned,
     `inset:0`, clipped, `pointer-events:none`, and at `z-index:0` so `.section-inner`'s existing
     `z-index:2` (see the Section system block above) puts every word of the hero in front of it.
     No new stacking context is invented here and none is needed.

     BUILT THE WAY THE ICONS ARE BUILT. Each band is `background:currentColor` masked by an
     inline-SVG data URI — the wb-icon technique, same escaping conventions (`%3C`/`%3E`, single
     quotes) — so the water is always cut from the surrounding text role. On the five dark grounds
     that is the light text colour; on the three light grounds, ink. Eight backgrounds, zero
     per-theme rules, and it cannot drift out of the token system because it never names a colour.

     `preserveAspectRatio='none'` because the tile is stretched to the band, not fitted: the SVG's
     1200x200 viewBox is geometry, not a size. The path's first and last points share a y, which is
     what lets `mask-repeat:repeat-x` tile it with no seam at the joins.

     THE LOOP IS SEAMLESS BY CONSTRUCTION, not by tuning. The mask repeats every
     `--water-wavelength`, and the drift translates by exactly `-1 x --water-wavelength`, so the
     frame at 100% is pixel-identical to the frame at 0% and the restart is invisible. That is the
     same reasoning as `.chyron`'s two-track `translateX(-100%)`, one level simpler because a
     repeating mask needs no duplicate element. The band is `calc(100% + --water-wavelength)` wide
     so the tail it drifts in from is already painted — a 100%-wide band would drag a wedge of empty
     ground in behind its right edge.

     PERFORMANCE GUARANTEE, stated because it is the reason this pattern is allowed to exist at
     all: every animated property here is compositor-only. `transform` and `opacity` and nothing
     else — no width/height/inset/background-position/filter, nothing that forces layout or a
     repaint on any frame. The drift runs off the main thread; a stalled main thread does not stall
     it, and it does not stall the main thread. Measured in Chromium over 2s of animation: 0 dropped
     frames, 0 forced style/layout recalculations.

     Durations are LITERALS in component-local custom properties (`--water-drift`), the
     `--chyron-duration` precedent: the `--dur-*` role tokens name the system's shared FEEL and
     exist to be retuned in one place, and ambient drift is not that — it is per-instance bespoke
     timing. Tens of seconds is the point. This is weather, not an animation anybody watches, and a
     wave that reads as "moving" is already too fast.

     REDUCED MOTION: the global guard caps the drift at `.01ms` with one iteration. Checked in a
     browser rather than assumed — with no `animation-fill-mode` the finished animation stops
     contributing and each band rests at its un-animated `translateX(0)`, i.e. the mask's phase 0:
     three full swells across the frame, exactly the composition the moving version passes through.
     A static texture, not a frozen glitch. No pattern-specific reduced-motion rule is needed and
     none is added. */
  .water-field{position:absolute;inset:0;z-index:0;overflow:hidden;pointer-events:none}
  .water-wave{--water-wavelength:720px;--water-drift:64s;--water-depth:34%;--water-opacity:.07;
    position:absolute;inset-block-end:0;inset-inline-start:0;
    block-size:var(--water-depth);inline-size:calc(100% + var(--water-wavelength));
    background:currentColor;opacity:var(--water-opacity);
    -webkit-mask-repeat:repeat-x;mask-repeat:repeat-x;
    -webkit-mask-size:var(--water-wavelength) 100%;mask-size:var(--water-wavelength) 100%;
    -webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1200 200' preserveAspectRatio='none'%3E%3Cpath fill='black' d='M0 60C200 20 400 20 600 60S1000 100 1200 60V200H0Z'/%3E%3C/svg%3E");
    mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1200 200' preserveAspectRatio='none'%3E%3Cpath fill='black' d='M0 60C200 20 400 20 600 60S1000 100 1200 60V200H0Z'/%3E%3C/svg%3E");
    animation:wb-water-drift var(--water-drift) linear infinite}
  /* Three bands, written as bare repeated elements by the consumer. Each is a different
     wavelength, depth, speed and opacity, so the crests never line up and the surface reads as
     depth rather than as one shape drawn three times. Opacity descends with the bands that sit
     higher in the frame — the topmost is the faintest, because it is the one closest to the text. */
  .water-wave:nth-child(2){--water-wavelength:520px;--water-drift:88s;--water-depth:25%;--water-opacity:.05}
  .water-wave:nth-child(3){--water-wavelength:940px;--water-drift:112s;--water-depth:17%;--water-opacity:.035}
  @keyframes wb-water-drift{to{transform:translateX(calc(var(--water-wavelength) * -1))}}

  .breadcrumb{font-family:var(--font-caption);font-size:var(--fs-mono);font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--text-caption);margin-bottom:var(--space-9)}
  .breadcrumb a{text-decoration:none}
  .breadcrumb a:hover{color:var(--text-1)}

  /* ── Hero deck (left-aligned variant) ──────────────────────────── */
  .hero-deck{max-width:760px}
  .hero-deck .t-subheading{color:var(--text-2);max-width:660px;margin-top:22px}

  /* ── Tension slider (0–10 continuum vote) ──────────────────────────────
     Selected-state relies on --highlight, tested only on ink/paper — place
     this component on .bg-ink, .bg-paper, or .bg-white only. The track (not
     the small dots) is the pointer / touch / focus target, sized 44px tall ×
     full-width to clear WCAG 2.5.5. Dot border uses --text-2 for a ≥3:1
     control edge; the line/consent/dots are aria-hidden decoration. ─────── */
  .tension-slider{margin-top:38px}
  .tension-aims{display:flex;justify-content:space-between;gap:var(--space-4);margin-bottom:26px}
  .tension-aim{max-width:220px}
  .tension-aim--right{text-align:right;margin-left:auto}
  .tension-track{position:relative;height:44px;cursor:pointer;touch-action:none}
  .tension-track:focus-visible{outline:2px solid var(--focus-ring);outline-offset:6px;border-radius:var(--radius-lg)}
  .tension-line{position:absolute;top:50%;left:0;right:0;height:2px;background:var(--text-2);opacity:.5;transform:translateY(-50%);pointer-events:none}
  .tension-consent{position:absolute;top:2px;width:0;height:0;border-left:6px solid transparent;border-right:6px solid transparent;border-top:7px solid var(--text-2);transform:translateX(-50%);pointer-events:none}
  .tension-dot{position:absolute;top:50%;width:20px;height:20px;border-radius:50%;background:var(--wash-1);border:2px solid var(--text-2);transform:translate(-50%,-50%);pointer-events:none;transition:transform var(--dur-control) var(--ease-standard),border-color var(--dur-control) var(--ease-standard),background var(--dur-control) var(--ease-standard)}
  .tension-dot.is-selected{background:var(--highlight);border-color:var(--highlight);transform:translate(-50%,-50%) scale(1.3)}
  .tension-readout{margin-top:18px}

  /* ── Architecture block (Now / Later states) ───────────────────── */
  .architecture-block{margin-top:38px;background:rgba(255,255,255,0.08);border:var(--bw-hair) solid rgba(255,255,255,0.12);border-radius:var(--radius-md);overflow:hidden}
  .arch-state{padding:var(--card-pad-y) var(--card-pad-x);border-bottom:var(--bw-hair) solid rgba(255,255,255,0.1)}
  .arch-state:last-child{border-bottom:0}
  .arch-label{font-family:var(--font-caption);font-size:var(--fs-mono);font-weight:700;letter-spacing:.09em;text-transform:uppercase;color:var(--text-link);margin-bottom:12px}
  .arch-flow{font-family:var(--font-h2);font-size:1.02rem;font-weight:700;line-height:1.45;letter-spacing:-.01em;color:var(--text-1)}
  .arch-note{font-family:var(--font-body);font-size:.9rem;line-height:1.6;color:var(--card-text);margin-top:12px}

  /* ── Layered architecture diagram (stack) ──────────────────────── */
  .arch-diagram{max-width:500px;margin:36px auto 0}
  .arch-stack{display:flex;flex-direction:column}
  .iface-layer,.wb-layer,.ag-layer{background:rgba(255,255,255,0.10);border:var(--bw-hair) solid rgba(255,255,255,0.15);border-radius:var(--radius-md)}
  .iface-layer{padding:var(--space-4) var(--space-5) 14px;position:relative}
  .wb-layer{border-radius:var(--radius-lg);padding:18px var(--space-5) var(--space-4)}
  .ag-layer{border-radius:var(--radius-lg);padding:18px var(--space-5) var(--space-4);margin-top:8px}
  .layer-t{font-family:var(--font-h1);font-weight:900;font-size:1.25rem;letter-spacing:-.03em;color:var(--text-1);line-height:1.1}
  .layer-s{font-family:var(--font-body);font-size:.82rem;font-weight:400;color:var(--card-text);margin-top:3px}
  .guide-c{margin-top:0;margin-bottom:10px;background:rgba(255,255,255,0.08);border:var(--bw-hair) solid rgba(255,255,255,0.12);border-radius:var(--radius-md);padding:10px var(--space-4);display:flex;align-items:baseline;gap:10px}
  .guide-c .gn{font-family:var(--font-h2);font-weight:700;font-size:1rem;letter-spacing:-.02em;color:var(--text-1)}
  .guide-c .gr{font-family:var(--font-body);font-size:.82rem;font-weight:400;color:var(--card-text)}
  .v-conn{display:flex;flex-direction:column;align-items:center;height:14px}
  .v-conn-line{width:1.5px;flex:1;background:rgba(255,255,255,0.3)}
  .dial-wrap{padding:2px 0;display:flex;flex-direction:column;align-items:center}
  .dial-comp{display:flex;flex-direction:column;align-items:center;width:100%;max-width:460px}
  .dial-svg{display:block;width:100%;max-width:420px;height:auto}
  .dial-nm{font-family:var(--font-caption);font-size:.62rem;font-weight:700;letter-spacing:.09em;text-transform:uppercase;color:var(--card-text);margin-top:4px}

  /* ── Market-positioning diagram (in-between flow) ──────────────── */
  .pos-layout{display:flex;gap:0;align-items:stretch;margin-top:56px}
  .flow-col{display:flex;flex-direction:column;align-items:center;flex:1;min-width:0;padding-left:var(--card-pad-x)}
  .f-box{width:100%;border-radius:var(--radius-lg);text-align:center}
  .f-box-sec{background:rgba(255,255,255,0.1);border:var(--bw-hair) solid rgba(255,255,255,0.18);padding:22px var(--space-6)}
  .f-box-sec .fb-t{font-family:var(--font-h2);font-weight:700;font-size:.95rem;letter-spacing:-.02em;color:var(--text-1)}
  .f-box-sec .fb-s{font-family:var(--font-body);font-size:.82rem;color:var(--text-1);font-weight:300;margin-top:4px}
  .f-box-pri{background:var(--white);padding:36px var(--space-6);box-shadow:0 4px 24px rgba(0,0,0,0.18)}
  .f-box-pri .fb-t{font-family:var(--font-h1);font-weight:900;font-size:1.5rem;letter-spacing:-.03em;color:var(--ink);line-height:1.1}
  .f-conn{display:flex;flex-direction:column;align-items:center;height:var(--space-8);justify-content:space-between;padding:2px 0}
  .f-au{width:0;height:0;border-left:4.5px solid transparent;border-right:4.5px solid transparent;border-bottom:5px solid rgba(255,255,255,0.4)}
  .f-ad{width:0;height:0;border-left:4.5px solid transparent;border-right:4.5px solid transparent;border-top:5px solid rgba(255,255,255,0.4)}
  .f-bl{width:1.5px;flex:1;background:rgba(255,255,255,0.3)}

  /* ── Chyron (continuously scrolling strip) ─────────────────────────
     A mark/logo strip that loops with no visible reset. The whole pattern lives here, not just the
     animation, because a consumer that owns the layout and imports only the keyframe re-derives
     the two things below that are easy to get wrong.

     TWO tracks, not one. `.chyron` clips; inside it sit two `.chyron-track`s side by side, the
     second a duplicate marked `aria-hidden` (it is the same marks again — a screen reader must not
     read the list twice). At the moment the first track has translated exactly its own width off
     the left edge, the second occupies the frame identically, so the loop's snap back to 0 is
     invisible. One track alone leaves a container-wide gap before it wraps.

     `translateX(-100%)` is "exactly its own width" because a percentage translate resolves against
     the TRANSFORMED ELEMENT's own border box, not its container's — which is why this needs no
     JS measurement. `linear`, never an ease: any easing makes each loop visibly accelerate and
     decelerate, so a continuous strip reads as a pulse. The trailing `padding-inline-end` (one
     gap's worth) is what keeps the seam between track 2's last mark and track 1's first mark
     spaced like every other gap.

     `.chyron-item` is SQUARE by default — `--chyron-size` on BOTH axes, not a width alone. Client
     logos generally arrive as square canvases with the mark small and centred in whitespace; in a
     width-only cell the row's block-size collapses to the tallest painted mark and every mark then
     renders at roughly a third of its apparent size. A square cell is the sane default.

     `--chyron-duration` is per-instance (default 40s, set on `.chyron`; override it inline on the
     instance) so a strip of 6 marks and a strip of 40 can both move at a readable speed — the loop
     distance is the track's OWN width, so one fixed duration makes a long strip sprint. It is
     deliberately a component-local custom property rather than a new `--dur-*` role token: the
     `--dur-*` roles name the system's shared FEEL (a control answering a pointer, a page reveal)
     and exist so that feel can be retuned in one place. A marquee's speed is not that — it is a
     function of how much content this particular instance happens to hold, so a system-wide token
     would be a knob nobody could safely turn. `--chyron-size` and `--chyron-gap` are the same kind
     of local knob, defaulted on `.chyron` for the same reason.

     REDUCED MOTION: the global guard in primitives.css caps this at `.01ms` with
     `animation-iteration-count:1`. Checked in a browser rather than assumed — with no
     `animation-fill-mode`, the finished animation stops contributing and the track rests at its
     un-animated transform, i.e. `translateX(0)`: track 1 in frame, marks fully visible, nothing
     blank and nothing stuck mid-slide. So no pattern-specific reduced-motion rule is needed and
     none is added. */
  .chyron{--chyron-duration:40s;--chyron-size:120px;--chyron-gap:var(--space-12);display:flex;flex-wrap:nowrap;inline-size:100%;overflow:hidden}
  .chyron-track{display:flex;flex:0 0 auto;align-items:center;gap:var(--chyron-gap);padding-inline-end:var(--chyron-gap);animation:wb-chyron var(--chyron-duration) linear infinite}
  .chyron-item{flex:0 0 auto;inline-size:var(--chyron-size);block-size:var(--chyron-size);display:flex;align-items:center;justify-content:center}
  .chyron-mark{inline-size:auto;block-size:auto;max-inline-size:100%;max-block-size:100%;object-fit:contain}
  @keyframes wb-chyron{to{transform:translateX(-100%)}}

  /* PAUSE (WCAG 2.2.2). An infinite strip that starts by itself and runs beside other content
     must be pausable by a control ON THE PAGE — the reduced-motion guard above satisfies taste,
     not the criterion, because an OS preference is not a page mechanism. `is-paused` freezes the
     tracks in place with `animation-play-state` rather than removing the animation, which would
     snap the strip back to the seam; resuming continues from the same spot. behavior.js flips
     the class from any `[data-chyron-toggle]` button naming the strip's id.

     `.chyron-toggle` is that button's page-tier dress: `.btn`'s caption voice at `.status-pill`'s
     quiet weight — hairline border, no fill, `color:inherit` so it sits on any of the eight
     grounds without a per-background variant. The 44px minimum block-size is the same AAA target
     floor the header-menu trigger set. */
  .chyron.is-paused .chyron-track{animation-play-state:paused}
  .chyron-toggle{font-family:var(--font-caption);font-size:.72rem;font-weight:700;letter-spacing:.07em;text-transform:uppercase;color:inherit;background:none;border:var(--bw-hair) solid var(--line-2);border-radius:var(--radius-sm);padding:var(--space-2) var(--space-4);min-block-size:44px;min-inline-size:44px;display:inline-flex;align-items:center;justify-content:center;cursor:pointer}

  /* ── Pagination (page tier) ─────────────────────────────────────
     Static listing pagination as ONE ROW: Previous, the numbered list with an elided middle, Next,
     an optional summary caption, then the per-page size control. The application tier has
     `.wb-pagination` for product UI; this one exists because a marketing listing sits in a `.bg-*`
     section where the `--control-*` roles do not resolve, and because the archive's pages are real
     URLs, so no script is involved.

     WHY NO SEPARATOR TEXT. An earlier cut of this row put the count between Previous and the
     numbers and the range between the numbers and Next, to stop two adjacent links to the same URL
     (a WAVE alert) — Previous and the list's first number can be the same page, and so can the last
     number and Next. Measured at the real content width that row needs 762px against a 740px
     column, so it wrapped on the page it was built for. The separators are gone and the
     ADJACENCY IS HANDLED IN THE MARKUP INSTEAD: the consumer renders a number as a TEXT CHIP, a
     `<span class="pagination-link">` rather than an `<a>`, whenever the number it sits beside would
     repeat that neighbour's destination — the first number when it is Previous's page (on page 2,
     "1"), the last number when it is Next's page (on the second-to-last page). There are then no
     two adjacent links to one URL to separate, and no text is doing a spacer's job. A number that
     duplicates Previous or Next from the MIDDLE of the list (on page 7, "6" and "8") is not
     adjacent to either and stays a link.

     A text chip keeps the link's exact dress on purpose — it is the same number in the same rhythm,
     and what was removed is a second route to a page the reader already has one step away, not the
     number itself. Which is why `:hover` is scoped to `a.pagination-link`: a chip must not offer an
     underline it cannot honour. `:focus-visible` needs no such scoping (a `<span>` is not
     focusable), and the filled `[aria-current]` chip is a `<span>` in exactly the same way.

     `.pagination-summary` is now an optional caption rather than a structural part: the consumer
     places "Page 7 of 19" after Next, or "All 187 pieces" on an unpaginated listing. It carries no
     margin of its own — the row's own `gap` is the separation.

     The current item matches on `[aria-current]` presence, not the exact token — the page list uses
     `aria-current="page"`, but the size menu's current page-size is a size, not a page, so it may
     read `aria-current="true"` instead — EXCEPT the literal string `"false"`, which some frameworks
     (React Router among them) emit on every NON-current link rather than omitting the attribute;
     `:not([aria-current="false"])` excludes that case so presence-matching does not fill every link
     in the list. It is styled as the filled state — the same fill `.btn` uses — plus a
     `border-color:var(--text-1)` edge: `--btn-fill` is not re-pointed per ground, so on the five
     darker backgrounds (bg-sky-deep/bg-forest/bg-plum-deep/bg-wine/bg-ink) the fill alone measures
     as little as 1.00:1 against the section and would read as no different from an unfilled link —
     the edge is what guarantees a silhouette there, not the fill or a greyscale reading by itself.
     44px targets throughout. */
  .pagination{display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-2);margin-top:var(--space-12);font-family:var(--font-caption);font-size:.78rem;font-weight:700;letter-spacing:.06em;text-transform:uppercase}
  .pagination-list{display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-2);list-style:none;margin:0;padding:0}
  .pagination-link{display:inline-flex;align-items:center;justify-content:center;min-inline-size:44px;min-block-size:44px;padding:0 var(--space-3);color:inherit;text-decoration:none;border:var(--bw-hair) solid var(--line-2);border-radius:var(--radius-sm)}
  a.pagination-link:hover{text-decoration:underline}
  .pagination-link:focus-visible{outline:2px solid var(--focus-ring);outline-offset:2px}
  .pagination-link[aria-current]:not([aria-current="false"]){background:var(--btn-fill);color:var(--btn-text);border-color:var(--text-1)}
  .pagination-ellipsis{padding:0 var(--space-2)}
  .pagination-summary{margin:0;color:var(--text-caption)}
  .pagination-size{margin-inline-start:auto}

  /* ── Dropdown (page tier) ───────────────────────────────────────
     A menu of links inside a native `<details>`: the `<summary class="dropdown-trigger">` names the
     current choice, `.dropdown-menu` is the list, and every `.dropdown-item` is a real `<a href>`.
     NO SCRIPT, and that is the whole difference from the application tier's `wb-dropdown`, which is
     a menu BUTTON whose items are commands and therefore needs behavior.js for its roving focus and
     its Escape key. A static site's choices — per page, sort, a filter — are URLs the server
     already renders, so `<details>` supplies the disclosure semantics, the keyboard toggle and the
     open/closed state and nothing else is required.

     The trade is that it closes only by toggling: the summary again, or following one of the links.
     There is no outside-click or Escape dismissal because both need script and nothing here is
     broken without them — that is a possible behavior.js follow-up, not a gap this pattern is
     papering over.

     The trigger wears `.pagination-link`'s dress (44px on both axes, hairline, `--radius-sm`, hover
     underline) so a size control reads as a sibling of the numbers beside it, and it drops the
     browser's own disclosure marker twice over. `list-style:none` is the standards path;
     `::-webkit-details-marker` is for older WebKit, which drew a marker `list-style` could not
     reach. Neither costs anything today — `display:inline-flex` on a `<summary>` is safe in current
     Safari, the toggle bug that made it unsafe being a pre-15.4 problem and out of support — and
     both stay because the marker is painted outside the border box and would hang off the control
     in any engine that still draws it. The chevron replacing it is a border-only square rotated
     45deg, flipped to 225deg when open (a `--dur-control` transition, which the global
     `prefers-reduced-motion` guard in primitives.css neutralises): the icon tier's
     `wb-icon--chevron-down` is application-tier and a page-tier component cannot reach across that
     boundary, and `currentColor` borders follow the trigger onto all eight grounds where a
     fixed-colour glyph would not.

     `.dropdown-menu` fills with `--wash-solid`, the opaque card fill, NOT the page ground: a panel
     that overlaps the content beneath it has to be opaque, and `--wash-solid` is the fill that is
     re-pointed per background family, so the menu's inherited text colour stays readable on the
     five dark grounds instead of landing light-on-near-white the way a fixed page-ground fill
     would. The current item takes the same treatment the pagination chip does, fill PLUS an opaque
     `border-color:var(--text-1)` edge over a transparent border every item reserves: `--btn-fill`
     against `--wash-solid` measures 1.08:1 to 1.61:1 on the five dark grounds, and there
     `--btn-text` and the menu's own inherited `--text-1` are both white, so without the edge the
     current item has no boundary at all. The edge is a border rather than an outline so it cannot
     collide with the `:focus-visible` ring.

     `z-index:20` orders the menu against its SECTION'S content and nothing further out: it is drawn
     inside `.section-inner`, which is `position:relative;z-index:2` and therefore its own stacking
     context, so 20 never competes with `.site-header` (900) or `.progress-track` (1000) whatever it
     is set to. It only has to beat the section's own decorative layers.

     PLACEMENT MODIFIERS, because `.section{overflow:hidden}` clips whatever leaves the section and a
     listing's pagination sits at exactly the trailing edge. Four items need about 188px below the
     trigger against 120px of section padding (80px at ≤768px), and `inset-inline-start:0` on a
     trigger already pushed to the row's inline end runs a 12rem panel past the content edge. Rather
     than open `.section` up for one component, `.dropdown--up` flips the menu above the trigger and
     `.dropdown--end` anchors it to the trigger's inline end. A dropdown at a section's end uses
     BOTH. */
  .dropdown{position:relative;display:inline-block}
  .dropdown-trigger{display:inline-flex;align-items:center;justify-content:center;min-inline-size:44px;min-block-size:44px;padding:0 var(--space-3);color:inherit;border:var(--bw-hair) solid var(--line-2);border-radius:var(--radius-sm);cursor:pointer;list-style:none}
  .dropdown-trigger::-webkit-details-marker{display:none}
  .dropdown-trigger::after{content:"";inline-size:.45em;block-size:.45em;margin-inline-start:var(--space-2);border-inline-end:var(--bw-hair) solid currentColor;border-block-end:var(--bw-hair) solid currentColor;transform:rotate(45deg);transition:transform var(--dur-control) var(--ease-standard)}
  .dropdown-trigger:hover{text-decoration:underline}
  .dropdown-trigger:focus-visible{outline:2px solid var(--focus-ring);outline-offset:2px}
  .dropdown[open]>.dropdown-trigger::after{transform:rotate(225deg)}
  .dropdown-menu{position:absolute;inset-inline-start:0;top:calc(100% + var(--space-1));z-index:20;min-inline-size:12rem;margin:0;padding:var(--space-1);list-style:none;background:var(--wash-solid);border:var(--bw-hair) solid var(--line-2);border-radius:var(--radius-sm)}
  .dropdown--up .dropdown-menu{top:auto;bottom:calc(100% + var(--space-1))}
  .dropdown--end .dropdown-menu{inset-inline-start:auto;inset-inline-end:0}
  .dropdown-item{display:flex;align-items:center;min-block-size:44px;padding:var(--space-2) var(--space-3);color:inherit;text-decoration:none;border:var(--bw-hair) solid transparent;border-radius:var(--radius-sm)}
  .dropdown-item:hover{background:var(--wash-2)}
  .dropdown-item:focus-visible{background:var(--wash-2);outline:2px solid var(--focus-ring);outline-offset:2px}
  .dropdown-item[aria-current]:not([aria-current="false"]){background:var(--btn-fill);color:var(--btn-text);border-color:var(--text-1)}

  /* ── Audio player (page tier) ──────────────────────────────────────
     Progressive enhancement over a native <audio>: the consumer ships
     <figure class="audio-player"><audio controls preload="none" src="…"></audio><figcaption>…</figcaption></figure>
     and behavior.js builds the control row (play/pause button in the `.btn` voice, a native range
     input as the seek bar, a tabular time readout) and a decorative wave, then removes the native
     `controls` attribute and marks the figure `is-ready`. Without the script the browser's own
     player renders — nothing is lost, only the voice.

     The toggle adds a `border:var(--bw-hair) solid var(--line-2)` hairline on top of the `.btn`
     fill — the chyron-toggle's own quiet button boundary, NOT the pagination current-page chip's
     contrast fix, despite sharing the fill: `--line-2` is a ~16%-alpha hairline, nowhere near opaque
     enough to guarantee a silhouette against a ground the way the chip's opaque `--text-1` edge
     does. What actually carries the toggle's identification on every ground is its label
     (`--btn-text` on `--btn-fill`, the button-voice pairing the contrast gate already verifies) —
     the hairline is a shape refinement matching `.chyron-toggle`'s dress, not a second contrast
     mechanism. The toggle and the seek range are this pattern's only interactive surfaces (the wave
     is `aria-hidden` and decorative), and both carry the house
     `:focus-visible{outline:2px solid var(--focus-ring);outline-offset:2px}` rule.

     The wave is twenty-four bars: a decorative visualisation of the sound when the browser exposes
     it, breathing generically when it does not. Both modes run only while `is-playing`, off the
     media's own play/pause events, so the motion is user-initiated and stops with the same button
     that started it (WCAG 2.2.2 is met by that control, not by the reduced-motion guard, which
     also neutralises it). Reactive mode (behavior.js) reads the media through a Web Audio
     AnalyserNode and writes each bar an inline `transform:scaleY(n)` per frame, marking the figure
     `is-reactive`; the rule below then takes the keyframes off the bars, because two things
     animating one transform is a fight the inline write wins only intermittently. The highlight
     colour stays under `is-playing`, which is true in both modes. Reduced motion turns off both
     halves: the global guard (primitives.css) neutralises the keyframes, and behavior.js stops the
     loop, on the preference's own change event as well as at the next play, since an rAF loop
     writing transforms is invisible to that guard (WCAG 2.3.3). Reactive mode needs the media
     PROVED readable by the graph, never merely assumed to be: routing an element the analyser may
     not read costs the AUDIO and not only the picture, because the source node built over it
     outputs silence and nothing can route the element back. Three things count as proof and
     nothing else does. `crossorigin="anonymous"` on the <audio>, with the host answering
     `access-control-allow-origin` (Vercel Blob answers `*`), which makes the media load itself a
     CORS fetch that fails outright rather than half-succeeding. A data: or blob: URL, which needs
     no CORS check at all. Or `data-wave-reactive` on the figure, the consumer vouching for a file
     they serve themselves. A same-origin-LOOKING src is not proof: the URL an element chose is
     fixed before its fetch, so a redirect to a storage origin never shows up in it. Anything
     unproved DECLINES rather than tries: the clip plays normally on its own output and the
     keyframes keep the bars. `is-reactive` means the loop has a spectrum to show right now, not
     merely that it is scheduled, so a context that stops running takes the class off and hands the
     bars back rather than holding them where the last frame left them.
     `aria-hidden`: it carries no information the time readout does not. The bars
     take the same `--highlight` the scroll progress bar uses, so "progress" has one colour in the
     system. The seek input keeps its native semantics (arrow keys, Home/End) and only borrows
     `accent-color`, which is the one property that styles a range consistently across engines
     without rebuilding it. Every wave timing derives from the single `--dur-reveal` token via
     calc() — no literal durations: the base cycle, the two stagger delays (phases at even thirds,
     -1/3 and -2/3 of the cycle), and the 5n variant's 1.5x-longer cycle. That is more than the
     css-tiers motion-token gate actually checks — its regex only reaches a bare duration sitting on
     the shorthand `animation:`/`transition:` property, not on the longhand `animation-delay`/
     `animation-duration` these stagger rules use — but a rule that mixes the token with a literal
     is the same defect either way: change `--dur-reveal` and the stagger stops being even thirds of
     the new cycle. One token rather than a fifth duration role for one component; `--dur-reveal`
     specifically because it is the page-tier-only editorial token, never used in the application
     tier, which is exactly this pattern's ground. */
  .audio-player{margin:var(--space-7) 0 0;padding:var(--space-4);border:var(--bw-hair) solid var(--line-2);border-radius:var(--radius-sm)}
  .audio-player-controls{display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-3)}
  /* The error handler (behavior.js) sets controls.hidden=true to fall back to the browser's own
     player UI. Without this rule that does nothing: an author-origin `display:flex` above beats the
     User-Agent stylesheet's own `[hidden]{display:none}` regardless of selector specificity — normal
     author rules always outrank normal UA rules in the cascade. Same idiom as application.css's
     `.wb-tabs__panel[hidden]` / `.wb-menu[hidden]`. */
  .audio-player-controls[hidden]{display:none}
  .audio-player-toggle{display:inline-flex;align-items:center;justify-content:center;min-inline-size:44px;min-block-size:44px;padding:var(--space-2) var(--space-4);font-family:var(--font-caption);font-size:.72rem;font-weight:700;letter-spacing:.07em;text-transform:uppercase;color:var(--btn-text);background:var(--btn-fill);border:var(--bw-hair) solid var(--line-2);border-radius:var(--radius-sm);cursor:pointer}
  .audio-player-toggle:focus-visible{outline:2px solid var(--focus-ring);outline-offset:2px}
  .audio-player-time{font-family:var(--font-caption);font-size:var(--fs-mono);font-variant-numeric:tabular-nums;color:var(--text-caption);white-space:nowrap}
  .audio-player-seek{flex:1 1 160px;min-inline-size:120px;block-size:44px;margin:0;accent-color:var(--highlight)}
  .audio-player-seek:focus-visible{outline:2px solid var(--focus-ring);outline-offset:2px}
  .audio-player-wave{display:flex;align-items:flex-end;gap:2px;block-size:28px;margin-top:var(--space-3)}
  .audio-player-wave span{flex:1 1 0;block-size:100%;background:var(--line-2);transform:scaleY(.25);transform-origin:50% 100%}
  .audio-player.is-playing .audio-player-wave span{background:var(--highlight);animation:wb-wave var(--dur-reveal) ease-in-out infinite alternate}
  .audio-player.is-playing .audio-player-wave span:nth-child(3n+1){animation-delay:calc(var(--dur-reveal) / -3)}
  .audio-player.is-playing .audio-player-wave span:nth-child(3n+2){animation-delay:calc(var(--dur-reveal) * -2 / 3)}
  .audio-player.is-playing .audio-player-wave span:nth-child(5n){animation-duration:calc(var(--dur-reveal) * 1.5)}
  /* Last, and deliberately not more specific than the rules above: it only has to beat the one
     that names `animation`, which it does on source order alone at equal specificity. The 3n/5n
     stagger rules set longhands (delay, duration) that are inert once the name is none, so their
     higher specificity costs nothing. No transition on the bars either: the per-bar easing in
     behavior.js already carries the motion, and a transition on top would lag every frame behind
     the sound it is drawn from. */
  .audio-player.is-reactive .audio-player-wave span{animation:none}
  .audio-player figcaption{margin-top:var(--space-3)}
  @keyframes wb-wave{from{transform:scaleY(.2)}to{transform:scaleY(1)}}

  /* ── Vision teaser & attribution ───────────────────────────────── */
  .vision-teaser{margin-top:var(--space-12);padding:var(--space-7) 0 0;border-top:var(--bw-hair) solid rgba(255,255,255,0.08)}
  .vision-teaser p{font-family:var(--font-body);font-size:.92rem;line-height:1.65;color:var(--text-2)}
  .vision-teaser a{color:var(--text-link);text-decoration:underline;text-underline-offset:3px}
  .vision-teaser a:hover{color:var(--text-1)}

  /* ── Footer ────────────────────────────────────────────────────── */
  .site-footer{padding:var(--space-7) var(--space-12);background:var(--paper);display:flex;align-items:center;justify-content:space-between;border-top:var(--bw-hair) solid rgba(27,29,24,0.1)}
  .footer-brand{display:flex;align-items:center;gap:12px;text-decoration:none;color:var(--text-1)}
  .footer-wordmark{font-family:var(--font-caption);font-size:.78rem;font-weight:700;letter-spacing:.04em;color:var(--text-1);text-transform:uppercase}
  .footer-link{font-family:var(--font-cite);font-size:.78rem;color:var(--text-caption);text-decoration:none}
  .footer-link:hover{color:var(--text-1)}

@media(max-width:768px){
    .site-header{padding:var(--space-3) var(--space-6)}.header-nav{display:none}
    .header-menu{display:flex}
    .section-inner{padding:var(--space-20) var(--space-7)}
    .section--hero .section-inner,.section--hero-compact .section-inner{padding-top:104px}
    .tension-dot{width:16px;height:16px}
    .tension-aim{max-width:140px}
    .pos-layout{flex-direction:column;gap:var(--space-8)}
    .flow-col{padding-left:0}
    .site-footer{padding:var(--space-5) var(--space-6);flex-direction:column;gap:8px;align-items:flex-start}
}


  /* ═══════════════════════════════════════════════════════════════════
     APPLICATION TIER — packages/css/src/application.css
     Phase 4a-i (Foundations + Forms). The fifth CSS tier concatenated by
     scripts/build-css.mjs, appended AFTER icons/primitives/composites/patterns. This is the
     layer for real application UI — form controls, chrome, the stuff that runs inside an
     `.app-chrome` / `.app-canvas` / `.app-card` / `.app-raised` shell — as distinct from the
     page-tier primitives/composites/patterns above it, which style marketing/editorial pages.

     RULES for every class in this file, now and for every task that follows (Tasks 6-9 build the
     controls that mount inside the `.wb-field` component below):

       1. Reference TIER-2 SEMANTIC ROLES ONLY — never a Tier-1 primitive (--white, --ink,
          --fg-dark, --coral, … or a literal hex) directly. That includes the control roles
          introduced in Task 1: --control-bg, --control-text, --control-border,
          --control-border-hover, --control-placeholder, --control-disabled-bg,
          --control-disabled-text — plus the general Tier-2 roles (--text-1/--text-2/
          --text-caption/--text-signal, --focus-ring, --hairline/--elevation-line/
          --overlay-scrim). Every one of those is re-pointed per `.app-*` surface and per theme
          in tokens.css, which is what makes a single class here correct on all four surfaces in
          both light and `.app-dark` with zero per-surface overrides in this file.
       2. NEVER a blur-behind-content filter or any translucency-over-content effect (the CSS
          property this tier bans outright — see tests/application-tier.test.mjs's literal-string
          gate). TUG elevation is a GROUND change plus a hairline (--elevation-line) — never
          glass. This is the line that keeps application UI reading as TUG, not as Gimlet/
          Aperture's Liquid Glass.
       3. --highlight (vermilion, surfaced here as --text-signal) is SIGNAL ONLY — a single call
          to attention — never decoration, never a fill, never the default state of anything.
       4. Sans (--font-caption) for labels, chrome, and UI microcopy; the serif --font-body stays
          reserved for page CONTENT, not controls.
     ═══════════════════════════════════════════════════════════════════ */

  /* ── Field — the form control ────────────────────────────────────────
     Tasks 5-6, merged. THIS IS ONE COMPONENT, not a wrapper plus a separately-classed control.
     A field is a labelled form control: an optional label, the control itself, and optionally a
     hint OR an error (never both — a field toggles between them via `.wb-field--invalid`).

     You write ONE class. The control inside is a plain native element with no class of its own:

       <label class="wb-field">
         <span class="wb-field__label">Project name</span>
         <input type="text">
         <span class="wb-field__hint">Shown on the dashboard.</span>
       </label>

     A control with no visible label is the same one class, with the label part omitted and an
     `aria-label` on the element instead — there is no separate "bare control" class to learn:

       <div class="wb-field"><input type="search" aria-label="Search"></div>

     WHY NO `.wb-input`. Aperture models these as two layers (a `PrismInputDirective` on the
     element, a `PrismFieldComponent` around it), but its consumer still writes a single tag —
     `<prism-field label hint>` projects the control and auto-wires for/id/aria-describedby, so
     the seam is hidden by the component API. A CSS-only port has no component API to hide it
     with, so carrying the same two layers just moved the framework's internal seam onto the
     person writing the markup: two class names to remember and combine for one conceptual thing.
     The seam is gone. `.wb-field` styles its own controls by element selector, which is also what
     gives textarea and select the identical treatment for free — matching Aperture's directive
     selector (`input[prismInput], textarea[prismInput], select[prismInput]`), which was always
     one class across all three.

     `.wb-field--invalid` / `--disabled` re-point --control-border / --control-bg / --control-text
     at the wrapper's scope rather than styling a specific element, so every control inside — the
     text controls below, and the checkbox/radio/switch further down — picks the state up just by
     consuming the control roles normally, with no per-state class of its own.

     Accessibility is structural: either wrap the control in the `<label class="wb-field">` (native
     association, no ids needed) or use a real `<label for="…">`; a hint or error must be
     referenced by the control's `aria-describedby`. See application/field.stories.ts. The invalid
     state is never colour alone — `.wb-field__error` pairs --text-signal with `.wb-icon--warn` in
     the markup, and the border thickens rather than only changing hue. */
  .wb-field{display:flex;flex-direction:column;gap:var(--space-2);font-family:var(--font-caption)}

  .wb-field__label{font-family:var(--font-caption);font-size:var(--fs-caption);font-weight:700;
    letter-spacing:.03em;color:var(--text-caption)}

  .wb-field__hint{font-family:var(--font-caption);font-size:var(--fs-caption);color:var(--text-2)}

  /* Always paired with .wb-icon--warn in the markup — the icon is the non-colour signal, this
     is the colour + copy that goes with it. Never ship this class without the icon beside it. */
  .wb-field__error{display:flex;align-items:center;gap:var(--space-1);font-family:var(--font-caption);
    font-size:var(--fs-caption);font-weight:700;color:var(--text-signal)}

  /* THE CONTROL. Text-entry inputs plus textarea and select, styled as one thing.
     The exclusion list is load-bearing, not defensive noise: checkbox, radio, and switch are all
     native `input` elements nested inside a field too, and they carry their own box/dot/track
     chrome further down this file — without the exclusions they would collect the text-input
     border and padding on top of it. Buttons and file inputs are excluded for the same reason
     (they own their own chrome, or the browser's).

     WRAPPED IN `:where()` FOR A REASON. `:not()` takes the specificity of its argument, so a bare
     chain of eight `:not([type=…])` would score this selector (0,9,1) — high enough to beat every
     later, more specific-looking rule that wants to adjust one property on one control. That is
     not theoretical: `.wb-search input[type=search]` is (0,2,1), so its `padding-left` silently
     lost and the search icon rendered straight through the placeholder text — invisible to the
     unit tests and to axe, caught only by looking at the visual baseline. `:where()` contributes
     zero specificity, so this rule sits at (0,1,1) and behaves like the low-specificity base it
     is meant to be. */
  .wb-field input:where(:not([type=checkbox]):not([type=radio]):not([type=button]):not([type=submit]):not([type=reset]):not([type=file]):not([type=range]):not([type=color])),
  .wb-field textarea,
  .wb-field select{display:block;width:100%;font-family:var(--font-caption);
    font-size:var(--fs-small);padding:var(--space-2) var(--space-3);border-radius:var(--radius-sm);
    border:1px solid var(--control-border);background:var(--control-bg);color:var(--control-text)}

  .wb-field textarea{resize:vertical;min-height:calc(var(--space-20))}

  /* Native select keeps its own disclosure arrow — suppressing `appearance` would mean shipping a
     custom arrow and re-implementing the popup's keyboard behaviour, which is Tier B. */
  .wb-field select{padding-right:var(--space-6)}

  .wb-field input::placeholder,.wb-field textarea::placeholder{color:var(--control-placeholder)}

  .wb-field input:hover,.wb-field textarea:hover,.wb-field select:hover{
    border-color:var(--control-border-hover)}

  /* --focus-ring is validated at 3:1 against every app surface ground by nonTextReport(). */
  .wb-field input:focus-visible,.wb-field textarea:focus-visible,.wb-field select:focus-visible{
    outline:2px solid var(--focus-ring);outline-offset:1px;border-color:var(--control-border-hover)}

  .wb-field input:disabled,.wb-field textarea:disabled,.wb-field select:disabled{
    background:var(--control-disabled-bg);color:var(--control-disabled-text);cursor:not-allowed}
  .wb-field input:disabled::placeholder,.wb-field textarea:disabled::placeholder{
    color:var(--control-disabled-text)}

  .wb-field--invalid{--control-border:var(--text-signal)}
  .wb-field--invalid .wb-field__label{font-weight:900}

  /* Shape, not colour alone — the border thickens as well as re-pointing. Reads off the control's
     own `aria-invalid` too, so a single control marked invalid inside an otherwise-valid field
     (or one used with no `.wb-field--invalid` on the wrapper) gets the same treatment. */
  .wb-field--invalid input:where(:not([type=checkbox]):not([type=radio])),
  .wb-field--invalid textarea,
  .wb-field--invalid select,
  .wb-field [aria-invalid="true"]{border-width:2px;border-color:var(--text-signal)}
  .wb-field--invalid input:focus-visible,.wb-field--invalid textarea:focus-visible,
  .wb-field--invalid select:focus-visible{outline-color:var(--text-signal)}

  .wb-field--disabled{--control-bg:var(--control-disabled-bg);--control-text:var(--control-disabled-text);
    cursor:not-allowed}
  .wb-field--disabled .wb-field__label{color:var(--control-disabled-text)}

  /* ── Search bar ───────────────────────────────────────────────────────
     Task 6, rewired onto the merged field. `.wb-search` is a MODIFIER ON A FIELD, not a component
     with its own control: write `<div class="wb-field wb-search">` and the input inside is styled
     by the field rules above exactly like every other text control. All this adds is room on both
     sides for the leading `.wb-icon--search` (muted, decorative, pointer-events:none) and the
     optional trailing clear button — no second copy of the control-role wiring to drift out of
     sync, and no `.wb-search__input` class to remember.

     `type="search"` gets Safari's rounded-corner default and cancel-button chrome suppressed so it
     doesn't fight the `--radius-sm` corner or double up with `.wb-search__clear`. NO AUTOSUGGEST
     and no results dropdown — Tier B, out of scope here.

     `.wb-search__clear` MUST be a real `<button>` with an accessible name (aria-label="Clear
     search" in the markup — see application/search-bar.stories.ts) — an icon-only control with no
     name is an axe failure, not a style nit.

     CONSTRAINT, enforced by a test: a `.wb-search` field holds the control ONLY — no stacked
     `.wb-field__label`, no hint. The icon and clear button are absolutely positioned against the
     field box at `top:50%`, which is the control's centre only while the control is the field's
     sole row; add a label above and both would centre against the label+control stack instead and
     visibly drift. Name a search bar with `aria-label` on the input (which is what app chrome
     wants anyway — a toolbar search rarely carries a visible label). If you need a labelled search
     control, use a plain `.wb-field` and skip the icon affordances. */
  .wb-search{position:relative;flex-direction:row;align-items:center}

  .wb-search .wb-icon--search{position:absolute;left:var(--space-3);top:50%;
    transform:translateY(-50%);color:var(--control-placeholder);pointer-events:none;z-index:1}

  .wb-search input[type=search]{padding-left:var(--space-8);padding-right:var(--space-8);
    -webkit-appearance:none;appearance:none}
  .wb-search input[type=search]::-webkit-search-cancel-button{-webkit-appearance:none;appearance:none}

  .wb-search__clear{position:absolute;right:var(--space-2);top:50%;transform:translateY(-50%);
    display:flex;align-items:center;justify-content:center;width:var(--space-6);
    height:var(--space-6);padding:0;border:0;border-radius:var(--radius-sm);
    background:transparent;color:var(--control-text);cursor:pointer}

  .wb-search__clear:hover{background:var(--control-disabled-bg)}

  .wb-search__clear:focus-visible{outline:2px solid var(--focus-ring);outline-offset:1px}

  /* ── Checkbox / radio ───────────────────────────────────────────────
     Task 7. Ports the anatomy of a native checkbox/radio (box or dot, glyph, label) but stays
     flat token surfaces + hairline borders — no glass, no blur, vermilion nowhere near the
     checked state (that fill is --control-text, the same ink the rest of this tier already uses
     for borders; vermilion is reserved for the invalid signal only, rule 3 of this file's banner).

     THE ACCESSIBILITY TRAP: the real `<input type="checkbox">`/`<input type="radio">` is never
     `display:none`d or `visibility:hidden`den — either removes it from the accessibility tree AND
     from keyboard access. Instead it is CLIPPED (`.wb-check input`/`.wb-radio input` below) —
     zero-size, off the visible canvas, but still present, still tabbable, still the thing a
     screen reader announces and Space toggles. `.wb-check__box`/`.wb-radio__dot` is a decorative
     sibling `<span>` that reflects the real input's `:checked`/`:indeterminate`/`:focus-visible`/
     `:disabled` state via sibling selectors (`input:checked + .wb-check__box`, etc.) — the input
     drives every visual, nothing here is reimplemented in JS. Wrapping both in a single `<label>`
     (see the stories) gives click-anywhere-to-toggle for free from native `<label>` semantics.

     Checked state is NEVER colour-only: the checkmark is a real `.wb-icon--check` element (Task
     2), toggled by opacity, not a colour swap on an otherwise-unchanged box — and indeterminate
     gets its own distinct dash glyph (a `.wb-check__box::after`), never reusing the checkmark at
     reduced opacity. Radio's own non-colour signal is structural: an empty ring vs. a ring with a
     filled centre dot, the same shape-change principle applied to a different anatomy.

     `.wb-radiogroup`/`.wb-checkgroup` are plain stacks; the ROLE semantics
     (`role="radiogroup"`/`role="group"` + a labelled group heading via `.wb-field__label` +
     `aria-labelledby`) live in the markup — see the stories, which are the documentation for the
     correct wiring. Radio's arrow-key navigation between options in the same `name` group is free,
     native `<input type="radio">` behaviour — nothing here reimplements it.

     Invalid state reads off `[aria-invalid="true"]` on the real input directly (no `.wb-check--
     invalid` modifier class needed) — the same border-thickening treatment the field's text
     controls use, so a checkbox/radio nested inside a `.wb-field--invalid` OR standalone with the
     attribute set both work off the one selector. Disabled reads off the native `:disabled`
     pseudo-class the same way. */
  .wb-check,.wb-radio{position:relative;display:inline-flex;align-items:center;gap:var(--space-2);
    font-family:var(--font-caption);font-size:var(--fs-small);color:var(--control-text);
    cursor:pointer}

  .wb-check:has(input:disabled),.wb-radio:has(input:disabled){cursor:not-allowed}

  /* Clipped, not hidden — see the tier banner above. The classic visually-hidden recipe: a 1px
     box, clipped to nothing, moved off-canvas by its own negative margin — present in the DOM and
     the accessibility tree, tabbable, and still the element `:checked`/`:focus-visible` etc. are
     read from below, unlike `display:none` or `visibility:hidden` which would drop it from both. */
  .wb-check input,.wb-radio input{position:absolute;width:1px;height:1px;margin:-1px;padding:0;
    border:0;overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap}

  .wb-check__box,.wb-radio__dot{position:relative;display:inline-flex;align-items:center;
    justify-content:center;flex:none;width:var(--space-5);height:var(--space-5);
    border:1px solid var(--control-border);background:var(--control-bg)}

  .wb-check__box{border-radius:var(--radius-sm)}
  .wb-radio__dot{border-radius:50%}

  .wb-check input:hover+.wb-check__box,.wb-radio input:hover+.wb-radio__dot{
    border-color:var(--control-border-hover)}

  /* Checked/indeterminate both fill the box with --control-text (ink, never vermilion), so the
     glyph paints in --app-surface-bg — the surface's own OPAQUE ground, not --control-bg (a
     translucent wash on `.app-dark`, since a resting unchecked box there is a subtle highlight
     over the dark surface rather than a solid fill — using it here would paint the glyph in a
     near-invisible wash on top of a solid --control-text fill). --app-surface-bg is the exact
     resolvable ground the app-text contrast gate already verifies --control-text/--text-1 clear
     7:1 against, so painting the glyph there is a correct, already-proven inversion on every
     surface × theme, not a fresh gamble. */
  .wb-check input:checked+.wb-check__box,.wb-check input:indeterminate+.wb-check__box{
    background:var(--control-text);border-color:var(--control-text)}

  .wb-check__box .wb-icon--check{color:var(--app-surface-bg);opacity:0}
  .wb-check input:checked+.wb-check__box .wb-icon--check{opacity:1}

  /* The indeterminate dash — a distinct shape from the checkmark, not the same glyph dimmed. */
  .wb-check__box::after{content:'';position:absolute;top:50%;left:50%;
    transform:translate(-50%,-50%);width:10px;height:2px;background:var(--app-surface-bg);opacity:0}
  .wb-check input:indeterminate+.wb-check__box::after{opacity:1}

  /* Radio's non-colour signal: an empty ring becomes a ring with a filled centre dot — shape, not
     just the ring's border colour changing. */
  .wb-radio__dot::after{content:'';position:absolute;top:50%;left:50%;width:10px;height:10px;
    border-radius:50%;background:var(--control-text);transform:translate(-50%,-50%) scale(0)}
  .wb-radio input:checked+.wb-radio__dot{border-color:var(--control-text)}
  .wb-radio input:checked+.wb-radio__dot::after{transform:translate(-50%,-50%) scale(1)}

  .wb-check input:focus-visible+.wb-check__box,.wb-radio input:focus-visible+.wb-radio__dot{
    outline:2px solid var(--focus-ring);outline-offset:2px}

  .wb-check input:disabled+.wb-check__box,.wb-radio input:disabled+.wb-radio__dot{
    background:var(--control-disabled-bg);border-color:var(--control-border)}
  .wb-check input:disabled+.wb-check__box .wb-icon--check{color:var(--control-disabled-text)}
  .wb-radio input:disabled+.wb-radio__dot::after{background:var(--control-disabled-text)}
  .wb-check input:disabled~.wb-check__label,.wb-radio input:disabled~.wb-radio__label{
    color:var(--control-disabled-text)}

  /* Shape, not colour alone, for invalid too — the same border-thickening the field's text controls use, off the real input's own `aria-invalid` attribute so no extra modifier class is needed
     on the wrapper (a `.wb-field--invalid` ancestor already re-points --control-border the same
     way for a control that prefers to inherit it instead — see the tier banner). */
  .wb-check input[aria-invalid="true"]+.wb-check__box,
  .wb-radio input[aria-invalid="true"]+.wb-radio__dot{border-width:2px;border-color:var(--text-signal)}

  .wb-check__label,.wb-radio__label{font-family:var(--font-caption)}

  .wb-checkgroup,.wb-radiogroup{display:flex;flex-direction:column;gap:var(--space-3)}

  /* ── Switch ─────────────────────────────────────────────────────────
     Task 8. A native `<input type="checkbox" role="switch">` — the checkbox is the correct
     underlying element (a switch IS a binary toggle), and `role="switch"` is what makes a screen
     reader announce "on/off" instead of "checked/unchecked". That attribute lives in the MARKUP,
     not here — see switch.stories.ts. Same clipped-not-hidden input as the checkbox/radio above,
     same reason: the real control stays in the accessibility tree and stays tabbable, and the
     visible track/thumb is a decorative sibling driven entirely by sibling selectors.

     ON/OFF IS THE ONE STATE IN THIS TIER MOST OFTEN GOT WRONG. The near-universal switch is a
     track that changes colour — which is a WCAG 1.4.1 failure (colour as the sole carrier of
     meaning) and unreadable in greyscale, in forced-colours mode, or to anyone who can't
     separate the two hues. Here the PRIMARY signal is the thumb's TRAVEL: off = thumb parked
     left, on = thumb parked right, a position change that survives every one of those
     conditions. The track's fill inversion is the SECONDARY, redundant cue — never the only one.
     No transition on that travel, matching the checkbox glyph and radio dot above: this tier
     ships no motion, so there is no reduced-motion guard to get wrong.

     Geometry is literal px, not tokens, on purpose — --radius-sm/-md/-lg are 2/3/4px (square-ish
     corners for panels and inputs) and none of them is a pill; the thumb's 16px travel is
     likewise derived from the track's own box (34px padding-box width - 14px thumb - 2px of
     parking space each side), not from the spacing scale. The tier's no-literals rule is about
     COLOUR (rule 1 of the banner) — every colour below is still a Tier-2 role.

     The track is wired to --control-border/--control-bg like every other bordered control here,
     which is what the gate's `switch-track` row measures: that border against --app-surface-bg,
     the surface BEHIND the switch, rather than against the fill inside it. The pill outline is
     the whole affordance — there is no glyph or label inside the track to fall back on if the
     silhouette dissolves into the page — so the outer boundary is the one that has to hold.
     Measured 8.40:1 (chrome/canvas) and 9.32:1 (card/raised) against the 3:1 bar. */
  .wb-switch{position:relative;display:inline-flex;align-items:center;gap:var(--space-2);
    font-family:var(--font-caption);font-size:var(--fs-small);color:var(--control-text);
    cursor:pointer}

  .wb-switch:has(input:disabled){cursor:not-allowed}

  /* Clipped, not hidden — identical recipe and identical reasoning to .wb-check/.wb-radio. */
  .wb-switch input{position:absolute;width:1px;height:1px;margin:-1px;padding:0;
    border:0;overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap}

  .wb-switch__track{position:relative;display:block;flex:none;width:var(--space-9);
    height:var(--space-5);border-radius:999px;
    border:1px solid var(--control-border);background:var(--control-bg)}

  .wb-switch input:hover+.wb-switch__track{border-color:var(--control-border-hover)}

  .wb-switch__thumb{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;
    background:var(--control-text);transform:translateX(0)}

  /* The primary signal. 16px = 34px padding-box width - 14px thumb - 2px left parking - 2px right. */
  .wb-switch input:checked+.wb-switch__track .wb-switch__thumb{transform:translateX(16px);
    background:var(--app-surface-bg)}

  /* Secondary, redundant cue only — the same ink fill + opaque-ground inversion the checked
     checkbox uses, for the same reason (--control-bg is a translucent wash on `.app-dark`, so
     the thumb would near-vanish on a solid --control-text track if it were painted in it). */
  .wb-switch input:checked+.wb-switch__track{background:var(--control-text);
    border-color:var(--control-text)}

  .wb-switch input:focus-visible+.wb-switch__track{outline:2px solid var(--focus-ring);
    outline-offset:2px}

  /* After the :checked rules, so a disabled switch reads as disabled in BOTH positions — the
     thumb keeps its travel (the state is still legible), it just loses the active ink. */
  .wb-switch input:disabled+.wb-switch__track{background:var(--control-disabled-bg);
    border-color:var(--control-border)}
  .wb-switch input:disabled+.wb-switch__track .wb-switch__thumb{
    background:var(--control-disabled-text)}
  .wb-switch input:disabled~.wb-switch__label{color:var(--control-disabled-text)}

  .wb-switch__label{font-family:var(--font-caption)}

  /* ── Button ─────────────────────────────────────────────────────────
     Phase 4a-ii. THE application-tier button, and the reason it exists: there was none. The page
     tier's `.btn` is wired to --btn-fill/--btn-text, Tier-2 roles re-pointed per `.bg-*` SECTION.
     An `.app-*` surface is not inside a `.bg-*` section, so a page `.btn` dropped into app UI
     falls through to the :root default and does not react to `.app-dark` at all — it is the
     page/app boundary violation this tier's banner forbids, in the one place a real product could
     not avoid making it. Gimlet ships `glass-button`; the Tier A inventory in
     specs/2026-08-11-application-ui-layer-design.md §5 simply has no row for it.

     Always a real `<button>` (or `<a>` for navigation). Nothing here fakes one out of a div: the
     native element brings keyboard activation, focus, and the correct role for free (D5).

     THE VARIANTS, and why there is no red one. --text-signal (vermilion) is SIGNAL ONLY under
     rule 3 of this file's banner — never a fill. So the destructive variant is NOT a red button:
     it is the ordinary button shape with signal-coloured text and border. That is deliberate and
     it is also better a11y — a filled red button conveys destructiveness by colour alone, which
     rule 7 of the accessibility contract (§6) forbids. Destructive intent must additionally be
     carried by the LABEL ("Delete project", not "OK"), which no stylesheet can enforce; the story
     for this variant says so and demonstrates it. */
  .wb-btn{display:inline-flex;align-items:center;justify-content:center;gap:var(--space-2);
    font-family:var(--font-caption);font-size:var(--fs-small);font-weight:700;line-height:1;
    padding:var(--space-2) var(--space-4);border-radius:var(--radius-sm);
    border:1px solid var(--control-border);background:var(--control-bg);color:var(--control-text);
    text-decoration:none;cursor:pointer;transition:border-color var(--dur-control) var(--ease-standard),background var(--dur-control) var(--ease-standard)}

  .wb-btn:hover{border-color:var(--control-border-hover)}

  /* Same ring, offset and role as every other control in this tier — --focus-ring is gated at 3:1
     against every app surface by nonTextReport(). */
  .wb-btn:focus-visible{outline:2px solid var(--focus-ring);outline-offset:1px;
    border-color:var(--control-border-hover)}

  /* Disabled carries three non-colour cues besides the dimmed fill: the cursor, the removed
     hover response, and (on a real <button>) the element being unfocusable and unactivatable.
     `[aria-disabled]` is covered too, for the cases where a control must stay focusable so a
     screen-reader user can discover WHY it is unavailable. */
  .wb-btn:disabled,.wb-btn[aria-disabled=true]{background:var(--control-disabled-bg);
    color:var(--control-disabled-text);border-color:var(--control-disabled-bg);cursor:not-allowed}
  .wb-btn:disabled:hover,.wb-btn[aria-disabled=true]:hover{border-color:var(--control-disabled-bg)}

  /* Primary inverts the control pair — fill becomes the text colour, text becomes the fill. This
     is the same "filled = on" move the switch's checked track already makes, so the tier has one
     idea of emphasis rather than two. controlTextReport() gates --control-text against
     --control-bg at 7:1, which is exactly this pair read in both directions. */
  /* The text colour is --app-surface-bg, NOT --control-bg, and that is load-bearing rather than a
     stylistic pick. Under `.app-dark`, --control-bg is --wash-2 = rgba(255,255,255,0.06): a
     TRANSLUCENT white. As a fill sitting on the dark surface it composites to a usable mid-grey,
     which is what makes it a correct control background. Used as TEXT on this button it composites
     against the button's own fill — which is --control-text, i.e. white — so it resolves to white
     on white and the label disappears. That shipped and was caught by looking at the generated
     visual baseline, not by any gate. --app-surface-bg is opaque on all four surfaces in both
     themes (paper/white/ink/forest), so inverting onto it is safe wherever the button lands. */
  .wb-btn--primary{background:var(--control-text);color:var(--app-surface-bg);
    border-color:var(--control-text)}
  .wb-btn--primary:hover{opacity:.85}
  .wb-btn--primary:disabled,.wb-btn--primary[aria-disabled=true]{opacity:1;
    background:var(--control-disabled-bg);color:var(--control-disabled-text);
    border-color:var(--control-disabled-bg)}

  /* Quiet: chrome-free until interacted with — toolbars, table row actions, anywhere a full
     button border would out-shout the content it sits beside. */
  .wb-btn--quiet{background:transparent;border-color:transparent}
  .wb-btn--quiet:hover{background:var(--control-disabled-bg);border-color:transparent}

  /* Destructive. Signal colour on text and border, never as a fill — see the block comment above.
     MUST BE PAIRED WITH `.wb-icon--warn` IN THE MARKUP, exactly like `.wb-field__error`. This is not
     belt-and-braces: --text-signal is re-pointed to --fg-light on the `.app-dark` card and raised
     surfaces, because vermilion measures 4.25:1 on --forest and cannot clear the 4.5:1 AA bar (see
     tokens.css's note beside `.app-dark .app-card`). So on two of the eight surface x theme
     combinations the destructive COLOUR DOES NOT EXIST — the variant renders identically to a
     default one. Discovered by reading the generated Dropdown baseline, where "Delete" and "Rename"
     were indistinguishable on both forest grounds. Colour alone was never sufficient here (§6 rule
     7); on those two surfaces it is nothing at all, so the glyph is the actual signal. */
  .wb-btn--danger{color:var(--text-signal);border-color:var(--text-signal)}
  .wb-btn--danger:hover{border-color:var(--text-signal)}

  /* Icon-only. The label moves to aria-label, so the class name is the reminder that one is
     REQUIRED — an icon-only button with no accessible name is unusable by a screen reader, and
     the story for this variant demonstrates the correct markup. */
  .wb-btn--icon{padding:var(--space-2);aspect-ratio:1}

  /* Pressed: a toolbar toggle that stays down (`aria-pressed="true"` in the markup). The same fill
     inversion as --primary and .wb-chip--selected, so the tier keeps one idea of "on"; aria-pressed
     carries it for assistive tech and the inversion survives greyscale. Pairs with --quiet and --icon
     for icon toolbars, which is what an application's tool bench is. */
  .wb-btn[aria-pressed=true]{background:var(--control-text);color:var(--app-surface-bg);
    border-color:var(--control-text)}
  .wb-btn[aria-pressed=true]:hover{opacity:.85;border-color:var(--control-text)}

  /* A row of related actions. Not a segmented/toggle control (that is `toggle-group`, Tier A but
     unbuilt) — just spacing, so buttons never sit flush against each other by accident. */
  .wb-btn-row{display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap}

  /* ── Panel ──────────────────────────────────────────────────────────
     Phase 4a-ii. The generic app container: a titled region with an optional footer. It sets NO
     background of its own — it takes the ground from whichever `.app-*` surface it is placed on
     (or nested inside), which is what lets the same class read correctly on all four. Put
     `.app-card` on the panel itself when it needs to be a card lifted off the canvas:

       <section class="wb-panel app-card">…</section>

     TUG elevation is a ground change plus a hairline (tier banner rule 2) — never a shadow stack
     and never glass, which is the whole difference from Gimlet's `glass-panel`. */
  .wb-panel{border:1px solid var(--elevation-line);border-radius:var(--radius-sm);overflow:hidden}

  .wb-panel__header{display:flex;align-items:center;justify-content:space-between;gap:var(--space-4);
    padding:var(--space-3) var(--space-4);border-bottom:1px solid var(--hairline)}

  .wb-panel__title{font-family:var(--font-caption);font-size:var(--fs-small);font-weight:700;
    color:var(--text-1);margin:0}

  .wb-panel__body{padding:var(--space-4)}

  .wb-panel__footer{display:flex;align-items:center;justify-content:flex-end;gap:var(--space-2);
    padding:var(--space-3) var(--space-4);border-top:1px solid var(--hairline)}

  /* ── Avatar ─────────────────────────────────────────────────────────
     Phase 4a-ii. Initials or an image. The initials variant is NOT decorative-only: it is real
     text, so it inherits --control-text on --control-bg and is covered by controlTextReport()'s
     7:1 bar rather than being an untested colour pair.

     A11y: an avatar that is the ONLY carrier of a person's name needs an accessible name — either
     `<img alt="…">` or `aria-label` on the initials. When the name is already beside it in the
     markup, the avatar is decorative and should be `aria-hidden` so a screen reader does not read
     the same person twice. The stories show both. */
  .wb-avatar{display:inline-flex;align-items:center;justify-content:center;flex:none;
    inline-size:var(--space-9);block-size:var(--space-9);border-radius:var(--radius-sm);overflow:hidden;
    background:var(--control-bg);border:1px solid var(--control-border);
    color:var(--control-text);font-family:var(--font-caption);font-size:var(--fs-caption);
    font-weight:700;line-height:1;text-transform:uppercase;letter-spacing:.02em}

  .wb-avatar img{inline-size:100%;block-size:100%;object-fit:cover;display:block}

  .wb-avatar--sm{inline-size:var(--space-6);block-size:var(--space-6);font-size:.6rem}
  .wb-avatar--lg{inline-size:var(--space-12);block-size:var(--space-12);font-size:var(--fs-small)}

  /* A row of overlapping avatars. `--stack` pulls each one over its predecessor; the ring is drawn
     with the surface's own ground so the overlap reads as separation on any surface. */
  .wb-avatar-group{display:flex;align-items:center}
  .wb-avatar-group .wb-avatar{margin-inline-start:calc(var(--space-2) * -1);
    box-shadow:0 0 0 2px var(--app-surface-bg)}
  .wb-avatar-group .wb-avatar:first-child{margin-inline-start:0}

  /* ── Chip ───────────────────────────────────────────────────────────
     Phase 4a-ii. A compact, usually removable token — a filter in force, a selected recipient.

     NOT the page tier's `.status-pill`, and the distinction is worth stating because the two look
     similar: a status pill REPORTS state the user cannot change (a step is done), and lives on
     `.bg-*` page grounds. A chip represents a user-controlled selection they can usually remove,
     and lives on `.app-*` surfaces. Choosing by appearance rather than by that rule is how a page
     component ends up in app UI and breaks the boundary in both directions. */
  .wb-chip-group{display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap}

  .wb-chip{display:inline-flex;align-items:center;gap:var(--space-1);
    font-family:var(--font-caption);font-size:var(--fs-caption);line-height:1;
    padding:var(--space-1) var(--space-3);border-radius:var(--radius-sm);
    border:1px solid var(--control-border);background:var(--control-bg);color:var(--control-text)}

  /* Selected is a FILL inversion, not a hue change — the same emphasis idea as .wb-btn--primary,
     and it survives greyscale. `aria-pressed`/`aria-selected` carries it for assistive tech. */
  /* Same inversion, same trap as .wb-btn--primary — see that rule's comment. --control-bg is
     translucent under `.app-dark` and would resolve to white-on-white here too. */
  .wb-chip--selected{background:var(--control-text);color:var(--app-surface-bg);
    border-color:var(--control-text)}

  /* The remove affordance MUST be a real <button> with an accessible name that includes WHAT is
     being removed ("Remove Design tag"), not a bare "Remove" repeated down the row. */
  .wb-chip__remove{display:inline-flex;align-items:center;justify-content:center;
    inline-size:var(--space-4);block-size:var(--space-4);padding:0;border:0;border-radius:var(--radius-sm);
    background:transparent;color:inherit;cursor:pointer}
  .wb-chip__remove:hover{background:var(--control-disabled-bg);color:var(--control-text)}
  .wb-chip__remove:focus-visible{outline:2px solid var(--focus-ring);outline-offset:1px}

  /* ── Accordion ──────────────────────────────────────────────────────
     Phase 4a-ii. Native `<details>`/`<summary>` (D5, and the browser-support answer is modern
     evergreen). That is the entire behaviour: open/close state, keyboard operation, and the
     correct expanded semantics all come from the element, with no JS and no ARIA to get wrong.

     The disclosure state is carried by the chevron's ROTATION plus the native expanded semantics —
     not by colour, so it satisfies §6 rule 7 without a second cue bolted on. */
  .wb-accordion{border:1px solid var(--elevation-line);border-radius:var(--radius-sm);
    overflow:hidden}

  .wb-accordion__item + .wb-accordion__item{border-top:1px solid var(--hairline)}

  .wb-accordion__trigger{display:flex;align-items:center;gap:var(--space-3);cursor:pointer;
    padding:var(--space-3) var(--space-4);font-family:var(--font-caption);
    font-size:var(--fs-small);font-weight:700;color:var(--text-1);list-style:none}

  /* Suppress the default disclosure triangle in both engines so the chevron is the only marker. */
  .wb-accordion__trigger::-webkit-details-marker{display:none}
  .wb-accordion__trigger::marker{content:''}

  .wb-accordion__trigger:hover{background:var(--control-disabled-bg)}

  /* The ring goes on the summary itself — it is the focusable element. */
  .wb-accordion__trigger:focus-visible{outline:2px solid var(--focus-ring);outline-offset:-2px}

  .wb-accordion__icon{transition:transform var(--dur-control) var(--ease-standard);flex:none}
  .wb-accordion__item[open] .wb-accordion__icon{transform:rotate(90deg)}

  .wb-accordion__panel{padding:0 var(--space-4) var(--space-4);color:var(--text-2);
    font-family:var(--font-caption);font-size:var(--fs-small)}

  /* ── Tabs ───────────────────────────────────────────────────────────
     Phase 4a-ii. The ARIA tabs pattern — the one component here with no native equivalent, so it
     is the one place custom ARIA is correct rather than lazy. behavior.js owns the roving
     tabindex and arrow-key movement; this file owns only appearance.

     THE SELECTED STATE IS NOT A COLOUR. It is a 2px underline plus a weight change, because a tab
     strip that marks the current tab by hue alone fails §6 rule 7 and is unreadable in greyscale.
     `aria-selected` is what assistive tech reads; the underline is what everyone else reads. */
  .wb-tabs__list{display:flex;align-items:stretch;gap:var(--space-1);
    border-bottom:1px solid var(--hairline);overflow-x:auto}

  .wb-tabs__tab{display:inline-flex;align-items:center;gap:var(--space-2);white-space:nowrap;
    padding:var(--space-3) var(--space-4);border:0;background:transparent;cursor:pointer;
    font-family:var(--font-caption);font-size:var(--fs-small);font-weight:400;color:var(--text-2);
    border-bottom:2px solid transparent;margin-bottom:-1px}

  .wb-tabs__tab:hover{color:var(--text-1)}

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

  .wb-tabs__tab[aria-selected=true]{color:var(--text-1);font-weight:700;
    border-bottom-color:var(--control-text)}

  .wb-tabs__tab:disabled,.wb-tabs__tab[aria-disabled=true]{color:var(--control-disabled-text);
    cursor:not-allowed}

  .wb-tabs__panel{padding:var(--space-4) 0;color:var(--text-2);font-family:var(--font-caption);
    font-size:var(--fs-small)}
  .wb-tabs__panel[hidden]{display:none}

  /* ── Pagination ─────────────────────────────────────────────────────
     Phase 4a-ii. A real `<nav>` with an accessible name, wrapping real buttons — the page numbers
     compose `.wb-btn`, so there is no second button implementation to drift.

     The current page is marked with `aria-current="page"` (what assistive tech reads) AND the
     filled `.wb-btn--primary` treatment (what everyone else reads) — again a fill, not a hue. */
  .wb-pagination{display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap}

  .wb-pagination__list{display:flex;align-items:center;gap:var(--space-1);
    list-style:none;margin:0;padding:0}

  /* An elision marker between page ranges. Not focusable, not a button — it is text, and it is
     `aria-hidden` in the markup so a screen reader is not told about a gap it cannot act on. */
  .wb-pagination__ellipsis{padding:0 var(--space-2);color:var(--text-2);
    font-family:var(--font-caption);font-size:var(--fs-small)}

  /* ── Table shell ────────────────────────────────────────────────────
     Phase 4a-ii. PRESENTATIONAL ONLY, per the Tier A scope: chrome, header, alignment, and an
     empty state. Sorting, selection and virtualisation are Tier B and are deliberately absent.

     Always a real `<table>` with a `<caption>` (visually hidden if need be) and `<th scope>` —
     that is what makes a data table navigable at all with a screen reader, and no amount of
     styling substitutes for it.

     WHY THERE IS NO ZEBRA MODIFIER. Gimlet's `data-table-shell` stripes alternate rows, and the
     obvious port is a `--wash-1` fill. It does not work here and would have shipped invisible:
     on `.app-card` and `.app-raised` the surface IS `--white` and so is `--wash-1`, so the stripe
     would be white on white; `--control-disabled-bg` has the mirror-image problem, matching the
     ground exactly on `.app-chrome`/`.app-canvas` where both are `--paper`. NO existing Tier-2
     role differs from the surface on all four. Row separation is a hairline instead — which works
     everywhere, needs no new token, and is the better accessibility answer anyway since zebra is
     decorative colour carrying structural meaning. A real zebra would need a new `--row-alt` role
     re-pointed per surface; that is a token change, not a component change, and is recorded as an
     Open Decision rather than smuggled in here. */
  .wb-table-wrap{overflow-x:auto;border:1px solid var(--elevation-line);
    border-radius:var(--radius-sm)}

  .wb-table{inline-size:100%;border-collapse:collapse;font-family:var(--font-caption);
    font-size:var(--fs-small);color:var(--text-1)}

  .wb-table caption{text-align:start;padding:var(--space-3) var(--space-4);
    font-weight:700;color:var(--text-1)}

  .wb-table th,.wb-table td{padding:var(--space-2) var(--space-4);text-align:start;
    border-bottom:1px solid var(--hairline)}

  .wb-table thead th{font-weight:700;color:var(--text-1);white-space:nowrap;
    border-bottom:1px solid var(--elevation-line)}

  .wb-table tbody tr:last-child td{border-bottom:0}

  /* Numeric columns align right so digits line up by place value — a legibility requirement in a
     data table, not decoration. Applied per-cell AND per-header so the two stay in step. */
  .wb-table__num{text-align:end;font-variant-numeric:tabular-nums}

  /* The empty state is a real row spanning the table, not an absolutely-positioned overlay, so it
     is read in document order by a screen reader exactly where the data would have been. */
  .wb-table__empty{padding:var(--space-12) var(--space-4);text-align:center;color:var(--text-2)}

  /* ── Visually-hidden utility ────────────────────────────────────────
     Phase 4a-ii. The classic clip recipe, extracted because three components in this tier now need
     it — a `<caption>` on a table whose panel heading already names it, a label for a control that
     shows only an icon, and live-region copy. It was previously inlined in the checkbox/radio rule
     above; that copy stays where it is (it also has to defeat the native control's own rendering),
     but nothing new should re-type this.

     CLIPPED, NOT HIDDEN, and the distinction is the whole point: `display:none` and
     `visibility:hidden` remove the element from the accessibility tree, which is the opposite of
     what is wanted. This keeps it in the tree and off the screen.

     Lives in the application tier rather than the page tiers because that is where the pressure
     appeared; it is tier-neutral in nature, and a page-tier need for it should move it rather than
     copy it. */
  .u-visually-hidden{position:absolute;width:1px;height:1px;margin:-1px;padding:0;
    overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap;border:0}

  /* ═══ OVERLAYS — Phase 4a-iii ═══════════════════════════════════════
     Ported from Aperture's overlay/modal-shell/dropdown/popover/tooltip/toast/split-button and
     Gimlet's glass-toast. The operator's modern-evergreen answer to spec §12 Q1 is what makes most
     of this small: `<dialog>` and the Popover API carry the hard parts natively, so the JS in
     behavior.js is confined to the two patterns with no native equivalent (menus and toasts).

     Elevation here is the same rule as everywhere in this tier — a ground change plus an
     --elevation-line hairline. No blur, no glow, no shadow stack, whatever the source systems do.
     ═══════════════════════════════════════════════════════════════════ */

  /* ── Overlay (scrim) ────────────────────────────────────────────────
     The dimming layer the modal composes on. `--overlay-scrim` is declared once at :root and NOT
     re-pointed per surface or theme — deliberately, per tokens.css: a scrim's job is to darken
     whatever is behind it, and a "dark-mode scrim" that lightens would defeat the purpose.

     For a native <dialog> the scrim is `::backdrop` (below) and this class is not needed. It exists
     for the non-dialog cases — a drawer, a busy state — where a bare scrim is drawn by hand. */
  .wb-overlay{position:fixed;inset:0;background:var(--overlay-scrim)}

  /* ── Modal ──────────────────────────────────────────────────────────
     A native `<dialog>` opened with `showModal()`. That single call is the entire accessibility
     story and it is why this component has almost no JS: the browser traps focus, closes on Esc,
     restores focus to the invoker, marks the rest of the document inert, and puts the dialog in the
     top layer. Every one of those is a documented failure point in hand-rolled modals — spec §6
     rule 4 exists because they are so often missing.

     `<dialog>` is display:none until opened, so the open state needs no class of its own.
     `aria-labelledby` pointing at `.wb-modal__title` is required in the markup; without a name the
     dialog is announced as an unlabelled group. */
  .wb-modal{padding:0;border:1px solid var(--elevation-line);border-radius:var(--radius-sm);
    background:var(--app-surface-bg);color:var(--text-1);max-inline-size:min(90vw,32rem);
    inline-size:100%}

  /* The scrim for a modal dialog. Same token as .wb-overlay so the two cannot drift. */
  .wb-modal::backdrop{background:var(--overlay-scrim)}

  /* Use a `<div>` for these, NOT `<header>`/`<footer>`. A bare `<header>` maps to the `banner`
     landmark and `<footer>` to `contentinfo` unless scoped by sectioning content (article, aside,
     main, nav, section) — and `<dialog>` is not sectioning content. So `<header>` inside a dialog
     announces a page-level banner from inside a modal, and a page with two modals has two. That is
     why `.wb-panel` can use `<header>` (it is a `<section>`) and this cannot. Caught here by axe
     reporting duplicate banner/contentinfo landmarks across the eight-surface matrix. */
  .wb-modal__header{display:flex;align-items:center;justify-content:space-between;
    gap:var(--space-4);padding:var(--space-3) var(--space-4);
    border-bottom:1px solid var(--hairline)}

  .wb-modal__title{font-family:var(--font-caption);font-size:var(--fs-small);font-weight:700;
    color:var(--text-1);margin:0}

  .wb-modal__body{padding:var(--space-4);font-family:var(--font-caption);
    font-size:var(--fs-small);color:var(--text-2)}

  .wb-modal__footer{display:flex;align-items:center;justify-content:flex-end;gap:var(--space-2);
    padding:var(--space-3) var(--space-4);border-top:1px solid var(--hairline)}

  /* ── Menu (dropdown + split-button share it) ────────────────────────
     The menu-button pattern. This is the one overlay with NO native equivalent — <select> is a
     different control with different semantics — so it is the one place custom ARIA is correct.
     behavior.js owns the keyboard contract; this file owns appearance only.

     The surface is a ground change plus a hairline, in the top layer via the Popover API so it
     escapes any `overflow:hidden` ancestor without a z-index arms race. */
  .wb-menu{position:absolute;min-inline-size:12rem;padding:var(--space-1);margin:0;
    border:1px solid var(--elevation-line);border-radius:var(--radius-sm);
    background:var(--app-surface-bg);list-style:none}

  .wb-menu[hidden]{display:none}

  .wb-menu__item{display:flex;align-items:center;gap:var(--space-2);inline-size:100%;
    padding:var(--space-2) var(--space-3);border:0;border-radius:var(--radius-sm);
    background:transparent;color:var(--text-1);cursor:pointer;text-align:start;
    font-family:var(--font-caption);font-size:var(--fs-small)}

  .wb-menu__item:hover{background:var(--control-disabled-bg)}

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

  .wb-menu__item:disabled,.wb-menu__item[aria-disabled=true]{color:var(--control-disabled-text);
    cursor:not-allowed}

  .wb-menu__separator{block-size:1px;margin:var(--space-1) 0;background:var(--hairline);border:0}

  /* Destructive menu item — signal on the text only, never a fill, same rule as .wb-btn--danger,
     INCLUDING its requirement to carry `.wb-icon--warn`. On the `.app-dark` card/raised surfaces
     --text-signal is --fg-light, so without the glyph this item is visually identical to every
     other one in the menu — the most dangerous command in the list, undifferentiated. */
  .wb-menu__item--danger{color:var(--text-signal)}

  /* The dropdown wrapper positions the menu against its trigger. */
  .wb-dropdown{position:relative;display:inline-block}

  /* ── Popover ────────────────────────────────────────────────────────
     Non-modal, light-dismiss. Uses the native Popover API (`popover` + `popovertarget`), which the
     modern-evergreen target makes available: the browser handles the top layer, outside-click
     dismissal and Esc with NO script at all, and `popovertarget` wires the trigger without an id
     dance in JS. That is why there is no popover code in behavior.js.

     Distinct from `.wb-menu`: a popover holds arbitrary content and is not a list of commands, so
     it takes no menu roles. Reaching for a menu when the content is a form is the common mistake. */
  .wb-popover{position:absolute;inline-size:max-content;max-inline-size:min(90vw,20rem);
    margin:0;padding:var(--space-4);border:1px solid var(--elevation-line);
    border-radius:var(--radius-sm);background:var(--app-surface-bg);color:var(--text-1);
    font-family:var(--font-caption);font-size:var(--fs-small)}

  .wb-popover::backdrop{background:transparent}

  /* ── Tooltip ────────────────────────────────────────────────────────
     WCAG 1.4.13 (Content on Hover or Focus) is the whole design here, and it is why the obvious
     CSS-only `:hover` tooltip is not enough. The criterion requires the content be:
       hoverable  — you can move the pointer onto the tip without it vanishing (no gap, no
                    pointer-events:none between trigger and tip);
       dismissible — Esc closes it without moving focus (behavior.js, since CSS cannot);
       persistent — it stays until dismissed or the trigger loses hover/focus.
     Hover must NOT be the only trigger (spec §5c), so `:focus-visible` shows it too — otherwise the
     tip is invisible to a keyboard user.

     The tip is `aria-describedby` from the trigger, never `aria-label`: describedby SUPPLEMENTS the
     accessible name, whereas a label would REPLACE it and the button would lose its own text. */
  .wb-tooltip{position:relative;display:inline-flex}

  .wb-tooltip__tip{position:absolute;inset-block-end:calc(100% + var(--space-1));inset-inline-start:0;
    z-index:1;inline-size:max-content;max-inline-size:16rem;padding:var(--space-1) var(--space-2);
    border:1px solid var(--elevation-line);border-radius:var(--radius-sm);
    background:var(--app-surface-bg);color:var(--text-1);font-family:var(--font-caption);
    font-size:var(--fs-caption);opacity:0;visibility:hidden;transition:opacity var(--dur-fast) var(--ease-standard)}

  /* Shown on hover of the WRAPPER (so the tip itself keeps it open — the "hoverable" requirement)
     and on keyboard focus of the trigger. `[data-wb-dismissed]` is set by behavior.js on Esc. */
  .wb-tooltip:hover .wb-tooltip__tip,
  .wb-tooltip:focus-within .wb-tooltip__tip{opacity:1;visibility:visible}
  .wb-tooltip[data-wb-dismissed] .wb-tooltip__tip{opacity:0;visibility:hidden}

  /* ── Toast ──────────────────────────────────────────────────────────
     A live region that ANNOUNCES without stealing focus (spec §5c). The region is the live element
     and it must exist in the DOM before a toast is inserted — a live region created at the same
     moment as its content is frequently not announced at all, which is the single most common
     reason toasts are silent to screen readers.

     `role="status"` / `aria-live="polite"` never interrupts; an assertive toast would talk over
     whatever the user is doing. A toast must never be the only way to convey something important,
     because a sighted user may miss it and it disappears. */
  .wb-toast-region{position:fixed;inset-block-end:var(--space-6);inset-inline-end:var(--space-6);
    display:flex;flex-direction:column;gap:var(--space-2);z-index:1000;
    /* The region spans the corner but must not swallow clicks meant for the page beneath it; each
       toast re-enables pointer events for itself. */
    pointer-events:none}

  .wb-toast{display:flex;align-items:flex-start;gap:var(--space-2);pointer-events:auto;
    min-inline-size:16rem;max-inline-size:24rem;padding:var(--space-3);
    border:1px solid var(--elevation-line);border-radius:var(--radius-sm);
    background:var(--app-surface-bg);color:var(--text-1);font-family:var(--font-caption);
    font-size:var(--fs-small)}

  .wb-toast__body{flex:1;min-inline-size:0}

  /* Error toasts carry the warn icon in the markup as the non-colour signal, exactly like
     .wb-field__error — never ship the signal colour without the glyph beside it. */
  .wb-toast--error{color:var(--text-signal)}

  /* ── Split button ───────────────────────────────────────────────────
     A primary action plus a menu of related ones. TWO separate buttons, not one with a hit-target
     trick: the action and the menu are different operations and each needs its own focus stop and
     its own accessible name. The menu trigger's name must say what it opens ("More save options"),
     not "Open menu" repeated across the page. */
  .wb-split{display:inline-flex;align-items:stretch}

  /* The seam: the two buttons meet, so the inner corners square off and the shared edge is not
     drawn twice. */
  .wb-split .wb-btn:first-child{border-start-end-radius:0;border-end-end-radius:0}
  .wb-split .wb-btn:last-child{border-start-start-radius:0;border-end-start-radius:0;
    margin-inline-start:-1px;padding-inline:var(--space-2)}

  /* ── Slider ─────────────────────────────────────────────────────────
     Phase 4a-iv. A native `<input type="range">`, styled. Gimlet ships `glass-slider`; the Tier A
     inventory in spec §5 has no row for it — one of three components the spec's list omits.

     Native is not a compromise here, it is the whole point: `type=range` brings arrow-key stepping,
     Home/End, Page Up/Down, the correct `slider` role, and live `aria-valuenow`/`valuemin`/`valuemax`
     with no script. The page tier's `.tension-slider` is a custom widget with a hand-rolled 11-stop
     keyboard implementation — that exists because it is a bespoke editorial instrument, and it is
     exactly the cost this component avoids.

     `.wb-field` deliberately excludes `[type=range]` from its text-control rule, so the range input
     arrives here unstyled rather than wearing a text input's border and padding.

     THE VALUE MUST BE VISIBLE. A slider whose current value is only conveyed by thumb position is
     unreadable to anyone who cannot judge sub-pixel offsets, and unusable when precision matters.
     The `<output>` in the markup is not decoration; it is the value's accessible, legible form. */
  .wb-slider{display:flex;flex-direction:column;gap:var(--space-2);
    font-family:var(--font-caption)}

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

  .wb-slider__value{min-inline-size:3ch;text-align:end;font-size:var(--fs-small);
    font-variant-numeric:tabular-nums;color:var(--text-1)}

  /* `appearance:none` removes the platform widget so track and thumb can take token colours; every
     behaviour above is retained because it lives in the element, not its appearance. */
  .wb-slider input[type=range]{appearance:none;-webkit-appearance:none;inline-size:100%;
    background:transparent;margin:0;cursor:pointer}

  .wb-slider input[type=range]::-webkit-slider-runnable-track{block-size:4px;
    border-radius:var(--radius-sm);background:var(--control-border)}
  .wb-slider input[type=range]::-moz-range-track{block-size:4px;
    border-radius:var(--radius-sm);background:var(--control-border)}

  /* The thumb is --control-text, the same "filled = the active thing" move the switch and the
     primary button make, so the tier has one idea of emphasis. Sized well past the 24px pointer
     target once its hit area is counted, per WCAG 2.5.8. */
  .wb-slider input[type=range]::-webkit-slider-thumb{appearance:none;-webkit-appearance:none;
    inline-size:16px;block-size:16px;margin-block-start:-6px;border-radius:50%;
    background:var(--control-text);border:1px solid var(--control-text)}
  .wb-slider input[type=range]::-moz-range-thumb{inline-size:16px;block-size:16px;
    border-radius:50%;background:var(--control-text);border:1px solid var(--control-text)}

  /* The ring goes on the INPUT, not the thumb: pseudo-element outlines are unreliable across
     engines, and the input is what actually receives focus. */
  .wb-slider input[type=range]:focus-visible{outline:2px solid var(--focus-ring);outline-offset:4px}

  .wb-slider input[type=range]:disabled{cursor:not-allowed}
  .wb-slider input[type=range]:disabled::-webkit-slider-thumb{
    background:var(--control-disabled-text);border-color:var(--control-disabled-text)}
  .wb-slider input[type=range]:disabled::-moz-range-thumb{
    background:var(--control-disabled-text);border-color:var(--control-disabled-text)}

  /* ── Toggle group (segmented control) ───────────────────────────────
     Phase 4a-iv. Gimlet ships `toggle-group`; spec §5 has no row for it either.

     BUILT ON NATIVE RADIOS, not buttons, for the single-select case — which is the case that
     matters, since a segmented control is almost always "pick exactly one". A `<fieldset>` with a
     `<legend>` and real radios brings the roving arrow-key behaviour, the group semantics, and the
     "one of N selected" announcement for free. The common alternative — a row of buttons with
     `aria-pressed` — gives every segment its own tab stop and never announces the group at all.

     The multi-select case IS a row of toggle buttons with `aria-pressed`, because that is genuinely
     what it is; the story shows both and says which to use when.

     SELECTED IS A FILL INVERSION, not a hue change — same as `.wb-chip--selected` and
     `.wb-btn--primary` — so it survives greyscale, and `:checked` carries it for assistive tech. */
  .wb-toggle-group{display:inline-flex;border:1px solid var(--control-border);
    border-radius:var(--radius-sm);padding:0;margin:0;overflow:hidden}

  /* A fieldset's legend must remain in the accessibility tree while being visually replaced by the
     surrounding label — clip it, never `display:none`. */
  .wb-toggle-group__legend{position:absolute;width:1px;height:1px;margin:-1px;padding:0;
    overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap;border:0}

  .wb-toggle{position:relative;display:inline-flex}

  /* Clipped, not hidden — the same accessibility trap the checkbox/radio/switch avoid: the input
     must stay in the DOM, tabbable and operable, with the label drawn as the visible control. */
  .wb-toggle input{position:absolute;width:1px;height:1px;margin:-1px;padding:0;
    overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap;border:0}

  .wb-toggle__label{display:inline-flex;align-items:center;justify-content:center;gap:var(--space-2);
    padding:var(--space-2) var(--space-4);cursor:pointer;white-space:nowrap;
    font-family:var(--font-caption);font-size:var(--fs-small);font-weight:400;
    color:var(--control-text);background:var(--control-bg)}

  .wb-toggle + .wb-toggle .wb-toggle__label{border-inline-start:1px solid var(--control-border)}

  .wb-toggle input:hover + .wb-toggle__label{background:var(--control-disabled-bg)}

  .wb-toggle input:checked + .wb-toggle__label{background:var(--control-text);
    color:var(--app-surface-bg);font-weight:700}

  /* The ring is drawn on the visible label, since the real input is clipped off-screen. */
  .wb-toggle input:focus-visible + .wb-toggle__label{outline:2px solid var(--focus-ring);
    outline-offset:-2px}

  .wb-toggle input:disabled + .wb-toggle__label{color:var(--control-disabled-text);
    background:var(--control-disabled-bg);cursor:not-allowed}

  /* ═══ MOTION — Phase 4a-v ═══════════════════════════════════════════
     Entry and exit motion for the components whose state change is a *surface arriving or
     leaving*. Everything here is progressive enhancement: each block is wrapped in an `@supports`
     for the feature it needs, and where the feature is missing the component simply appears
     instantly — which is exactly how it behaved before this section existed. Nothing below is
     load-bearing for usability.

     All of it is neutralised by the `prefers-reduced-motion` guard in primitives.css. The state
     changes still happen; only the interpolation is removed.

     Durations come from the role scale: --dur-surface for something arriving or leaving,
     --dur-control for a control answering. Nothing in the application tier uses --dur-reveal —
     a control that takes 700ms to respond feels broken.
     ═══════════════════════════════════════════════════════════════════ */

  /* ── Accordion expand / collapse ────────────────────────────────────
     Animating a native `<details>` open is historically impossible: the element toggles between
     rendered and not-rendered, and `height:auto` is not an interpolable value. Two modern features
     together make it work, and both are needed:

       `interpolate-size: allow-keywords`  lets `block-size:auto` participate in a transition.
       `::details-content`                 exposes the collapsible part as a styleable box, which
                                           is the thing whose height is being animated.

     `content-visibility` is transitioned with `allow-discrete` so the content is not clipped away
     at frame one — without it the box animates while its contents have already vanished.

     Guarded by `@supports selector(::details-content)`: where it is unsupported the accordion
     opens instantly, exactly as it did in Phase 4a-ii. No JS is added, and the native keyboard and
     semantics are untouched — which was the whole reason for choosing `<details>`. */
  @supports (interpolate-size: allow-keywords) {
    :root{interpolate-size:allow-keywords}
  }

  /* THE GUARD IS A CONJUNCTION, and it has to be. An earlier version gated only on
     `selector(::details-content)`, which Chromium, WebKit and Firefox all support — but
     `interpolate-size: allow-keywords`, without which `block-size:auto` is not interpolable, is
     Chromium-only today. So Safari and Firefox passed the guard, took the `block-size` override,
     and could not animate it: the accordion snapped while carrying styles that existed purely to
     support an animation it was not getting. Requiring both means those engines get the untouched
     native element instead, which is the intended degradation. Verified per-engine with Playwright,
     not from a support table. */
  @supports (interpolate-size: allow-keywords) and (selector(::details-content)) {
    .wb-accordion__item::details-content{
      block-size:0;overflow:hidden;
      transition:block-size var(--dur-surface) var(--ease-standard),
                 content-visibility var(--dur-surface) var(--ease-standard) allow-discrete}
    .wb-accordion__item[open]::details-content{block-size:auto}
  }

  /* ── Modal / dialog entry and exit ──────────────────────────────────
     A `<dialog>` is `display:none` when closed, so an entry transition needs two things beyond the
     obvious: `@starting-style` to declare what "before it opened" looks like, and
     `transition-behavior:allow-discrete` so `display` and the top-layer `overlay` property
     participate rather than snapping at frame zero. Without `overlay` in the list the dialog drops
     out of the top layer immediately on close and the exit animation plays behind the page.

     The movement is small and vertical — 4px — because a dialog is already announced by the focus
     move and the scrim. A large entrance would be decoration competing with the content. */
  @supports (transition-behavior: allow-discrete) {
    /* SCOPED TO `dialog.wb-modal`, not `.wb-modal`. The closed state here is `opacity:0`, and a
       plain `<div class="wb-modal">` — which is how the shell is shown inline, and how the whole
       AllSurfaces matrix renders it — has no `[open]` attribute to turn it back on. Unscoped, this
       rule made every non-dialog specimen invisible. Any consumer using the shell outside a real
       dialog would have hit the same thing. */
    dialog.wb-modal{opacity:0;translate:0 4px;
      transition:opacity var(--dur-surface) var(--ease-standard),
                 translate var(--dur-surface) var(--ease-standard),
                 display var(--dur-surface) allow-discrete,
                 overlay var(--dur-surface) allow-discrete}
    dialog.wb-modal[open]{opacity:1;translate:0 0}
    @starting-style{ dialog.wb-modal[open]{opacity:0;translate:0 4px} }

    .wb-modal::backdrop{opacity:0;
      transition:opacity var(--dur-surface) var(--ease-standard),
                 display var(--dur-surface) allow-discrete,
                 overlay var(--dur-surface) allow-discrete}
    .wb-modal[open]::backdrop{opacity:1}
    @starting-style{ .wb-modal[open]::backdrop{opacity:0} }

    /* Popover: same mechanics, shorter throw. `:popover-open` is the state selector — and the same
       scoping lesson as the dialog above applies, so this is keyed to `[popover]`. The AllSurfaces
       specimen renders a plain `<div class="wb-popover">` with no `popover` attribute, which can
       never match `:popover-open` and would have been stuck at `opacity:0`. */
    .wb-popover[popover]{opacity:0;translate:0 -4px;
      transition:opacity var(--dur-control) var(--ease-standard),
                 translate var(--dur-control) var(--ease-standard),
                 display var(--dur-control) allow-discrete,
                 overlay var(--dur-control) allow-discrete}
    .wb-popover[popover]:popover-open{opacity:1;translate:0 0}
    @starting-style{ .wb-popover[popover]:popover-open{opacity:0;translate:0 -4px} }
  }

  /* ── Menu open ──────────────────────────────────────────────────────
     The menu is toggled by the `hidden` attribute from behavior.js, so `display` has to be part of
     the transition for anything to be visible on the way in. Same `allow-discrete` mechanism as
     the dialog; `@starting-style` supplies the pre-open state.

     --dur-control rather than --dur-surface: a menu is a direct answer to a click or keypress and
     should feel immediate. Anything slower reads as lag, not polish. */
  @supports (transition-behavior: allow-discrete) {
    .wb-menu{opacity:1;translate:0 0;
      transition:opacity var(--dur-control) var(--ease-standard),
                 translate var(--dur-control) var(--ease-standard),
                 display var(--dur-control) allow-discrete}
    .wb-menu[hidden]{display:none;opacity:0;translate:0 -4px}
    @starting-style{ .wb-menu:not([hidden]){opacity:0;translate:0 -4px} }
  }

  /* ── Toast arrival ──────────────────────────────────────────────────
     Toasts are inserted into the live region, so they animate on ENTRY only — there is no exit to
     animate, because dismissal removes the node. The movement comes from the region's own edge
     (inline-end), which is where the eye is being asked to look.

     A keyframe rather than a transition because there is no "before" state to transition FROM: the
     element does not exist until it is appended. `--dur-surface`, and the reduced-motion guard
     collapses it to nothing. */
  @keyframes wb-toast-in{from{opacity:0;translate:8px 0}to{opacity:1;translate:0 0}}
  .wb-toast{animation:wb-toast-in var(--dur-surface) var(--ease-standard)}

  /* ── Tab panel change ───────────────────────────────────────────────
     A cross-fade only, with NO movement. A tab panel swapping content is not an arrival — the
     surface was already there — and sliding panels sideways on every tab press is the single most
     common piece of gratuitous motion in an application UI. Fade only, at control speed. */
  @keyframes wb-panel-in{from{opacity:0}to{opacity:1}}
  .wb-tabs__panel:not([hidden]){animation:wb-panel-in var(--dur-control) var(--ease-standard)}
