/* ════════════════════════════════════════════════════════════════════
   tokens.css — the design contract.
   Every color, size, and effect in the UI resolves to a token defined
   here. Components never hardcode values; they read the contract.

   Layer order for the whole system is declared here (this file is
   concatenated first — see web/routes/assets.rs). Page-level styles in
   {% block head %} are unlayered and therefore always win.

   `islands` holds the Svelte components' scoped styles, appended by
   assets.rs from the Vite build. It sits after `features` so a component
   can override the feature CSS it replaces during the migration, and
   before `utilities` so print rules still win.

   The dark palette lives in _dark-palette.css and is injected by
   assets.rs into both dark selectors — see that file for why.
   ════════════════════════════════════════════════════════════════════ */
@layer tokens, base, layout, components, features, islands, utilities;

@layer tokens {
    :root {
        color-scheme: light dark;

        /* ── Brand ─────────────────────────────────────────────────
           --brand is admin-configurable and is the ONLY brand value
           /assets/theme.css overrides. Everything below derives from it
           with color-mix, so the derivations follow automatically —
           don't restate them in theme.css or the two copies will drift.

           The two exceptions are --brand-ink (the label on a brand fill)
           and --brand-text (the brand used AS ink): picking those needs a
           contrast decision CSS can't yet express, so theme.css computes
           them in Rust from the configured color's luminance. The values
           here are the matching defaults for #3b5bdb. */
        --brand: #3b5bdb;
        --brand-strong: color-mix(in srgb, var(--brand) 84%, black);
        --brand-soft: color-mix(in srgb, var(--brand) 9%, transparent);
        --brand-border: color-mix(in srgb, var(--brand) 28%, transparent);
        --brand-text: var(--brand);
        --brand-ink: #ffffff;
        /* A violet-leaning companion to the brand, derived so an admin brand
           change propagates. Used only for the signature topbar hairline. */
        --accent: color-mix(in oklab, var(--brand) 55%, #8b5cf6);
        /* Quiet texture for the app-level backdrops (.shell and .page):
           hairline 45° stripes in two brand tints over the page
           background. Derived from --brand/--accent, so an admin brand
           change and the dark palette propagate on their own. */
        --bg-stripes: repeating-linear-gradient(
            45deg,
            transparent 0px,
            transparent 10px,
            color-mix(in srgb, var(--brand) 7%, transparent) 10px,
            color-mix(in srgb, var(--brand) 7%, transparent) 11px,
            transparent 11px,
            transparent 20px,
            color-mix(in srgb, var(--accent) 6%, transparent) 20px,
            color-mix(in srgb, var(--accent) 6%, transparent) 21px
        );

        /* ── Neutrals ──────────────────────────────────────────────
           A quiet, slightly cool ramp. Surfaces are layered:
           bg (page) → surface (cards) → surface-2 (insets/wells). */
        --bg: #f6f7f9;
        --surface: #ffffff;
        --surface-2: #eff1f4;
        --ink: #16191f;
        --ink-2: #3f4550;
        --muted: #667085;
        /* One step quieter than --muted, for text that must recede
           without disappearing (muted badges, decorative field icons).
           Derived so it tracks the palette in both themes. */
        --ink-subtle: color-mix(in srgb, var(--muted) 78%, transparent);
        --line: #e4e7ec;
        --line-strong: #d0d5dd;

        /* ── Semantics (paired text/background) ────────────────────── */
        --ok: #067647;
        --ok-bg: #e7f6ef;
        --warn: #93540b;
        --warn-bg: #fdf3e2;
        --danger: #c22219;
        --danger-bg: #fdebe9;
        /* Solid destructive fill. Unlike --danger (which the dark palette
           lightens for use as text), this stays saturated in both themes so
           --danger-ink labels always have contrast on it. */
        --danger-fill: #d92d20;
        --danger-ink: #ffffff;

        /* ── Type scale ────────────────────────────────────────────
           Body is 15px on desktop-class screens; controls stay
           --text-control so iOS Safari never zooms on focus. */
        --font-sans:
            "IBM Plex Sans", -apple-system, BlinkMacSystemFont, "Segoe UI",
            Roboto, "Helvetica Neue", Arial, sans-serif;
        --font-mono:
            ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono",
            monospace;
        --text-xs: 0.75rem; /* 12 — badges, overlines            */
        --text-sm: 0.8125rem; /* 13 — meta, table headers          */
        --text-md: 0.9375rem; /* 15 — body                         */
        --text-lg: 1.0625rem; /* 17 — card titles, emphasized body */
        --text-xl: 1.25rem; /* 20 — section headings             */
        --text-2xl: 1.5rem; /* 24 — page titles                  */
        --text-3xl: 1.875rem; /* 30 — hero numbers                 */
        /* Any control the user can focus. iOS Safari zooms the page when a
           focused field is under 16px, and that zoom unpins position:fixed
           chrome like the bottom tab bar. One token, applied once in
           base.css, replaces what used to be four separate fixes. */
        --text-control: 16px;
        --leading-tight: 1.3;
        --leading-body: 1.55;
        --tracking-tight: -0.014em;
        --tracking-wide: 0.05em;

        /* ── Weight ────────────────────────────────────────────────
           Four steps, no more. The system previously used six values
           (400/500/550/600/650/700) with 550 and 650 doing the same job
           as 500 and 600. */
        --weight-normal: 400;
        --weight-medium: 500;
        --weight-semibold: 600;
        --weight-bold: 700;

        /* ── Spacing scale (4px base) ────────────────────────────────
           -0-5 and -1-5 exist because the scale had nothing below 4px,
           which is why sub-step gaps were being hand-written in rem. */
        --sp-0-5: 0.125rem;
        --sp-1: 0.25rem;
        --sp-1-5: 0.375rem;
        --sp-2: 0.5rem;
        --sp-3: 0.75rem;
        --sp-4: 1rem;
        --sp-5: 1.25rem;
        --sp-6: 1.5rem;
        --sp-8: 2rem;
        --sp-12: 3rem;

        /* ── Shape ─────────────────────────────────────────────────── */
        --r-sm: 8px;
        --r-md: 12px;
        --r-lg: 16px;
        --r-full: 999px;
        /* Anything interactive and box-shaped: buttons, inputs, selects,
           textareas. Buttons used to sit on an orphan 10px that matched no
           token and no input. */
        --r-control: var(--r-sm);

        /* ── Depth — hairline-first; shadows only where things float. */
        --shadow-xs: 0 1px 2px rgba(16, 24, 40, 0.06);
        --shadow-sm:
            0 1px 2px rgba(16, 24, 40, 0.06), 0 1px 3px rgba(16, 24, 40, 0.1);
        --shadow-md:
            0 4px 8px -2px rgba(16, 24, 40, 0.1),
            0 2px 4px -2px rgba(16, 24, 40, 0.06);
        --shadow-lg:
            0 12px 16px -4px rgba(16, 24, 40, 0.12),
            0 4px 6px -2px rgba(16, 24, 40, 0.05);

        /* Backdrop treatment, behind the scrim. */
        --blur-scrim: blur(2px);
        /* Behind a drawer or modal. */
        --scrim: rgba(10, 13, 18, 0.5);

        /* ── Motion ────────────────────────────────────────────────── */
        --ease: cubic-bezier(0.2, 0, 0, 1);
        --t-fast: 120ms var(--ease);
        --t-med: 200ms var(--ease);
        /* Gentle overshoot for enter/press affordances. Subtle by design — a
           ~9% overshoot, not a bounce. Never used for anything that moves
           layout. */
        --ease-spring: cubic-bezier(0.34, 1.3, 0.64, 1);
        --t-slow: 320ms var(--ease);

        /* ── State ─────────────────────────────────────────────────── */
        --opacity-disabled: 0.45;
        --opacity-quiet: 0.65;

        /* ── Focus ring ────────────────────────────────────────────── */
        --ring: 0 0 0 3px color-mix(in srgb, var(--brand) 24%, transparent);
        /* A soft brand halo for buttons/cards that also lift on focus. Pairs
           with, does not replace, --ring (which stays the accessible 3px
           outline). */
        --ring-glow: 0 0 0 3px color-mix(in srgb, var(--brand) 24%, transparent),
            0 2px 10px -2px color-mix(in srgb, var(--brand) 35%, transparent);

        /* ── Z-index registry ──────────────────────────────────────
           Nothing may set a z-index outside this list. */
        --z-topbar: 20;
        --z-tabbar: 25;
        --z-scrim: 35;
        --z-drawer: 40;
        --z-overlay: 100;
        --z-tour: 120;

        /* ── Shell metrics ─────────────────────────────────────────
           Two layouts only: cellular (default) and desktop (≥1024px, the
           single breakpoint — see layout.css). No tablet rail. Media
           queries can't read custom properties, so 1024px is written
           literally there; it appears nowhere else. */
        --topbar-h: 3.5rem;
        --tabbar-h: 3.75rem;
        --sidebar-w: 264px;
        --page-pad: 1rem;
        --card-pad-y: 1.25rem;
        --card-pad-x: 1.25rem;
        --content-max: 72rem;
    }

    /* The dark palette is injected here by assets.rs, into both
       (prefers-color-scheme: dark) and [data-theme="dark"]. */
}

@layer tokens{
@media (prefers-color-scheme: dark){
:root:not([data-theme="light"]):not([data-theme="dark"]){
/* ════════════════════════════════════════════════════════════════════
   _dark-palette.css — the dark theme's token values.

   NOT a standalone stylesheet: this file is a bare declaration list, and
   the leading underscore marks it as a fragment. `web/routes/assets.rs`
   injects it into two different selectors:

     @media (prefers-color-scheme: dark) { :root:not([data-theme=…]) { … } }
     :root[data-theme="dark"]                                        { … }

   The two blocks used to be hand-copied and carried a "keep both blocks
   in sync" comment. Including one file twice makes that impossible to get
   wrong — which matters, because the one other place a token value was
   duplicated (--brand-strong, in tokens.css and assets.rs) had already
   drifted.

   Only the *derived* values live here. Brand tokens come from the admin's
   branding and are derived in tokens.css / theme.css, so they are absent
   by design.
   ════════════════════════════════════════════════════════════════════ */

--bg: #0c0f14;
--surface: #151920;
--surface-2: #1e242e;
--ink: #e8ebf0;
--ink-2: #c4cad4;
--muted: #94a0b8;
--line: #262d38;
--line-strong: #333c4a;

/* The brand reads as ink on dark surfaces only once lightened. */
--brand-text: color-mix(in srgb, var(--brand) 60%, white);
--brand-soft: color-mix(in srgb, var(--brand) 16%, transparent);

--ok: #75d0a1;
--ok-bg: #132b1e;
--warn: #ecc06c;
--warn-bg: #2e2513;
--danger: #f49790;
--danger-bg: #371d1b;

/* Shadows need far more opacity to read against a dark background. */
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.45);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.5);
--shadow-md: 0 4px 10px -2px rgba(0, 0, 0, 0.55);
--shadow-lg: 0 12px 20px -4px rgba(0, 0, 0, 0.6);

/* The scrim sits over a dark page, so it must go darker still. */
--scrim: rgba(0, 0, 0, 0.62);

}}
:root[data-theme="dark"]{
color-scheme: dark;
/* ════════════════════════════════════════════════════════════════════
   _dark-palette.css — the dark theme's token values.

   NOT a standalone stylesheet: this file is a bare declaration list, and
   the leading underscore marks it as a fragment. `web/routes/assets.rs`
   injects it into two different selectors:

     @media (prefers-color-scheme: dark) { :root:not([data-theme=…]) { … } }
     :root[data-theme="dark"]                                        { … }

   The two blocks used to be hand-copied and carried a "keep both blocks
   in sync" comment. Including one file twice makes that impossible to get
   wrong — which matters, because the one other place a token value was
   duplicated (--brand-strong, in tokens.css and assets.rs) had already
   drifted.

   Only the *derived* values live here. Brand tokens come from the admin's
   branding and are derived in tokens.css / theme.css, so they are absent
   by design.
   ════════════════════════════════════════════════════════════════════ */

--bg: #0c0f14;
--surface: #151920;
--surface-2: #1e242e;
--ink: #e8ebf0;
--ink-2: #c4cad4;
--muted: #94a0b8;
--line: #262d38;
--line-strong: #333c4a;

/* The brand reads as ink on dark surfaces only once lightened. */
--brand-text: color-mix(in srgb, var(--brand) 60%, white);
--brand-soft: color-mix(in srgb, var(--brand) 16%, transparent);

--ok: #75d0a1;
--ok-bg: #132b1e;
--warn: #ecc06c;
--warn-bg: #2e2513;
--danger: #f49790;
--danger-bg: #371d1b;

/* Shadows need far more opacity to read against a dark background. */
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.45);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.5);
--shadow-md: 0 4px 10px -2px rgba(0, 0, 0, 0.55);
--shadow-lg: 0 12px 20px -4px rgba(0, 0, 0, 0.6);

/* The scrim sits over a dark page, so it must go darker still. */
--scrim: rgba(0, 0, 0, 0.62);

}
:root[data-theme="light"]{color-scheme: light;}
}
/* ════════════════════════════════════════════════════════════════════
   base.css — reset and element defaults.
   Bare elements only (plus the button/input vocabulary). Anything with
   a project class name belongs in components.css or features.css.
   ════════════════════════════════════════════════════════════════════ */

/* IBM Plex Sans — the UI typeface, served from /assets/fonts (assets.rs).
   This vendor drop ships only Regular + Bold (roman + italic), so faces are
   declared at weight 400/700 only; the 500/600 tokens map to the nearest
   available weight and `font-synthesis: none` on body keeps them clean rather
   than faux-bold. @font-face is layer-agnostic, so it sits outside the layers.
   `swap` lets the system stack paint first, so a blocked font never blocks. */
@font-face {
    font-family: "IBM Plex Sans";
    font-style: normal;
    font-weight: 400;
    font-display: swap;
    src: url("/assets/fonts/IBMPlexSans-Regular.ttf") format("truetype");
}
@font-face {
    font-family: "IBM Plex Sans";
    font-style: italic;
    font-weight: 400;
    font-display: swap;
    src: url("/assets/fonts/IBMPlexSans-Italic.ttf") format("truetype");
}
@font-face {
    font-family: "IBM Plex Sans";
    font-style: normal;
    font-weight: 700;
    font-display: swap;
    src: url("/assets/fonts/IBMPlexSans-Bold.ttf") format("truetype");
}
@font-face {
    font-family: "IBM Plex Sans";
    font-style: italic;
    font-weight: 700;
    font-display: swap;
    src: url("/assets/fonts/IBMPlexSans-BoldItalic.ttf") format("truetype");
}

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

    /* The hidden attribute must always win, even over components that set
       their own display (e.g. flex labels hidden by the picker filter). */
    [hidden] {
        display: none !important;
    }

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

    body {
        margin: 0;
        min-height: 100vh;
        min-height: 100dvh;
        display: flex;
        flex-direction: column;
        font-family: var(--font-sans);
        font-size: var(--text-md);
        line-height: var(--leading-body);
        background: var(--bg);
        color: var(--ink);
        overflow-wrap: break-word;
        -webkit-font-smoothing: antialiased;
        /* Plex ships only 400/700, so never fake the 500/600 the tokens ask
           for — a clean 400 beats a smeared synthetic bold. */
        font-synthesis: none;
    }

    /* A quiet brand wash on selected text, in place of the OS default. */
    ::selection {
        background: color-mix(in oklab, var(--brand) 22%, var(--surface));
    }

    /* ── Typography ──────────────────────────────────────────────── */
    h1 {
        margin: 0 0 var(--sp-5);
        font-size: var(--text-2xl);
        font-weight: var(--weight-bold);
        line-height: var(--leading-tight);
        letter-spacing: var(--tracking-tight);
    }
    h2 {
        margin: var(--sp-8) 0 var(--sp-3);
        font-size: var(--text-lg);
        font-weight: var(--weight-semibold);
        line-height: var(--leading-tight);
        letter-spacing: var(--tracking-tight);
    }
    h3 {
        margin: var(--sp-6) 0 var(--sp-2);
        font-size: var(--text-md);
        font-weight: var(--weight-semibold);
    }
    p {
        margin: var(--sp-2) 0;
    }
    a {
        color: var(--brand-text);
        text-decoration-thickness: 1px;
        text-underline-offset: 2px;
    }
    strong {
        font-weight: var(--weight-semibold);
    }
    hr {
        border: none;
        border-top: 1px solid var(--line);
        margin: var(--sp-6) 0;
    }

    code {
        font-family: var(--font-mono);
        font-size: 0.875em;
        background: var(--surface-2);
        border: 1px solid var(--line);
        padding: 0.1rem 0.4rem;
        border-radius: 6px;
        /* Long unbroken strings (URLs, tokens, ids) must wrap, not
           overflow the card on narrow viewports. */
        overflow-wrap: anywhere;
        word-break: break-word;
    }
    code.code--block {
        display: block;
        padding: var(--sp-2) var(--sp-3);
        margin-bottom: var(--sp-1);
        line-height: 1.5;
        max-width: 100%;
    }

    /* ── Forms ───────────────────────────────────────────────────── */
    label {
        display: block;
        margin: var(--sp-4) 0 var(--sp-1);
        font-size: var(--text-sm);
        font-weight: var(--weight-medium);
        color: var(--ink-2);
    }
    /* A label that WRAPS its control becomes an aligned field: caption on
       its own line, control below, uniform gap — mixed-length labels no
       longer stagger the inputs beside them. Row-shaped control labels
       (.checkbox, .option, .chip-radio) are excluded here rather than
       relying on layer order: they don't declare flex-direction, so a
       column set in this layer would leak through. */
    label:not(.checkbox):not(.option):not(.chip-radio):has(
            input,
            select,
            textarea,
            details
        ) {
        display: flex;
        flex-direction: column;
        align-items: stretch;
        gap: var(--sp-1-5);
    }
    /* Hint text inside a field sits under the control, quiet. */
    label small,
    label .muted {
        font-weight: var(--weight-normal);
        color: var(--muted);
        font-size: var(--text-xs);
    }
    input,
    select,
    textarea {
        width: 100%;
        max-width: 100%;
        padding: 0.6rem 0.75rem;
        border: 1px solid var(--line-strong);
        border-radius: var(--r-sm);
        font-size: var(--text-control);
        font-family: inherit;
        line-height: 1.4;
        background: var(--surface);
        color: inherit;
        transition:
            border-color var(--t-fast),
            box-shadow var(--t-fast);
    }
    input::placeholder,
    textarea::placeholder {
        color: var(--muted);
        opacity: 0.8;
    }
    input:focus,
    select:focus,
    textarea:focus {
        outline: none;
        border-color: var(--brand);
        box-shadow: var(--ring);
    }
    input[type="color"] {
        width: 6rem;
        height: 2.5rem;
        padding: 0.2rem;
        cursor: pointer;
    }
    input[type="radio"],
    input[type="checkbox"] {
        accent-color: var(--brand);
    }
    textarea {
        resize: vertical;
    }
    textarea.code {
        font-family: var(--font-mono);
    }
    label.checkbox {
        display: flex;
        align-items: center;
        gap: var(--sp-2);
        margin: var(--sp-4) 0 var(--sp-1);
        font-size: var(--text-md);
        font-weight: var(--weight-normal);
        color: var(--ink);
    }
    label.checkbox input {
        width: auto;
    }
    fieldset {
        border: none;
        padding: 0;
        margin: 0;
        min-width: 0;
    }
    legend {
        padding: 0;
        margin: var(--sp-4) 0 var(--sp-1);
        font-size: var(--text-sm);
        font-weight: var(--weight-medium);
        color: var(--ink-2);
    }

    /* ── Buttons ─────────────────────────────────────────────────
       Soft-rectangle buttons with an explicit hierarchy:
         (default)          solid brand — the one primary action
         .btn--ghost        outlined secondary
         .btn--danger       solid destructive
         .btn--danger-ghost outlined destructive
         .btn--sm           compact variant
       Sized for touch (min 2.75rem) at every breakpoint. */
    /* A bare <button> is an unstyled control, not a primary action.
       Styling the element itself as a large brand pill meant every icon
       button, tab, toolbar control and chat affordance in the app had to
       undo it — 26 `min-height: 0` / `margin-top: 0` declarations across 11
       files existed only for that. The visual treatment is opt-in via .btn;
       what remains here is the reset every button wants. */
    button {
        appearance: none;
        margin: 0;
        padding: 0;
        border: 0;
        background: none;
        color: inherit;
        font: inherit;
        text-align: inherit;
        cursor: pointer;
    }
    button:focus-visible {
        outline: none;
        box-shadow: var(--ring);
    }
    button[disabled] {
        cursor: default;
        opacity: var(--opacity-disabled);
        pointer-events: none;
    }

    /* Sized for touch (min 2.75rem) at every breakpoint. */
    .btn {
        appearance: none;
        margin-top: var(--sp-5);
        min-height: 2.75rem;
        padding: 0.6rem 1.15rem;
        border: 1px solid transparent;
        border-radius: var(--r-control);
        background: var(--brand);
        color: var(--brand-ink);
        font-size: var(--text-md);
        font-weight: var(--weight-medium);
        font-family: inherit;
        line-height: 1.2;
        cursor: pointer;
        text-decoration: none;
        display: inline-flex;
        align-items: center;
        justify-content: center;
        gap: var(--sp-1-5);
        white-space: nowrap;
        transition:
            background var(--t-fast),
            border-color var(--t-fast),
            box-shadow var(--t-fast),
            transform var(--t-fast);
    }
    .btn:hover {
        background: var(--brand-strong);
    }
    .btn:active {
        transform: translateY(1px);
    }
    .btn:focus-visible {
        outline: none;
        box-shadow: var(--ring);
    }
    .btn--block {
        width: 100%;
    }
    .btn--ghost {
        background: var(--surface);
        color: var(--ink-2);
        border-color: var(--line-strong);
    }
    .btn--ghost:hover {
        background: var(--surface-2);
        color: var(--ink);
        border-color: var(--line-strong);
    }
    .btn--sm {
        margin-top: 0;
        min-height: 2.25rem;
        padding: var(--sp-1-5) var(--sp-3);
        font-size: var(--text-sm);
        border-radius: var(--r-sm);
    }
    .btn--danger {
        background: var(--danger-fill);
        color: var(--danger-ink);
    }
    .btn--danger:hover {
        background: color-mix(in srgb, var(--danger-fill) 84%, black);
    }
    .btn--danger-ghost {
        background: transparent;
        color: var(--danger);
        border-color: color-mix(in srgb, var(--danger) 38%, var(--line));
    }
    .btn--danger-ghost:hover {
        background: var(--danger-bg);
        color: var(--danger);
    }
    /* Transient "copied to clipboard" confirmation. */
    .btn--ghost.is-copied {
        color: var(--ok);
        border-color: currentColor;
    }

    /* Visually hidden but available to assistive tech. */
    .sr-only {
        position: absolute;
        width: 1px;
        height: 1px;
        padding: 0;
        margin: -1px;
        overflow: hidden;
        clip: rect(0, 0, 0, 0);
        white-space: nowrap;
        border: 0;
    }

    /* Honor reduced-motion globally. */
    @media (prefers-reduced-motion: reduce) {
        * {
            transition: none !important;
            animation: none !important;
        }
    }
}

