/*
 * The entire design system for halcora.com, in one file.
 *
 * One stylesheet, no preprocessor, no framework, no build step beyond Eleventy's
 * passthrough copy — mirroring the halcora monorepo's single-`globals.css` convention.
 * Order is enforced by `@layer`, not by where a rule happens to sit, so a component
 * can be written next to its siblings without worrying about what it might outrank.
 *
 * Two things in here are load-bearing and documented at the point of use:
 *   - the colour discipline (search "Colour discipline"), and
 *   - the claim state line's markup contract (search "state line").
 */

@layer tokens, base, layout, components, utilities;

@layer tokens {
  :root {
    /* Brand — copied by value from halcora packages/ui/src/styles/globals.css.
       See test/tokens.test.mjs, which guards these against accidental edits. */
    --brand-navy: oklch(0.208 0.042 265.755);
    --brand-turquoise: oklch(0.715 0.143 215.221);
    --background: oklch(0.984 0.003 247.858);
    --foreground: oklch(0.129 0.042 264.695);

    /* Aging and urgency, and nothing else. Local to this site — halcora's product
       palette is entirely cool, and "this claim is aging" needs a colour that means
       something. Approved in docs/design/specs/2026-08-24-visual-direction.md. */
    --amber: #b45309;

    --surface: #ffffff;
    --muted: oklch(0.968 0.007 247.896);

    /* Slate-600, which is ONE RUNG DARKER THAN HALCORA'S. The product UI sets its
       secondary text on white, where slate-500 measures 4.76:1. This site sets it on a
       tinted canvas AND on a tinted band, where the same token measures 4.55:1 and
       4.35:1 — the second of those fails AA, and it fails on the single most
       predictable pairing in the vocabulary (`.lede`, `.faq p`, `.stat__label`,
       `.prose__meta` and the state line's inactive states, all inside
       `.section--muted`). Same ramp, one step down: 7.25 / 6.92 / 7.58 across canvas,
       band and card. Fixed in the token rather than flipped per section, because a
       flip only covers the surfaces someone remembered to enumerate. */
    --muted-foreground: oklch(0.446 0.043 257.281);

    /* Two hairlines, and the difference between them is WCAG 1.4.11.

       `--border` SEPARATES THINGS — card edges, the header rule, the mobile-nav panel,
       the post-list and FAQ dividers. It has no contrast floor to meet, and it is not
       ours to choose: it is the product's own `--border`
       (halcora packages/ui/src/styles/globals.css), copied by value, because a visitor
       carries the weight of a rule between the app and this site. It measures 1.20:1
       against the canvas and 1.15:1 against a card, which is a hairline you feel rather
       than read — DEFINITION FOR A CARD COMES FROM ITS SHADOW, exactly as it does in the
       app's own `Card` (`rounded-xl border shadow-sm`). See `.card` below. An earlier
       round took this token two rungs darker to make cards read without elevation; that
       was the wrong lever, and the top and bottom rules of the page were what paid for
       it.

       `--control-border` is the BOUNDARY OF AN INTERACTIVE CONTROL — `.button--ghost`
       has no fill and no underline, so its border is the only thing that says "button" —
       and must clear 3:1 against every surface it sits on. It measures 4.55 / 4.35 /
       4.76 / 3.79 on canvas, band, card and footer. It deliberately does NOT match the
       app's `--input`, which is the same near-invisible value as `--border`: a 1.20:1
       control boundary is a 1.4.11 failure, and matching the product is not a reason to
       ship one. */
    --border: oklch(0.922 0.013 255.508);
    --control-border: oklch(0.554 0.046 257.417);

    /* Colour discipline ---------------------------------------------------------
       WCAG 2.x ratios, computed from the colours Chrome actually paints for the
       tokens above — canvas resolves to #f8fafc and the navy field to #0f172b:

         turquoise on navy    7.54:1     turquoise on canvas   2.26:1
         amber     on navy    3.55:1     amber     on canvas   4.80:1
         navy      on canvas 17.04:1     white     on navy    17.83:1

       (The visual direction quotes 5.9:1 for amber on the canvas. It is 4.80:1 —
       still over the 4.5:1 floor, but by 0.3 rather than 1.4.)

       Two rules follow, and both are expressed as tokens rather than as prose so
       that a component never has to decide:

       1. TURQUOISE IS A DARK-FIELD COLOUR. It carries text only where the field is
          navy. There is deliberately no `.accent` class and no turquoise anywhere
          in `:root` below — the only way to reach it is to be inside a dark
          section, which is what keeps it to at most one moment per screen and
          keeps it out of decoration.
       2. THE AGING MARKER FLIPS. Amber on the canvas, turquoise on the navy field,
          via `--aging-fg`. Amber on navy is 3.55:1 and fails AA for body-sized
          text, so it is never allowed to land there.

       The focus ring is navy rather than turquoise for the same reason: an
       indicator has to clear 3:1 against what it sits on, and turquoise does not
       on the canvas. */
    --focus-ring: var(--brand-navy);
    --aging-fg: var(--amber);
    --stat-accent: var(--foreground);

    /* Headings are NAVY, not ink. #020618 reads as black next to the wordmark; the brand
       colour reads as the brand. It costs nothing — 17.04 on the canvas, 17.83 on a card,
       16.28 on the muted band, 14.20 on the footer, against ink's 19.27 / 20.16 / 18.40 /
       16.05. A token rather than a colour on `h1, h2, h3`, because on the navy field a
       heading still has to be white. */
    --heading: var(--brand-navy);
    --button-primary-bg: var(--brand-navy);
    --button-primary-fg: #ffffff;

    /* Hover is a real state, so it is a real colour rather than a filter. A `filter:
       brightness()` was the draft's answer and it is a no-op exactly where the site
       needs it most: on the navy field `--button-primary-bg` is #ffffff, and
       brightening white does nothing at all, so the main CTA surface had no hover
       feedback whatsoever. `.button--ghost` is unfilled, so a filter barely moved it
       either.

       The two primary steps are matched in magnitude on purpose — 1.51:1 away from
       navy on the light field, 1.49:1 away from white on the dark one — so the control
       feels the same to use on both. Legibility holds through the transition: white on
       the light hover is 11.84:1, navy on the dark hover is 12.00:1. */
    --button-primary-bg-hover: oklch(0.34 0.045 265.755);
    --control-wash-hover: oklch(0.929 0.013 255.508);
    --control-border-hover: var(--foreground);

    /* The turquoise CTA, carried over from the app's `Button variant="accent"`
       (`--accent-brand` on `--accent-brand-foreground`). The foreground is NAVY, not
       white: white on turquoise is 2.37:1 and fails, navy on turquoise is 7.54:1. It is
       bound by once-per-screen.

       IT CARRIES NO RIM, and neither does the app's. It used to carry two — a darker
       turquoise on light surfaces and a white one on the brand field — on the reasoning
       that a turquoise FILL is 2.26:1 on the canvas and therefore fails 1.4.11. That
       reasoning was measuring a surface this control cannot reach. 1.4.11 asks for 3:1
       on a control's boundary only where the boundary is what identifies the control,
       and here the fill does that job: the accent is only ever emitted by the `cta`
       module, which always paints `.section--navy`, so the fields it actually occurs on
       are the navy base (7.54:1) and the brand field (3.26:1 at Chrome's brightest
       painted pixel). It clears 3:1 on both. The white ring the brand field added was
       visible, unwanted, and buying nothing.

       Two things keep that from rotting. `.button` sets `border: 1px solid transparent`,
       so the box metrics are unchanged and a rim cannot creep back through the base;
       and the pairing is checked where it is CLAIMED rather than where it is convenient
       — test/modules.test.mjs asserts from built output that no accent button ever sits
       outside a `.section--navy`, which is the premise test/tokens.test.mjs's field list
       for this control rests on.

       HOVER STEPS TOWARD THE LIGHT, not toward the ink, and that is a consequence of
       the line above rather than an inconsistency with `--button-primary-bg-hover`. The
       primary's hover already flips by field — a step toward the ink on the canvas, a
       step toward the light on the navy band — because a hover has to move AWAY from
       the field it sits on. This control only ever sits on a dark one, so it only ever
       needs the second half, and it takes it at `:root` rather than as a re-point. The
       magnitude is matched to the primary's on purpose: 1.30:1 from the rest fill,
       which is what the darker step it replaces also measured. Navy on the hover fill is
       9.83:1, and the hover fill against the brand field's brightest painted pixel is
       4.25:1 — a darker step measured 2.63:1 there and would have taken the control's
       own outline under 1.4.11's 3:1 for as long as a pointer was on it.

       It does not follow the app's `hover:bg-accent-brand/90`. An alpha hover is a
       DIFFERENT COLOUR on every field it lands on, so it cannot be measured once. */
    --button-accent-bg: var(--brand-turquoise);
    --button-accent-fg: var(--brand-navy);
    --button-accent-bg-hover: oklch(0.795 0.143 215.221);

    /* halcora ships no webfont; neither does this site. Zero font requests, zero
       layout shift, and exact consistency with the product UI. The cost is that no
       typeface personality is available, so the discipline lives in the tracking,
       the weights and the rhythm below. */
    --font-sans:
      ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
      'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
      'Segoe UI Symbol', 'Noto Color Emoji';

    /* Three weights, and only three. 400 body, 500 UI, 650 display. A weight the
       installed face does not carry gets synthesized, and a synthesized weight on a
       system stack reads as a rendering fault — which is why there is no 300 and no
       800. test/tokens.test.mjs fails the build if a fourth appears. */
    --weight-body: 400;
    --weight-ui: 500;
    --weight-display: 650;

    /* Tracking is a deliberate axis, because it is the only expressive one left.
       Display sets tight; uppercase labels set wide. */
    --track-display: -0.02em;
    --track-label: 0.12em;
    --track-state: 0.14em;
    --track-eyebrow: 0.16em;

    --step--1: clamp(0.83rem, 0.8rem + 0.15vw, 0.9rem);
    --step-0: clamp(1rem, 0.96rem + 0.2vw, 1.12rem);
    --step-1: clamp(1.25rem, 1.15rem + 0.5vw, 1.5rem);
    --step-2: clamp(1.6rem, 1.4rem + 1vw, 2.1rem);
    --step-3: clamp(2rem, 1.6rem + 2vw, 3rem);
    --step-4: clamp(2.5rem, 1.9rem + 3vw, 4rem);

    /* The rhythm carries what the type cannot, so the two spaces that set the page's
       cadence scale with the viewport instead of stepping at a breakpoint. */
    --space-xs: 0.5rem;
    --space-sm: 0.875rem;
    --space-md: 1.5rem;
    --space-lg: clamp(2rem, 1.5rem + 2vw, 3rem);
    --space-xl: clamp(3.5rem, 2.2rem + 5vw, 6.5rem);

    --gutter: clamp(1.25rem, 4vw, 2.5rem);

    /* How much wider a page OPENS than it breathes. `--space-xl` between every section
       and `--space-md` inside every stack is an even pulse: regular, and with nothing
       emphasised. A multiplier rather than a sixth space token, because the opening is
       relative to whatever step the first section is already on — a tight section opens
       at 1.4 x tight, not at some absolute value that would tower over it. */
    --opening: 1.4;

    /* THE CORNER SCALE, AND ITS THREE STEPS, ARE THE PRODUCT'S.

       This site has to look like it belongs to the same company as the app, and a corner
       is one of the few things a visitor carries between the two — which is why this is
       not the 4px it briefly was, and why one value is not allowed to serve every
       component. THE APP USES A DIFFERENT CURVE ON A BUTTON THAN ON A CARD, so this does
       too, derived exactly as halcora packages/ui/src/styles/globals.css derives it:

         --radius            0.5rem = 8px      the base; the app's --radius / --radius-lg
         --radius-control    base - 2px = 6px  the app's --radius-md — `Button` is
                                               `rounded-md` (packages/ui/.../button.tsx)
         --radius-card       base + 4px = 12px the app's --radius-xl — `Card` is
                                               `rounded-xl` (packages/ui/.../card.tsx)

       Written as `calc()` off the base rather than as three literals, so the RELATIONSHIP
       is what is copied. Re-skinning the base moves all three together, exactly as it
       does in the app. The base itself is still used directly, by the skip-link chip,
       which has no counterpart in the product. */
    --radius: 8px;
    --radius-control: calc(var(--radius) - 2px);
    --radius-card: calc(var(--radius) + 4px);
    --measure: 68ch;

    /* THE READING COLUMN OF AN ARTICLE, AND IT IS A FRACTION OF THE CONTAINER RATHER
       THAN A COUNT OF CHARACTERS. Every other capped thing on the site is a band inside
       a page, so `--measure`'s 68ch — about 612px next to a 1140px container — reads as
       a column deliberately narrower than the page. A blog post is the whole page, and at
       68ch it read as a second, unrelated width stacked under a header whose navigation
       runs the full container: the reader sees two grids and no relationship between
       them.

       Two thirds of the container is the relationship. It is a real grid line — eight of
       the twelve columns the 1140px container implies — so the article's right edge lands
       where a wide element's would, and the third that is left over is what keeps the
       page from becoming a wall of text. It is 760px at the container's full width, which
       is roughly 85 characters of this stack at `--step-0`: wider than the 68ch it
       replaces, and still short of the ~90 where a line stops being scannable. Below
       about 1180px viewport the container is the narrower of the two and this cap simply
       stops binding, so nothing changes on a phone. */
    --container: 1140px;
    --measure-prose: calc(var(--container) * 2 / 3);
    /* THERE IS NO ELEVATION TOKEN, and exactly one `box-shadow` in the file — on
       `.card`, spelled out at the point of use. A token would be a vocabulary, and a
       vocabulary invites a second level; the card is not floating as a design gesture,
       it is a card, and the app draws its cards with a shadow. See `.card`. */
  }
}

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

  * {
    margin: 0;
  }

  html {
    -webkit-text-size-adjust: 100%;
  }

  @media (prefers-reduced-motion: no-preference) {
    html {
      scroll-behavior: smooth;
    }
  }

  /* The blanket, rather than gating each transition: it also covers whatever the page
     templates add later, which a per-rule gate would silently miss. */
  @media (prefers-reduced-motion: reduce) {
    *,
    *::before,
    *::after {
      animation-duration: 1ms !important;
      animation-iteration-count: 1 !important;
      transition-duration: 1ms !important;
    }
  }

  /* Sticky footer. A column with the main region allowed to grow puts the footer at the
     bottom of a short page and directly after the content of a long one, with nothing
     fixed and nothing overlapping. `svh` rather than `dvh`: the dynamic unit changes as a
     mobile URL bar hides, which would reflow the page mid-scroll. */
  body {
    display: flex;
    flex-direction: column;
    min-block-size: 100vh;
    min-block-size: 100svh;
    background: var(--background);
    color: var(--foreground);
    font-family: var(--font-sans);
    font-size: var(--step-0);
    font-weight: var(--weight-body);
    line-height: 1.6;
    -webkit-font-smoothing: antialiased;
  }

  /* A column, so a section can be told to ABSORB the leftover height of a short page
     (`.section--fill`, which the 404 uses) instead of guessing at it with a viewport
     fraction. `flex: 1 0 auto` keeps main at least as tall as its content, so no section
     is ever shrunk to fit; `align-items` stays at its `stretch` default, which is what
     block layout already did. */
  main {
    display: flex;
    flex: 1 0 auto;
    flex-direction: column;
  }

  /* `<button>`, `<input>`, `<select>` and `<textarea>` do NOT inherit the font — every
     UA stylesheet sets its own, so a control renders in the OS UI face while everything
     around it renders in the site's stack. Today only the menu toggle is affected and it
     has no visible text; the first form this site grows would show it immediately.
     All five names, not just the one that shows. */
  button,
  input,
  optgroup,
  select,
  textarea {
    font: inherit;
    letter-spacing: inherit;
  }

  h1,
  h2,
  h3 {
    color: var(--heading);
    font-weight: var(--weight-display);
    letter-spacing: var(--track-display);
    text-wrap: balance;
  }

  h1 {
    font-size: var(--step-4);
    line-height: 1.05;
    letter-spacing: -0.03em;
  }

  h2 {
    font-size: var(--step-3);
    line-height: 1.1;
  }

  h3 {
    font-size: var(--step-1);
    line-height: 1.25;
    letter-spacing: -0.01em;
  }

  p {
    text-wrap: pretty;
  }

  a {
    color: inherit;
    text-underline-offset: 0.2em;
  }

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

  /* Dates, durations and counts all line up in a column without anyone remembering to
     ask for it. Everywhere else a number appears, the component sets it explicitly. */
  time {
    font-variant-numeric: tabular-nums;
  }

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

