/* bright-links Public tier — all of it, in one file, because the CSP
 * (Security/SecurityHeaders.cs) is `style-src 'self'` with no 'unsafe-inline' and
 * `img-src 'self'`: no <style>, no style="", no @font-face from a CDN, no data: URI.
 *
 * Palette, scale and ease are ported from bright-workers-frontend
 * (_variables.scss, its index.html splash, ForbiddenPage.scss, OrgSelectMorph.tsx)
 * so the two products stay diffable by eye.
 */

/* --- Tokens ---------------------------------------------------------------- */
:root {
    /* Themes the caret, scrollbars and native controls with the scheme resolved
       at the bottom of this file. */
    color-scheme: light dark;

    --bl-navy: #112c3f;
    --bl-navy-deep: #081b27;
    --bl-navy-surface: #0f2b3d;

    --bl-brand-1: #fdd21d;
    --bl-brand-2: #e13575;
    --bl-brand-3: #7d43b8;
    --bl-brand-4: #03c5d6;
    --bl-brand-gradient: linear-gradient(
        135deg,
        var(--bl-brand-1) 0%,
        var(--bl-brand-2) 36%,
        var(--bl-brand-3) 69%,
        var(--bl-brand-4) 100%
    );

    --bl-grey-01: #f9fafb;
    --bl-grey-02: #f3f4f6;
    --bl-grey-03: #e5e7eb;
    --bl-grey-04: #d1d5db;
    --bl-grey-05: #9ca3af;
    --bl-grey-06: #6b7280;
    --bl-grey-07: #4b5563;
    --bl-grey-10: #111827;

    /* Only the status tints that clear AA on the surface they sit on; the mid
       tones of each ramp read as icon fills and fail as body text. */
    --bl-orange-tint: #fff4e7;
    --bl-orange-ink: #c65300;
    --bl-orange-ink-dark: #ffd39f;
    --bl-red-tint: #ffebe9;
    --bl-red-ink: #b2000b;
    --bl-red-ink-dark: #ff7485;

    /* Semantic layer — the dark block at the bottom reassigns only these. */
    --bl-backdrop: var(--bl-navy);
    --bl-surface: #ffffff;
    --bl-surface-sunken: var(--bl-grey-02);
    --bl-text: var(--bl-grey-10);
    --bl-text-muted: var(--bl-grey-07);
    --bl-text-faint: var(--bl-grey-06);
    --bl-border: var(--bl-grey-03);
    /* grey-06, not the grey-04 this started as: an input's visible boundary is a
       user interface component, so SC 1.4.11 wants 3:1 against the card. grey-04
       measures 1.47:1 on white; this is 4.83:1. */
    --bl-border-strong: var(--bl-grey-06);
    --bl-input-bg: #ffffff;
    --bl-action-bg: var(--bl-navy);
    --bl-action-ink: #ffffff;
    /* White on the raw yellow stop is 1.6:1, so the stops have to darken for the label to
       survive the sweep. Mixing toward black takes the chroma with it - the yellow lands a
       dull olive - which is what the @supports block below fixes; this is the fallback.
       Left-to-right, not 135deg, so it reads as a bar rather than a surface. */
    --bl-progress-fill: linear-gradient(
        90deg,
        color-mix(in srgb, var(--bl-brand-1) 55%, #000000) 0%,
        color-mix(in srgb, var(--bl-brand-2) 55%, #000000) 36%,
        color-mix(in srgb, var(--bl-brand-3) 55%, #000000) 69%,
        color-mix(in srgb, var(--bl-brand-4) 55%, #000000) 100%
    );
    --bl-invalid-ink: var(--bl-red-ink);
    --bl-invalid-tint: var(--bl-red-tint);
    --bl-tone-neutral-tint: var(--bl-grey-02);
    --bl-tone-neutral-ink: var(--bl-grey-07);
    --bl-tone-waiting-tint: var(--bl-orange-tint);
    --bl-tone-waiting-ink: var(--bl-orange-ink);
    --bl-shadow: 0 18px 40px -24px rgba(8, 27, 39, 0.55), 0 2px 6px -2px rgba(8, 27, 39, 0.14);

    /* cyan-600, the same $color-primary both repos ring focus with. */
    --bl-focus: #0891b2;
    /* --bl-text, because the palette guarantees that reads against the surface, so this
       clears 3:1 on the resting border under any practice colours. A token rather than a
       dark-scheme override, so nothing can out-order the invalid state's red border. */
    --bl-focus-border: var(--bl-text);

    --bl-space-1: 0.25rem;
    --bl-space-2: 0.5rem;
    --bl-space-3: 0.75rem;
    --bl-space-4: 1rem;
    --bl-space-5: 1.25rem;
    --bl-space-6: 1.5rem;
    --bl-space-8: 2rem;
    --bl-space-10: 2.5rem;
    --bl-radius-md: 0.625rem;
    --bl-radius-lg: 1rem;
    --bl-radius-full: 9999px;

    /* OrgSelectMorph's EASE. */
    --bl-ease: cubic-bezier(0.22, 1, 0.36, 1);
    /* easeOutCubic, for the content settle only. Measured against --bl-ease over the
       same 480ms: 90% of the movement lands at 259ms rather than 180ms, so the rise
       is actually perceptible instead of being over in a couple of frames. */
    --bl-ease-settle: cubic-bezier(0.33, 1, 0.68, 1);
    /* easeOutBack: y passes 1 and comes back, so the card overshoots its size and settles
       into it. One overshoot, not a decaying bounce - that needs keyframes, and on a page
       collecting a National Insurance number one is the right amount of playful. */
    --bl-ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);

    --bl-font: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
    --bl-font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
    --bl-gutter: var(--bl-space-6);
}

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

/* Durations go to ~0 rather than `none` so nothing depends on the animation
   never having run. `animation-delay` is pinned too — every animation here uses
   fill-mode `both`, so a surviving delay would hold an element invisible at its
   `from` state even with motion off. */
@media (prefers-reduced-motion: reduce) {
    *,
    *::before,
    *::after {
        animation-duration: 0.01ms !important;
        animation-delay: 0.01ms !important;
        animation-iteration-count: 1 !important;
        transition-duration: 0.01ms !important;
        scroll-behavior: auto !important;
    }
}

html {
    /* iOS inflates text in landscape otherwise. */
    -webkit-text-size-adjust: 100%;
}

/* --- Page shell ---------------------------------------------------------- */
body {
    min-height: 100vh;
    /* dvh tracks the collapsing toolbar in the WhatsApp in-app webview. */
    min-height: 100dvh;
    /* The card's spring overshoots its width by ~3%, and on a phone the card is already
       full-bleed - measured 6px of horizontal scroll at 390px for about 140ms without this.
       clip, not hidden: hidden would make the other axis compute to auto and turn the body
       into a scroll container, and an engine that doesn't know clip simply doesn't clip. */
    overflow-x: clip;
    /* Row flex, so tall content grows the container instead of clipping the top
       of the card the way column centring does. */
    display: flex;
    align-items: center;
    justify-content: center;
    padding: var(--bl-gutter);
    /* Flat. The two brand-tinted radial washes that were here are gone for the same
       reason as the card's glow: they are not something a practice can be handed
       control of, and one backdrop colour is. */
    background-color: var(--bl-backdrop);
    color: var(--bl-text);
    font-family: var(--bl-font);
    font-size: 1rem;
    line-height: 1.5;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
}

/* --- Card ---------------------------------------------------------------- */
/* Three layers, because one negative-z pseudo doesn't work here: the transform below makes
   .card a stacking context, and inside one a negative-z child paints ABOVE its own
   background - the gradient covered the whole card rather than its edge. So ::before is the
   gradient, ::after insets the surface over it, and the content is lifted above both. */
.card {
    position: relative;
    width: 100%;
    max-width: 30rem;
    display: flex;
    flex-direction: column;
    gap: var(--bl-space-6);
    padding: var(--bl-space-8) var(--bl-space-8) var(--bl-space-10);
    border-radius: var(--bl-radius-lg);
    /* One plain shadow, not the four-layer brand glow this replaced: a glow made of
       four gradient stops is not something a practice can reasonably be handed control
       of, whereas a single shadow colour is - which is what the palette exposes. */
    box-shadow: var(--bl-shadow);
    /* The form itself grows, rather than something growing over it. */
    animation: bl-card-grow 400ms var(--bl-ease-spring) both;
}

/* OrgSelectMorph's .bwm-panel, drawn as the whole box; the surface below insets over
   it and leaves a 4px edge showing. */
.card::before {
    content: "";
    position: absolute;
    inset: 0;
    border-radius: inherit;
    background-image: var(--bl-brand-gradient);
}

/* The interior, OrgSelectMorph's .bwm-navy inset a little further. Opaque from the first
   frame: the card is recognisably itself at two thirds, so the gradient is only ever the rim
   rather than a phase to pass through. */
.card::after {
    content: "";
    position: absolute;
    inset: 4px;
    border-radius: inherit;
    background-color: var(--bl-surface);
}

/* Thank-you and already-submitted-by-you, the two positive tones. */
.card:has(.state--positive) {
    animation-name: bl-card-rise;
}

/* Positioned, so the content layer paints above both pseudos rather than under them. */
.card > * {
    position: relative;
    z-index: 1;
}

/* The two block wrappers are excluded and their rows named instead: animating both would
   compound a tall block's offset with each row's own, and `.form > *` would catch the
   antiforgery input. Chained :not() rather than :not(.form, .outcome), since one rejected
   selector in a list drops the whole rule.

   160ms in, 240ms long, so the rows land exactly as the card finishes growing at 400ms. */
.card > *:not(.form):not(.outcome),
.field,
.action,
.outcome > * {
    animation: bl-row-rise 240ms var(--bl-ease-settle) 160ms both;
}

/* Except on a state page, where the header is the row that was already on screen - the view
   transition carries it across unchanged, and re-entering it would animate something the
   reader never saw leave. Same specificity as the rule above, so it wins on order. */
.card:has(.outcome) > .attribution {
    animation: none;
}

/* Three fields or fewer cascade 80ms apart, the button falling into the sequence by position
   rather than needing rules of its own. Past three a cascade reads as lag, so they arrive
   together - which is also what an engine without :has() does, dropping these rules whole.
   :nth-of-type counts the field divs, so the button and the antiforgery input can't be
   mistaken for fields. */
.form:not(:has(.field:nth-of-type(4))) > *:nth-child(2) {
    animation-delay: 240ms;
}

.form:not(:has(.field:nth-of-type(4))) > *:nth-child(3) {
    animation-delay: 320ms;
}

.form:not(:has(.field:nth-of-type(4))) > *:nth-child(4) {
    animation-delay: 400ms;
}

/* The counterpart to .form: the region a navigation replaces, in the same place under the
   same header. Centred within itself rather than by the card, which would shrink-wrap the
   header out of its full-width row and drop the code chip in beside the name. */
.outcome {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: var(--bl-space-4);
    text-align: center;
}

.card__heading {
    font-size: 1.5rem;
    font-weight: 600;
    line-height: 1.25;
    letter-spacing: -0.01em;
    color: var(--bl-text);
}

.card__body {
    max-width: 26rem;
    color: var(--bl-text-muted);
    line-height: 1.6;
}

/* Phones, not tablets: 640px, matching ForbiddenPage's $breakpoint-sm and
   OrgSelectMorph's MOBILE_MAX. `align-items: stretch` fills the screen; the
   insets are real numbers because the layout sets viewport-fit=cover. */
@media (max-width: 39.9375rem) {
    body {
        padding: 0;
        align-items: stretch;
    }

    /* Content centred against the viewport, as it already is on desktop where the card is
       shrink-to-fit. Safe with the tallest form: the card grows past the viewport rather than
       centring inside it, so nothing is pushed above the scroll origin. */
    .card {
        /* Named, because the header band below has to subtract exactly this. */
        --bl-pad-top: max(var(--bl-space-8), calc(env(safe-area-inset-top) + var(--bl-space-6)));

        max-width: none;
        border-radius: 0;
        box-shadow: none;
        padding:
            var(--bl-pad-top)
            max(var(--bl-space-6), env(safe-area-inset-right))
            max(var(--bl-space-10), calc(env(safe-area-inset-bottom) + var(--bl-space-6)))
            max(var(--bl-space-6), env(safe-area-inset-left));
        /* The spring overshoot reads as a bounce against the screen's own edges once the card
           is full-bleed - there's no surrounding chrome for it to spring past. Settle, not
           spring: same grow, no overshoot. Longhand override, not a new `animation` shorthand -
           this must not reset the name/duration/fill-mode the base rule already set. */
        animation-timing-function: var(--bl-ease-settle);
    }

    /* A minimum on the header rather than a fixed gap, which is what makes the third exact: a
       name wrapping to three lines eats into the gap instead of pushing the content down. dvh
       to track the collapsing toolbar, vh first for engines that don't know it.

       Every page, not just the outcomes, so the header doesn't move across a navigation. The
       cost is a long form starting a third down and scrolling that much sooner. */
    .card > .attribution {
        min-height: calc(33vh - var(--bl-pad-top) - var(--bl-space-6));
        min-height: calc(33dvh - var(--bl-pad-top) - var(--bl-space-6));
    }

    /* Past three fields the page scrolls anyway, so a third of the screen spent on air is a
       third of the screen the reader has to swipe past. The band goes and the row gap does the
       spacing. Same quantity query as the stagger, and the same graceful failure: without
       :has() the band stays, which is only ever too much space rather than a broken layout. */
    .card:has(.field:nth-of-type(4)) > .attribution {
        min-height: 0;
    }

    /* Thumb reach, with the space opening above the button rather than under it. Flow, not
       position: fixed - the card's animation leaves it with a transform, which would make a
       fixed child position against the card instead of the viewport. */
    .form {
        flex: 1;
    }

    .action {
        margin-top: auto;
    }

}

/* --- Scroll affordance --------------------------------------------------- */
/* A soft fade at whichever edge still has content past it, the opacity tracking scroll
   position with no script.

   Both start at opacity 0, which is what makes "only when it scrolls" work: a page with
   nothing to scroll has an inactive timeline, so the animation never applies and the base
   style stands. An engine without scroll() gets the same nothing.

   Fixed so they don't join the body's flex row, and pointer-events: none so the bottom one
   can't swallow a tap on the button beneath it. */
@supports (animation-timeline: scroll()) {
    @media (max-width: 39.9375rem) {
        body::before,
        body::after {
            content: "";
            position: fixed;
            left: 0;
            right: 0;
            z-index: 2;
            height: 1.5rem;
            opacity: 0;
            pointer-events: none;
            /* The primary colour, which is the brand gradient unbranded and a flat wash of the
               practice's own accent once a palette arrives - all four stops are that one colour.
               The gradient can't also carry the fade, so a mask does it: the paint is the brand,
               the ramp is the mask. */
            background-image: var(--bl-brand-gradient);

            /* Two properties, because the two have to multiply rather than override: scroll
               drives `opacity`, page load drives `filter: opacity()` on the content rows' own
               160ms/240ms. Both on `opacity` and the later animation would simply win. */
            animation-timeline: scroll(root block), auto;
            animation-duration: auto, 240ms;
            animation-delay: 0s, 160ms;
            animation-timing-function: linear, var(--bl-ease-settle);
            animation-fill-mode: both, both;
        }

        /* A shallow dome rather than a band: the mask is an ellipse wider than the screen and
           only as tall as the element, anchored to the edge, so it reaches full height at the
           centre and thins out to nothing at the corners. The 3.5rem in the ranges is scroll
           distance, not size - a 1.5rem window would snap. */
        body::before {
            top: 0;
            mask-image: radial-gradient(150% 1.5rem at 50% 0%, #000000, transparent);
            animation-name: bl-scroll-hint-in, bl-hint-appear;
            animation-range: 0 3.5rem, normal;
        }

        body::after {
            bottom: 0;
            mask-image: radial-gradient(150% 1.5rem at 50% 100%, #000000, transparent);
            animation-name: bl-scroll-hint-out, bl-hint-appear;
            animation-range: calc(100% - 3.5rem) 100%, normal;
        }
    }
}

/* The load-in half of the hint. filter rather than opacity so it multiplies with the
   scroll-driven opacity instead of replacing it; no `to`, so it resolves against the
   element's own `filter: none`, which is opacity(1). */
@keyframes bl-hint-appear {
    from {
        filter: opacity(0);
    }
}

@keyframes bl-scroll-hint-in {
    from {
        opacity: 0;
    }

    to {
        opacity: 1;
    }
}

@keyframes bl-scroll-hint-out {
    from {
        opacity: 1;
    }

    to {
        opacity: 0;
    }
}

/* --- Attribution --------------------------------------------------------- */
/* The practice name is the whole of branding in v1, so it is set as a quiet
   prefix plus the name at heading weight rather than one flat sentence. The mark
   is OrgSelectMorph's .bwm-chip: 34px, 9px radius, brand gradient. */
.attribution {
    display: flex;
    /* flex-start, not center: a 120-char name wraps to three lines, and centring
       would drop the mark alongside the second one. */
    align-items: flex-start;
    gap: var(--bl-space-3);
    text-align: left;
}

.attribution__mark {
    flex: 0 0 auto;
    display: flex;
    align-items: center;
    justify-content: center;
    /* Lands the mark's centre between the prefix and the name's first line,
       matching where centring put it in the one- and two-line cases. */
    margin-top: 0.1875rem;
    width: 2.125rem;
    height: 2.125rem;
    border-radius: 9px;
    background-image: var(--bl-brand-gradient);
    color: #ffffff;
}

.attribution__mark svg {
    width: 1.125rem;
    height: 1.125rem;
}

.attribution__line {
    display: flex;
    flex-direction: column;
    /* Lets a long name wrap instead of widening the flex row. */
    min-width: 0;
    font-size: inherit;
    font-weight: inherit;
}

/* text-muted, not text-faint: at 12px uppercase, grey-06 is only 4.83:1 on white
   and drops below AA on any tinted surface. The size and weight carry the
   hierarchy here, so the colour doesn't need to. Same for .code__label. */
.attribution__prefix {
    font-size: 0.625rem;
    font-weight: 500;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--bl-text-muted);
}

.attribution__name {
    font-size: 1.25rem;
    font-weight: 600;
    line-height: 1.3;
    color: var(--bl-text);
    /* Caller-supplied, up to 120 chars (docs/api-contract.md), and it may well be
       one unbroken word. break-word first for Safari < 15.4. */
    overflow-wrap: break-word;
    overflow-wrap: anywhere;
}

/* --- Verification code --------------------------------------------------- */
/* Its whole job is to be compared by eye with the code in the WhatsApp message - hence
   monospace, wide tracking, and the label demoted to a tooltip (docs/open-decisions.md §5:
   "compared by eye, on a phone, by someone who is already slightly suspicious").

   One unbroken text node rather than the two groups of three that decision names: splitting
   it would put markup between the characters, and FormTests asserts the code renders
   contiguously - the guarantee that the page shows the string the message did. */
.code {
    position: relative;
    flex: 0 0 auto;
    display: inline-flex;
    padding: var(--bl-space-1) var(--bl-space-2);
    background-color: var(--bl-surface-sunken);
    border: 1px solid var(--bl-border);
    border-radius: var(--bl-radius-md);
    /* A question mark: the chip does nothing when clicked, it only explains itself. */
    cursor: help;
}

/* Pushed to the right of the header row. */
.attribution .code {
    margin-inline-start: auto;
}

/* Never display:none, so a screen reader always has the label; opacity alone hides it
   from sighted users until hover. CSS can't dismiss it on Escape, which SC 1.4.13 asks
   for - the reason the text is in the DOM rather than in a CSS `content` string is that
   AT gets it regardless of the tooltip. */
/* Above the chip. A small gap rather than a generous one because the chip sits 2rem
   from the card's top edge and on the phone layout that edge is the viewport's - any
   more and the tooltip would be off-screen. */
.code__label {
    position: absolute;
    bottom: calc(100% + var(--bl-space-1));
    right: 0;
    z-index: 2;
    width: max-content;
    max-width: 12rem;
    padding: var(--bl-space-1) var(--bl-space-2);
    background-color: var(--bl-text);
    color: var(--bl-surface);
    border-radius: var(--bl-radius-md);
    font-size: 0.75rem;
    font-weight: 500;
    line-height: 1.35;
    opacity: 0;
    pointer-events: none;
    transition: opacity 120ms ease;
}

.code:hover .code__label,
.code:focus-within .code__label {
    opacity: 1;
}

.code__value {
    font-family: var(--bl-font-mono);
    font-size: 0.75rem;
    font-weight: 600;
    line-height: 1.4;
    letter-spacing: 0.18em;
    font-variant-numeric: tabular-nums;
    color: var(--bl-text);
    /* Cancels the tracking that lands after the last character, so the code sits
       optically centred in the chip rather than pushed left. */
    margin-inline-end: -0.18em;
}

/* --- State icons --------------------------------------------------------- */
/* Three tones because the terminal pages are not all the same news. The happy one
   takes the brand gradient; no glow, for the same reason the card lost one - a
   practice supplies a colour, not four bleeding stops. */
.state {
    flex: 0 0 auto;
    display: flex;
    align-items: center;
    justify-content: center;
    width: 4rem;
    height: 4rem;
    border-radius: var(--bl-radius-full);
}

.state svg {
    width: 2rem;
    height: 2rem;
}

.state--positive {
    background-image: var(--bl-brand-gradient);
    color: #ffffff;
}

.state--waiting {
    background-color: var(--bl-tone-waiting-tint);
    color: var(--bl-tone-waiting-ink);
}

.state--closed {
    background-color: var(--bl-tone-neutral-tint);
    color: var(--bl-tone-neutral-ink);
}

/* --- Form ---------------------------------------------------------------- */
.form {
    display: flex;
    flex-direction: column;
    gap: var(--bl-space-5);
}

.field {
    display: flex;
    flex-direction: column;
    gap: var(--bl-space-2);
}

.field__label {
    font-size: 0.875rem;
    font-weight: 600;
    color: var(--bl-text);
}

.field__input {
    width: 100%;
    min-height: 3rem;
    padding: var(--bl-space-3) var(--bl-space-4);
    font-family: inherit;
    /* 1rem is a floor, not a preference: iOS zooms the viewport on focus below
       16px, scrolling the practice name out of view as the client starts typing. */
    font-size: 1rem;
    line-height: 1.5;
    color: var(--bl-text);
    background-color: var(--bl-input-bg);
    border: 1px solid var(--bl-border-strong);
    border-radius: var(--bl-radius-md);
    /* Drops Safari's own inner shadow and radius. */
    -webkit-appearance: none;
    appearance: none;
    transition: border-color 140ms ease, box-shadow 140ms ease;
}

.field__input::placeholder {
    color: var(--bl-text-faint);
}

/* text-muted resolves darker than the resting border in light and lighter in
   dark, so hover reads as "more prominent" in both schemes. */
.field__input:hover {
    border-color: var(--bl-text-muted);
}

/* No ring on the fields. `outline: none` is explicit because appearance: none does NOT
   suppress the UA ring - Chromium draws its own outline: auto, so dropping the declaration
   swapped ours for the browser's rather than removing one. The border is therefore the whole
   indicator, which is why its focused colour is chosen for contrast rather than for looks.
   :focus, not :focus-visible: equivalent for text inputs per spec, and an engine that
   doesn't know the newer pseudo-class must not discard the only indicator there is. */
.field__input:focus {
    outline: none;
    border-color: var(--bl-focus-border);
}

/* Masking without JS means type="password" (docs/threat-model.md). Tracking the
   dots apart makes the character count countable, which is the only feedback a
   masked field can give. */
.field__input--masked {
    letter-spacing: 0.18em;
    font-family: var(--bl-font-mono);
}

/* The server-driven half, and it must keep its own rule: a selector list is not forgiving,
   so pairing it with :user-invalid below would mean an engine that doesn't know that
   pseudo-class (iOS < 16.5, Android WebView < 119) throws both away - and a rejected
   National Insurance number would render identically to an untouched field. */
.field__input--invalid {
    /* An inset ring rather than border-width: 2px, so the state reads as heavier
       without changing the box - a 2px border grows the field by 2px and shunts
       everything below it down when :user-invalid fires mid-page. Weight, not just
       colour, so the client-side state below doesn't rely on red being visible. */
    box-shadow: inset 0 0 0 1px var(--bl-invalid-ink);
    border-color: var(--bl-invalid-ink);
    background-color: var(--bl-invalid-tint);
}

/* :user-invalid, not :invalid — a required empty field is invalid from first
   paint, so :invalid would redden the form before anyone typed. Unsupported, this
   rule alone drops and client-side feedback waits for the server round trip. */
.field__input:user-invalid {
    box-shadow: inset 0 0 0 1px var(--bl-invalid-ink);
    border-color: var(--bl-invalid-ink);
    background-color: var(--bl-invalid-tint);
}

.field__input--invalid:focus {
    border-color: var(--bl-invalid-ink);
}

/* Chrome autofills despite autocomplete="off". CSS can't stop the offer, but an
   inset shadow is the only way to stop the field turning yellow. */
.field__input:-webkit-autofill,
.field__input:-webkit-autofill:focus {
    -webkit-box-shadow: 0 0 0 100vmax var(--bl-input-bg) inset;
    -webkit-text-fill-color: var(--bl-text);
}

/* Class name is load-bearing: FormTests binds an error to the right field by
   asserting "field-error" appears after that field and not after a clean one, so
   this is rendered only when there is something to say. Glyph included because
   red alone can't carry the meaning. */
.field-error {
    display: flex;
    align-items: flex-start;
    gap: var(--bl-space-2);
    font-size: 1rem;
    font-weight: 500;
    color: var(--bl-invalid-ink);
}

.field-error svg {
    flex: 0 0 auto;
    width: 1.125rem;
    height: 1.125rem;
    margin-top: 0.0625rem;
}

/* --- Submit ------------------------------------------------------------- */
/* Navy, not the brand gradient: white on the gradient's yellow end is ~1.5:1.
   The gradient earns a 4px hairline and a 64px disc; the one control that has to
   be read and pressed gets navy at ~14:1 — the pairing the splash's own recovery
   button already uses. */
.action {
    position: relative;
    /* Keeps the progress fill inside the button's radius. */
    overflow: hidden;
    display: flex;
    align-items: center;
    justify-content: center;
    gap: var(--bl-space-2);
    width: 100%;
    min-height: 3.25rem;
    padding: var(--bl-space-3) var(--bl-space-6);
    font-family: inherit;
    font-size: 1.0625rem;
    font-weight: 600;
    color: var(--bl-action-ink);
    background-color: var(--bl-action-bg);
    border: 0;
    border-radius: var(--bl-radius-md);
    cursor: pointer;
    -webkit-appearance: none;
    appearance: none;
    /* Stops iOS reading a double-tap as a zoom. */
    touch-action: manipulation;
    transition: background-color 160ms ease, transform 140ms ease, box-shadow 140ms ease;
}

/* Takes 20% off the alpha of whatever the button currently is, so it works for any
   practice palette without a second token to keep in step. Guarded so a tap doesn't
   leave hover stuck on after the press. */
@media (hover: hover) {
    .action:hover {
        background-color: color-mix(in srgb, var(--bl-action-bg) 80%, transparent);
    }
}

/* :active spans exactly the hold, so the button stays down with no script and no timer.
   60ms down against the base rule's 140ms up is what reads as struck rather than slid.

   The inset shadow does as much work as the travel: 4px alone reads as sliding down the
   card, and darkening the top edge is what makes it sink into it. Black at low alpha, so it
   deepens any palette rather than tinting it. */
.action:active {
    transform: translateY(4px) scale(0.97);
    box-shadow: inset 0 3px 8px -1px rgba(0, 0, 0, 0.32);
    transition: background-color 160ms ease, transform 60ms ease, box-shadow 60ms ease;
}

/* Two steps, because a button genuinely wants :focus-visible - a pointer user
   shouldn't get a ring on tap. Ring on :focus first so it survives an engine that
   doesn't know :focus-visible (appearance: none removed the native one); the
   second rule then takes it back for pointer focus where the pseudo-class works. */
.action:focus {
    outline: 2px solid var(--bl-focus);
    outline-offset: 2px;
}

.action:focus:not(:focus-visible) {
    outline: none;
}

/* A deliberately fake progress bar. Pure CSS cannot observe a request in flight,
   so this measures nothing: it advances smoothly, then creeps for up to 10s,
   topping out at 99% rather than stopping dead - a genuinely slow response should
   still read as "still working", not "stuck". The navigation is what finishes it,
   which is why a quick response simply cuts it short and a slow one leaves it
   sitting near the end. */
.action::after {
    content: "";
    position: absolute;
    inset: 0;
    z-index: 0;
    transform: scaleX(0);
    transform-origin: left center;
    background-image: var(--bl-progress-fill);
    /* Declared here and paused, not inside a state selector. A paused animation
       keeps the progress it has made; one declared under :active is discarded the
       instant the press ends and snaps back to zero. */
    animation: bl-progress 10000ms linear paused forwards;
}

/* Any validation error resets the bar rather than leaving it part-filled: dropping
   the animation reverts the fill to scaleX(0), and re-adding it starts from zero.
   This is also why repeated presses on an incomplete form can't ratchet it forward
   a step at a time - the form isn't valid, so there is no animation to advance. */
form:invalid .action::after {
    animation-name: none;
}

.action > span {
    position: relative;
    z-index: 1;
    transition: opacity 200ms ease;
}

/* The label leaves as the bar arrives - same three selectors as the bar below, so the two
   are one event and neither can fire without the other. In particular the label must not
   blank on keyboard focus, which is what the :focus-visible rule takes back. */
form:valid .action:focus:not(:active) > span {
    opacity: 0;
}

form:valid .action:focus-visible:not(:active) > span {
    opacity: 1;
}

/* Chromium focuses a submit button on click and holds that focus for the whole
   in-flight window - measured against a 2.5s response - which is what drives the
   bar. `form:valid` so a submit the browser itself blocks never starts it. */
/* :not(:active) is what makes this press-up only. Chromium focuses a submit button
   on mousedown, not on release (measured: the fill reached 0.565 mid-hold before
   this), so :focus alone advanced the bar while the button was held down. */
form:valid .action:focus:not(:active)::after {
    animation-play-state: running;
}

/* Keyboard focus lands before the press, so it must not start the bar. Matched at
   the same specificity as the rule above and placed after it, so it wins on order;
   its own rule rather than a selector list, so an engine that doesn't know
   :focus-visible drops only this one and the bar still works. */
form:valid .action:focus-visible:not(:active)::after {
    animation-play-state: paused;
}

/* Nothing keys the bar off :active, unlike the transform above: :active runs during the
   hold, which let the bar creep forward on a long press and ratchet along on repeated ones.
   The cost of using focus instead is measured - WebKit doesn't focus a button on click or
   tap, so Safari and iOS show no bar and fall back to the webview's own indicator. */

/* Fields lock while the button holds focus (no :has() support = no lock, same as no CSS).
   Locks the whole .field, not just the input: a <label for> forwards focus to its target
   regardless of the input's own pointer-events, so locking only the input left a way around it.

   Best-effort, not a guarantee: any real click still blurs the button, even on a locked field -
   no CSS way to stop that, and script-src 'none' rules out JS on mousedown. Safe rather than
   cosmetic-only because SubmitEndpoints is idempotent on the link. */
form:valid:has(.action:focus:not(:active)) .field {
    pointer-events: none;
    cursor: not-allowed;
    opacity: 0.6;
}

/* Keyboard focus lands before the press, so tabbing to the button must not lock the fields -
   same reasoning and same selector shape as the bar's own :focus-visible override below. */
form:valid:has(.action:focus-visible:not(:active)) .field {
    pointer-events: auto;
    cursor: auto;
    opacity: 1;
}

/* Button locks itself too, so a stray second click can't resubmit. Keyboard Enter/Space still
   can - pointer-events doesn't block that, and there's no JS available to add a real disabled. */
form:valid .action:focus:not(:active) {
    pointer-events: none;
    cursor: not-allowed;
}

form:valid .action:focus-visible:not(:active) {
    pointer-events: auto;
    cursor: pointer;
}

/* Instant-on at 90% would just look like a rendering fault, and a label that vanishes with
   no bar behind it would just look broken. */
@media (prefers-reduced-motion: reduce) {
    .action::after {
        display: none;
    }

    form:valid .action:focus:not(:active) > span {
        opacity: 1;
    }
}

/* --- Motion ------------------------------------------------------------- */
/* Only a `from`: the resting transform and surface stay declared on .card, so the
   animation interpolates up to them and whatever the breakpoint says still wins.
   No radius animation now - at two thirds the card is already card-shaped, and a
   large starting radius would read as a pill rather than a tile. */
@keyframes bl-card-grow {
    from {
        opacity: 0;
        transform: scale(0.667);
    }
}

/* The positive outcome arrives from below rather than just growing in place. The spring
   easing carries the rise past 0 and settles back, so it reads as landing. Every other
   state keeps bl-card-grow: only the good news gets to bounce. */
@keyframes bl-card-rise {
    from {
        opacity: 0;
        transform: translateY(2rem) scale(0.667);
    }
}

/* A percentage in translateY resolves against the element's own height, so taller rows
   travel further. The blur needs no `to`: a one-function filter list interpolated against
   the element's own `none` treats none as blur(0). 4px, because more than that on 15px label
   text arrives as a smear rather than a focus. */
@keyframes bl-row-rise {
    from {
        opacity: 0;
        transform: translateY(25%);
        filter: blur(4px);
    }
}

/* Fast then a long slow creep toward 99%, so a slow response never looks stalled or
   finished. Percentages, not absolute time - keep in step with animation-duration above. */
@keyframes bl-progress {
    from {
        transform: scaleX(0);
    }

    4% {
        transform: scaleX(0.4);
    }

    6.5% {
        transform: scaleX(0.6);
    }

    10% {
        transform: scaleX(0.85);
    }

    30% {
        transform: scaleX(0.97);
    }

    to {
        transform: scaleX(0.99);
    }
}

/* --- Between pages ------------------------------------------------------ */
/* Every state is a separate document, so without this the flow is a series of white flashes.
   Pure CSS, which script-src 'none' requires, and an engine without it navigates as before.
   Only fires on a real same-origin link click - an address-bar entry and a scripted
   navigation measurably do not.

   Three names, because a named element is captured on its own and left out of its ancestor's
   snapshot: unnamed, everything inside .card would be one image stretched from the form's
   height to the outcome's. .form and .outcome share a name deliberately - same region, and
   they never coexist in a document, which is what sharing requires. */
@view-transition {
    navigation: auto;
}

.card {
    view-transition-name: bl-card;
}

.attribution {
    view-transition-name: bl-header;
}

.form,
.outcome {
    view-transition-name: bl-body;
}

::view-transition-group(bl-card),
::view-transition-group(bl-header),
::view-transition-group(bl-body) {
    animation-duration: 460ms;
    animation-timing-function: var(--bl-ease);
}

/* Shorter than the 460ms box morph: the content has swapped by the time the box finishes
   settling, rather than two sets of copy overlapping for the whole transition. */
::view-transition-old(bl-body),
::view-transition-new(bl-body) {
    animation-duration: 300ms;
}

::view-transition-old(root),
::view-transition-new(root) {
    animation-duration: 260ms;
}

@media (prefers-reduced-motion: reduce) {
    @view-transition {
        navigation: none;
    }
}

/* --- Dark scheme -------------------------------------------------------- */
/* Reassigns semantic tokens only; delete this block and light is untouched. The
   inverted button is the splash's recovery button (#112c3f on #ffffff). */
@media (prefers-color-scheme: dark) {
    :root {
        --bl-backdrop: var(--bl-navy-deep);
        --bl-surface: var(--bl-navy-surface);
        --bl-surface-sunken: #0b2230;
        --bl-text: var(--bl-grey-01);
        --bl-text-muted: var(--bl-grey-04);
        --bl-text-faint: var(--bl-grey-05);
        --bl-border: #2a4c60;
        /* 3.5:1 on the card — the light-mode grey would sink to ~2:1 and fail
           SC 1.4.11 for an input boundary. */
        --bl-border-strong: #5c8199;
        --bl-input-bg: #0b2230;
        --bl-action-bg: #ffffff;
        --bl-action-ink: var(--bl-navy);
        /* The other direction: the dark-scheme button is white with navy ink, so the fill has
           to go lighter rather than darker. Same fallback caveat as the light scheme - mixing
           toward white is what made this a pastel wash. */
        --bl-progress-fill: linear-gradient(
            90deg,
            color-mix(in srgb, var(--bl-brand-1) 62%, #ffffff) 0%,
            color-mix(in srgb, var(--bl-brand-2) 62%, #ffffff) 36%,
            color-mix(in srgb, var(--bl-brand-3) 62%, #ffffff) 69%,
            color-mix(in srgb, var(--bl-brand-4) 62%, #ffffff) 100%
        );
        --bl-invalid-ink: var(--bl-red-ink-dark);
        --bl-invalid-tint: rgba(255, 116, 133, 0.12);
        --bl-tone-neutral-tint: rgba(249, 250, 251, 0.08);
        --bl-tone-neutral-ink: var(--bl-grey-04);
        --bl-tone-waiting-tint: rgba(255, 211, 159, 0.19);
        --bl-tone-waiting-ink: var(--bl-orange-ink-dark);
        --bl-focus: #67e8f9;
        --bl-shadow: 0 18px 40px -24px rgba(0, 0, 0, 0.8), 0 2px 6px -2px rgba(0, 0, 0, 0.5);
    }

}

/* --- Saturated progress fill -------------------------------------------- */
/* Sets lightness alone and keeps chroma, where mixing toward black or white scales chroma
   down with it - the purple stop lands at 0.11 mixed and keeps its own 0.179 here.

   Both lightness values are the measured floor for the label sweeping over them: 6.54:1 for
   white at 0.45, 4.80:1 for navy at 0.70. @supports rather than a second declaration,
   because an unparseable custom property is only invalid where it's used - the bar would
   disappear rather than fall back to the mix. */
@supports (color: oklch(from #ffffff l c h)) {
    :root {
        --bl-progress-fill: linear-gradient(
            90deg,
            oklch(from var(--bl-brand-1) 0.45 c h) 0%,
            oklch(from var(--bl-brand-2) 0.45 c h) 36%,
            oklch(from var(--bl-brand-3) 0.45 c h) 69%,
            oklch(from var(--bl-brand-4) 0.45 c h) 100%
        );
    }

    @media (prefers-color-scheme: dark) {
        :root {
            --bl-progress-fill: linear-gradient(
                90deg,
                oklch(from var(--bl-brand-1) 0.7 c h) 0%,
                oklch(from var(--bl-brand-2) 0.7 c h) 36%,
                oklch(from var(--bl-brand-3) 0.7 c h) 69%,
                oklch(from var(--bl-brand-4) 0.7 c h) 100%
            );
        }
    }
}

/* --- Forced colours ----------------------------------------------------- */
/* High contrast drops background images, taking the hairline and both gradient
   surfaces with it; borders keep the shapes, glyphs keep the meaning. */
@media (forced-colors: active) {
    .card {
        border: 1px solid CanvasText;
    }

    .attribution__mark,
    .state {
        border: 1px solid CanvasText;
    }

    .action {
        border: 1px solid ButtonText;
    }

    /* The border change is the only focus indicator, and high contrast may flatten it,
       so put a ring back here specifically. */
    .field__input:focus {
        outline: 2px solid Highlight;
        outline-offset: 1px;
    }
}

/* --- Print -------------------------------------------------------------- */
@media print {
    body {
        min-height: 0;
        padding: 0;
        background: #ffffff;
        color: #000000;
    }

    .card {
        max-width: none;
        border: 1px solid #cccccc;
        border-radius: 0;
        box-shadow: none;
        animation: none;
    }

    .card::before,
    .card::after,
    .state {
        animation: none;
    }
}