/* ════════════════════════════════════════════════════════════════════
   layout.css — the application shell.

   Two layouts, mobile-first:
     • cellular (default): sticky top bar, bottom tab bar, off-canvas
       drawer (opened by the Menu tab / closed by the scrim or Menu).
     • desktop (≥ 1024px): persistent labeled sidebar, no tab bar.
   There is deliberately no tablet/icon-rail layout in between.
   ════════════════════════════════════════════════════════════════════ */
@layer layout {
    /* ── Top bar ─────────────────────────────────────────────────── */
    .topbar {
        position: sticky;
        top: 0;
        z-index: var(--z-topbar);
        display: flex;
        align-items: center;
        gap: var(--sp-3);
        height: var(--topbar-h);
        padding: 0 var(--page-pad);
        /* The translucent wash carries a whisper of a left→right ramp:
           mixing in --ink means it shifts darker in light mode and lighter
           in dark mode without a second themed value. */
        background:
            linear-gradient(
                90deg,
                transparent,
                color-mix(in srgb, var(--ink) 5%, transparent)
            ),
            color-mix(in srgb, var(--surface) 86%, transparent);
        -webkit-backdrop-filter: blur(10px) saturate(1.4);
        backdrop-filter: blur(10px) saturate(1.4);
        border-bottom: 1px solid var(--line);
    }
    /* A 2px brand→accent hairline riding the topbar's bottom edge — the one
       overt brand flourish in the chrome. Decorative and non-interactive; sits
       within the topbar's own box, so it never overlaps content or the
       (separate, bottom-fixed) .tabbar. */
    .topbar::after {
        content: "";
        position: absolute;
        inset-inline: 0;
        bottom: -1px;
        height: 2px;
        background: linear-gradient(90deg, var(--brand), var(--accent));
        pointer-events: none;
    }
    .brand {
        display: inline-flex;
        align-items: center;
        gap: 0.6rem;
        text-decoration: none;
        color: var(--ink);
        font-weight: var(--weight-bold);
        font-size: var(--text-lg);
        letter-spacing: var(--tracking-tight);
    }
    .brand__logo {
        width: 28px;
        height: 28px;
        border-radius: 8px;
        background-size: cover;
        background-position: center;
        flex: none;
        box-shadow: var(--shadow-xs);
    }
    /* The default mark fills its slot; the auth lockup calls the macro at a
       larger size directly, outside .brand__logo, so it uses its own width. */
    .brand__logo > svg {
        display: block;
        width: 100%;
        height: 100%;
    }

    /* Right-aligned cluster in the anonymous topbar (language + theme). */
    .topbar__actions {
        margin-left: auto;
        display: inline-flex;
        align-items: center;
        gap: var(--sp-2);
    }
    .topbar__lang {
        display: inline-flex;
        align-items: center;
        gap: 0.35rem;
    }
    .topbar__lang-icon {
        display: inline-flex;
        color: var(--ink-subtle);
    }
    .topbar__lang select {
        font: inherit;
        /* ≥16px on cellular so iOS Safari never zooms the page on focus
           (zoom unpins fixed chrome); desktop drops back to body size. */
        font-size: var(--text-control);
        padding: 0.3rem 0.5rem;
        border-radius: var(--r-sm);
        border: 1px solid var(--line);
        background: var(--surface);
        color: var(--ink);
    }

    /* Theme switcher. Rendered by the ThemeToggle island, so it is present
       only when scripting is available — no CSS visibility gate needed. */
    .theme-toggle {
        display: inline-flex;
        align-items: center;
        justify-content: center;
        width: 2.5rem;
        height: 2.5rem;
        margin: 0;
        margin-inline-start: auto;
        padding: 0;
        border: none;
        border-radius: var(--r-full);
        background: transparent;
        color: var(--muted);
        cursor: pointer;
        flex: none;
    }
    .theme-toggle:hover {
        background: var(--surface-2);
        color: var(--ink);
        box-shadow: none;
    }
    .theme-toggle .icon {
        width: 1.35em;
        height: 1.35em;
    }
    .theme-toggle__icon {
        display: none;
        align-items: center;
    }
    .theme-toggle[data-mode="auto"] .theme-toggle__icon--auto,
    .theme-toggle[data-mode="light"] .theme-toggle__icon--light,
    .theme-toggle[data-mode="dark"] .theme-toggle__icon--dark {
        display: inline-flex;
    }

    /* ── Shell grid ──────────────────────────────────────────────── */
    .shell {
        flex: 1;
        display: block;
        min-height: 0;
        /* Quiet texture behind the cards, so the main panel reads as a
           surface rather than a void. Cards, the topbar and the desktop
           sidebar all paint over it. */
        background-image: var(--bg-stripes);
    }

    /* ── Sidebar: off-canvas drawer on cellular ────────────────────
       The state rules below (RTL, no-JS, open) all wrap their gate in
       :where() so every selector here stays at .sidebar's specificity and
       source order alone decides the winner — which is what lets the
       desktop block further down override the lot with a plain .sidebar. */
    .sidebar {
        position: fixed;
        top: 0;
        bottom: 0;
        inset-inline-start: 0;
        z-index: var(--z-drawer);
        width: min(84vw, 300px);
        display: flex;
        flex-direction: column;
        gap: var(--sp-2);
        padding: var(--sp-4) var(--sp-3);
        padding-bottom: calc(var(--sp-4) + env(safe-area-inset-bottom));
        /* The same quiet brand tint as the .tabbar, so the nav reads as
           chrome and stands apart from the page it navigates. */
        background: color-mix(in srgb, var(--brand) 7%, var(--surface));
        border-inline-end: 1px solid
            color-mix(in srgb, var(--brand) 22%, var(--line));
        overflow-y: auto;
        overscroll-behavior: contain;
        transform: translateX(-100%);
        transition: transform var(--t-med);
        /* Thin scrollbar that reads as chrome, not a bolted-on OS widget,
           in both engine families and both themes. Firefox reads the two
           -moz properties; WebKit/Blink (Chrome, Edge, Safari) read the
           pseudo-elements below. */
        scrollbar-width: thin;
        scrollbar-color: var(--line-strong) transparent;
    }
    .sidebar::-webkit-scrollbar {
        width: 6px;
    }
    .sidebar::-webkit-scrollbar-track {
        background: transparent;
    }
    .sidebar::-webkit-scrollbar-thumb {
        background-color: var(--line-strong);
        border-radius: var(--r-full);
    }
    .sidebar::-webkit-scrollbar-thumb:hover {
        background-color: var(--muted);
    }
    :where([dir="rtl"]) .sidebar {
        transform: translateX(100%);
    }
    /* No way to open the drawer means the sidebar reverts to an ordinary
       block in the page flow, so every destination stays reachable. The old
       checkbox hack worked without scripting; this is what replaces that
       guarantee.

       Gated on `js` rather than `islands` on purpose. The drawer is opened by
       an island, so the accurate signal is `islands` — but that class only
       lands once the deferred bundle runs, which is after first paint, so
       gating here would flash a full-height in-flow sidebar on every single
       load and then snap it away. Instead the pre-paint script sets `js`
       optimistically and *retracts* it on `load` if `islands` never arrived
       (see base.html), which costs a reflow only in the failure case. */
    :where(html:not(.js)) .sidebar {
        position: static;
        z-index: auto;
        width: auto;
        transform: none;
        transition: none;
        border-inline-end: 0;
        border-block-start: 1px solid var(--line);
    }
    :where(html[data-nav-open]) .sidebar {
        transform: none;
        box-shadow: var(--shadow-lg);
    }

    /* Scrim behind the open drawer; tapping it closes. */
    .nav-scrim {
        position: fixed;
        inset: 0;
        z-index: var(--z-scrim);
        background: var(--scrim);
        -webkit-backdrop-filter: var(--blur-scrim);
        backdrop-filter: var(--blur-scrim);
        opacity: 0;
        visibility: hidden;
        cursor: pointer;
        transition:
            opacity var(--t-med),
            visibility var(--t-med);
    }
    .nav-scrim.is-open {
        opacity: 1;
        visibility: visible;
    }

    /* ── Side navigation ─────────────────────────────────────────── */
    .sidenav {
        display: flex;
        flex-direction: column;
        gap: 2px;
    }
    .sidenav__group {
        display: flex;
        flex-direction: column;
        gap: 2px;
    }
    .sidenav__group + .sidenav__group,
    .sidenav__group:not(:first-child) {
        margin-top: var(--sp-4);
    }
    .sidenav__heading {
        margin: 0 var(--sp-3) var(--sp-1);
        font-size: var(--text-xs);
        font-weight: var(--weight-bold);
        text-transform: uppercase;
        letter-spacing: var(--tracking-wide);
        color: var(--muted);
    }
    /* Collapsible role group (multi-role users): the heading doubles as the
       <summary> of a native <details>, so groups toggle with no script. The
       default disclosure marker is replaced by the shared chevron icon —
       down when open, toward the inline end when closed. */
    summary.sidenav__heading {
        display: flex;
        align-items: center;
        justify-content: space-between;
        gap: var(--sp-2);
        cursor: pointer;
        list-style: none;
        user-select: none;
        -webkit-user-select: none;
    }
    summary.sidenav__heading::-webkit-details-marker {
        display: none;
    }
    summary.sidenav__heading:hover {
        color: var(--ink);
    }
    summary.sidenav__heading .icon {
        width: 1.1em;
        height: 1.1em;
        flex: none;
        transition: transform var(--t-fast);
    }
    details:not([open]) > summary.sidenav__heading .icon {
        transform: rotate(-90deg);
    }
    :where([dir="rtl"]) details:not([open]) > summary.sidenav__heading .icon {
        transform: rotate(90deg);
    }
    .sidenav__item {
        position: relative;
        display: flex;
        align-items: center;
        gap: var(--sp-3);
        padding: 0.55rem var(--sp-3);
        border-radius: var(--r-sm);
        color: var(--ink-2);
        text-decoration: none;
        font-weight: var(--weight-medium);
        font-size: var(--text-md);
        line-height: 1.3;
        transition:
            background var(--t-fast),
            color var(--t-fast);
    }
    .sidenav__item .icon {
        width: 1.25em;
        height: 1.25em;
        /* Brand-tinted so icons sit slightly above the label gray; tracks the admin --brand and the dark palette via the mixed tokens. */
        color: color-mix(in oklab, var(--brand) 40%, var(--muted));
        flex: none;
        transition: color var(--t-fast);
    }
    .sidenav__item:hover {
        background: var(--surface-2);
        color: var(--ink);
    }
    .sidenav__item:hover .icon {
        color: var(--ink);
    }
    /* Active item — stamped by the template as aria-current="page". */
    .sidenav__item[aria-current="page"] {
        background: var(--brand-soft);
        color: var(--brand-text);
        font-weight: var(--weight-semibold);
    }
    .sidenav__item[aria-current="page"] .icon {
        color: var(--brand-text);
    }
    .sidenav__label {
        overflow: hidden;
        text-overflow: ellipsis;
    }
    /* Count badge at the trailing edge of the row. Fixed height + flex
       centering (not the old line-height/text-align combo) keeps it a
       true circle at one digit and a pill from two digits up, and
       flex-shrink:0 stops the row from ever squeezing it narrower than
       its content. That squeeze was the bug: with the badge allowed to
       shrink below its content width, the body's global
       `overflow-wrap: break-word` (base.css) had license to break "29"
       into "2" / "9" whenever a long, wrapped label left the row tight
       on space. white-space: nowrap is a second, independent guard
       against the same failure mode. */
    .sidenav__badge {
        flex-shrink: 0;
        margin-inline-start: auto;
        display: inline-flex;
        align-items: center;
        justify-content: center;
        height: 1.375rem;
        min-width: 1.375rem;
        padding: 0 0.4rem;
        border-radius: var(--r-full);
        background: var(--brand);
        color: var(--brand-ink);
        font-size: var(--text-xs);
        font-weight: var(--weight-bold);
        font-variant-numeric: tabular-nums;
        line-height: 1;
        white-space: nowrap;
        text-align: center;
    }
    /* Drawer footer: account, language, sign-out. */
    .sidenav__footer {
        margin-top: auto;
        padding-top: var(--sp-3);
        border-top: 1px solid var(--line);
        display: flex;
        flex-direction: column;
        gap: var(--sp-1);
    }
    .sidenav__user {
        display: flex;
        align-items: center;
        gap: var(--sp-3);
        padding: 0.5rem var(--sp-3);
        min-width: 0;
        color: var(--muted);
        font-size: var(--text-sm);
        font-weight: var(--weight-medium);
        border-radius: var(--r-sm);
        text-decoration: none;
    }
    .sidenav__user:hover {
        background: var(--surface-2);
        color: var(--ink);
    }
    .sidenav__user .icon {
        width: 1.25em;
        height: 1.25em;
        flex: none;
    }
    .sidenav__user .sidenav__label {
        overflow: hidden;
        text-overflow: ellipsis;
        white-space: nowrap;
    }
    .sidenav__footer button {
        margin-top: 0;
    }
    /* Every footer form shares the language field's inline gutter, so the
       sign-out button and the select align to the same column edges
       instead of the button spanning the full footer width. */
    .sidenav__footer form {
        padding-inline: var(--sp-3);
    }
    .sidenav__lang {
        margin: 0;
        display: flex;
        flex-direction: column;
        gap: var(--sp-1);
        padding: 0 var(--sp-3);
    }
    .sidenav__lang-label {
        display: flex;
        align-items: center;
        gap: var(--sp-3);
        color: var(--muted);
        font-size: var(--text-xs);
        font-weight: var(--weight-medium);
    }
    .sidenav__lang-label .icon {
        width: 1.25em;
        height: 1.25em;
        flex: none;
    }
    .sidenav__lang-select {
        margin-top: 0;
        width: 100%;
        padding: 0.4rem 0.5rem;
        /* ≥16px on cellular so iOS Safari never zooms the page on focus
           (zoom unpins fixed chrome); desktop drops back to --text-sm. */
        font-size: var(--text-control);
    }

    /* ── Bottom tab bar (cellular only) ──────────────────────────
       Up to four role-aware destinations plus the Menu tab, a real <button>
       driven by the NavToggle island. Icon-only: the caption spans stay
       in the DOM (and the i18n catalog) for screen readers but are
       visually hidden. The bar carries a subtle tint of the admin brand
       color so it reads as chrome, not page. */
    .tabbar {
        position: fixed;
        bottom: 0;
        inset-inline: 0;
        z-index: var(--z-tabbar);
        display: flex;
        align-items: stretch;
        height: calc(var(--tabbar-h) + env(safe-area-inset-bottom));
        padding-bottom: env(safe-area-inset-bottom);
        background: color-mix(
            in srgb,
            var(--brand) 7%,
            color-mix(in srgb, var(--surface) 93%, transparent)
        );
        -webkit-backdrop-filter: blur(12px) saturate(1.4);
        backdrop-filter: blur(12px) saturate(1.4);
        border-top: 1px solid color-mix(in srgb, var(--brand) 22%, var(--line));
    }
    /* How much bottom chrome the fixed tab bar occupies. A full-height pane
       that sizes itself against the viewport must subtract this instead of
       `--tabbar-h` directly, so it follows the bar in and out (today that is
       `.content:has(.chat-thread)` in features.css). */
    body {
        --tabbar-reserve: calc(var(--tabbar-h) + env(safe-area-inset-bottom));
    }
    /* iOS Safari lets the page scroll past its content while the software
       keyboard is up, and position:fixed chrome unpins from the visual
       viewport when it does — the bar ends up floating mid-screen. It is
       dead weight while typing anyway, so remove it whenever a text-entry
       control has focus; it re-pins on blur.

       The two rules below share one condition — keep them in step. Zeroing
       the reserve is not cosmetic: a pane that kept subtracting the strip the
       bar just vacated leaves an empty band there, which a software keyboard
       hides but anything else renders as a bare slab of page background. */
    body:has(
            input:focus:not([type="checkbox"]):not([type="radio"]):not(
                    [type="color"]
                ),
            textarea:focus,
            [contenteditable]:focus
        ) {
        --tabbar-reserve: 0px;
    }
    body:has(
            input:focus:not([type="checkbox"]):not([type="radio"]):not(
                    [type="color"]
                ),
            textarea:focus,
            [contenteditable]:focus
        )
        .tabbar {
        display: none;
    }
    .tabbar__item {
        position: relative;
        flex: 1;
        display: flex;
        align-items: center;
        justify-content: center;
        min-width: 0;
        /* Menu is a <label>: neutralize the global form-label margin that
           knocked it out of line with the anchor tabs. */
        margin: 0;
        padding: 0;
        color: var(--ink-2);
        /* The components-layer `.icon` sizing (1.15em) outranks this
           layer; sizing the item's font keeps the glyph large either
           way (1.15em here ≈ the 1.7rem set below). */
        font-size: 1.5rem;
        text-decoration: none;
        cursor: pointer;
        -webkit-tap-highlight-color: transparent;
    }
    .tabbar__item .icon {
        box-sizing: content-box;
        width: 1.7rem;
        height: 1.7rem;
        padding: 0.35rem 0.85rem;
        border-radius: var(--r-full);
        transition:
            background var(--t-fast),
            color var(--t-fast);
    }
    /* Active tab: the same brand-soft pill + brand ink the sidenav uses,
       with a heavier stroke so the glyph itself reads bolder. */
    .tabbar__item[aria-current="page"] {
        color: var(--brand-text);
    }
    .tabbar__item[aria-current="page"] .icon {
        background: var(--brand-soft);
        stroke-width: 2;
    }
    /* Captions: kept for screen readers, hidden from sight. */
    .tabbar__label {
        position: absolute;
        width: 1px;
        height: 1px;
        padding: 0;
        margin: -1px;
        overflow: hidden;
        clip: rect(0, 0, 0, 0);
        white-space: nowrap;
    }
    /* Unread/pending dot on a tab icon's top-right corner. */
    .tabbar__dot {
        position: absolute;
        top: calc(50% - 1.1rem);
        inset-inline-start: calc(50% + 0.5rem);
        width: 9px;
        height: 9px;
        border-radius: var(--r-full);
        background: var(--danger);
        border: 2px solid var(--surface);
    }

    /* ── Content column ──────────────────────────────────────────── */
    .content {
        min-width: 0;
        width: 100%;
        max-width: var(--content-max);
        margin-inline: auto;
        padding: var(--page-pad);
        /* Keep the last card clear of the floating tab bar. */
        padding-bottom: calc(
            var(--tabbar-h) + env(safe-area-inset-bottom) + var(--sp-6)
        );
    }
    .content > * + * {
        margin-top: var(--page-pad);
    }
    .content
        input:not([type="checkbox"]):not([type="radio"]):not([type="color"]),
    .content select {
        max-width: 34rem;
    }
    .content .card p {
        max-width: 75ch;
    }
    /* Site footer: one quiet copyright line closing the content column
       (and the anonymous .page). The height-locked classroom room hides
       it and carries the line in its own header instead (features.css). */
    .site-footer {
        margin-top: var(--sp-8);
        text-align: center;
    }
    .copyright {
        margin: 0;
        font-size: var(--text-xs);
        color: var(--ink-subtle);
    }
    /* Cheap page-enter affordance: a short fade + slight rise, opacity and
       transform only so it never thrashes layout. The spring's ~9% overshoot
       gives it a subtle settle. Duration matches --t-med, restated here as a
       literal because that token bundles its own easing and this needs the
       spring. Gated on no-preference; the base.css reduced-motion guard also
       zeroes it. */
    @media (prefers-reduced-motion: no-preference) {
        .content {
            animation: content-enter 200ms var(--ease-spring);
        }
        @keyframes content-enter {
            from {
                opacity: 0;
                transform: translateY(6px);
            }
        }
    }

    /* ── Auth pages (no nav): centered single card ───────────────── */
    .page {
        flex: 1;
        width: 100%;
        display: grid;
        place-items: start center;
        padding: var(--page-pad);
        padding-top: clamp(var(--sp-6), 8vh, var(--sp-12));
        /* Same stripe texture as the signed-in shell, so the auth pages
           and the app read as one surface. */
        background-image: var(--bg-stripes);
    }
    .page > * + * {
        margin-top: var(--page-pad);
    }

    /* ── Desktop (≥ 1024px): persistent sidebar, no tab bar ──────── */
    @media (min-width: 1024px) {
        :root {
            --page-pad: 2rem;
            --card-pad-y: 1.75rem;
            --card-pad-x: 2rem;
            --topbar-h: 3.75rem;
            /* Holds at the 264px base until the viewport has room to
               spare, then eases up to 320px on wide monitors. */
            --sidebar-w: clamp(264px, 16vw, 320px);
        }
        .tabbar {
            display: none;
        }
        .nav-scrim {
            display: none;
        }
        /* Mouse-and-keyboard screens don't zoom on focus; restore the
           compact control sizes the cellular block inflated to 16px. */
        .topbar__lang select {
            font-size: var(--text-md);
        }
        .sidenav__lang-select {
            font-size: var(--text-sm);
        }
        /* The footer's ghost buttons (Save fallback, Sign out) take the
           language select's exact vertical metrics — same padding, text
           size and line height, min-height released — so the footer
           controls read as one family instead of the button standing
           taller and fatter than the select beside it. */
        .sidenav__footer .btn {
            min-height: 0;
            padding-block: 0.4rem;
            line-height: 1.4;
        }
        .shell {
            display: grid;
            grid-template-columns: var(--sidebar-w) minmax(0, 1fr);
            align-items: start;
        }
        .sidebar {
            position: sticky;
            top: var(--topbar-h);
            bottom: auto;
            inset-inline-start: auto;
            z-index: 1;
            width: auto;
            height: calc(100vh - var(--topbar-h));
            height: calc(100dvh - var(--topbar-h));
            padding: var(--sp-5) var(--sp-3);
            /* Opaque page color, not transparent: it masks the .shell
               stripe texture, which belongs to the content panel only. */
            background: var(--bg);
            border-inline-end: 1px solid var(--line);
            transform: none;
            transition: none;
            box-shadow: none;
        }
        [dir="rtl"] .sidebar {
            transform: none;
        }
        .content {
            padding-bottom: calc(var(--page-pad) * 2);
        }
        /* Full-width buttons are a phone affordance; cap them in the
           wide content column. Auth pages keep the full width. */
        .content .btn--block,
        .content button.block {
            width: auto;
            min-width: 16rem;
        }
    }

    /* Very large screens: let content breathe a little more. */
    @media (min-width: 1440px) {
        :root {
            --page-pad: 2.5rem;
        }
    }
}