@layer layout {
  .container {
    width: min(100% - var(--gutter) * 2, var(--container));
    margin-inline: auto;
  }

  /* PHRASING, NOT A PULSE — the page's largest scale.

     Every section on `--space-xl` and every stack on `--space-md` is a metronome: even,
     and with nothing emphasised. With no typeface personality available on a system
     stack, rhythm is the only place elegance can come from, so it has to have a shape.
     There are three, and this is the first: THE PAGE OPENS WIDER THAN IT BREATHES. The
     first section under the header carries `--opening` times its own step above it, so a
     visitor reads an opening rather than the first of n identical bands.

     The step is a custom property so that the opening scales with whichever step the
     section is on, and so a modifier changes one value instead of restating a shorthand.
     Order matters below: the modifiers must follow `.section`, which declares the same
     property at the same specificity. */
  .section {
    --section-step: var(--space-xl);

    padding-block: var(--section-step);
  }

  .section--tight {
    --section-step: var(--space-lg);
  }

  main > .section:first-child {
    padding-block-start: calc(var(--section-step) * var(--opening));
  }

  /* Absorb whatever height a short page leaves over, and centre in it. The 404 is the
     one page whose content cannot fill a viewport, and a viewport FRACTION cannot
     express "the space left after the header and the footer" — `min(60svh, 32rem)` plus
     section padding overshot by about 100px at 900 tall and at 320x700, so the footer of
     a page with six lines on it needed a scroll. Growing into the leftover cannot
     overshoot by construction: there is nothing to overshoot into. */
  .section--fill {
    /* The smallest step in the set, because a centred section's padding is only a floor:
       on a tall viewport the centring supplies the air and the padding is invisible, and
       on a short one every pixel it claims is a pixel of scroll. */
    --section-step: var(--space-md);

    display: flex;
    flex: 1 0 auto;
    flex-direction: column;
    justify-content: center;
  }

  .section--muted {
    background: var(--muted);
  }

  /* The dark field re-points the SEMANTIC tokens rather than overriding component by
     component: everything inside — cards, ledes, buttons, the aging marker, the focus
     ring — adapts from here, and a component added later adapts without being told.
     The footer used to share this block and no longer does; it is a light field now,
     and its own block is in the components layer with each ratio recorded. */
  .section--navy {
    --surface: rgb(255 255 255 / 6%);
    --foreground: #ffffff;
    --muted-foreground: rgb(255 255 255 / 78%);
    --border: rgb(255 255 255 / 22%);
    --control-border: rgb(255 255 255 / 38%);
    --focus-ring: #ffffff;
    --aging-fg: var(--brand-turquoise);
    --stat-accent: var(--brand-turquoise);
    --button-primary-bg: #ffffff;
    --button-primary-fg: var(--brand-navy);
    --button-primary-bg-hover: oklch(0.869 0.022 252.894);

    /* THE TURQUOISE CTA CANNOT PAINT A DARK FIELD. On a dark band the call to action is
       the LIGHT button, at the user's request — "on dark background I prefer the buttons
       to be very light" — and this is what makes that structural rather than something a
       page author has to remember: the accent tokens are re-pointed at the light
       button's own, so a `.button--accent` inside any dark section renders white with a
       navy label instead of turquoise. Measured: the label is 17.84:1 on white wherever
       the button lands; the fill stands off the plain navy field by 17.84:1 and off the
       brand field's brightest pixel by 8.11:1, and its hover by 5.46:1.

       It is the same trade `.section--brand` already makes one level down, where
       `--stat-accent` goes white because turquoise measures 3.43:1 over the mesh: a
       token named for the accent renders as the field's own light value, because the
       field is what decides. The `cta` module — the only thing that paints a dark band —
       stopped offering the variant in the same change, so this is the half that still
       holds for a module written later. `--button-accent-*` keeps its turquoise at
       `:root` for a light surface, where the navy label is 7.55:1; nothing emits it
       today, and test/modules.test.mjs fails if anything starts to. */
    --button-accent-bg: var(--button-primary-bg);
    --button-accent-fg: var(--button-primary-fg);
    --button-accent-bg-hover: var(--button-primary-bg-hover);
    --control-wash-hover: rgb(255 255 255 / 12%);
    --control-border-hover: #ffffff;
    --heading: #ffffff;

    background: var(--brand-navy);
    color: var(--foreground);
  }

  /* THE BRAND FIELD — an OPT-IN garnish on the dark section, and never its default.

     Ported from the app's own unauthenticated screens
     (halcora apps/portal-ui/src/components/auth-layout.tsx), where the sign-in card sits
     beside a dark panel carrying three stacked layers: a mesh of radial gradients built
     on the brand accent and the primary, a white grid under a radial mask, and an SVG
     grain. It is the one place the product allows itself an atmosphere.

     IT IS A GARNISH AND IT IS SPELLED LIKE ONE. `.section--navy.section--brand` is two
     classes a page reaches for on purpose, not something `.section--navy` does — used on
     every dark band it would stop meaning anything, and "boldness in exactly one place"
     is already spent on the state line. At most one per page.

     THIS BLOCK MUST FOLLOW `.section--navy`: both are (0,1,0) and both declare
     `--control-border`, so the order in the file is what decides.

     Three tokens are re-pointed, and each one closes a MEASURED failure rather than
     expressing a preference. The worst case is computed from these declarations by
     test/helpers/brand-field.mjs — the brightest pixel the mesh can actually paint, with
     a grid line through it — and comes out at #13566d against the plain field's #0f172b.
     Chrome's own brightest painted pixel on this band is #135a6f, which is where that
     number is checked rather than trusted:

       turquoise text        3.43:1  fails 4.5   so --aging-fg / --stat-accent go white
       --control-border 38%  2.54:1  fails 3     so the ghost rim goes to 55% (3.66:1)

     The consequence is worth stating plainly: ON THIS FIELD THE MESH IS THE TURQUOISE.
     A stat's figure and an aging marker render WHITE here. That is the same trade the
     footer makes — a component that does not belong on a surface renders wrong-looking
     rather than illegible — and it is also why the field cannot be everywhere: it spends
     the accent itself.

     The turquoise CTA used to be a fourth re-point, to a white rim. It is gone: the rim
     was unwanted on the render, and the accent's own fill clears 1.4.11 on this field
     without one — see `--button-accent-bg` for the measurement and for what holds it. */
  .section--brand {
    /* The app's panel base is `bg-slate-950`, which is this site's `--foreground` value.
       Written literally rather than as `var(--foreground)`, because inside
       `.section--navy` that token is white and the field would paint itself out. */
    --brand-field-base: oklch(0.129 0.042 264.695);

    --aging-fg: #ffffff;
    --stat-accent: #ffffff;
    --control-border: rgb(255 255 255 / 55%);

    position: relative;
    /* Contains the grain's `mix-blend-mode`, so it blends with the field under it and
       not with whatever section happens to be painted behind. */
    isolation: isolate;
    overflow: hidden;
  }

  /* The field paints BEHIND the content. Stated as "everything that is not the field"
     rather than as a negative z-index on the field itself, so a section that is given
     the class without the decorative element still stacks exactly as before. */
  .section--brand > :not(.brand-field) {
    position: relative;
    z-index: 1;
  }

  /* The second scale of phrasing: PROXIMITY ENCODES RELATEDNESS.
     A uniform gap says every element in a stack is equally related to its neighbour,
     which is never true. Four rules, read top to bottom:
       - the default step separates two ideas;
       - a heading OPENS a new movement, so it gets more air above it than between
         ordinary siblings;
       - a heading then HOLDS the line under it — its lede belongs to it;
       - an eyebrow is not a sibling of the heading at all, it is part of it.
     Specificity is doing real work here. `.stack > .eyebrow + *` (0,2,0) beats both
     middle rules, so a label always binds tightest; the two middle rules are level at
     (0,1,1), so an h2 followed by an h3 takes the later one and binds. */
  .stack > * + * {
    margin-block-start: var(--space-md);
  }

  .stack > * + :is(h2, h3) {
    margin-block-start: var(--space-lg);
  }

  .stack > :is(h1, h2, h3) + * {
    margin-block-start: var(--space-sm);
  }

  .stack > .eyebrow + * {
    margin-block-start: var(--space-xs);
  }

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

  .grid--3 {
    grid-template-columns: repeat(auto-fit, minmax(min(100%, 17rem), 1fr));
  }
}

@layer components {
  .skip-link {
    position: absolute;
    inset-inline-start: -9999px;
  }

  /* The chip is navy, so the default navy ring would land invisibly on top of it.
     Re-point the token and tuck the ring inside: white on navy is 17.8:1. */
  .skip-link:focus {
    --focus-ring: #ffffff;

    inset-inline-start: var(--gutter);
    inset-block-start: var(--space-xs);
    z-index: 10;
    outline-offset: -4px;
    background: var(--brand-navy);
    color: #ffffff;
    font-size: var(--step--1);
    font-weight: var(--weight-ui);
    text-decoration: none;
    padding: var(--space-xs) var(--space-sm);
    border-radius: var(--radius);
  }

  .site-header {
    background: var(--surface);
    border-block-end: 1px solid var(--border);
    position: sticky;
    inset-block-start: 0;
    z-index: 5;
  }

  .site-header__inner {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-md);
    padding-block: var(--space-sm);
  }

  /* 36px, matching the app's `h-9` on the same asset
     (halcora apps/portal-ui/src/components/brand-mark.tsx). */
  .site-header__brand img {
    height: 2.25rem;
    width: auto;
  }

  /* A real 44x44 target rather than padding around a 2px-tall span, which is what the
     draft's `padding: .55rem .7rem` actually produced — a 37x33 box, under the 44px
     comfortable-target size and visibly undersized next to a 36px wordmark. `place-items`
     centres the bars in it without the padding having to be computed. */
  .site-header__toggle {
    display: none;
    place-items: center;
    inline-size: 2.75rem;
    block-size: 2.75rem;
    padding: 0;
    background: none;
    border: 1px solid var(--control-border);
    border-radius: var(--radius-control);
    cursor: pointer;
    transition:
      background-color 120ms ease,
      border-color 120ms ease;
  }

  .site-header__toggle:hover {
    border-color: var(--control-border-hover);
    background: var(--control-wash-hover);
  }

  .site-header__bars,
  .site-header__bars::before,
  .site-header__bars::after {
    display: block;
    width: 20px;
    height: 2px;
    background: var(--foreground);
    content: '';
  }

  /* Symmetric. The draft translated the bars to -6 and +4, which is a lopsided icon. */
  .site-header__bars::before {
    transform: translateY(-6px);
  }

  .site-header__bars::after {
    transform: translateY(6px);
  }

  .site-nav {
    display: flex;
    align-items: center;
    gap: var(--space-md);
  }

  .site-nav__list {
    display: flex;
    gap: var(--space-md);
    list-style: none;
    padding: 0;
  }

  .site-nav__list a {
    text-decoration: none;
    font-size: var(--step--1);
    font-weight: var(--weight-ui);
    color: var(--muted-foreground);
  }

  .site-nav__list a:hover,
  .site-nav__list a[aria-current='page'] {
    color: var(--foreground);
  }

  /* Below this width the toggle appears and the nav collapses behind it. The literal
     is duplicated in src/assets/js/nav.js, which has to resync `aria-expanded` when
     the toggle stops being displayed and cannot read a media query out of a
     stylesheet; test/tokens.test.mjs pins the two together. */
  @media (width < 60rem) {
    .site-header__toggle {
      display: grid;
    }

    .site-nav {
      display: none;
      position: absolute;
      inset-inline: 0;
      inset-block-start: 100%;
      flex-direction: column;
      align-items: stretch;
      gap: var(--space-sm);
      background: var(--surface);
      border-block-end: 1px solid var(--border);
      padding: var(--space-md);
    }

    .site-nav--open {
      display: flex;
    }

    .site-nav__list {
      flex-direction: column;
    }
  }

  .button {
    display: inline-block;
    padding: 0.7rem 1.25rem;
    border-radius: var(--radius-control);
    font-size: var(--step--1);
    font-weight: var(--weight-ui);
    letter-spacing: 0.01em;
    text-decoration: none;
    border: 1px solid transparent;
    transition:
      background-color 120ms ease,
      border-color 120ms ease;
  }

  .button--primary {
    background: var(--button-primary-bg);
    color: var(--button-primary-fg);
  }

  .button--primary:hover {
    background: var(--button-primary-bg-hover);
  }

  /* An unfilled control, so its border is the entire affordance — which is why it uses
     `--control-border` (≥3:1 on every surface) and not the decorative `--border`. */
  .button--ghost {
    border-color: var(--control-border);
    background: transparent;
    color: var(--foreground);
  }

  .button--ghost:hover {
    border-color: var(--control-border-hover);
    background: var(--control-wash-hover);
  }

  /* The app's turquoise CTA — see `--button-accent-*` for the ratios and for why the
     fill, not a rim, is what identifies it. The site spends it once, on
     /how-it-works. */
  .button--accent {
    background: var(--button-accent-bg);
    color: var(--button-accent-fg);
  }

  .button--accent:hover {
    background: var(--button-accent-bg-hover);
  }

  /* A ROW OF BUTTONS. Two inline-block controls inside a paragraph are separated by a
     word space and nothing else — too tight to read as two choices, and with no say in
     how they wrap at 320. This started life as `.error__actions` on the 404 page; the
     hero and the CTA need exactly the same shape, so it is one class rather than three
     identical ones. */
  .actions {
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-sm);
  }

  /* THE THREE DECORATIVE LAYERS OF THE BRAND FIELD, and nothing else — no text, no
     interactive target, and `aria-hidden` on the element that emits it
     (src/_includes/partials/modules/brand-field.njk).

     IT DEGRADES BY CONSTRUCTION, which is what lets it be reached for casually:

       - the mesh is ONE `background` shorthand, so an engine without `color-mix()` drops
         the whole declaration as invalid and the plain navy of `.section--navy` shows
         through unchanged — there is no half-painted state to design for;
       - the grid sits behind an `@supports` for the mask it depends on, because an
         unmasked 44px grid across a whole band is a DIFFERENT design rather than a
         degraded one;
       - the grain is an inline data URI, so it costs no network request and cannot 404.

     There is no motion in any of it — three static paint layers — so
     `prefers-reduced-motion` has nothing here to switch off; the base layer's blanket
     clamp still covers anything a later change adds. */
  .brand-field {
    position: absolute;
    inset: 0;
    pointer-events: none;
    background:
      radial-gradient(
        120% 120% at 20% 15%,
        color-mix(in oklch, var(--brand-turquoise) 38%, transparent) 0%,
        transparent 45%
      ),
      radial-gradient(
        90% 90% at 85% 85%,
        color-mix(in oklch, var(--brand-navy) 45%, transparent) 0%,
        transparent 50%
      ),
      radial-gradient(
        80% 80% at 75% 10%,
        color-mix(in oklch, var(--brand-turquoise) 22%, transparent) 0%,
        transparent 40%
      ),
      var(--brand-field-base);
  }

  /* THE MESH IS THE APP'S OWN, 38/45/22. It was briefly dimmed to 22/45/12 to clear a
     worst case of #197792, on which the 78% secondary text measured 3.80:1. That figure
     was an artefact of the model, not a colour anywhere on the screen: the model
     composited all three radials at their full declared alpha AT THE SAME PIXEL, and
     they are centred at 20%/15%, 85%/85% and 75%/10%, each falling to transparent long
     before it reaches either of the others. No pixel takes more than one of them at
     strength. The model now integrates the falloff, and the bound it produces is
     #13566d — verified against Chrome's brightest painted pixel on this band, #135a6f,
     where white measures 7.72:1 rather than the 3.80:1 that was reported. */
  .brand-field::before,
  .brand-field::after {
    content: '';
    position: absolute;
    inset: 0;
  }

  @supports (mask-image: radial-gradient(#000, transparent)) {
    .brand-field::before {
      background-image:
        linear-gradient(to right, #ffffff 1px, transparent 1px),
        linear-gradient(to bottom, #ffffff 1px, transparent 1px);
      background-size: 44px 44px;
      mask-image: radial-gradient(120% 100% at 50% 0%, #000000 30%, transparent 75%);
      opacity: 0.07;
    }
  }

  .brand-field::after {
    background-image: url('data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%27160%27 height=%27160%27%3E%3Cfilter id=%27n%27%3E%3CfeTurbulence type=%27fractalNoise%27 baseFrequency=%270.85%27 numOctaves=%273%27/%3E%3C/filter%3E%3Crect width=%27100%25%27 height=%27100%25%27 filter=%27url(%23n)%27/%3E%3C/svg%3E');
    mix-blend-mode: overlay;
    opacity: 0.15;
  }

  /* A structural label, not an accent. Deliberately NOT turquoise — that is 2.26:1 on
     the canvas, and an eyebrow appearing above every section would also spend the
     once-per-screen budget several times over.

     It is set apart from the h2 it labels by TRACKING FIRST: 0.16em, the widest value
     in the system, against the heading's -0.02em. The step down in ink is secondary and
     is only affordable because `--muted-foreground` moved a rung darker — at 7.25:1 the
     hierarchy no longer costs any contrast, which is what it would have done before. */
  .eyebrow {
    font-size: var(--step--1);
    font-weight: var(--weight-ui);
    letter-spacing: var(--track-eyebrow);
    text-transform: uppercase;
    font-variant-numeric: tabular-nums;
    color: var(--muted-foreground);
  }

  .lede {
    font-size: var(--step-1);
    line-height: 1.45;
    color: var(--muted-foreground);
    max-width: var(--measure);
  }

  /* ---------------------------------------------------------------------------
     The claim state line — the one memorable element on the site.

     MARKUP CONTRACT (Task 7 consumes this; do not change it without updating both):

       <figure class="state-line">
         <ol class="state-line__run" role="list" aria-label="The life of a claim">
           <li>Submitted</li>
           <li>Accepted</li>
           <li>Pending</li>
           <li class="state-line__repeats">
             <ol class="state-line__group" role="list" aria-label="Pending, cycle after cycle">
               <li data-repeat>Pending</li>
               <li data-repeat>Pending</li>
               <li data-repeat>Pending</li>
             </ol>
             <p class="state-line__elapsed">47 days</p>
           </li>
           <li>???</li>
         </ol>
       </figure>

     WHY THE REPEATS ARE NESTED. The elapsed time measures the REPETITION, not the
     lifecycle: 47 days is how long this claim has been going round, and the direction's
     own sketch brackets the run of PENDINGs rather than the whole line. A flat list
     cannot express that. The bracket has to span exactly the repeated states, and CSS
     has no way to select "from the first element carrying an attribute to the last" —
     an absolutely-positioned caption would need their geometry, and a grid placement
     would need line numbers hardcoded against however many states Task 7 writes. Making
     the repeats a real group removes the problem instead of working around it: the
     group is one box, so the bracket is just its width, and it stays correct for any
     number of repeats. It is also the more honest markup — those states ARE a group.

     Notes, each of which is a requirement rather than a preference:

       - It is TYPE, not an image. With CSS disabled it reads as an ordered list of
         claim states, the repeated ones as a nested run with "47 days" beside them,
         which is exactly the pitch.
       - `role="list"` on both lists, because `list-style: none` drops list semantics in
         Safari/VoiceOver. Keep it.
       - The connectors between states are drawn rules, not "→" glyphs, so no screen
         reader announces "right arrow" six times.
       - `data-repeat` is a BOOLEAN attribute on each repeated state. It only tints;
         the meaning is carried by the word "Pending" appearing four times in the
         text, so nothing here depends on colour alone (WCAG 1.4.1).
       - Below 44rem the run becomes vertical at the SAME type size. It must never
         shrink its way onto one line.
     --------------------------------------------------------------------------- */
  .state-line {
    font-variant-numeric: tabular-nums;
    font-size: var(--step--1);
    font-weight: var(--weight-ui);
    letter-spacing: var(--track-state);
    text-transform: uppercase;
    color: var(--muted-foreground);
  }

  /* One gap value for BOTH levels. The connector lives inside the `li` that follows it,
     so the space before it comes from the run's column-gap and the space after it from
     the item's own gap — set them apart and every connector butts flush against the
     word on its left and floats away from the word on its right. On the one element the
     whole design rests on, that asymmetry is the first thing the eye finds. */
  .state-line__run,
  .state-line__group {
    display: flex;
    flex-wrap: wrap;
    align-items: start;
    gap: var(--space-xs) var(--space-sm);
    list-style: none;
    padding: 0;
  }

  /* `:not()` rather than a later override: `.state-line li` out-specifies
     `.state-line__repeats` (0,1,1 against 0,1,0), so the repeated group would silently
     stay a flex row however far down the file its own rule sat. */
  .state-line li:not(.state-line__repeats) {
    display: flex;
    align-items: center;
    gap: var(--space-sm);
    white-space: nowrap;
  }

  /* Drawn, not typed. A run of "→" glyphs would be announced by a screen reader six
     times over and would carry no meaning the list order does not already carry.
     `currentcolor` means the connector picks up the amber of a repeated state. */
  .state-line li + li::before {
    content: '';
    flex: none;
    inline-size: 1.75rem;
    block-size: 1px;
    background: currentcolor;
    opacity: 0.4;
  }

  .state-line [data-repeat] {
    color: var(--aging-fg);
  }

  /* The repeated group with its measure hanging below it. The run aligns to `start` so
     that this two-row item keeps its STATES on the same line as every other state — the
     bracket overhangs downward, which is where the sketch puts it. The connector
     arriving from the previous state stays in row 1 for the same reason: centred on the
     states, not on the pair. */
  .state-line__repeats {
    display: grid;
    grid-template-columns: auto auto;
    align-items: center;
    column-gap: var(--space-sm);
    color: var(--aging-fg);
  }

  .state-line__repeats::before {
    grid-row: 1;
  }

  .state-line__group {
    grid-column: 2;
  }

  /* The bracket from the direction's sketch, drawn rather than typed: a rule the width
     of the group with a tick turned UP at each end — `└──── 47 days ────┘`, the shape
     the sketch draws — so it reads as gathering exactly those states and nothing else.
     `currentcolor` keeps it on the aging colour, which flips to turquoise on the navy
     field with everything else.

     WHICH BORDER IS DROPPED IS THE WHOLE GESTURE, and it shipped inverted: dropping the
     block-END left the rule along the top with its ticks hanging DOWN, away from the
     states and toward the measure, which reads as a lid on the words below rather than
     as a brace under the words above. Dropping the block-START is the sketch: the rule
     sits under the group, both ends rise toward the states it encloses, and the measure
     reads beneath it. test/tokens.test.mjs pins the side, because `border: 1px solid
     currentcolor` alone is true of both and the difference is only visible on screen. */
  .state-line__elapsed {
    position: relative;
    grid-column: 2;
    padding-block-start: 0.75rem;
    text-align: center;
    text-transform: none;
    letter-spacing: normal;
  }

  .state-line__elapsed::before {
    content: '';
    position: absolute;
    inset-block-start: 0.2rem;
    inset-inline: 0;
    block-size: 0.3rem;
    border: 1px solid currentcolor;
    border-block-start: 0;
  }

  @media (width < 44rem) {
    .state-line__run,
    .state-line__group {
      flex-direction: column;
      align-items: flex-start;
      gap: 0;
    }

    .state-line li:not(.state-line__repeats) {
      flex-direction: column;
      align-items: flex-start;
      gap: 0;
    }

    .state-line li + li::before {
      inline-size: 1px;
      block-size: 1.15rem;
      margin-block: 0.35rem;
      margin-inline-start: 0.05em;
    }

    /* Vertical, so the bracket turns on its side: it runs down the RIGHT of the group
       with its ticks turned in toward it, and the measure sits alongside. The connector
       arriving from the previous state takes row 1 above the group, so the group's
       states line up with every other state's left edge rather than indenting behind
       it. `align-self: stretch` is what makes the bracket span the whole group instead
       of just the state it happens to sit beside. */
    .state-line__repeats {
      grid-template-columns: auto auto;
      justify-content: start;
      align-items: start;
    }

    .state-line__repeats::before {
      grid-area: 1 / 1;
    }

    .state-line__group {
      grid-area: 2 / 1;
    }

    .state-line__elapsed {
      display: flex;
      grid-area: 2 / 2;
      align-items: center;
      align-self: stretch;
      padding-block-start: 0;
      padding-inline-start: 0.75rem;
      text-align: start;
    }

    .state-line__elapsed::before {
      inset-block: 0;
      inset-inline: 0.2rem auto;
      inline-size: 0.3rem;
      block-size: auto;
      border: 1px solid currentcolor;
      border-inline-start: 0;
    }
  }

  /* The aging bucket — `0–30`, `31–60`, `61–90`, `90+`. Uppercase plus tracking, never
     `font-variant: small-caps`, which a system face fakes by scaling capitals.
     Amber on the canvas, turquoise on the navy field, via `--aging-fg`.

     Use it only where a section is genuinely about time. Where the content is a
     sequence but not an aging one, use `.eyebrow`; decorative `01 / 02 / 03` markers
     are forbidden outright. */
  .aging {
    display: inline-block;
    border-inline-start: 2px solid currentcolor;
    padding-inline-start: var(--space-xs);
    font-size: var(--step--1);
    font-weight: var(--weight-ui);
    line-height: 1.3;
    letter-spacing: var(--track-label);
    text-transform: uppercase;
    font-variant-numeric: tabular-nums;
    color: var(--aging-fg);
  }

  /* THE ONLY `box-shadow` IN THE FILE, and the only place elevation is reintroduced.

     `--border` is the product's own hairline and is near-invisible by design (1.20:1 on
     the canvas, 1.15:1 on a card), so on its own the card was a rectangle you inferred
     rather than saw. The app solves that with a shadow — its `Card` is
     `rounded-xl border shadow-sm` (halcora packages/ui/src/components/card.tsx) — and
     this site looking like it belongs to the same company outranks the aesthetic
     argument a previous round made for a flat surface. Copied by value from Tailwind
     v4's own `--shadow-sm`, so "the same shadow as the app" stays true without importing
     anything. Nothing else on this site is allowed to float. */
  .card {
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: var(--radius-card);
    box-shadow:
      0 1px 3px 0 rgb(0 0 0 / 10%),
      0 1px 2px -1px rgb(0 0 0 / 10%);
    padding: var(--space-md);
  }

  .card h3 {
    margin-block-end: var(--space-xs);
  }

  .card p {
    color: var(--muted-foreground);
    font-size: var(--step--1);
  }

  /* An aging bucket labelling a card BINDS to the heading under it, the same way
     `.stack > .eyebrow + *` binds a label to the thing it labels.

     The marker is emitted as a `<div class="aging">` and deliberately NOT as a `<p>`:
     `.card p` is (0,1,1) and `.aging` is (0,1,0), so a paragraph would silently lose the
     aging colour — the entire point of the marker — and read as ordinary secondary text.
     test/modules.test.mjs asserts the element it is emitted as. */
  .card > .aging + * {
    margin-block-start: var(--space-xs);
  }

  /* The single figure a dark band exists to show. `--stat-accent` is turquoise only on
     the navy field; on the canvas it resolves to ink, because turquoise at 2.26:1 is
     not a colour text can be set in. */
  .stat__value {
    font-size: var(--step-3);
    font-weight: var(--weight-display);
    line-height: 1.05;
    letter-spacing: var(--track-display);
    font-variant-numeric: tabular-nums;
    color: var(--stat-accent);
  }

  .stat__label {
    font-size: var(--step--1);
    color: var(--muted-foreground);
  }

  /* PUT `.prose` (and `.measure` below) ON A CHILD OF `.container`, NEVER ON THE
     CONTAINER ITSELF.

     The container's gutter is expressed as `width: 100% - 2 * gutter` plus auto side
     margins, so capping that same element's width re-centres the narrowed column: the
     article then floats in the middle of the viewport while the logo above it and the
     footer below it sit on the container's left edge, and the page loses the single
     alignment grid the whole design depends on. As a child it inherits the container's
     content edge for free. Both layouts were corrected to do this. */
  /* The error page. A real visitor lands here, so it is designed rather than stubbed.

     IT MUST FIT WITHOUT SCROLLING. A page whose footer needs a scroll to reveal it, on a
     page with six lines of content, reads as broken — which is the last thing to tell
     someone who has just hit a broken link. Three things buy the room: the section
     absorbs the leftover height instead of claiming a viewport fraction
     (`.section--fill`); the heading drops one step, because 404 is not the loudest thing
     this site has to say; and only the route a lost visitor most likely wants is a
     button, with the other two as a line of prose under it. Verified at 1280x900,
     1280x720, 375x667 and 320x700. */
  .error {
    max-width: var(--measure);
  }

  .error h1 {
    font-size: var(--step-3);
  }

  .error__routes {
    margin-block-start: var(--space-sm);
    font-size: var(--step--1);
    color: var(--muted-foreground);
  }

  /* AN ARTICLE'S COLUMN IS TWO THIRDS OF THE CONTAINER, not 68ch — see `--measure-prose`
     for why the relationship is to the page's grid rather than to a character count. The
     header's navigation runs the full container above it, and a 68ch column under it read
     as an unrelated second width. */
  .prose {
    max-width: var(--measure-prose);
  }

  /* THE HEADINGS INSIDE AN ARTICLE ARE ONE RUNG SMALLER THAN THE PAGE'S, both of them.

     The scale is built for a marketing band, where an `h1` is the only thing on the
     screen and an `h2` opens a whole section of the site. Inside a post they were doing
     a different job and doing it too loudly: `--step-4` set the title at 64px over a
     reading column, and `--step-3` set every subheading at 48px — the size the title
     itself now takes, so a subheading competed with the thing it sits under rather than
     dividing it.

     One rung down each keeps the ladder and fixes the ratios: 48 / 33.6 / 24 against a
     17.9px body at 1280, so the title leads, a subheading is unmistakably subordinate to
     it, and `h3` is where it always was. Nothing else about a heading changes — the
     weight, the tracking and the colour are the page's. */
  .prose h1 {
    font-size: var(--step-3);
  }

  .prose h2 {
    font-size: var(--step-2);
  }

  /* The third scale of phrasing, inside an article: a heading opens a movement and then
     holds the paragraph under it. Same shape as `.stack`, same specificity reasoning. */
  .prose > * + * {
    margin-block-start: var(--space-md);
  }

  .prose > * + :is(h2, h3) {
    margin-block-start: var(--space-lg);
  }

  .prose > :is(h1, h2, h3) + * {
    margin-block-start: var(--space-sm);
  }

  /* A POST'S OPTIONAL HEADER IMAGE — see layouts/post.njk, and docs/modules.md for how a
     post opts in. No post ships one today.

     THE CAP IS THE POINT. An image at its own aspect ratio across the article column is
     easily 400px tall, which puts the title of the piece below the fold on a laptop — the
     reader arrives at a picture and has to scroll to find out what they are reading. The
     height is capped instead and the image crops to fill, so the header is a band across
     the top of the column whatever proportions the file happens to have. 15rem, and NOT a
     viewport fraction: the 404 page's own note records what a `vh` cap cost when it was
     used to guess at leftover space, and test/tokens.test.mjs holds the whole stylesheet
     to `100vh`/`100svh` on the body and nothing else. 240px leaves the title of the piece
     on screen at 720 tall, which is the number that matters here.

     The width/height attributes on the tag still reserve the box, so nothing moves under
     the reader while it loads, and the corner is the card's — this is the only picture in
     an article and it should read as the same family of object. */
  .post-image {
    inline-size: 100%;
    max-block-size: 15rem;
    object-fit: cover;
    border-radius: var(--radius-card);
  }

  .prose__meta {
    color: var(--muted-foreground);
    font-size: var(--step--1);
    font-variant-numeric: tabular-nums;
  }

  .post-list {
    list-style: none;
    padding: 0;
  }

  /* THE TITLE IS THE ENTRY. At body size and body weight it read as the least important
     line in its own list item — a link, a date and a sentence, all identical in colour
     and weight, with nothing to scan by.

     One rung up the type scale and one step up the weight ramp, and no further: the user
     asked for "a bit larger (not too much)", `--step-1` is the smallest increase the
     scale can express, and `--weight-display` is the top of a deliberately narrow ramp
     (400 / 500 / 650) that exists because a system stack synthesises anything else into
     something that looks like a rendering fault. Tracking comes with the size, exactly as
     it does on a heading — the display axis sets tight. */
  .post-list a {
    font-size: var(--step-1);
    font-weight: var(--weight-display);
    letter-spacing: var(--track-display);
    line-height: 1.25;
  }

  .post-list > li + li {
    margin-block-start: var(--space-md);
    border-block-start: 1px solid var(--border);
    padding-block-start: var(--space-md);
  }

  .faq {
    border-block-end: 1px solid var(--border);
    padding-block: var(--space-md);
  }

  .faq summary {
    cursor: pointer;
    font-weight: var(--weight-ui);
  }

  .faq p {
    margin-block-start: var(--space-sm);
    color: var(--muted-foreground);
    max-width: var(--measure);
  }

  /* THE CONTACT FORM — the site's only one, and the only place these classes are used.

     It adds five classes and no tokens. Every colour it paints is a token the contrast
     sweep in test/tokens.test.mjs already visits, which is the whole reason it reaches
     for `--control-border` rather than `--border`: a text input's rim is what says
     "control" (WCAG 1.4.11's 3:1), exactly as `.button--ghost`'s is, and `--border` is
     the decorative hairline with no floor to meet. Measured by that sweep on the muted
     band this form sits on: the rim 4.35:1, the label 15.39:1, the hint 6.92:1.

     `.field` is a grid rather than a `<p>` with a `<br>` so the label and its control
     are one unit at any width, and the label is a REAL `<label for>` rather than a
     placeholder — a placeholder disappears the moment someone types, which is when they
     most need to know what they were asked. */
  .form {
    display: grid;
    gap: var(--space-md);
  }

  .field {
    display: grid;
    gap: var(--space-xs);
  }

  .field label {
    font-size: var(--step--1);
    font-weight: var(--weight-ui);
  }

  .field input,
  .field textarea {
    inline-size: 100%;
    padding: 0.7rem 0.85rem;
    background: var(--surface);
    color: var(--foreground);
    border: 1px solid var(--control-border);
    border-radius: var(--radius-control);
    transition: border-color 120ms ease;
  }

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

  .field textarea {
    min-block-size: 9rem;
    line-height: 1.6;
    resize: vertical;
  }

  /* THE HONEYPOT, HIDDEN THE ONLY WAY THAT HIDES IT FROM EVERYONE. `display: none` takes
     it out of the accessibility tree, out of the tab order and off the screen in one
     declaration. A field hidden with `.visually-hidden` is still announced and still
     focusable, which turns a trap for bots into a trap for screen-reader users. It is
     still submitted — the browser posts a hidden field like any other. */
  .honeypot {
    display: none;
  }

  /* THE PHI NOTICE, AND THE PANELS THAT REPORT WHAT HAPPENED.

     One treatment, because they are one thing: a panel that has to be read. It is set on
     the card surface with the brand's own rule down its leading edge — PROMINENT WITHOUT
     BEING AN ERROR, which rules out the amber. Amber is this site's aging-and-urgency
     colour, and a red-adjacent panel above a textarea reads as "you have done something
     wrong" before the visitor has typed anything.

     Nothing here relies on colour to carry meaning (WCAG 1.4.1): the notice opens with
     what not to send, and each outcome panel opens with what happened, both in bold text
     rather than in a hue. */
  .notice,
  .outcome {
    background: var(--surface);
    border: 1px solid var(--border);
    border-inline-start: 3px solid var(--brand-navy);
    border-radius: var(--radius-card);
    padding: var(--space-md);
  }

  /* One rung up from the form's own type, because it is the most important text on the
     page and it is competing with four labelled inputs. */
  .notice p {
    font-size: var(--step-0);
  }

  /* REVEALED BY `:target`, WHICH IS WHAT LETS A FORM WITH NO SCRIPT SAY WHAT HAPPENED.
     The function answers a browser with `303 /contact#message-sent` (or `-problem`,
     `-throttled`, `-failed`), the fragment selects one of these, and the browser moves
     focus to it because the markup gives it `tabindex="-1"`. No script, no second
     bundle, and nothing to reveal on a page nobody was redirected to. */
  .outcome {
    display: none;

    /* THE STICKY HEADER WOULD OTHERWISE SIT ON TOP OF IT. A fragment navigation aligns
       the target with the top of the viewport, which is where `.site-header` already is
       — measured at 1280x900, the confirmation landed entirely underneath it and the
       page read as though nothing had happened. The header is a 2.25rem wordmark plus
       `--space-sm` of padding either side and a rule, so 5.5rem clears it with the
       panel's own corner visible under it at every width the header keeps that shape. */
    scroll-margin-block-start: 5.5rem;
  }

  .outcome:target {
    display: block;
  }

  .form-note {
    font-size: var(--step--1);
    color: var(--muted-foreground);
  }

  /* No margin above it: every section already carries `--space-xl` of block padding,
     and stacking a margin on top of that opened a band of dead canvas taller than the
     footer itself. */
  /* THE FOOTER IS A LIGHT FIELD — the product's own sidebar surface,
     `--sidebar: oklch(0.922 0.013 255.508)` = #e0e6ee, at the user's request.

     It used to share the navy block, so every token in it had to be re-derived rather
     than deleted. Each foreground the footer actually renders, measured against #e0e6ee:

       .site-footer__brand      --brand-navy      14.20:1   pass
       .site-footer__list a     --foreground      16.05:1   pass
       .site-footer__legal      --muted-foreground 6.04:1   pass  (slate-500 was 3.79 —
                                                                   a fail, closed in the
                                                                   previous round)
       any .button--ghost       --control-border   3.79:1   pass (3:1 boundary floor)
       focus ring               --brand-navy      14.20:1   pass

     So four of the six inherit safely from `:root` and are listed here to record that
     they were measured, not assumed. TWO CANNOT INHERIT and are re-pointed below:
     turquoise is 1.88:1 on this surface and amber is 4.00:1, both failures. Neither
     component belongs in a footer, so rather than inventing a second amber they degrade
     to the brand navy — a component misplaced here renders wrong-looking rather than
     illegible, which is the failure mode to prefer.

     The boundary comes from the app too. The footer surface is the product's
     `--sidebar`, so its rule is the product's `--sidebar-border` — slate-300, 1.36:1
     against the footer. An earlier round used slate-400 here on the argument that the
     surface step from the canvas is only 1.20:1 and the page would otherwise read as one
     flat sheet; that rule is the bottom edge of every page on the site, and at slate-400
     it read as a line drawn under the content rather than as where the chrome begins. */
  .site-footer {
    --border: oklch(0.869 0.02 252.894);
    --aging-fg: var(--brand-navy);
    --stat-accent: var(--brand-navy);

    /* THE FOOTER IS CHROME, AND CHROME DOES NOT GET A CONTENT SECTION'S RHYTHM.

       It was on `--space-lg` with a `--space-md` inner gap, which is 181px at 320 wide —
       a third of a 568-tall phone, on a page whose whole job is to say one short thing.
       The error page was the symptom and the footer was the cause; the error block was
       already as small as it should be, so this is where the room comes from.

       Two changes, and both are the round-3 phrasing argument applied to the chrome
       rather than a small-screen special case:

       - A STEP OF ITS OWN, with a lower FLOOR than a section's and the same ceiling.
         `--space-lg` bottoms out at 2rem, which is a content-sized breath to spend on a
         page's last 20 vertical pixels of usefulness. At 1280 this resolves to the same
         3rem `--space-lg` does, so the desktop footer is unchanged; at 320 it is 1.5rem.
       - THE THREE LINES ARE ONE GROUP — a wordmark, its links and its legal line are the
         same thought, not three ideas — so they take `--space-sm`, the step that binds,
         not `--space-md`, the step that separates. That is the same rule `.stack` follows.

       Result at 320: 181px -> 144px, and /404.html fits a 568-tall viewport. */
    --footer-step: clamp(1.5rem, 1rem + 2.5vw, 3rem);

    background: oklch(0.922 0.013 255.508);
    border-block-start: 1px solid var(--border);
    padding-block: var(--footer-step);
  }

  .site-footer__inner {
    display: grid;
    gap: var(--space-sm);
  }

  .site-footer__brand {
    color: var(--heading);
    font-weight: var(--weight-display);
    letter-spacing: var(--track-display);
  }

  .site-footer__list {
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-md);
    list-style: none;
    padding: 0;
    font-size: var(--step--1);
  }

  .site-footer__legal {
    font-size: var(--step--1);
    font-variant-numeric: tabular-nums;
    color: var(--muted-foreground);
  }
}

@layer utilities {
  .visually-hidden {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }

  /* A child of `.container`, never the container itself — see `.prose`. */
  .measure {
    max-width: var(--measure);
  }
}