/* ════════════════════════════════════════════════════════════════════
   components.css — the shared component vocabulary.
   Cross-feature building blocks: cards, badges, callouts, banners,
   tables, tabs, lists, metrics. Feature-specific styling (homework,
   chat, flashcards…) lives in features.css.
   ════════════════════════════════════════════════════════════════════ */
@layer components {
    /* ── Cards ───────────────────────────────────────────────────
       Hairline border + flat surface; depth is reserved for floating
       layers. Width classes only matter inside .page (auth); in the
       app shell cards span the content column. */
    .card {
        background: var(--surface);
        padding: var(--card-pad-y) var(--card-pad-x);
        border: 1px solid var(--line);
        border-radius: var(--r-lg);
        box-shadow: var(--shadow-xs);
        width: min(100%, 26rem);
    }
    .card--wide {
        width: min(100%, 52rem);
    }
    .card--xwide {
        width: min(100%, 64rem);
    }
    .content .card,
    .content .card--wide,
    .content .card--xwide {
        width: 100%;
        max-width: none;
    }
    /* A heading opening a boxed container sits flush with its padding —
       the document-flow top margin would double the box's own padding
       (e.g. the nested Type panel on homework detail). */
    .card > h1:first-child,
    .card > h2:first-child,
    .card > h3:first-child {
        margin-top: 0;
    }

    /* Brand lockup above the lone card on anonymous auth pages (sign in, sign
       up, first-run setup, password reset, invalid link). Mark from
       ui::brand_mark; the name repeats layout.site_name. .page's `* + *` gap
       handles spacing to the card below. */
    .auth-brand {
        display: flex;
        flex-direction: column;
        align-items: center;
        gap: var(--sp-2);
        text-align: center;
    }
    .auth-brand__name {
        font-size: var(--text-xl);
        font-weight: var(--weight-bold);
        letter-spacing: var(--tracking-tight);
        color: var(--ink);
    }

    .muted {
        color: var(--muted);
        font-size: var(--text-sm);
    }

    /* Overline: the tiny uppercase field/section label. One definition;
       per-feature classes add only their margins. */
    .overline,
    .answer__label,
    .feedback__label,
    .fc-side-label,
    .subgroup__label,
    .history-excerpt__label,
    .metric__label,
    .fact__label,
    .aiu-cols {
        display: block;
        font-size: var(--text-xs);
        font-weight: var(--weight-semibold);
        text-transform: uppercase;
        letter-spacing: var(--tracking-wide);
        color: var(--muted);
    }

    /* ── Banners (page-level notices) ────────────────────────────── */
    .banner {
        padding: var(--sp-3) var(--sp-4);
        border-radius: var(--r-sm);
        border: 1px solid transparent;
        font-size: var(--text-sm);
        font-weight: var(--weight-medium);
        margin: var(--sp-1) 0 var(--sp-3);
    }
    .banner--error {
        background: var(--danger-bg);
        color: var(--danger);
        border-color: color-mix(in srgb, var(--danger) 25%, transparent);
    }
    .banner--ok {
        background: var(--ok-bg);
        color: var(--ok);
        border-color: color-mix(in srgb, var(--ok) 25%, transparent);
    }
    /* Was shipped in markup with no rule at all, so two teacher-facing
       warnings rendered as unstyled transparent text. */
    /* Flash rendered by the Toast island: dismissible, and it retires
       itself instead of sitting at the top of the page until the next
       navigation. */
    .banner--toast {
        display: flex;
        align-items: center;
        gap: var(--sp-3);
    }
    .banner__close {
        margin-inline-start: auto;
        padding: 0 var(--sp-1);
        font-size: var(--text-lg);
        line-height: 1;
        color: inherit;
        opacity: var(--opacity-quiet);
    }
    .banner__close:hover {
        opacity: 1;
    }
    .banner--warn {
        background: var(--warn-bg);
        color: var(--warn);
        border-color: color-mix(in srgb, var(--warn) 25%, transparent);
    }
    /* A banner whose whole body is a form (the time-zone confirm): message
       and actions on one wrapping row, message taking the free space. */
    .banner__form {
        display: flex;
        flex-wrap: wrap;
        align-items: center;
        gap: var(--sp-3);
    }
    .banner__form span {
        flex: 1 1 20rem;
    }

    /* ── Callouts (inline statuses and secondary forms) ──────────── */
    .callout {
        margin-top: var(--sp-4);
        padding: var(--sp-3) var(--sp-4);
        border: 1px solid var(--line);
        border-inline-start: 3px solid var(--line-strong);
        border-radius: var(--r-sm);
        background: var(--surface-2);
    }
    .callout--warn {
        border-color: color-mix(in srgb, var(--warn) 30%, transparent);
        border-inline-start-color: var(--warn);
        background: var(--warn-bg);
    }
    .callout--ok {
        border-color: color-mix(in srgb, var(--ok) 30%, transparent);
        border-inline-start-color: var(--ok);
        background: var(--ok-bg);
    }
    .callout__title {
        margin: 0 0 var(--sp-1);
        font-weight: var(--weight-semibold);
        font-size: var(--text-md);
    }
    .callout--warn .callout__title {
        color: var(--warn);
    }
    .callout--ok .callout__title {
        color: var(--ok);
    }
    .callout__body {
        margin: 0;
        font-size: var(--text-sm);
    }
    .callout__body + .callout__body {
        margin-top: var(--sp-1);
    }


    /* ── Shared surface treatments ──────────────────────────────────
       Three recurring "boxes" defined once each. The alias-list form is
       this system's existing way to share a treatment without forcing a
       second class into markup (see .overline above). Each alias keeps
       its own padding/layout below in features.css; only the surface
       itself is shared.

       Before this, 11 card surfaces, 6 inset wells and 7 brand accent
       stripes were spelled out independently — which is how features.css
       came to hold half the stylesheet. */

    /* Raised: sits on the page background. */
    .class-card {
        background: var(--surface);
        border: 1px solid var(--line);
        border-radius: var(--r-lg);
        padding: var(--card-pad-y) var(--card-pad-x);
    }
    /* Raised, compact: a row or tile inside a card. */
    .work-item,
    .fc-suggestion,
    .qcard {
        background: var(--surface);
        border: 1px solid var(--line);
        border-radius: var(--r-md);
    }
    /* Inset well: recedes into its parent. */
    .feedback,
    .history-search,
    .history-excerpt,
    .invite-panel,
    .subcard {
        background: var(--surface-2);
        border: 1px solid var(--line);
        border-radius: var(--r-sm);
    }
    /* Brand accent stripe: an aside tinted and marked on its leading edge. */
    .infocard,
    .qlist__info,
    .md blockquote {
        background: color-mix(in srgb, var(--brand) 5%, var(--surface));
        border-inline-start: 3px solid var(--brand);
        border-radius: var(--r-md);
    }

    /* ── Badges ──────────────────────────────────────────────────── */
    .badge {
        display: inline-block;
        padding: var(--sp-0-5) var(--sp-2);
        border-radius: var(--r-full);
        font-size: var(--text-xs);
        font-weight: var(--weight-semibold);
        background: var(--surface-2);
        border: 1px solid var(--line);
        color: var(--muted);
        white-space: nowrap;
    }
    .badge--ok {
        background: var(--ok-bg);
        border-color: color-mix(in srgb, var(--ok) 30%, transparent);
        color: var(--ok);
    }
    .badge--warn {
        background: var(--warn-bg);
        border-color: color-mix(in srgb, var(--warn) 30%, transparent);
        color: var(--warn);
    }
    .badge--error {
        background: var(--danger-bg);
        border-color: color-mix(in srgb, var(--danger) 30%, transparent);
        color: var(--danger);
    }
    .badge--brand {
        background: var(--brand-soft);
        border-color: var(--brand-border);
        color: var(--brand-text);
    }
    /* Neutral badge for secondary metadata (e.g. an upcoming due date). */
    .badge--muted {
        background: color-mix(in srgb, var(--ink) 6%, transparent);
        border-color: var(--line);
        color: var(--ink-subtle);
    }
    /* Automatic, background-generated work (lesson synopsis, progress summary):
       its own accent so the usage log separates it from teacher-triggered
       generation at a glance. */
    .badge--auto {
        background: color-mix(in srgb, var(--accent) 12%, transparent);
        border-color: color-mix(in srgb, var(--accent) 30%, transparent);
        color: var(--accent);
    }

    /* Empty state with a call-to-action (see the `empty_cta` macro). */
    .empty-state {
        display: flex;
        flex-direction: column;
        align-items: flex-start;
        gap: var(--sp-3);
        padding: var(--sp-4) 0;
    }

    /* Framed mascot panel: a baked-background raster illustration (two theme
       variants, served from /assets/mascot-*.png). Framed, not cut out — the
       border + radius make an exact background-hex match unnecessary. The image
       is a centered 1024² source cropped by `cover`. */
    .mascot {
        display: block;
        width: 100%;
        max-width: 420px;
        /* 4/3, not 16/9: a 16/9 crop of a centered square drops ~44% of the
           illustration's height and tends to clip the mascot's head/feet; 4/3
           keeps far more of the composition while still reading as a banner.
           (If a future source is authored for a wide crop, revisit.) */
        aspect-ratio: 4 / 3;
        border-radius: var(--r-lg);
        border: 1px solid var(--line);
        background-color: #EDF1FB;
        background-position: center;
        background-size: cover;
        background-repeat: no-repeat;
        background-image: url("/assets/mascot-light.png");
        background-image: image-set(
            url("/assets/mascot-light.webp") type("image/webp"),
            url("/assets/mascot-light.png") type("image/png")
        );
    }
    /* Smaller variant for empty states, where the panel is an accent, not a
       banner. */
    .mascot--sm {
        max-width: 240px;
    }

    /* Dark swap — both selectors, mirroring how assets.rs injects the dark
       palette (app_css, lines 128-134): the OS preference ONLY when the visitor
       hasn't pinned a theme, and the explicit data-theme="dark". */
    @media (prefers-color-scheme: dark) {
        :root:not([data-theme="light"]):not([data-theme="dark"]) .mascot {
            background-color: #1B2130;
            background-image: url("/assets/mascot-dark.png");
            background-image: image-set(
                url("/assets/mascot-dark.webp") type("image/webp"),
                url("/assets/mascot-dark.png") type("image/png")
            );
        }
    }
    :root[data-theme="dark"] .mascot {
        background-color: #1B2130;
        background-image: url("/assets/mascot-dark.png");
        background-image: image-set(
            url("/assets/mascot-dark.webp") type("image/webp"),
            url("/assets/mascot-dark.png") type("image/png")
        );
    }

    /* Dashboard welcome hero: the mascot bleeds in from the card's left edge
       at full box height, facing the greeting. The card's height is pinned
       to its tallest resting content — three lines of body text — via the
       body's min-height, so the mascot's square edge is a *known* length:
       --mascot-clear is that same calc, giving the mascot its width and the
       text its left clearance from one value. (Deriving the width from the
       height at layout time is what broke here twice: `aspect-ratio` on the
       pinned element either tracks tall cards into the text column or, once
       capped by max-width, transfers the cap to the height and cuts the
       backdrop short of the card's bottom edge.) Should content ever exceed
       the resting height, the mascot keeps its width and full height and
       `cover` crops the square art's sides. The mask fades the
       illustration's own backdrop into the card surface so it reads as part
       of the panel rather than a pasted rectangle. */
    .welcome-card {
        position: relative;
        overflow: hidden;
        padding: 0;
        /* Resting card height: three lines of welcome text (~7.125rem:
           h1 + two paragraphs at their fixed type sizes) plus the vertical
           card padding, which widens at the desktop breakpoint. */
        --mascot-clear: calc(7.125rem + 2 * var(--card-pad-y));
    }
    .mascot--bleed {
        position: absolute;
        inset: 0 auto 0 0;
        width: var(--mascot-clear);
        max-width: none;
        aspect-ratio: auto;
        border: 0;
        border-radius: 0;
        -webkit-mask-image: linear-gradient(
            to right,
            #000 62%,
            transparent 98%
        );
        mask-image: linear-gradient(to right, #000 62%, transparent 98%);
    }
    .welcome-card__body {
        /* Painted above the mascot, and padded past its width — the mask
           has fully faded the art by 98% of that width, so text always
           starts on clear surface. min-height (border-box, so it includes
           the padding) is what pins the card square; shorter content
           centers in the pinned height. */
        position: relative;
        min-height: var(--mascot-clear);
        align-content: center;
        padding: var(--card-pad-y) var(--card-pad-x) var(--card-pad-y)
            var(--mascot-clear);
    }
    .welcome-card__body > :first-child {
        margin-top: 0;
    }
    /* On phones the square would eat most of the card's width — drop the
       illustration and let the text own the panel again. */
    @media (max-width: 640px) {
        .mascot--bleed {
            display: none;
        }
        .welcome-card__body {
            min-height: 0;
            padding-left: var(--card-pad-x);
        }
    }

    /* ── Confirmation dialog ─────────────────────────────────────────
       Rendered by the ConfirmDialog island, one per page. Replaces
       window.confirm, which ignores the site's theme and can't be styled.
       A native <dialog> brings focus trapping, Escape-to-close and the top
       layer for free — which is why this floating surface is the one absent
       from the --z-* registry: it needs no z-index at all.

       (This block replaces the .loading-overlay/.spinner pair, whose only
       entry point was `data-overlay` on a form — an attribute no template
       ever set.) */
    .dialog {
        max-width: min(100%, 28rem);
        padding: var(--card-pad-y) var(--card-pad-x);
        border: 1px solid var(--line);
        border-radius: var(--r-lg);
        background: var(--surface);
        color: var(--ink);
        box-shadow: var(--shadow-lg);
    }
    .dialog::backdrop {
        background: var(--scrim);
        -webkit-backdrop-filter: var(--blur-scrim);
        backdrop-filter: var(--blur-scrim);
    }
    .dialog__message {
        margin: 0 0 var(--sp-5);
        font-size: var(--text-lg);
        line-height: var(--leading-body);
    }
    .dialog__actions {
        display: flex;
        justify-content: flex-end;
        gap: var(--sp-2);
    }

    /* ── Simple lists ────────────────────────────────────────────── */
    ul.list {
        list-style: none;
        padding: 0;
        margin: var(--sp-2) 0;
    }
    ul.list > li {
        padding: var(--sp-4) 0;
        border-top: 1px solid var(--line);
    }
    ul.list > li:first-child {
        border-top: none;
    }

    /* Dashboard navigation rows. */
    ul.nav-list {
        list-style: none;
        padding: 0;
        margin: var(--sp-2) 0;
    }
    ul.nav-list > li {
        border-top: 1px solid var(--line);
    }
    ul.nav-list > li:first-child {
        border-top: none;
    }
    ul.nav-list a {
        display: flex;
        align-items: center;
        gap: var(--sp-3);
        padding: var(--sp-4) var(--sp-1);
        text-decoration: none;
        color: var(--ink);
        font-weight: var(--weight-medium);
        transition: color var(--t-fast);
    }
    ul.nav-list a:hover {
        color: var(--brand-text);
    }
    ul.nav-list a .icon {
        color: var(--muted);
    }
    ul.nav-list a:hover .icon {
        color: var(--brand-text);
    }

    /* ── Tables ──────────────────────────────────────────────────── */
    .table-wrap {
        overflow-x: auto;
        margin: var(--sp-2) 0;
        border: 1px solid var(--line);
        border-radius: var(--r-md);
    }
    table.data {
        width: 100%;
        border-collapse: collapse;
        font-size: var(--text-sm);
    }
    table.data th,
    table.data td {
        text-align: start;
        padding: 0.6rem 0.85rem;
        border-bottom: 1px solid var(--line);
        vertical-align: top;
    }
    table.data tr:last-child td {
        border-bottom: none;
    }
    table.data tbody tr {
        transition: background var(--t-fast);
    }
    table.data tbody tr:hover {
        background: var(--surface-2);
    }
    table.data th {
        font-size: var(--text-xs);
        font-weight: var(--weight-semibold);
        text-transform: uppercase;
        letter-spacing: var(--tracking-wide);
        color: var(--muted);
        background: var(--surface-2);
    }
    table.data th .data__sort {
        display: inline-flex;
        align-items: center;
        gap: 0.25rem;
        color: inherit;
        text-decoration: none;
        white-space: nowrap;
    }
    table.data th .data__sort:hover {
        color: var(--ink);
    }
    table.data th .data__sort.is-active {
        color: var(--brand-text);
    }
    table.data th .data__sort-ind {
        font-size: 0.85em;
        line-height: 1;
    }

    /* ── Metric tiles ────────────────────────────────────────────── */
    .metrics {
        display: grid;
        grid-template-columns: repeat(auto-fit, minmax(8.5rem, 1fr));
        gap: var(--sp-3);
        margin: var(--sp-3) 0 var(--sp-4);
    }
    .metric {
        background: var(--surface);
        border: 1px solid var(--line);
        border-radius: var(--r-md);
        padding: var(--sp-4);
    }
    .metric__value {
        font-size: var(--text-3xl);
        font-weight: var(--weight-bold);
        font-variant-numeric: tabular-nums;
        letter-spacing: var(--tracking-tight);
        line-height: 1.1;
    }
    .metric__label {
        margin-top: var(--sp-1);
    }
    /* Denser variant for data-heavy admin pages: recessed surface, smaller
       value, tighter grid. Was a private .aiu-metric reimplementation that
       had drifted to a sentence-case label. */
    .metrics--compact {
        grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
    }
    .metric--compact {
        background: var(--surface-2);
        border-radius: var(--r-sm);
        padding: var(--sp-3);
    }
    .metric--compact .metric__value {
        font-size: var(--text-xl);
    }

    /* Numeric cell: tabular figures so columns align, never wrapping. */
    .num {
        font-variant-numeric: tabular-nums;
        text-align: end;
        white-space: nowrap;
    }
    .num--strong {
        font-weight: var(--weight-bold);
        color: var(--ink);
    }
    a.metric {
        display: block;
        color: inherit;
        text-decoration: none;
        transition:
            border-color var(--t-fast),
            box-shadow var(--t-fast);
    }
    a.metric:hover {
        border-color: var(--brand-border);
        box-shadow: var(--shadow-sm);
    }

    /* ── Tabs (in-page section switcher) ───────────────────────────
       .tabs is a legacy alias for the ribbon pill language (see .ribbon
       below); kept so existing templates need no churn. New markup should
       use .ribbon. */
    .tabs {
        display: flex;
        gap: var(--sp-1);
        margin: 0 0 var(--sp-5);
        overflow-x: auto;
        scrollbar-width: none;
    }
    .tabs::-webkit-scrollbar {
        display: none;
    }
    .tab {
        display: inline-flex;
        align-items: center;
        gap: var(--sp-1-5);
        padding: var(--sp-2) var(--sp-3);
        white-space: nowrap;
        text-decoration: none;
        color: var(--muted);
        font-size: var(--text-sm);
        font-weight: var(--weight-medium);
        background: none;
        border: 1px solid transparent;
        border-radius: var(--r-sm);
        cursor: pointer;
        transition:
            color var(--t-fast),
            background var(--t-fast),
            border-color var(--t-fast);
    }
    .tab:hover {
        color: var(--ink);
        background: var(--surface-2);
    }
    .tab[aria-current="page"] {
        color: var(--brand-text);
        background: var(--brand-soft);
        border-color: var(--brand-border);
    }
    .tab:focus-visible {
        outline: none;
        box-shadow: var(--ring);
    }

    /* ── Ribbon (section switcher) ───────────────────────────────────
       A single row of tabs that scrolls horizontally when it doesn't fit.
       It used to carry two mirrored controls — a tab row and a native
       <select> — swapped by a container query, which meant every ribbon
       rendered its whole tab list twice in the HTML. One scrollable row
       works at every width, so the twin is gone.

       Two modes, one look:
       • Panel mode — rendered by the Tabs island, which shows one sibling
         .ribbon-panel at a time. Without the bundle there is no strip and
         the panels stack, each under its own heading.
       • .ribbon--nav — real <a> links (admin sections). No JavaScript at
         all: navigation is what a link already does. */
    .ribbon {
        display: flex;
        gap: var(--sp-1);
        margin: 0 0 var(--sp-5);
        padding-bottom: var(--sp-3);
        border-bottom: 1px solid var(--line);
        overflow-x: auto;
        /* Hide the scrollbar but keep the overflow scrollable: the tabs are
           a short list and a visible bar under them reads as a divider. */
        scrollbar-width: none;
    }
    .ribbon::-webkit-scrollbar {
        display: none;
    }
    /* A pill, not an underline — a distinct, intentional active state shared
       by panel buttons (aria-selected) and nav links (aria-current), so the
       lesson and admin ribbons read as one component. */
    .ribbon__tab {
        display: inline-flex;
        align-items: center;
        gap: var(--sp-1-5);
        padding: var(--sp-2) var(--sp-3);
        white-space: nowrap;
        text-decoration: none;
        color: var(--muted);
        font-size: var(--text-sm);
        font-weight: var(--weight-medium);
        background: none;
        border: 1px solid transparent;
        border-radius: var(--r-sm);
        cursor: pointer;
        transition:
            color var(--t-fast),
            background var(--t-fast),
            border-color var(--t-fast);
    }
    .ribbon__tab:hover {
        color: var(--ink);
        background: var(--surface-2);
    }
    .ribbon__tab[aria-selected="true"],
    .ribbon__tab[aria-current="page"] {
        color: var(--brand-text);
        background: var(--brand-soft);
        border-color: var(--brand-border);
    }
    /* One focus treatment for both <button> and <a> tabs (the global ring
       only targets buttons), so keyboard focus reads the same everywhere. */
    .ribbon__tab:focus-visible {
        outline: none;
        box-shadow: var(--ring);
    }

    /* ── Action rows / backlinks ─────────────────────────────────── */
    .actions {
        display: flex;
        flex-wrap: wrap;
        align-items: center;
        gap: var(--sp-2);
        margin-top: var(--sp-3);
    }
    .actions form {
        margin: 0;
    }
    .backlink {
        display: inline-flex;
        align-items: center;
        gap: 0.4rem;
        margin-top: var(--sp-6);
        font-size: var(--text-sm);
        font-weight: var(--weight-medium);
        color: var(--muted);
        text-decoration: none;
    }
    .backlink:hover {
        color: var(--brand-text);
        text-decoration: underline;
    }

    /* Label-plus-action row: text left, controls right. */
    .row-between {
        display: flex;
        justify-content: space-between;
        align-items: center;
        gap: var(--sp-3);
        flex-wrap: wrap;
    }
    .row-between form {
        margin: 0;
    }
    /* Inline action cluster (e.g. Copy + Delete beside a signup link). */
    .link-actions {
        display: flex;
        align-items: center;
        gap: var(--sp-2);
    }
    .link-actions form {
        margin: 0;
    }
    /* Export/print toolbar pinned to the top of a notes card: a right-aligned
       action cluster set off from the content by a hairline rule — the same
       top-of-card control strip surface as .ribbon (shared spacing + line). */
    .note-toolbar {
        display: flex;
        flex-wrap: wrap;
        align-items: center;
        justify-content: flex-end;
        gap: var(--sp-2);
        margin-bottom: var(--sp-5);
        padding-bottom: var(--sp-3);
        border-bottom: 1px solid var(--line);
    }

    /* Label + input + trailing button on one row. */
    .inline-form {
        display: flex;
        align-items: flex-end;
        flex-wrap: wrap;
        gap: var(--sp-2);
    }
    .inline-form label {
        margin-bottom: 0;
    }
    .inline-form input {
        width: 18rem;
        max-width: 100%;
    }
    .inline-form .btn {
        flex: none;
        margin-top: 0;
    }

    /* ── Inline SVG icons ────────────────────────────────────────── */
    .icon {
        width: 1.15em;
        height: 1.15em;
        flex: none;
        display: inline-block;
        vertical-align: -0.18em;
    }

    /* Monospace public id chip (rides on the base `code` styling). */
    .student-id {
        font-family: var(--font-mono);
        font-weight: var(--weight-medium);
        color: var(--muted);
    }

    /* ── Page header: title + meta chips + trailing actions ─────── */
    .page-head {
        display: flex;
        flex-wrap: wrap;
        align-items: flex-start;
        justify-content: space-between;
        gap: var(--sp-3);
        margin-bottom: var(--sp-4);
    }
    .page-head h1 {
        margin: 0;
    }
    .page-head__meta {
        flex-basis: 100%;
        display: flex;
        flex-wrap: wrap;
        align-items: center;
        gap: var(--sp-2) var(--sp-3);
        color: var(--muted);
        font-size: var(--text-sm);
    }

    /* ── Facts: a responsive definition grid for profile-style data ── */
    .facts {
        display: grid;
        grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
        gap: var(--sp-3);
        margin: var(--sp-3) 0;
    }
    .facts dd {
        margin: 0;
    }
    /* A fact can be a <label> wrapping a select (editable profile). */
    label.fact {
        margin: 0;
    }
    .fact {
        border: 1px solid var(--line);
        border-radius: var(--r-md);
        background: var(--surface);
        padding: var(--sp-3) var(--sp-4);
        min-width: 0;
    }
    /* Type comes from the .overline alias above; only the gap is its own. */
    .fact__label {
        margin-bottom: 0.2rem;
    }
    .fact__value {
        font-weight: var(--weight-medium);
        overflow-wrap: anywhere;
    }
    .fact__value .muted {
        font-weight: var(--weight-normal);
    }

    /* User avatar (Plan 19): a circular image, generated SVG or uploaded
       PNG, always referenced through a versioned /assets/avatar URL. */
    .avatar {
        width: 32px;
        height: 32px;
        border-radius: 50%;
        object-fit: cover;
        flex-shrink: 0;
        vertical-align: middle;
        background: var(--surface-2, #eee);
    }

    .avatar--sm {
        width: 24px;
        height: 24px;
    }

    .avatar--lg {
        width: 64px;
        height: 64px;
    }

    .avatar--card {
        width: 88px;
        height: auto;
        align-self: stretch;
        border-radius: 0;
    }

    /* An archived student (plan 20): grayscale wherever they still appear. */
    .avatar--archived {
        filter: grayscale(1);
        opacity: 0.7;
    }

    .sidenav__user .avatar {
        border-radius: var(--r-control);
    }
}

/* ════════════════════════════════════════════════════════════════════
   features.css — feature-specific styling.
   Ordered by flow: homework (take → results → grading) → learning
   history & classes → rendered markdown → chat → flashcards.
   ════════════════════════════════════════════════════════════════════ */
@layer features {
    /* ═══ Homework: student list ═════════════════════════════════ */
    ul.work-list {
        list-style: none;
        padding: 0;
        margin: var(--sp-4) 0 0;
        display: grid;
        gap: var(--sp-3);
    }
    .work-item {
        padding: var(--sp-4) var(--sp-5);
        transition:
            box-shadow var(--t-fast),
            border-color var(--t-fast),
            transform var(--t-fast);
    }
    .work-item:hover {
        box-shadow: var(--shadow-sm);
        border-color: var(--brand-border);
    }
    .work-item__title {
        font-weight: var(--weight-semibold);
        font-size: var(--text-lg);
        letter-spacing: var(--tracking-tight);
    }
    .work-item__meta {
        display: flex;
        flex-wrap: wrap;
        align-items: center;
        gap: 0.4rem 0.6rem;
        margin-top: var(--sp-2);
    }
    .work-item__link {
        display: block;
        color: inherit;
        text-decoration: none;
        cursor: pointer;
    }
    .work-item--detachable {
        display: flex;
        align-items: center;
        gap: var(--sp-3);
    }
    .work-item--detachable .work-item__link {
        flex: 1;
        min-width: 0;
    }
    .work-item--detachable form {
        display: contents;
    }
    .work-item__detach {
        display: inline-flex;
        align-items: center;
        gap: var(--sp-1-5);
        padding: var(--sp-2);
        border: 0;
        background: none;
        border-radius: var(--r-sm);
        color: var(--muted);
        cursor: pointer;
        transition: color 0.15s ease, background-color 0.15s ease;
    }
    .work-item__detach .icon {
        width: 24px;
        height: 24px;
    }
    .work-item__detach-label {
        max-width: 0;
        overflow: hidden;
        white-space: nowrap;
        opacity: 0;
        font-size: var(--text-sm);
        font-weight: var(--weight-semibold);
        transition: max-width 0.18s ease, opacity 0.18s ease;
    }
    .work-item__detach:hover,
    .work-item__detach:focus-visible {
        color: var(--danger);
        background: var(--danger-bg);
    }
    .work-item__detach:hover .work-item__detach-label,
    .work-item__detach:focus-visible .work-item__detach-label {
        max-width: 6em;
        opacity: 1;
    }
    .work-item--avatar {
        padding: 0;
        overflow: hidden;
    }
    .work-item--avatar .work-item__link {
        display: flex;
        align-items: stretch;
    }
    .work-item--avatar .work-item__body {
        flex: 1;
        min-width: 0;
        padding: var(--sp-4) var(--sp-5);
    }
    /* Trailing call-to-action inside a full-row link (a span, not a button). */
    .work-item__action {
        align-self: center;
        margin-inline-end: var(--sp-5);
        flex: none;
    }
    /* When the row carries its own trailing button too (the classroom list's
       delete), the link's CTA gives up its end margin so the pair sits
       together on the row's gap instead of drifting apart. */
    .work-item--detachable .work-item__link .work-item__action {
        margin-inline-end: 0;
    }

    /* ═══ Question cards (take + result + review) ════════════════ */
    /* Surface comes from the "raised, compact" alias in components.css. */
    .qcard {
        padding: var(--sp-5);
        margin: var(--sp-4) 0;
    }
    .qcard__head {
        display: flex;
        align-items: flex-start;
        gap: var(--sp-3);
    }
    .qcard__num {
        flex: none;
        width: 1.75rem;
        height: 1.75rem;
        border-radius: var(--r-full);
        display: inline-flex;
        align-items: center;
        justify-content: center;
        background: var(--brand-soft);
        color: var(--brand-text);
        font-weight: var(--weight-bold);
        font-size: var(--text-sm);
    }
    .qcard__prompt {
        flex: 1;
        margin: 0;
        padding-top: 0.15rem;
        font-weight: var(--weight-semibold);
    }
    .qcard__points {
        flex: none;
        margin-top: 0.15rem;
    }
    .qcard__body {
        margin-top: var(--sp-4);
    }
    /* Verdict accent stripes on the results page. */
    .qcard--correct,
    .qcard--incorrect,
    .qcard--pending {
        border-inline-start-width: 3px;
    }
    .qcard--correct {
        border-inline-start-color: var(--ok);
    }
    .qcard--incorrect {
        border-inline-start-color: var(--danger);
    }
    .qcard--pending {
        border-inline-start-color: var(--warn);
    }

    /* Answers + feedback on the results page. */
    .answer {
        margin: var(--sp-1) 0 var(--sp-2);
    }
    .answer__label,
    .feedback__label {
        margin-bottom: 0.15rem;
    }
    .answer__value {
        font-weight: var(--weight-medium);
    }
    .verdict {
        display: inline-flex;
        align-items: center;
        gap: 0.35rem;
        font-weight: var(--weight-semibold);
        font-size: var(--text-sm);
    }
    .verdict-correct {
        color: var(--ok);
        font-weight: var(--weight-medium);
    }
    .verdict-incorrect {
        color: var(--danger);
        font-weight: var(--weight-medium);
    }
    .verdict-pending {
        color: var(--warn);
        font-weight: var(--weight-medium);
    }
    .feedback {
        margin-top: var(--sp-3);
        padding: var(--sp-3) var(--sp-4);
    }

    /* Grade-attempt page question separators. */
    .question {
        margin: var(--sp-5) 0;
        padding-top: var(--sp-4);
        border-top: 1px solid var(--line);
    }
    .question:first-of-type {
        border-top: none;
    }
    .prompt {
        font-weight: var(--weight-semibold);
        margin: 0 0 var(--sp-1);
    }

    /* ═══ Selectable answer options (MC / true-false) ════════════ */
    .hint {
        margin: 0 0 var(--sp-2);
        font-size: var(--text-sm);
    }
    .options {
        display: grid;
        gap: var(--sp-2);
    }
    .option {
        display: flex;
        align-items: center;
        gap: var(--sp-3);
        margin: 0;
        padding: var(--sp-3) var(--sp-4);
        border: 1px solid var(--line-strong);
        border-radius: var(--r-sm);
        font-weight: var(--weight-normal);
        color: var(--ink);
        cursor: pointer;
        transition:
            background var(--t-fast),
            border-color var(--t-fast);
    }
    .option:hover {
        background: var(--surface-2);
    }
    .option input {
        width: auto;
        flex: none;
        margin: 0;
    }
    .option:has(input:checked) {
        border-color: var(--brand);
        background: var(--brand-soft);
    }
    .blanks {
        display: grid;
        gap: var(--sp-3);
    }
    .option-detail {
        margin: 0.1rem 0 0.4rem 1.85rem;
    }
    .option-detail label {
        margin-top: var(--sp-1);
    }

    /* ═══ Matching: exclusive chip picker ════════════════════════ */
    .matching {
        display: grid;
        gap: var(--sp-3);
        margin-top: var(--sp-1);
    }
    .match-row {
        border: 1px solid var(--line);
        border-radius: var(--r-sm);
        padding: var(--sp-4);
        background: var(--surface);
    }
    .match-row__left {
        margin: 0 0 var(--sp-2);
        font-weight: var(--weight-semibold);
    }
    .match-opts {
        display: flex;
        flex-wrap: wrap;
        gap: 0.45rem;
    }
    .chip-radio {
        margin: 0;
    }
    .chip-radio input {
        position: absolute;
        inset-inline-start: 0;
        opacity: 0;
        width: 1px;
        height: 1px;
    }
    .chip-radio > span {
        display: inline-block;
        padding: 0.45rem 0.9rem;
        border: 1px solid var(--line-strong);
        border-radius: var(--r-full);
        font-size: var(--text-sm);
        font-weight: var(--weight-medium);
        cursor: pointer;
        user-select: none;
        transition:
            background var(--t-fast),
            border-color var(--t-fast),
            color var(--t-fast);
    }
    .chip-radio > span:hover {
        background: var(--surface-2);
    }
    .chip-radio input:checked + span {
        background: var(--brand);
        border-color: var(--brand);
        color: var(--brand-ink);
    }
    .chip-radio input:disabled + span {
        opacity: 0.4;
        cursor: not-allowed;
        text-decoration: line-through;
    }
    .chip-radio input:disabled + span:hover {
        background: transparent;
    }
    .chip-radio input:focus-visible + span {
        box-shadow: var(--ring);
    }
    .chip-radio--none > span {
        color: var(--muted);
    }

    /* ═══ Info blocks + paged take form ══════════════════════════ */
    /* Surface comes from the "brand accent stripe" alias in components.css.
       This rule used to add `border: 1px solid var(--brand-border)`, and the
       physical shorthand reset all four borders from a later cascade layer —
       so the leading stripe never rendered, while .qlist__info (the same
       information block in the question-list view) drew it correctly. */
    .infocard {
        padding: var(--sp-4) var(--sp-5);
        margin: var(--sp-4) 0;
    }
    .hw-desc {
        margin-bottom: var(--sp-2);
    }
    .hw-page__title {
        margin-top: var(--sp-2);
    }
    .hw-page[hidden] {
        display: none;
    }
    .pager {
        display: flex;
        align-items: center;
        justify-content: space-between;
        gap: var(--sp-3);
        margin: var(--sp-5) 0 var(--sp-2);
    }
    .pager [hidden] {
        display: none;
    }
    .pager__status {
        font-size: var(--text-sm);
        color: var(--muted);
    }
    /* Client-paginated list ([data-paginate] + a sibling .pager): off-page
       rows are marked [hidden], but flex/grid items set their own display,
       which beats the [hidden] UA rule — so force them out of flow. */
    [data-paginate] > [hidden] {
        display: none !important;
    }
    .hw-submit[hidden] {
        display: none;
    }

    /* ═══ Teacher question list (homework detail) ════════════════ */
    .qlist {
        display: flex;
        flex-direction: column;
        gap: var(--sp-2);
        margin: var(--sp-2) 0 var(--sp-4);
    }
    .qlist__item {
        display: flex;
        gap: var(--sp-3);
        align-items: flex-start;
    }
    .qlist__num {
        flex: none;
        width: 1.6rem;
        height: 1.6rem;
        border-radius: var(--r-full);
        display: inline-flex;
        align-items: center;
        justify-content: center;
        background: var(--brand-soft);
        color: var(--brand-text);
        font-weight: var(--weight-bold);
        font-size: var(--text-xs);
    }
    .qlist__info {
        padding: var(--sp-2) var(--sp-4);
    }
    .qlist__break {
        display: flex;
        align-items: center;
        gap: 0.4rem;
        color: var(--muted);
        font-size: var(--text-sm);
        text-transform: uppercase;
        letter-spacing: var(--tracking-wide);
        border-top: 1px dashed var(--line-strong);
        padding-top: var(--sp-2);
    }
    .qlist__break .md {
        text-transform: none;
        letter-spacing: normal;
    }

    /* ═══ Learning History + classes ═════════════════════════════ */
    /* The search + filter cluster reads as one inset panel, visually
       separate from the result feed below it. */
    .history-search {
        display: flex;
        gap: var(--sp-2);
        flex-wrap: wrap;
        margin: var(--sp-3) 0 var(--sp-1);
        padding: var(--sp-4);
    }
    .history-search input[type="search"] {
        flex: 1;
        min-width: 12rem;
        margin-top: 0;
    }
    .history-search button,
    .history-search .btn {
        margin-top: 0;
    }
    /* Invite/create forms presented as the same inset well as the search
       clusters (classroom invite, signup-link creation). Stacked fields
       keep their normal rhythm; only the first control loses its top
       margin so it doesn't double up with the panel padding. */
    .invite-panel {
        padding: var(--sp-4);
        margin: var(--sp-3) 0 var(--sp-1);
    }
    .invite-panel > :first-child {
        margin-top: 0;
    }
    .history-list {
        list-style: none;
        padding: 0;
        margin: var(--sp-4) 0 0;
        display: grid;
        gap: var(--sp-4);
    }
    /* Each class entry is a proper card — surface, hairline, soft radius —
       so a feed of classes reads as discrete cards rather than bare text
       runs. Shared by the teacher feed, the dashboard and the student
       history/notebook lists. */
    .class-card {
        background: var(--surface);
        border: 1px solid var(--line);
        border-radius: var(--r-md);
        padding: var(--sp-4) var(--sp-5);
        box-shadow: var(--shadow-xs);
    }
    /* Card header: the student's avatar beside a two-line identity block
       (name/theme on top, public id underneath). */
    .class-card__head {
        display: flex;
        align-items: flex-start;
        gap: var(--sp-3);
    }
    /* The avatar's top-left corner echoes the card's own corner; the other
       three stay square against the content it sits next to. This @layer is
       later than components', so it wins over the round base `.avatar`. */
    .class-card__avatar {
        width: 56px;
        height: 56px;
        flex: none;
        border-radius: var(--r-md) 0 0 0;
    }
    /* Let a long name/theme wrap instead of stretching the flex row. */
    .class-card__ident {
        min-width: 0;
    }
    .class-card__title {
        font-weight: var(--weight-semibold);
        font-size: var(--text-lg);
        letter-spacing: var(--tracking-tight);
    }
    .class-card__sub {
        margin-top: 0.15rem;
    }
    .class-card__title a {
        color: inherit;
        text-decoration: none;
    }
    .class-card__title a:hover {
        color: var(--brand-text);
        text-decoration: underline;
    }
    .class-card__date {
        color: var(--muted);
        font-size: var(--text-sm);
        margin-top: 0.15rem;
    }
    .class-card__actions {
        margin-top: var(--sp-4);
        display: flex;
        justify-content: flex-end;
    }
    .subgroup {
        margin-top: var(--sp-4);
    }
    .subgroup__label {
        margin: 0 0 var(--sp-2);
    }
    .subcards {
        list-style: none;
        padding: 0;
        margin: 0;
        display: grid;
        gap: var(--sp-2);
    }
    /* Sub-items live in inset wells (surface-2, hairline-less), one level
       below their class card — content, not input fields. */
    .subcard {
        display: flex;
        align-items: center;
        flex-wrap: wrap;
        gap: var(--sp-2);
        border: 1px solid transparent;
        padding: var(--sp-3) var(--sp-4);
        color: inherit;
        text-decoration: none;
        transition:
            border-color var(--t-fast),
            background var(--t-fast);
    }
    .subcard:hover {
        border-color: var(--brand-border);
        background: color-mix(in srgb, var(--brand) 6%, var(--surface-2));
    }
    .subcard__title {
        font-weight: var(--weight-semibold);
        flex: 1;
    }
    /* Homework status accents. */
    .hw-card {
        border-inline-start-width: 3px;
    }
    .hw-card--todo {
        border-inline-start-color: var(--danger);
    }
    .hw-card--review {
        border-inline-start-color: var(--warn);
    }
    .hw-card--done {
        border-inline-start-color: var(--ok);
    }

    /* Teacher classes: day separators + feed. */
    .class-day {
        margin-top: var(--sp-5);
    }
    .class-day:first-of-type {
        margin-top: var(--sp-2);
    }
    /* Day separators anchor the feed: a small date chip + rule, clearly
       a different visual species from the cards they group. */
    .class-day__sep {
        display: flex;
        align-items: center;
        gap: var(--sp-3);
        margin: 0 0 var(--sp-3);
        color: var(--muted);
        font-size: var(--text-xs);
        font-weight: var(--weight-bold);
        text-transform: uppercase;
        letter-spacing: var(--tracking-wide);
    }
    .class-day__sep::after {
        content: "";
        flex: 1;
        height: 1px;
        background: var(--line);
    }
    .class-list {
        list-style: none;
        padding: 0;
        margin: 0;
        display: grid;
        gap: var(--sp-4);
    }
    .class-filters {
        flex-basis: 100%;
        display: flex;
        flex-wrap: wrap;
        gap: var(--sp-3);
        margin-top: var(--sp-2);
    }
    .class-filters label {
        flex: 1;
        min-width: 10rem;
        margin: 0;
    }
    .feed-pager {
        display: flex;
        align-items: center;
        justify-content: center;
        gap: var(--sp-4);
        margin: var(--sp-5) 0 var(--sp-1);
    }
    .feed-pager form {
        margin: 0;
    }
    .feed-pager button,
    .feed-pager .btn {
        margin-top: 0;
    }
    .feed-pager__status {
        font-size: var(--text-sm);
        color: var(--muted);
    }
    /* Collapsible "Add a new class" disclosure. */
    .add-class {
        border: 1px solid var(--line);
        border-radius: var(--r-md);
        padding: var(--sp-2) var(--sp-4);
        margin: var(--sp-3) 0 var(--sp-4);
        background: var(--surface);
    }
    .add-class[open] {
        padding-bottom: var(--sp-3);
    }
    .add-class__summary {
        cursor: pointer;
        font-weight: var(--weight-semibold);
        display: flex;
        align-items: center;
        gap: 0.4rem;
        list-style: none;
        padding: var(--sp-1) 0;
    }
    .add-class__summary::-webkit-details-marker {
        display: none;
    }

    /* Tags. */
    .tag-list {
        list-style: none;
        display: flex;
        flex-wrap: wrap;
        gap: 0.35rem;
        padding: 0;
        margin: 0 0 var(--sp-2);
    }
    .tag-chip {
        display: inline-flex;
        align-items: center;
        gap: 0.15rem;
        padding: 0.15rem 0.25rem 0.15rem 0.6rem;
        font-size: var(--text-xs);
        font-weight: var(--weight-medium);
        border-radius: var(--r-full);
        background: var(--brand-soft);
        color: var(--brand-text);
        border: 1px solid var(--brand-border);
    }
    .tag-chip form {
        margin: 0;
        display: inline-flex;
    }
    .tag-chip__remove {
        margin: 0;
        width: 1.15rem;
        height: 1.15rem;
        padding: 0;
        border: none;
        border-radius: var(--r-full);
        background: transparent;
        color: var(--brand-text);
        font-size: 0.95rem;
        line-height: 1;
    }
    .tag-chip__remove:hover {
        background: color-mix(in srgb, var(--brand) 24%, transparent);
        box-shadow: none;
    }
    .history-list .tag-list,
    .class-feed .tag-list {
        margin-top: var(--sp-2);
    }
    .history-list .tag-chip,
    .class-feed .tag-chip {
        padding: 0.15rem 0.6rem;
    }
    /* Multi-select picker. Rendered by the MultiSelect island; the
       server-rendered fallback is the bare checkbox list, which submits
       correctly on its own — so nothing here has to style a closed state
       that a no-JS visitor could get stuck in.

       Was a <details>/<summary> disclosure whose caret was a CSS ::after
       "▾"/"▴" pair, with an always-rendered search box kept [hidden] until
       script revealed it. */
    .tag-select {
        border: 1px solid var(--line-strong);
        border-radius: var(--r-sm);
        background: var(--surface);
        margin-top: var(--sp-1-5);
    }
    .tag-select__summary {
        width: 100%;
        display: flex;
        align-items: center;
        justify-content: space-between;
        gap: var(--sp-2);
        padding: 0.6rem 0.75rem;
        font-size: var(--text-control);
        color: inherit;
        cursor: pointer;
    }
    /* The chevron points down when closed, up when open — one rotated
       icon instead of two swapped glyphs. Rotating a vertical chevron is
       direction-neutral, so RTL needs no special case. */
    .tag-select__summary .icon {
        margin-inline-start: auto;
        color: var(--muted);
        transition: transform var(--t-fast);
    }
    .tag-select.is-open .tag-select__summary .icon {
        transform: rotate(180deg);
    }
    .tag-select__menu {
        display: flex;
        flex-direction: column;
        padding: var(--sp-1-5) var(--sp-3) var(--sp-2);
        max-height: 13rem;
        overflow-y: auto;
        border-top: 1px solid var(--line);
    }
    .tag-select__menu .checkbox {
        margin: var(--sp-1-5) 0;
    }
    .tag-select__search {
        margin: var(--sp-2) 0 var(--sp-1);
        padding: 0.4rem 0.6rem;
        font-size: var(--text-sm);
    }
    /* Live count of checked options, shown in the summary. */
    .tag-select__count {
        min-width: 1.35rem;
        padding: 0.05rem 0.45rem;
        margin-inline-start: auto;
        border-radius: var(--r-full);
        background: var(--brand-soft);
        color: var(--brand-text);
        font-size: var(--text-xs);
        font-weight: var(--weight-bold);
        text-align: center;
    }

    /* Search highlight + matched-content excerpt. */
    /* Every search hit highlights the same way, chat included, on the token
       radius. --r-sm can read as a box around a few characters — flagged for a
       visual check in the Phase 6 pass; drop to 2px if it looks too round. */
    .history-list mark,
    .class-feed mark,
    .history-excerpt mark,
    .chat__body mark {
        background: color-mix(in srgb, var(--brand) 30%, transparent);
        color: inherit;
        border-radius: var(--r-sm);
        padding: 0 0.12em;
    }
    /* Surface comes from the "inset well" alias in components.css. */
    .history-excerpt {
        margin-top: var(--sp-3);
        padding: var(--sp-2) var(--sp-3);
    }
    .history-excerpt__label {
        margin: 0 0 var(--sp-1);
    }
    .history-excerpt__text {
        margin: 0;
        font-size: var(--text-sm);
        color: var(--ink);
        max-height: 4.5rem;
        overflow: hidden;
        white-space: pre-wrap;
        word-break: break-word;
    }
    .history-excerpt__text--clip {
        -webkit-mask-image: linear-gradient(
            to bottom,
            #000 calc(100% - 1.5rem),
            transparent 100%
        );
        mask-image: linear-gradient(
            to bottom,
            #000 calc(100% - 1.5rem),
            transparent 100%
        );
    }

    /* ═══ Rendered Markdown ══════════════════════════════════════ */
    .md > :first-child {
        margin-top: 0;
    }
    .md > :last-child {
        margin-bottom: 0;
    }
    .md h1,
    .md h2,
    .md h3,
    .md h4,
    .md h5,
    .md h6 {
        font-weight: var(--weight-bold);
        line-height: var(--leading-tight);
        letter-spacing: var(--tracking-tight);
        margin: 1.2em 0 0.5em;
    }
    .md h1 {
        font-size: var(--text-2xl);
    }
    .md h2 {
        font-size: var(--text-xl);
    }
    .md h3 {
        font-size: var(--text-lg);
    }
    .md h4 {
        font-size: var(--text-md);
    }
    .md h5,
    .md h6 {
        font-size: var(--text-sm);
        text-transform: uppercase;
        letter-spacing: var(--tracking-wide);
        color: var(--muted);
    }
    .md h1 + *,
    .md h2 + *,
    .md h3 + *,
    .md h4 + *,
    .md h5 + *,
    .md h6 + * {
        margin-top: 0;
    }
    .md p {
        margin: 0.6rem 0;
        line-height: var(--leading-body);
    }
    .md ul,
    .md ol {
        margin: 0.6rem 0;
        padding-inline-start: 1.4rem;
    }
    .md li {
        margin: 0.25rem 0;
    }
    .md li > ul,
    .md li > ol {
        margin: 0.25rem 0;
    }
    .md a {
        color: var(--brand-text);
    }
    .md img {
        max-width: 100%;
        height: auto;
        border-radius: var(--r-sm);
        margin: 0.6rem 0;
    }
    .md hr {
        border: none;
        border-top: 1px solid var(--line);
        margin: 1.2rem 0;
    }
    /* Surface (tint + leading stripe) comes from the "brand accent stripe"
       alias in components.css. The radius is a deliberate override: a
       blockquote squares off the edge the stripe runs down. */
    .md blockquote {
        margin: var(--sp-3) 0;
        padding: var(--sp-2) var(--sp-4);
        border-radius: 0 var(--r-sm) var(--r-sm) 0;
        color: var(--ink-2);
    }
    .md blockquote > :first-child {
        margin-top: 0;
    }
    .md blockquote > :last-child {
        margin-bottom: 0;
    }
    .md blockquote p {
        margin: 0.35rem 0;
    }
    .md blockquote blockquote {
        margin-block: var(--sp-2);
        border-inline-start-color: var(--line-strong);
        background: transparent;
    }
    .md pre {
        background: var(--surface-2);
        border: 1px solid var(--line);
        border-radius: var(--r-sm);
        padding: var(--sp-3) var(--sp-4);
        margin: var(--sp-3) 0;
        overflow-x: auto;
    }
    .md pre code {
        background: none;
        border: none;
        padding: 0;
        border-radius: 0;
        font-size: var(--text-sm);
    }
    .md table {
        border-collapse: collapse;
        font-size: var(--text-sm);
        margin: var(--sp-3) 0;
        display: block;
        overflow-x: auto;
        max-width: 100%;
    }
    .md table th,
    .md table td {
        padding: 0.45rem 0.7rem;
        border: 1px solid var(--line);
        text-align: start;
        vertical-align: top;
    }
    .md table th {
        background: var(--surface-2);
        font-weight: var(--weight-semibold);
        font-size: var(--text-xs);
        text-transform: uppercase;
        letter-spacing: var(--tracking-wide);
        color: var(--muted);
    }

    /* ═══ Chat ═══════════════════════════════════════════════════ */
    .chat {
        list-style: none;
        margin: 0 0 var(--sp-4);
        padding: 0;
        display: flex;
        flex-direction: column;
        gap: var(--sp-2);
    }
    .chat__msg {
        max-width: min(80%, 44rem);
        padding: var(--sp-2) var(--sp-4);
        border-radius: var(--r-md);
        border-end-start-radius: 4px;
        background: var(--surface-2);
        border: 1px solid var(--line);
        align-self: flex-start;
    }
    .chat__msg--mine {
        align-self: flex-end;
        /* Opaque, not `--brand-soft` (which mixes toward transparent): the
           thread's message list carries the striped backdrop, and a
           translucent bubble let the stripes run through the text. Same tint
           over --surface, so it tracks both themes. */
        background: color-mix(in srgb, var(--brand) 9%, var(--surface));
        border-color: var(--brand-border);
        border-radius: var(--r-md);
        border-end-end-radius: 4px;
    }
    /* Group form: fieldsets carry the grouping, so they get the visual break
       and the legend does the labelling. */
    .group-form fieldset {
        border: 1px solid var(--line);
        border-radius: var(--r-md);
        padding: var(--sp-3) var(--sp-4) var(--sp-4);
        margin: 0 0 var(--sp-4);
    }
    .group-form legend {
        padding: 0 var(--sp-2);
        font-weight: 600;
        font-size: var(--text-sm);
        color: var(--muted);
        text-transform: uppercase;
        letter-spacing: 0.04em;
    }
    /* The member picker only applies to the hand-picked audience. Written as
       "hide when something else is selected" so a browser without :has()
       degrades to showing it — the server ignores it for rule-based audiences
       either way, so a stale tick is harmless. */
    .group-form:has(#audience option:not([value="manual"]):checked)
        .group-form__picker {
        display: none;
    }
    .group-form__danger {
        margin-top: var(--sp-4);
        padding-top: var(--sp-4);
        border-top: 1px solid var(--line);
    }

    /* Owner-only controls on a group thread. */
    .chat-thread__settings {
        margin-inline-start: auto;
    }
    /* The delete control sits in the message's meta row and stays quiet until
       the bubble is hovered or it takes focus — it must be reachable by
       keyboard, so `opacity` rather than `display`. */
    .chat__delete {
        margin-inline-start: auto;
        display: flex;
    }
    .chat__delete button {
        padding: 0;
        border: 0;
        background: none;
        color: var(--muted);
        cursor: pointer;
        opacity: 0;
        transition: opacity var(--dur-fast) ease;
    }
    .chat__msg:hover .chat__delete button,
    .chat__delete button:focus-visible {
        opacity: 1;
    }
    .chat__delete button:hover {
        color: var(--danger-fill);
    }
    @media (hover: none) {
        /* No hover to reveal it on a touch screen. */
        .chat__delete button {
            opacity: 1;
        }
    }

    /* A message from the site: centered and full-width, so it reads as an
       announcement rather than as something either side said. */
    .chat__msg--system {
        align-self: stretch;
        max-width: none;
        text-align: center;
        border-radius: var(--r-md);
        border-style: dashed;
    }
    .chat__msg--system .chat__meta {
        justify-content: center;
    }
    .chat__detail {
        color: var(--muted);
        font-size: var(--text-sm);
    }
    .chat__msg--pending {
        opacity: var(--opacity-quiet);
    }
    .chat__msg--pending .chat__time {
        font-style: italic;
    }
    .chat__meta {
        display: flex;
        gap: var(--sp-2);
        align-items: baseline;
        font-size: var(--text-xs);
        margin-bottom: 0.2rem;
    }
    .chat__sender {
        font-weight: var(--weight-semibold);
    }
    .chat__receipt {
        display: inline-flex;
        align-items: center;
        margin-inline-start: auto;
        color: var(--ink);
        opacity: 0.6;
        line-height: 1;
    }
    .chat__receipt .icon {
        width: 1.3em;
        height: 1.3em;
        stroke-width: 2.2;
    }
    .chat__receipt-icon {
        display: none;
        align-items: center;
    }
    .chat__receipt-icon--read .icon + .icon {
        margin-inline-start: -0.45em;
    }
    .chat__receipt .chat__receipt-icon--sent {
        display: inline-flex;
    }
    .chat__receipt.is-read {
        color: var(--brand-text);
        opacity: 1;
    }
    .chat__receipt.is-read .chat__receipt-icon--sent {
        display: none;
    }
    .chat__receipt.is-read .chat__receipt-icon--read {
        display: inline-flex;
    }
    .chat__empty {
        list-style: none;
    }
    .chat-list__you {
        font-weight: var(--weight-semibold);
        color: var(--ink);
    }
    .new-convo {
        display: flex;
        flex-wrap: wrap;
        align-items: center;
        gap: var(--sp-2);
    }
    .new-convo select {
        max-width: 26rem;
    }
    .chat__body > :first-child {
        margin-top: 0;
    }
    .chat__body > :last-child {
        margin-bottom: 0;
    }

    /* Message search results (chat home). */
    .chat-search {
        margin: var(--sp-3) 0 var(--sp-5);
    }
    .chat-search__list {
        list-style: none;
        margin: 0;
        padding: 0;
        display: flex;
        flex-direction: column;
        gap: var(--sp-3);
    }
    .chat-search__open {
        display: block;
        text-decoration: none;
        color: inherit;
        padding: var(--sp-3) var(--sp-4);
        border: 1px solid var(--line);
        border-radius: var(--r-md);
        background: var(--surface);
        transition:
            border-color var(--t-fast),
            background var(--t-fast);
    }
    .chat-search__open:hover {
        border-color: var(--brand-border);
        background: color-mix(in srgb, var(--brand) 5%, var(--surface));
    }
    .chat-search__head {
        display: flex;
        align-items: center;
        gap: var(--sp-2);
        flex-wrap: wrap;
        margin-bottom: var(--sp-2);
    }
    .chat-search__name {
        font-weight: var(--weight-semibold);
    }
    .chat--snippet {
        max-height: 15rem;
        overflow: hidden;
        margin: 0;
        pointer-events: none;
    }
    .chat--fade-top {
        -webkit-mask-image: linear-gradient(
            to bottom,
            transparent 0,
            #000 2.5rem
        );
        mask-image: linear-gradient(to bottom, transparent 0, #000 2.5rem);
    }
    .chat--fade-bottom {
        -webkit-mask-image: linear-gradient(
            to bottom,
            #000 calc(100% - 2.5rem),
            transparent 100%
        );
        mask-image: linear-gradient(
            to bottom,
            #000 calc(100% - 2.5rem),
            transparent 100%
        );
    }
    .chat--fade-both {
        -webkit-mask-image: linear-gradient(
            to bottom,
            transparent 0,
            #000 2.5rem,
            #000 calc(100% - 2.5rem),
            transparent 100%
        );
        mask-image: linear-gradient(
            to bottom,
            transparent 0,
            #000 2.5rem,
            #000 calc(100% - 2.5rem),
            transparent 100%
        );
    }
    .chat__msg--match {
        outline: 2px solid var(--brand);
        background: color-mix(in srgb, var(--brand) 12%, var(--surface));
        margin: 0.2rem;
    }

    /* Chat thread: bottom-pinned composer, independent scroll. */
    .content:has(.chat-thread) {
        padding: 0;
        max-width: none;
        /* `--tabbar-reserve` (layout.css), not `--tabbar-h`: it collapses to
           0 while a text control has focus, when the tab bar is hidden. Using
           the raw token here kept the strip reserved after the bar went away,
           leaving a dead band under the composer. */
        height: calc(100vh - var(--topbar-h) - var(--tabbar-reserve));
        height: calc(100dvh - var(--topbar-h) - var(--tabbar-reserve));
        min-height: 0;
        overflow: hidden;
        /* A column, not a block: banners that render above the thread (invite,
           timezone prompt, flash) are in-flow siblings here. As a block they
           pushed the height-locked thread down and its composer off the
           clipped bottom edge; as flex children they take their natural
           height and the thread absorbs whatever is left. */
        display: flex;
        flex-direction: column;
    }
    @media (min-width: 1024px) {
        .content:has(.chat-thread) {
            height: calc(100vh - var(--topbar-h));
            height: calc(100dvh - var(--topbar-h));
        }
    }
    /* Height-locked pane: the thread fills the column exactly, so the
       site footer below it would only ever be clipped — drop it outright
       (the classroom room does the same via its body:has() rule). */
    .content:has(.chat-thread) .site-footer {
        display: none;
    }
    .chat-thread {
        display: flex;
        flex-direction: column;
        /* `flex: 1`, not `height: 100%`: the thread takes the column's
           *remaining* height, so any banner above it shortens the thread
           instead of pushing the composer past the clipped bottom edge.
           `min-height: 0` keeps the message list scrollable inside it. */
        flex: 1;
        min-height: 0;
        margin: 0;
        padding: 0;
        border: 0;
        border-radius: 0;
        box-shadow: none;
    }
    .chat-thread__header {
        flex: none;
        display: flex;
        flex-direction: column;
        gap: 0.25rem;
        padding: var(--sp-3) var(--page-pad);
        border-bottom: 1px solid var(--line);
        background: var(--surface);
    }
    .chat-thread__header h1 {
        margin: 0;
        font-size: var(--text-lg);
    }
    .chat-thread__header .muted {
        margin: 0;
    }
    .chat-thread__title {
        display: flex;
        align-items: center;
        gap: var(--sp-3);
    }
    .chat-thread__title .avatar {
        border-radius: var(--r-md);
    }
    /* Collapsed by default (no `open` attribute) so a big group's roster stays
       out of the way; the summary is the only thing shown until it is opened. */
    .chat-thread__members summary {
        cursor: pointer;
        font-weight: var(--weight-semibold);
        list-style: none;
        padding: var(--sp-1) 0;
    }
    .chat-thread__members summary::-webkit-details-marker {
        display: none;
    }
    .chat-thread__members ul {
        list-style: none;
        display: flex;
        flex-wrap: wrap;
        gap: var(--sp-2);
        margin: var(--sp-1) 0 0;
        padding: 0;
    }
    .chat-thread__members li {
        display: inline-flex;
        align-items: center;
        gap: 0.4rem;
    }
    /* "Official communication" badge: a group flying the site's identity wears
       it beside its name in the header and on its inbox card. Coloured with the
       brand accent so it reads as a mark of authority, and sized to sit inline
       with the title text. */
    .verified-badge {
        display: inline-flex;
        align-items: center;
        color: var(--brand-text);
        vertical-align: -0.15em;
        margin-right: 0.2em;
    }
    .verified-badge .icon {
        width: 1.2em;
        height: 1.2em;
        vertical-align: 0;
    }
    .chat-thread .chat {
        flex: 1;
        min-height: 0;
        overflow-y: auto;
        overscroll-behavior: contain;
        margin: 0;
        padding: var(--page-pad);
        /* The same stripe texture as the app backdrops (tokens.css), read in
           the gaps around the bubbles. Theme-aware on its own — the token is
           color-mix over the brand, so nothing is hardcoded here. */
        background-image: var(--bg-stripes);
    }
    .chat-thread .banner {
        margin: var(--sp-2) var(--page-pad) 0;
    }
    .chat-thread__composer {
        flex: none;
        border-top: 1px solid var(--line);
        background: var(--surface);
    }
    /* Read-only threads: the advisory sits where the composer would be, so the
       thread still ends in a footer rather than trailing off. */
    .chat-thread__composer--readonly {
        padding: var(--sp-3) var(--page-pad);
        text-align: center;
    }
    .chat-thread__composer--readonly p {
        margin: 0;
    }
    .chat-thread__composer form {
        display: flex;
        align-items: stretch;
    }
    .chat-thread__composer textarea {
        flex: 1;
        margin: 0;
        border: 0;
        border-radius: 0;
        background: transparent;
        resize: none;
        /* The script grows the field to fit its text; min-height keeps the
           two-row baseline (matches the no-JS rows="2"), max-height caps it. */
        min-height: calc(2.8em + 2 * var(--sp-4));
        max-height: 8rem;
        padding: var(--sp-4) var(--page-pad);
        /* No scrollbar ever: the field auto-grows to fit, and once it reaches
           max-height it scrolls with the bar hidden (Firefox + WebKit/Blink). */
        overflow-y: auto;
        scrollbar-width: none;
    }
    .chat-thread__composer textarea::-webkit-scrollbar {
        display: none;
    }
    .chat-thread__composer textarea:focus {
        box-shadow: none;
    }
    .chat-thread__composer button {
        flex: none;
        margin: 0;
        border-radius: 0;
        padding-inline: var(--sp-5);
    }
    /* Author-layer button rules beat the UA's [hidden] style, so the toggled
       composer pieces need explicit guards. */
    .chat-thread__composer [hidden],
    .chat-thread__composer form[hidden] {
        display: none;
    }
    /* The emoji/mic triggers read as quiet inline icons, not brand blocks. */
    .chat-emoji__btn,
    .chat-mic__btn,
    .chat-rec__cancel {
        background: transparent;
        border-color: transparent;
        color: var(--muted);
        box-shadow: none;
        padding-inline: var(--sp-4);
    }
    .chat-emoji__btn:hover,
    .chat-mic__btn:hover,
    .chat-rec__cancel:hover {
        background: var(--surface-2);
        color: var(--ink);
    }
    .chat-emoji__btn .icon,
    .chat-mic__btn .icon {
        width: 1.35em;
        height: 1.35em;
    }
    /* Desktop-only: phone keyboards already ship an emoji plane. */
    .chat-emoji__btn {
        display: none;
    }
    @media (hover: hover) and (pointer: fine) {
        .chat-emoji__btn:not([hidden]) {
            /* Centred explicitly. As a flex *item* of the composer form this
               blockifies to `flex`, and a flex container defaults to
               align-items: stretch — which pinned the icon to the top of a
               button stretched to the composer's full height. `.btn` (the
               send button beside it) carries the same three declarations;
               these triggers are deliberately not `.btn`, so they need it. */
            display: inline-flex;
            align-items: center;
            justify-content: center;
        }
    }

    /* Recording bar: swapped in for the compose form while the mic is live.
       A red wash + pulsing dot make the recording state unmistakable. The
       min-height matches the compose textarea so the bar keeps the composer's
       height when it swaps in. A real border-top (not an inset shadow) draws
       the red accent so the cancel button's hover background can't paint over
       it and break the line. */
    .chat-rec {
        display: flex;
        align-items: stretch;
        gap: var(--sp-3);
        min-height: calc(2.8em + 2 * var(--sp-4));
        padding-inline-start: var(--sp-4);
        background: color-mix(in srgb, var(--danger-fill) 10%, var(--surface));
        border-top: 2px solid var(--danger-fill);
    }
    /* The send action turns red while recording, so the button that fires the
       clip is clearly not the everyday brand-blue submit. */
    .chat-rec__send {
        background: var(--danger-fill);
        border-color: var(--danger-fill);
        color: var(--danger-ink);
    }
    .chat-rec__send:hover {
        background: color-mix(in srgb, var(--danger-fill) 88%, black);
        border-color: color-mix(in srgb, var(--danger-fill) 88%, black);
    }
    .chat-rec__dot {
        align-self: center;
        width: 0.65rem;
        height: 0.65rem;
        border-radius: var(--r-full);
        background: var(--danger-fill);
        animation: chat-rec-pulse 1.2s ease-in-out infinite;
    }
    @keyframes chat-rec-pulse {
        0%,
        100% {
            opacity: 1;
        }
        50% {
            opacity: 0.3;
        }
    }
    .chat-rec__time {
        align-self: center;
        margin-inline-end: auto;
        padding-block: var(--sp-4);
        font-variant-numeric: tabular-nums;
        font-weight: var(--weight-semibold);
    }

    /* Voice-message bubble: play toggle, seekable "waveform", time label. */
    .voice-msg {
        display: flex;
        align-items: center;
        gap: var(--sp-3);
        width: min(18rem, 100%);
        padding-block: var(--sp-1);
    }
    .voice-msg__play {
        flex: none;
        width: 2.4rem;
        height: 2.4rem;
        margin: 0;
        padding: 0;
        border-radius: var(--r-full);
    }
    .voice-msg__play .icon {
        width: 1.15em;
        height: 1.15em;
    }
    .voice-msg__ic {
        display: none;
        align-items: center;
    }
    .voice-msg .voice-msg__ic--play {
        display: inline-flex;
    }
    .voice-msg.is-playing .voice-msg__ic--play {
        display: none;
    }
    .voice-msg.is-playing .voice-msg__ic--pause {
        display: inline-flex;
    }
    .voice-msg__track {
        position: relative;
        flex: 1;
        height: 1.4rem;
        cursor: pointer;
        overflow: hidden;
    }
    /* Decorative uniform "waveform" comb; the fill repeats it in full ink. */
    .voice-msg__track::before {
        content: "";
        position: absolute;
        inset: 0.15rem 0;
        background: repeating-linear-gradient(
            90deg,
            currentColor 0 3px,
            transparent 3px 8px
        );
        border-radius: 2px;
        opacity: 0.25;
    }
    .voice-msg__fill {
        position: absolute;
        inset-block: 0.15rem;
        inset-inline-start: 0;
        width: 0;
        background: repeating-linear-gradient(
            90deg,
            currentColor 0 3px,
            transparent 3px 8px
        );
        opacity: 0.85;
        pointer-events: none;
    }
    .voice-msg__time {
        flex: none;
        min-width: 2.6rem;
        text-align: end;
        font-size: var(--text-xs);
        font-variant-numeric: tabular-nums;
        color: var(--muted);
    }
    .chat__body audio {
        max-width: 100%;
    }

    /* Emoji palette: an iOS-flavored translucent panel above the composer. */
    .chat-thread__composer {
        position: relative;
    }
    .emoji-picker {
        position: absolute;
        inset-inline-start: var(--sp-3);
        bottom: calc(100% + var(--sp-2));
        width: min(23rem, calc(100vw - 2 * var(--sp-3)));
        display: flex;
        flex-direction: column;
        border: 1px solid var(--line);
        border-radius: var(--r-lg);
        background: color-mix(in srgb, var(--surface) 88%, transparent);
        -webkit-backdrop-filter: blur(20px) saturate(1.4);
        backdrop-filter: blur(20px) saturate(1.4);
        box-shadow: var(--shadow-lg);
        overflow: hidden;
        z-index: var(--z-overlay);
    }
    .emoji-picker[hidden] {
        display: none;
    }
    /* Fuzzy-finder search over emoji names, above the browse grid. */
    .emoji-picker__search {
        padding: var(--sp-2) var(--sp-3);
        border-bottom: 1px solid var(--line);
    }
    .emoji-picker__search-input {
        width: 100%;
        margin: 0;
        padding: var(--sp-2) var(--sp-3);
        font-size: var(--text-sm);
        line-height: 1.3;
        border-radius: var(--r-sm);
        background: var(--surface);
    }
    .emoji-picker__sections,
    .emoji-picker__results {
        position: relative;
        height: 17rem;
        overflow-y: auto;
        overflow-x: hidden;
        overscroll-behavior: contain;
        scroll-behavior: smooth;
        padding: var(--sp-2) var(--sp-3) var(--sp-3);
    }
    /* Explicit guards: the author-layer display rules on these pieces beat the
       UA [hidden] style, so search/browse toggling needs them spelled out. */
    .emoji-picker__sections[hidden],
    .emoji-picker__results[hidden],
    .emoji-picker__tabs[hidden],
    .emoji-picker__empty[hidden] {
        display: none;
    }
    .emoji-picker__empty {
        margin: 0;
        padding: var(--sp-5) var(--sp-3);
        text-align: center;
        color: var(--muted);
        font-size: var(--text-sm);
    }
    .emoji-picker__heading {
        margin: var(--sp-2) 0 var(--sp-1);
        font-size: var(--text-xs);
        font-weight: var(--weight-semibold);
        text-transform: uppercase;
        letter-spacing: var(--tracking-wide);
        color: var(--muted);
    }
    .emoji-picker__grid {
        display: grid;
        /* minmax(0, …) lets cells shrink below an emoji's intrinsic width, so
           wide glyphs (flags, ZWJ sequences) can't force horizontal overflow. */
        grid-template-columns: repeat(8, minmax(0, 1fr));
    }
    .emoji-picker__emoji {
        display: flex;
        align-items: center;
        justify-content: center;
        margin: 0;
        padding: 0;
        height: 2.2rem;
        border: 0;
        border-radius: var(--r-sm);
        background: transparent;
        box-shadow: none;
        font-size: 1.4rem;
        line-height: 1;
        cursor: pointer;
        overflow: hidden;
    }
    .emoji-picker__emoji:hover {
        background: color-mix(in srgb, var(--ink) 8%, transparent);
    }
    .emoji-picker__tabs {
        display: flex;
        gap: 2px;
        padding: var(--sp-1) var(--sp-2);
        border-top: 1px solid var(--line);
        background: color-mix(in srgb, var(--surface-2) 70%, transparent);
    }
    .emoji-picker__tab {
        flex: 1;
        display: flex;
        align-items: center;
        justify-content: center;
        margin: 0;
        padding: 0.3rem 0;
        border: 0;
        border-radius: var(--r-sm);
        background: transparent;
        box-shadow: none;
        font-size: 1rem;
        line-height: 1;
        filter: grayscale(1);
        opacity: 0.7;
        cursor: pointer;
    }
    .emoji-picker__tab:hover {
        background: color-mix(in srgb, var(--ink) 8%, transparent);
        opacity: 1;
    }
    .emoji-picker__tab.is-active {
        background: color-mix(in srgb, var(--ink) 10%, transparent);
        filter: none;
        opacity: 1;
    }

    /* ═══ Flashcards ═════════════════════════════════════════════ */
    .fc-stage {
        width: min(100%, 34rem);
        margin: 0 auto;
        text-align: center;
    }
    .fc-progress {
        margin-bottom: var(--sp-3);
    }
    .fc-reveal {
        position: absolute;
        inset-inline-start: 0;
        opacity: 0;
        pointer-events: none;
    }
    /* The entrance animation lives on the scene, NOT on .fc-card: an
       animated transform with a fill mode would override the :checked
       flip transform below. */
    .fc-scene {
        perspective: 1200px;
        animation: fc-enter 0.35s var(--ease) both;
    }
    .fc-card {
        position: relative;
        min-height: 16rem;
        transform-style: preserve-3d;
        transition: transform 0.5s cubic-bezier(0.2, 0.7, 0.3, 1);
    }
    .fc-reveal:checked ~ .fc-card {
        transform: rotateY(180deg);
    }
    .fc-face {
        position: absolute;
        inset: 0;
        display: flex;
        align-items: center;
        justify-content: center;
        padding: var(--card-pad-y) var(--card-pad-x);
        background: var(--surface);
        border: 1px solid var(--line);
        border-radius: var(--r-lg);
        box-shadow: var(--shadow-md);
        backface-visibility: hidden;
        -webkit-backface-visibility: hidden;
        /* No overflow clipping here: a scrollable face gets its own
           compositing layer in Chromium and backface-visibility is
           ignored. Long content scrolls inside .md instead. */
    }
    .fc-face .md {
        font-size: var(--text-lg);
        max-width: 100%;
        max-height: 100%;
        overflow: auto;
    }
    .fc-face--back {
        transform: rotateY(180deg);
    }
    .fc-reveal-btn {
        margin-top: var(--sp-5);
    }
    .fc-reveal:focus-visible ~ .fc-reveal-btn {
        box-shadow: var(--ring);
    }
    .fc-reveal:checked ~ .fc-reveal-btn {
        display: none;
    }
    .fc-actions {
        display: grid;
        grid-template-columns: repeat(5, 1fr);
        gap: var(--sp-2);
        margin-top: var(--sp-5);
        max-height: 0;
        overflow: hidden;
        opacity: 0;
        transition:
            opacity var(--t-med),
            max-height var(--t-med);
    }
    .fc-reveal:checked ~ .fc-actions {
        max-height: 200px;
        opacity: 1;
    }
    .fc-rate {
        margin-top: 0;
        padding: 0.55rem 0.25rem;
        font-size: var(--text-sm);
        font-weight: var(--weight-semibold);
        border: 1px solid transparent;
    }
    .fc-rate--0,
    .fc-rate--1 {
        background: var(--danger-bg);
        color: var(--danger);
    }
    .fc-rate--0:hover,
    .fc-rate--1:hover {
        background: color-mix(in srgb, var(--danger) 18%, var(--danger-bg));
    }
    .fc-rate--2 {
        background: var(--warn-bg);
        color: var(--warn);
    }
    .fc-rate--2:hover {
        background: color-mix(in srgb, var(--warn) 18%, var(--warn-bg));
    }
    .fc-rate--3 {
        background: var(--ok-bg);
        color: var(--ok);
    }
    .fc-rate--3:hover {
        background: color-mix(in srgb, var(--ok) 18%, var(--ok-bg));
    }
    .fc-rate--4 {
        background: var(--brand);
        color: var(--brand-ink);
    }
    .fc-rate--4:hover {
        background: var(--brand-strong);
    }
    @keyframes fc-enter {
        from {
            opacity: 0;
            transform: translateY(12px);
        }
        to {
            opacity: 1;
            transform: translateY(0);
        }
    }

    /* Suggestion rows. */
    .fc-suggestion {
        display: flex;
        flex-wrap: wrap;
        justify-content: space-between;
        align-items: flex-start;
        gap: var(--sp-3) var(--sp-4);
        padding: var(--sp-4) var(--sp-5);
    }
    .fc-suggestion__body {
        flex: 1;
        min-width: min(100%, 16rem);
    }
    .fc-suggestion__actions {
        display: flex;
        flex-direction: column;
        gap: 0.4rem;
    }
    .fc-suggestion__actions form {
        margin: 0;
    }
    .fc-side-label {
        margin: var(--sp-2) 0 0.15rem;
    }
    .fc-side-label:first-child {
        margin-top: 0;
    }
    .fc-pending {
        margin: var(--sp-4) 0;
        padding: var(--sp-4) var(--sp-5);
        border: 1px solid color-mix(in srgb, var(--warn) 40%, var(--line));
        border-radius: var(--r-md);
        background: var(--warn-bg);
    }
    .fc-pending__title {
        display: flex;
        align-items: center;
        gap: var(--sp-2);
        margin-top: 0;
    }
    .fc-suggestion--pending {
        border-inline-start: 3px solid var(--warn);
    }

    /* ── First-run review tutorial ─────────────────────────────── */
    .fc-tut__head {
        margin-bottom: var(--sp-5);
    }
    .fc-tut__head h1 {
        margin-bottom: var(--sp-2);
    }
    .fc-tut__scene {
        margin-block: var(--sp-5);
    }
    /* The illustrative rating row is not interactive in the tutorial. */
    .fc-tut__actions .fc-rate {
        cursor: default;
    }

    /* Static, no-JS explanation (start-aligned, readable list). */
    .fc-tut__legend {
        margin-top: var(--sp-6);
        text-align: start;
    }
    .fc-tut__legend h2 {
        font-size: var(--text-md);
        margin-bottom: var(--sp-4);
    }
    .fc-legend {
        list-style: none;
        margin: 0;
        padding: 0;
        display: grid;
        gap: var(--sp-4);
    }
    .fc-legend__row {
        display: grid;
        grid-template-columns: auto minmax(4.5rem, auto) 1fr;
        align-items: baseline;
        gap: var(--sp-2) var(--sp-3);
    }
    .fc-swatch {
        align-self: center;
        width: 0.85rem;
        height: 0.85rem;
        border-radius: var(--r-sm);
    }
    .fc-swatch--0,
    .fc-swatch--1 {
        background: var(--danger);
    }
    .fc-swatch--2 {
        background: var(--warn);
    }
    .fc-swatch--3 {
        background: var(--ok);
    }
    .fc-swatch--4 {
        background: var(--brand);
    }

    .fc-tut__cta {
        margin-top: var(--sp-6);
        display: grid;
        gap: var(--sp-3);
    }

    /* "How reviewing works" replay affordance on the deck: a ghost button
       paired under the primary Start-review CTA, left-aligned to it (start,
       so it follows the reading direction). */
    .fc-tut-link {
        margin-top: var(--sp-3);
        text-align: start;
    }

    /* Once the JS tour is live, the static fallback is redundant. Keep the CTA
       form in the DOM (the tour submits it) but out of view. Leave room at the
       bottom so the fixed coach sheet never covers the mock card. */
    .fc-tut.fc-tour-ready .fc-tut__legend,
    .fc-tut.fc-tour-ready .fc-tut__cta {
        display: none;
    }
    .fc-tut.fc-tour-ready {
        padding-bottom: 15rem;
    }
    /* The rating row clips its children (overflow:hidden drives the reveal
       animation), which would square off the highlight ring. While a control
       is highlighted the row is already revealed, so let the ring escape. */
    .fc-tut__actions:has(.is-tour-active) {
        overflow: visible;
    }
    /* The control the current step describes, lifted with a brand ring. */
    .is-tour-active {
        position: relative;
        z-index: 1;
        border-radius: var(--r-md);
        box-shadow:
            0 0 0 3px var(--brand),
            var(--shadow-md);
        animation: fc-pulse 1.6s var(--ease) infinite;
    }
    @keyframes fc-pulse {
        0%,
        100% {
            box-shadow:
                0 0 0 3px var(--brand),
                var(--shadow-md);
        }
        50% {
            box-shadow:
                0 0 0 6px color-mix(in srgb, var(--brand) 35%, transparent),
                var(--shadow-md);
        }
    }

    /* The tour panel: a fixed bottom sheet on phones, a floating card ≥40rem. */
    .fc-coach {
        position: fixed;
        z-index: var(--z-tour);
        inset-inline: 0;
        inset-block-end: 0;
        margin-inline: auto;
        width: min(100%, 30rem);
        background: var(--surface);
        border: 1px solid var(--line);
        border-radius: var(--r-lg) var(--r-lg) 0 0;
        box-shadow: var(--shadow-lg);
        padding: var(--sp-5);
        padding-block-end: calc(var(--sp-5) + env(safe-area-inset-bottom, 0px));
        text-align: start;
        animation: fc-enter 0.25s var(--ease) both;
    }
    @media (min-width: 1024px) {
        .fc-coach {
            inset-block-end: var(--sp-5);
            border-radius: var(--r-lg);
        }
    }
    /* Title and close (✕) share a flex row, so the body always starts below
       both and can never run under the button — even when the title wraps. */
    .fc-coach__head {
        display: flex;
        align-items: flex-start;
        gap: var(--sp-3);
        margin-bottom: var(--sp-2);
    }
    .fc-coach__title {
        flex: 1;
        margin: 0;
        font-size: var(--text-md);
    }
    .fc-coach__close {
        flex: none;
        display: grid;
        place-items: center;
        width: 2rem;
        height: 2rem;
        /* Nudge into the corner, aligned with the title's cap height. */
        margin-block-start: -0.25rem;
        margin-inline-end: -0.25rem;
        border: 0;
        border-radius: var(--r-full);
        background: transparent;
        color: var(--muted);
        font-size: 1.5rem;
        line-height: 1;
        cursor: pointer;
    }
    .fc-coach__close:hover {
        background: var(--line);
    }
    .fc-coach__body {
        margin: 0 0 var(--sp-4);
    }
    .fc-coach__dots {
        display: flex;
        justify-content: center;
        gap: 0.4rem;
        margin-bottom: var(--sp-4);
    }
    .fc-coach__dot {
        width: 0.45rem;
        height: 0.45rem;
        border-radius: var(--r-full);
        background: var(--line);
        transition: background var(--t-med);
    }
    .fc-coach__dot.is-done {
        background: color-mix(in srgb, var(--brand) 45%, var(--line));
    }
    .fc-coach__dot.is-active {
        background: var(--brand);
        transform: scale(1.25);
    }
    .fc-coach__nav {
        display: flex;
        gap: var(--sp-3);
    }
    .fc-coach__nav > * {
        flex: 1;
    }

    /* ── AI usage (admin) ─────────────────────────────────────────────
       The teacher/student breakdown: a three-column grid used as a table
       whose rows expand. Only what is genuinely specific to this view
       lives here — the tiles are .metric--compact, the pills are .badge,
       the column captions are .overline, and the numeric cells are .num.

       Previously these rules sat in two <style> blocks inside the two
       templates, unlayered (so they outranked every layer) and with the
       tile definitions copy-pasted verbatim between the two files. */
    .aiu-grid {
        display: grid;
        grid-template-columns: minmax(0, 1fr) 6.5rem 8.5rem;
        gap: var(--sp-3);
        align-items: center;
    }
    .aiu-cols {
        padding: var(--sp-1-5) var(--sp-3);
        border-bottom: 1px solid var(--line);
    }
    .aiu-teacher {
        border-bottom: 1px solid var(--line);
    }
    /* The last divider would double up with whatever follows the list
       (back link / card edge) — the rows above already separate it. */
    .aiu-teacher:last-of-type {
        border-bottom: none;
    }
    .aiu-teacher__row {
        position: relative;
        padding: var(--sp-3);
        cursor: pointer;
        list-style: none;
    }
    .aiu-teacher__row::-webkit-details-marker {
        display: none;
    }
    .aiu-teacher__row:hover {
        background: var(--surface-2);
    }
    .aiu-teacher__name {
        display: flex;
        align-items: center;
        gap: var(--sp-2);
        min-width: 0;
        font-weight: var(--weight-semibold);
        color: var(--ink);
    }
    /* Both name rows truncate their label the same way. */
    .aiu-teacher__name > span,
    .aiu-person__name > span:first-child {
        overflow: hidden;
        text-overflow: ellipsis;
        white-space: nowrap;
    }
    .aiu-caret {
        flex: none;
        transition: transform var(--t-fast);
        color: var(--muted);
        font-size: var(--text-xs);
    }
    .aiu-teacher[open] .aiu-caret {
        transform: rotate(90deg);
    }
    /* Thin meter under each teacher row: token share of the largest group. */
    .aiu-meter {
        position: absolute;
        inset-inline-start: var(--sp-3);
        bottom: var(--sp-1);
        height: 3px;
        width: calc(100% - 2 * var(--sp-3));
        border-radius: var(--r-full);
        background: color-mix(in srgb, var(--brand) 14%, transparent);
        overflow: hidden;
    }
    .aiu-meter__fill {
        height: 100%;
        border-radius: var(--r-full);
        background: var(--brand);
    }
    .aiu-people {
        padding: var(--sp-1) 0 var(--sp-2);
        background: color-mix(in srgb, var(--surface-2) 55%, transparent);
        border-top: 1px dashed var(--line);
    }
    .aiu-person {
        padding: var(--sp-2) var(--sp-3);
        padding-inline-start: var(--sp-8);
        text-decoration: none;
        color: inherit;
        border-radius: var(--r-sm);
    }
    .aiu-person:hover {
        background: var(--brand-soft);
    }
    .aiu-person:hover .aiu-person__name {
        color: var(--brand-text);
    }
    .aiu-person__name {
        display: flex;
        align-items: center;
        gap: var(--sp-2);
        min-width: 0;
        color: var(--ink);
    }
    .aiu-when {
        font-variant-numeric: tabular-nums;
        white-space: nowrap;
        color: var(--muted);
    }
    .aiu-dim {
        color: var(--muted);
    }

    @media (min-width: 1024px) {
        .aiu-grid {
            grid-template-columns: minmax(0, 1fr) 7rem 9rem;
        }
    }

    /* ═══ Classroom: video call interface ════════════════════════
       Full-panel like the chat thread above: the section sheds its card
       chrome and fills the content area. The stage is Meet-style — the
       remote feed letterboxes onto a pattern backdrop instead of cropping
       (any video surface stays dark in both themes). The page also runs
       immersive: topbar/sidebar/tabbar are hidden via body:has() rather
       than a new layout variant, so the authenticated shell (and its
       Live/SSE island) stays mounted underneath, just invisible; only the
       header's back link remains as chrome. */
    body:has(.classroom-room) .topbar,
    body:has(.classroom-room) .sidebar,
    body:has(.classroom-room) .tabbar,
    body:has(.classroom-room) .site-footer {
        display: none;
    }
    body:has(.classroom-room) .shell {
        display: block; /* collapse the ≥1024px sidebar grid column */
    }
    .content:has(.classroom-room) {
        padding: 0;
        max-width: none;
        height: 100vh;
        height: 100dvh;
        min-height: 0;
        overflow: hidden;
        /* A column, not a block: an in-flow banner above the room (invite,
           timezone prompt, flash) used to push the height-locked room down
           and clip its call controls off the bottom. As flex children the
           banners keep their natural height and the room takes the rest. */
        display: flex;
        flex-direction: column;
    }
    .classroom-room {
        display: flex;
        flex-direction: column;
        /* `flex: 1`, not `height: 100%` — see the `.content:has()` note above.
           `min-height: 0` keeps the body's own scroll areas shrinkable. */
        flex: 1;
        min-height: 0;
        margin: 0;
        padding: 0;
        border: 0;
        border-radius: 0;
        box-shadow: none;
    }
    .classroom-room__header {
        flex: none;
        position: relative;
        display: flex;
        flex-direction: column;
        gap: 0.25rem;
        padding: var(--sp-3) var(--page-pad);
        border-bottom: 1px solid var(--line);
        background: var(--surface);
    }
    /* The copyright line rides the header's trailing edge (the page is
       height-locked, so the normal .site-footer is hidden here). Absolute
       so the header's column layout never reflows around it; desktop-only
       because a phone-width header has no spare trailing room. */
    .classroom-room__copyright {
        display: none;
        position: absolute;
        inset-inline-end: var(--page-pad);
        top: 50%;
        transform: translateY(-50%);
    }
    @media (min-width: 1024px) {
        .classroom-room__copyright {
            display: block;
        }
    }
    /* Scheduled-class countdown chip: clock, m:ss label, and a slim track
       that fills with the elapsed share of the class window. Positioned like
       the copyright — absolute on the header, so the header's column layout
       never reflows around it. Right-aligned by default; centered on desktop,
       where the copyright owns the trailing edge. */
    .classroom-room__countdown {
        position: absolute;
        top: 50%;
        transform: translateY(-50%);
        inset-inline-end: var(--page-pad);
        display: flex;
        align-items: center;
        gap: var(--sp-2);
        font-size: var(--text-sm);
        font-variant-numeric: tabular-nums;
        color: var(--ink-2);
    }
    .classroom-room__countdown[hidden] {
        display: none;
    }
    @media (min-width: 1024px) {
        .classroom-room__countdown {
            inset-inline-end: auto;
            left: 50%;
            transform: translate(-50%, -50%);
        }
    }
    .classroom-room__countdown .icon {
        width: 1rem;
        height: 1rem;
        flex-shrink: 0;
    }
    .classroom-room__countdown-track {
        width: 6rem;
        height: 0.25rem;
        border-radius: var(--r-full);
        background: var(--line);
        overflow: hidden;
    }
    @media (max-width: 640px) {
        /* Keep the chip clear of a long name in the phone-width header. */
        .classroom-room__countdown-track {
            width: 4rem;
        }
    }
    .classroom-room__countdown-track span {
        display: block;
        width: 0%;
        height: 100%;
        border-radius: var(--r-full);
        background: var(--brand);
        /* The clock ticks once a second; glide between ticks. */
        transition: width 1s linear;
    }
    .classroom-room__countdown--over {
        color: var(--danger);
    }
    .classroom-room__countdown--over .classroom-room__countdown-track span {
        background: var(--danger);
    }
    .classroom-room__header h1 {
        margin: 0;
        font-size: var(--text-lg);
    }
    .classroom-room__header .muted {
        margin: 0;
    }
    /* The mount point and the island root it hydrates: both are flex
       columns so the stage inside can claim the full remaining height. */
    .classroom-room__body,
    .classroom {
        flex: 1;
        display: flex;
        flex-direction: column;
        min-height: 0;
    }
    /* Scheduled-class strip (Plan 14): the countdown chip and the "Live
       transcription in use" notice. A normal flex row above the stage, so it
       never covers the video or the controls. */
    .classroom__meta {
        display: flex;
        align-items: center;
        justify-content: center;
        flex-wrap: wrap;
        gap: var(--sp-2) var(--sp-4);
        padding: var(--sp-2) var(--sp-3);
        font-size: var(--text-sm);
    }
    .classroom__meta > span {
        display: inline-flex;
        align-items: center;
        gap: var(--sp-2);
    }
    .classroom__meta .icon {
        width: 1rem;
        height: 1rem;
    }
    /* The hover/long-press title carries the full explanation. */
    .classroom__transcription {
        color: var(--muted);
        cursor: help;
    }
    /* Centered states: join screen, errors, "ended", the no-JS fallback. */
    .classroom__center {
        flex: 1;
        display: flex;
        flex-direction: column;
        gap: var(--sp-4);
        align-items: center;
        justify-content: center;
        padding: var(--page-pad);
        text-align: center;
    }
    .classroom__stage {
        position: relative;
        flex: 1;
        min-height: 0;
        background-color: #0d0f12;
        /* The same stripe texture as the app backdrops (tokens.css), on
           the stage's own always-dark surface. */
        background-image: var(--bg-stripes);
        overflow: hidden;
    }
    .classroom__remote {
        display: block;
        width: 100%;
        height: 100%;
        object-fit: contain;
    }
    .classroom__status {
        position: absolute;
        inset: 0;
        display: flex;
        align-items: center;
        justify-content: center;
        margin: 0;
        color: #fff;
    }
    /* Mid-call drop: dim the frozen last frame and spin until the peer
       returns or the server times the room out. */
    .classroom__status--lost {
        flex-direction: column;
        gap: var(--sp-3);
        background: rgba(0, 0, 0, 0.55);
    }
    .classroom__status--lost p {
        margin: 0;
    }
    .classroom__spinner {
        width: 2.5rem;
        height: 2.5rem;
        border: 3px solid rgba(255, 255, 255, 0.25);
        border-top-color: #fff;
        border-radius: 50%;
        animation: classroom-spin 0.9s linear infinite;
    }
    @keyframes classroom-spin {
        to {
            transform: rotate(360deg);
        }
    }
    .classroom__local {
        position: absolute;
        right: var(--sp-4);
        bottom: var(--sp-4);
        width: clamp(7rem, 24vw, 15rem);
        height: auto;
        max-height: 40vh;
        min-height: 4rem;
        object-fit: contain;
        border-radius: var(--r-md);
        box-shadow: var(--shadow-md);
        background: #0d0f12;
    }
    .classroom__controls {
        position: absolute;
        left: 50%;
        transform: translateX(-50%);
        bottom: var(--sp-4);
        display: flex;
        gap: var(--sp-2);
    }
    /* Call controls read from across the room: bigger buttons, and a heavier
       stroke than the 1.6 the shared icon set ships with. Every button paints
       its 1px border (base.css ships it transparent) the color of its own
       background, so the Leave button's red ring below changes no geometry. */
    .classroom__controls .btn--icon {
        width: 3.5rem;
        height: 3.5rem;
        border-color: var(--brand);
    }
    .classroom__controls .btn--icon:hover {
        border-color: var(--brand-strong);
    }
    .classroom__controls .btn--icon .icon {
        width: 1.75rem;
        height: 1.75rem;
        stroke-width: 2.1;
    }
    /* Leave inverts the row's palette — brand-colored icon on the ink ground,
       ringed in red — so the one destructive control reads apart from the
       rest without shifting size. Hover deepens only the ring: the shared
       .btn/.btn--danger hover rules would repaint the background and erase
       the inversion. */
    .classroom__controls .btn--danger {
        background-color: var(--brand-ink);
        color: var(--brand);
        border-color: var(--danger-fill);
    }
    .classroom__controls .btn--danger:hover {
        background-color: var(--brand-ink);
        color: var(--brand);
        border-color: color-mix(in srgb, var(--danger-fill) 84%, black);
    }
    /* Device picker: floats above the control row on the video surface. */
    .classroom__devices {
        position: absolute;
        left: 50%;
        transform: translateX(-50%);
        bottom: calc(var(--sp-4) + 3.5rem + var(--sp-3));
        display: flex;
        flex-direction: column;
        gap: var(--sp-3);
        width: min(22rem, calc(100% - 2 * var(--sp-4)));
        padding: var(--sp-4);
        background: var(--surface);
        border: 1px solid var(--line);
        border-radius: var(--r-md);
        box-shadow: var(--shadow-md);
    }
    .classroom__devices label {
        display: flex;
        flex-direction: column;
        gap: var(--sp-2);
    }
    .classroom__devices select {
        width: 100%;
        min-width: 0;
    }
    @media (max-width: 640px) {
        /* Lift the corner preview clear of the centred control row. */
        .classroom__local {
            bottom: calc(var(--sp-4) + 3.5rem + var(--sp-3));
        }
    }

    /* In-classroom chat (Plan 15). The stage and the panel share a flex row:
       opening the panel gives it a fixed column and the stage's flex:1
       absorbs the difference — the video simply shrinks. */
    .classroom__main {
        position: relative;
        flex: 1;
        display: flex;
        min-height: 0;
    }
    .classroom__chat {
        flex: none;
        display: flex;
        flex-direction: column;
        min-height: 0;
        width: min(24rem, 38vw);
        background: var(--surface);
        border-inline-start: 1px solid var(--line);
    }
    .classroom__chat-head {
        flex: none;
        display: flex;
        align-items: center;
        justify-content: space-between;
        gap: var(--sp-2);
        padding: var(--sp-2) var(--sp-3);
        border-bottom: 1px solid var(--line);
    }
    .classroom__chat-head h2 {
        margin: 0;
        font-size: var(--text-md);
    }
    /* The close control is a bare glyph on the header line, not a circled
       button; it only tints on hover. */
    .classroom__chat-close {
        display: inline-flex;
        align-items: center;
        justify-content: center;
        padding: 0;
        background: none;
        border: 0;
        color: var(--ink-2);
        cursor: pointer;
    }
    .classroom__chat-close:hover {
        color: var(--brand-text);
    }
    .classroom__chat-close .icon {
        width: 1.25rem;
        height: 1.25rem;
    }
    .classroom__chat .banner {
        margin: var(--sp-3) var(--sp-3) 0;
    }
    /* The log owns the scrolling; .chat provides the bubble column itself. */
    .classroom__chat-log {
        flex: 1;
        overflow-y: auto;
        margin: 0;
        padding: var(--sp-3);
    }
    .classroom__chat-form {
        flex: none;
        display: flex;
        align-items: flex-end;
        gap: var(--sp-2);
        padding: var(--sp-2) var(--sp-3);
        border-top: 1px solid var(--line);
    }
    .classroom__chat-form textarea {
        flex: 1;
        min-width: 0;
        resize: none;
        max-height: 8rem;
        overflow-y: auto;
    }
    /* Squared send, sized to the one-row composer (0.6rem padding × 2 + one
       1.4-line of --text-control + 1px borders) so button and box sit flush
       on the same row; only the box grows as the draft wraps. */
    .classroom__chat-form .btn--icon {
        width: calc(1.4 * var(--text-control) + 1.2rem + 2px);
        height: calc(1.4 * var(--text-control) + 1.2rem + 2px);
        border-radius: var(--r-sm);
    }
    /* Unread count riding the control-row toggle's corner. */
    .classroom__chat-toggle {
        position: relative;
    }
    .classroom__chat-badge {
        position: absolute;
        top: -0.3rem;
        inset-inline-end: -0.3rem;
        display: inline-flex;
        align-items: center;
        justify-content: center;
        min-width: 1.25rem;
        height: 1.25rem;
        padding: 0 0.3rem;
        border-radius: var(--r-full);
        background: var(--danger);
        color: #fff;
        font-size: var(--text-xs);
        font-weight: var(--weight-semibold);
        line-height: 1;
    }
    @media (max-width: 720px) {
        /* Narrow screens: a half-height bottom sheet over the stage instead
           of a side column — the video keeps its full width, and the close
           button in the panel header dismisses the sheet. */
        .classroom__chat {
            position: absolute;
            inset-inline: 0;
            bottom: 0;
            z-index: 5;
            width: auto;
            height: 55%;
            border-inline-start: 0;
            border-top: 1px solid var(--line);
            border-start-start-radius: var(--r-md);
            border-start-end-radius: var(--r-md);
            box-shadow: var(--shadow-md);
        }
    }

    /* Screen share. The wrapper around the camera previews is inert until a
       share starts — display:contents keeps the pip inside positioning itself
       against the stage, exactly as it does with no wrapper at all. */
    .classroom__thumbs {
        display: contents;
    }
    /* Sharing: the screen owns the stage and both cameras drop to a column of
       equal-width thumbnails (identical for presenter and viewer). Height
       follows each stream's own aspect ratio, like the pip — no cropping, no
       bars; the guards cover extreme portrait streams and the pre-metadata
       2:1 default of a <video> with nothing in it yet. */
    .classroom__stage--share .classroom__thumbs {
        position: absolute;
        top: var(--sp-4);
        right: var(--sp-4);
        display: flex;
        flex-direction: column;
        gap: var(--sp-2);
    }
    .classroom__stage--share .classroom__thumbs video {
        width: clamp(6rem, 18vw, 11rem);
        height: auto;
        min-height: 3rem;
        max-height: 30vh;
        object-fit: contain;
        border-radius: var(--r-md);
        box-shadow: var(--shadow-md);
        background: #0d0f12;
    }
    @media (max-width: 640px) {
        /* Narrow screens: shrink the tiles so two of them can't reach down
           into the centred control row. */
        .classroom__stage--share .classroom__thumbs video {
            width: clamp(4.5rem, 22vw, 7rem);
            max-height: 22vh;
        }
    }

    /* Peer media indicators: a muted track only delivers silence and a
       disabled camera only black frames, so the peer announces the *why* over
       the control channel and these name it. The pill takes the stage's
       top-left — the share thumbnails own the top-right. */
    .classroom__peer-muted {
        position: absolute;
        top: var(--sp-4);
        left: var(--sp-4);
        display: flex;
        align-items: center;
        gap: var(--sp-2);
        padding: var(--sp-2) var(--sp-3);
        border-radius: var(--r-full);
        background: rgba(0, 0, 0, 0.55);
        color: #fff;
        font-size: var(--text-sm);
    }
    .classroom__peer-muted .icon {
        width: 1.125rem;
        height: 1.125rem;
    }
    /* Camera off: rides the .classroom__status centering, but without the
       --lost dimming — the black frame IS the state; this just captions it. */
    .classroom__cam-off {
        flex-direction: column;
        gap: var(--sp-3);
        color: rgba(255, 255, 255, 0.8);
    }
    .classroom__cam-off p {
        margin: 0;
    }
    .classroom__cam-off .icon {
        width: 3rem;
        height: 3rem;
    }
    /* In share mode the peer's camera is a thumbnail; the wrapper exists only
       to anchor the glyph, so it must not disturb the tile column's layout. */
    .classroom__thumb {
        position: relative;
        display: flex;
    }
    .classroom__thumb-off {
        position: absolute;
        inset: 0;
        display: flex;
        align-items: center;
        justify-content: center;
        color: rgba(255, 255, 255, 0.8);
    }
    .classroom__thumb-off .icon {
        width: 1.5rem;
        height: 1.5rem;
    }

    .btn--icon {
        width: 3rem;
        height: 3rem;
        padding: 0;
        display: flex;
        align-items: center;
        justify-content: center;
        border-radius: 50%;
        flex-shrink: 0;
    }

    /* ═══ Avatar crop (account settings) ══════════════════════════
       The profile-picture picker and its framing dialog. `.dialog--crop`
       only sizes the shared `.dialog` from components.css around the
       preview; everything else about that surface (backdrop, radius, top
       layer) is inherited.

       `.avatar-crop` is the island mount point, and `[data-island]` is
       `display: contents` from a later layer (islands.css), so it cannot
       lay its own children out — the preview, the actions row and the
       banner each carry their spacing and sit directly in the card's
       flow. The same markup is what the no-JS fallback renders, so these
       rules apply with or without the bundle. */
    .avatar-crop > .avatar {
        display: block;
        margin-block: var(--sp-2) var(--sp-4);
    }
    .avatar-crop__actions {
        display: flex;
        flex-wrap: wrap;
        align-items: center;
        gap: var(--sp-2);
    }
    .avatar-crop__actions .btn {
        margin: 0;
    }
    /* The label is the control, so the input itself only has to stay
       reachable — visually hidden, never display:none, which would take it
       out of the tab order. */
    .avatar-crop__actions input[type="file"] {
        position: absolute;
        width: 1px;
        height: 1px;
        padding: 0;
        margin: -1px;
        overflow: hidden;
        clip: rect(0, 0, 0, 0);
        border: 0;
    }
    /* …and its focus has to be visible on the label standing in for it. */
    .avatar-crop__actions:has(input[type="file"]:focus-visible) label.btn {
        box-shadow: var(--ring);
    }
    .dialog--crop {
        max-width: min(100%, 24rem);
    }
    .avatar-crop__canvas {
        display: block;
        width: 320px;
        max-width: 100%;
        height: auto;
        aspect-ratio: 1;
        margin-inline: auto;
        margin-block: var(--sp-3);
        border-radius: 50%;
        cursor: grab;
        /* Required: without it a touch drag scrolls the page instead of
           moving the frame. */
        touch-action: none;
        background: var(--surface-2, #eee);
    }
    .avatar-crop__canvas:active {
        cursor: grabbing;
    }
    .avatar-crop__canvas:focus-visible {
        outline: none;
        box-shadow: var(--ring);
    }
}

/* ════════════════════════════════════════════════════════════════════
   utilities.css — the small escape-hatch set.
   Deliberately tiny: if a pattern recurs, promote it to a component in
   components.css instead of growing this file.
   ════════════════════════════════════════════════════════════════════ */
@layer utilities {
    /* An inline row of items, vertically centered. */
    .row {
        display: flex;
        align-items: center;
        gap: 0.6rem;
    }

    /* Print-only content (revealed by the @media print block below). */
    .only-print {
        display: none;
    }
}

/* ════════════════════════════════════════════════════════════════════
   Print stylesheet — the note "print view", delivered inline.
   The toolbar's Print button just calls window.print(); these rules turn
   the current page into a clean, chrome-free copy of the note card alone
   (no nav, no toolbar, no sibling edit/flashcard sections), so there is
   no separate print-view page to render or navigate to. Left unlayered so
   it wins the cascade over every @layer regardless of order.
   ════════════════════════════════════════════════════════════════════ */
@media print {
    /* App chrome and anything explicitly marked non-printable. */
    .topbar,
    .sidebar,
    .nav-scrim,
    .tabbar,
    .banner,
    .note-toolbar,
    .backlink,
    .no-print {
        display: none !important;
    }
    .only-print {
        display: revert !important;
    }

    /* Print only the note; drop sibling cards (edit form, lesson picker…). */
    .card:not(.note-print) {
        display: none !important;
    }

    /* A tabbed note (teacher View/Edit): the ribbon is screen-only chrome,
       and the rendered note must print even when the Edit tab is the one on
       screen. The edit panel opts out via .no-print above. */
    .ribbon {
        display: none !important;
    }
    [data-ribbon-panel="view"] {
        display: block !important;
    }

    /* Let the note span the sheet: strip shell layout and card framing. */
    .shell,
    .content,
    .page {
        display: block;
        margin: 0;
        padding: 0;
        max-width: none;
    }
    .note-print {
        margin: 0;
        padding: 0;
        max-width: none;
        width: auto;
        border: 0;
        border-radius: 0;
        box-shadow: none;
        background: #fff;
    }

    /* Ink on paper. */
    body {
        background: #fff;
        color: #000;
    }
    .md a {
        color: #000;
        text-decoration: underline;
    }

    /* Keep headings with their content; don't split rich blocks across pages. */
    .md h1,
    .md h2,
    .md h3 {
        break-after: avoid;
    }
    .md pre,
    .md blockquote,
    .md table,
    .md img {
        break-inside: avoid;
    }
}

@layer islands {
[data-island]{display:contents}[data-island][data-island-block]{display:block}:where(html.islands) [data-fallback]{display:none}@keyframes island-fade-in{0%{opacity:0;transform:translateY(.5rem)}to{opacity:1;transform:none}}div.svelte-m3nblz{display:contents}.suggest.svelte-1iqxfkb{margin:var(--sp-4) 0 var(--sp-2)}.suggest__hint.svelte-1iqxfkb{margin:0 0 var(--sp-2);color:var(--muted);font-size:var(--text-sm)}.suggest__card.svelte-1iqxfkb{display:flex;align-items:flex-start;gap:var(--sp-2);margin:var(--sp-2) 0;padding:var(--sp-2) var(--sp-3);border:1px solid var(--line);border-radius:var(--r-md);cursor:pointer}.suggest__card.svelte-1iqxfkb:hover{border-color:var(--brand)}.suggest__card.svelte-1iqxfkb input:where(.svelte-1iqxfkb){width:auto;margin-top:.2rem}.suggest__body.svelte-1iqxfkb{display:flex;flex-direction:column;gap:.15rem}.suggest__title.svelte-1iqxfkb{font-weight:var(--weight-medium)}.suggest__focus.svelte-1iqxfkb{color:var(--muted);font-size:var(--text-sm)}.live-toasts.svelte-f8wy86{position:fixed;inset-block-end:var(--sp-4);inset-inline-end:var(--sp-4);z-index:var(--z-overlay);display:flex;flex-direction:column;gap:var(--sp-2);max-width:min(24rem,calc(100vw - 2rem));pointer-events:none}.live-toast.svelte-f8wy86{margin:0;box-shadow:var(--shadow-lg);pointer-events:auto}@media(prefers-reduced-motion:no-preference){.live-toast.svelte-f8wy86{animation:svelte-f8wy86-live-toast-in .2s var(--ease-spring)}}@keyframes svelte-f8wy86-live-toast-in{0%{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}div.svelte-153xwav,div.svelte-8h9vov,div.svelte-d31y6o{display:contents}

}
