/* ═══════════════════════════════════════════════════════════════════════════
   GENERATED FILE — DO NOT EDIT.

   Built by build/bundle-css.sh from src/Sedna.UI/css-parts/. Edit the part
   that owns the rule and re-run that script; a guard test fails the build if
   this file and the parts disagree. Adding a part needs no change here — the
   directory is the source of truth.

   Contents, in cascade order:
     00-header.css
     00-palette.css
     01-tokens.css
     02-theme-light.css
     03-theme-colour-blind.css
     04-theme-contrast.css
     06-base-elements.css
     10-frame-shell.css
     11-frame-sidebar.css
     12-frame-collapsed-rail.css
     13-frame-topbar.css
     14-frame-nav-filter.css
     15-frame-user-widget.css
     16-frame-page-scroll.css
     17-frame-hover-hints.css
     18-frame-status-bar.css
     19-frame-responsive.css
     20-frame-layouts.css
     21-frame-search.css
     22-frame-nav-areas.css
     23-frame-topbar-nav.css
     24-frame-bottombar.css
     30-cards.css
     31-badges.css
     32-tables.css
     33-buttons.css
     34-forms.css
     35-toolbar.css
     36-modal.css
     37-alerts.css
     38-toasts.css
     39-spinner.css
     40-skeleton.css
     41-progress.css
     42-empty-state.css
     42-state-art.css
     43-content-grids.css
     44-markdown.css
     44-quill.css
     45-form-controls.css
     46-form-layout.css
     47-form-file.css
     48-form-numeric.css
     49-form-combo.css
     49-form-select-base.css
     50-tabs.css
     51-segmented.css
     52-avatar.css
     53-chip.css
     54-data-viz.css
     54-stat.css
     55-page-structure.css
     56-code-block.css
     57-navigation-aids.css
     58-menu.css
     59-table-extensions.css
     60-lists.css
     61-buttons-extra.css
     62-media-and-prose.css
     63-drag.css
     63-drawer.css
     64-files.css
     64-lightbox.css
     64-media-player.css
     64-palette-spotlight.css
     65-popover.css
     66-calendar.css
     66-chat.css
     66-status-indicator.css
     67-output.css
     68-guard.css
     69-timeline.css
     70-rtl.css
     71-forced-colors.css
     72-print.css
     80-utilities.css
     90-density.css
     95-reduced-motion.css
   ═══════════════════════════════════════════════════════════════════════════ */

/* Layer order, declared once and up front so it does not depend on which layer
   happens to appear first below.

   The consequence that matters to a consuming app: EVERY rule in this file is
   layered, and an unlayered rule beats a layered one whatever its specificity.
   So your own stylesheet — which is unlayered unless you say otherwise — always
   wins, and you no longer have to out-specify anything to override it.

   Two things to know:
     * !important inverts layer order, which is why this library uses none. An
       !important inside a layer becomes HARDER for you to override, not easier.
     * Because your unlayered :root wins outright, a token you set at bare :root
       now also beats this library's [data-variant="light"] value for it. Set
       both blocks, as the rebrand recipe in the catalogue shows. */
@layer sedna.tokens, sedna.base, sedna.frame, sedna.paint, sedna.utilities, sedna.overrides;

/* ── 00-header.css → @layer sedna.tokens ──────────────────────────────────────────────── */
@layer sedna.tokens {
/* ═══════════════════════════════════════════════════════════════════════════
   Sedna.UI — the shared stylesheet
   ───────────────────────────────────────────────────────────────────────────
   The token layer + every tier-2 (page content) class + the CSS for the tier-1
   frame components. One file ships, so a consuming app writes one <link>.

   Two rules govern every edit here:

   1. NO HARD-CODED COLOURS outside the token blocks below. Every colour,
      shadow and tint is a `var(--…)`. A test (CssTokenContractTests) fails the
      build if a hex or rgb() literal appears past the `:root` blocks — that is
      what makes an app able to rebrand by redefining a handful of tokens.
   2. Content UI is a CLASS, never a component. There is no `<DataTable>`, no
      `<Card>` and no `<AppShell>`; pages write markup and apply these classes.

   Consuming apps load this file first, then their own override file which
   redefines only the tokens it needs (typically the --brand* family, --accent
   and --sidebar-active). Apps must not invent tokens; a missing token is added
   here, the version is bumped, and the app upgrades.

   Every theme difference is expressed as a token, so the light and colour-blind
   blocks below contain NO selector overrides and CSS load order is irrelevant.

   Everything here is inside a cascade layer. A consuming app's stylesheet is
   unlayered, so it beats every rule in this file whatever the specificity — which
   means overriding the library never needs a longer selector, and a library rule can
   never out-specify an app. Redefine tokens, not rules.

   Icons: classes size `i` elements (`.btn i`, `.nav-link i`, …). Remix Icon is
   bundled at ../lib/remixicon/ and is the only icon set; link its stylesheet
   alongside this one. See THIRD-PARTY-NOTICES.md for its licence.
   ═══════════════════════════════════════════════════════════════════════════ */


}

/* ── 00-palette.css → @layer sedna.tokens ──────────────────────────────────────────────── */
@layer sedna.tokens {
/* ───────────────────────────────────────────────────────────────────────────
   TIER 1 — THE PALETTE
   ───────────────────────────────────────────────────────────────────────────
   Ramps. Nothing in the library styles anything with these directly; they are
   the raw material the semantic tokens in 01-tokens.css point at.

   Two rules make the tier split worth having, and a guard enforces each:

   1. A palette token holds a LITERAL. It never references another token. If a
      ramp step could point at a role, the tiers would invert and "what colour
      is this" would stop having an answer.
   2. A palette token is declared ONCE, here, at :root. It is not remapped by
      variant, colour-vision or contrast — a ramp step is a colour, not a
      decision. Every remap belongs in tier 2, where the roles live.

   Generated in OKLCH with a fixed hue per family, a lightness curve shared
   across all families, and a chroma bell peaking at the anchor. That is why
   step 600 of any two families carries the same visual weight: a green badge
   and a violet badge sit at the same optical level, which HSL cannot deliver.

   The brand anchors appear at their published values, unrounded. Do not round
   them and do not "tidy" a step by eye — docs/BRANDING.md §7.1 names six pairs
   sitting within 0.3 of the WCAG AA floor, and a nudge takes one below it.

   Full tables, with the measured contrast for every step:
   docs/BRANDING.md §2.
   ─────────────────────────────────────────────────────────────────────────── */

:root {
    /* ── The two absolutes ─────────────────────────────────────────────────
       Neither is a ramp step: the slate ramp runs 50 (#f8fafc, Ice White) to
       950 (#04071b), and both ends are deliberately short of the extremes.

       They are here so tier 2 never has to carry a raw literal. That matters
       most for --on-solid, which is the "white" in "white on --brand" — the
       4.55:1 pair that sets where --brand sits. Substituting slate-50 there
       would quietly lower every white-on-solid ratio the manual quotes, which
       is the exact failure the tier rules exist to prevent.

       High contrast also needs true black-on-white and white-on-black; that is
       the mode's whole point, not a gap in the ramps. */
    --white: #ffffff;
    --black: #000000;

    /* ── Slate — the neutral spine ─────────────────────────────────────────
       Four of the seven core brand colours are steps on this one ramp:
       50 Ice White, 400 Dust Gray, 800 Navy Slate, 900 Deep Space. */
    --slate-50: #f8fafc;
    --slate-100: #eef4fb;
    --slate-200: #dde7f3;
    --slate-300: #c8d5e5;
    --slate-400: #94a3b8;
    --slate-500: #707e93;
    --slate-600: #515e72;
    --slate-700: #354255;
    --slate-750: #2a3649;
    --slate-800: #1e293b;
    --slate-850: #162033;
    --slate-900: #0f172a;
    --slate-925: #0a1225;
    --slate-950: #04071b;

    /* ── Coral — the brand ramp ────────────────────────────────────────────
       500 is Sedna Red, the brand you SEE. 600 is the brand you CLICK: the
       darkest-chroma point on this hue that still carries white text at AA
       (4.55:1). See docs/BRANDING.md §3.1 — that gap is a constraint, not a
       preference. */
    --coral-50: #fff4f1;
    --coral-100: #ffe9e3;
    --coral-200: #ffd1c5;
    --coral-300: #ffb4a0;
    --coral-400: #ff8f74;
    --coral-500: #ff6b4a;
    --coral-600: #d73f1a;
    --coral-700: #be2e06;
    --coral-800: #a22000;
    --coral-900: #7b1403;
    --coral-950: #580000;

    /* ── Orbit — accent and information ───────────────────────────────────
       400 is Orbit Blue. Links and info own blue outright now that the brand
       is coral (§3.2). */
    --orbit-50: #eff8ff;
    --orbit-100: #e0f1ff;
    --orbit-200: #bde3ff;
    --orbit-300: #90d2ff;
    --orbit-400: #59c3ff;
    --orbit-500: #3aa8e2;
    --orbit-600: #158fc6;
    --orbit-700: #0077a5;
    --orbit-800: #005e82;
    --orbit-900: #004661;
    --orbit-950: #002c3f;

    /* ── Navy — the mark, illustration, marketing ─────────────────────────
       Deliberately NOT consumed by any semantic token. It exists so diagrams,
       spot illustration and the mark itself have a governed range; 900 is the
       orbit stroke in the logo. */
    --navy-50: #f4f7fc;
    --navy-100: #e9eff9;
    --navy-200: #d4def0;
    --navy-300: #bacae7;
    --navy-400: #9db4dd;
    --navy-500: #809dd2;
    --navy-600: #6484c2;
    --navy-700: #4b6cab;
    --navy-800: #35548f;
    --navy-900: #17346e;
    --navy-950: #0e2450;

    /* ── Green — go ───────────────────────────────────────────────────────
       The filled step is 700, not 500: --on-solid is white and the mid tone
       cannot carry it at AA. Every filled family follows that rule. */
    --green-200: #a2f4b6;
    --green-300: #75e594;
    --green-400: #44d272;
    --green-500: #22c55e;
    --green-600: #00a043;
    --green-700: #008433;
    --green-800: #006924;
    --green-900: #004e16;

    /* ── Amber — warn ─────────────────────────────────────────────────────── */
    --amber-200: #ffd5a2;
    --amber-300: #feba61;
    --amber-400: #f09f24;
    --amber-500: #f59e0b;
    --amber-600: #b87300;
    --amber-700: #995e00;
    --amber-800: #7a4900;
    --amber-900: #5d3400;

    /* ── Crimson — danger ─────────────────────────────────────────────────
       Danger is crimson rather than red because red sits 7° from the coral
       brand in OKLCH: a Delete button and a Save button would be the same
       colour at a glance. Crimson is 32° away and slightly darker, so the two
       separate on hue AND lightness. §3.3 — this one is forced. */
    --crimson-200: #ffceda;
    --crimson-300: #ffafc4;
    --crimson-400: #ff87ab;
    --crimson-500: #f44588;
    --crimson-600: #d92b73;
    --crimson-700: #c72268;
    --crimson-800: #a60653;
    --crimson-900: #80003d;

    /* ── Violet — secret ─────────────────────────────────────────────────── */
    --violet-200: #ded7ff;
    --violet-300: #cbbfff;
    --violet-400: #b6a2ff;
    --violet-500: #a283ff;
    --violet-600: #8e61f7;
    --violet-700: #754bd3;
    --violet-800: #5d37ad;
    --violet-900: #452685;

    /* ── Cyan, Orange, Teal — categorical badge hues, no meaning ─────────── */
    --cyan-200: #9cecfc;
    --cyan-300: #6adcf1;
    --cyan-400: #22d3ee;
    --cyan-500: #00b0c8;
    --cyan-600: #0096aa;
    --cyan-700: #007b8d;
    --cyan-800: #00616f;
    --cyan-900: #004853;

    --orange-200: #ffd4ae;
    --orange-300: #feb979;
    --orange-400: #f59e4b;
    --orange-500: #da852e;
    --orange-600: #bf6e10;
    --orange-700: #a15800;
    --orange-800: #814400;
    --orange-900: #623000;

    --teal-200: #a5eee1;
    --teal-300: #79dfcf;
    --teal-400: #47cbb9;
    --teal-500: #14b8a6;
    --teal-600: #009a8b;
    --teal-700: #007f72;
    --teal-800: #00645a;
    --teal-900: #004b42;

    /* ── Indigo — inline code ─────────────────────────────────────────────── */
    --indigo-200: #d4dbff;
    --indigo-300: #bbc5ff;
    --indigo-400: #9fabff;
    --indigo-500: #8590fd;
    --indigo-600: #6f79e0;
    --indigo-700: #5a61bf;
    --indigo-800: #464b9b;
    --indigo-900: #323777;
}
}

/* ── 01-tokens.css → @layer sedna.tokens ──────────────────────────────────────────────── */
@layer sedna.tokens {
/* ═══ Tokens — the contract ═════════════════════════════════════════════════
   Everything below this block references these and nothing else. */
:root {
    /* ── Appearance ─────────────────────────────────────────────────────────
       Consumed once, as `html { color-scheme: var(--color-scheme) }`, which is
       why it is a token and not a literal: it has to move with the theme. It
       cannot be declared here as `color-scheme: dark` — a token block holds
       custom properties only, and a test enforces that.
       This is what makes the parts of a control the page cannot reach match the
       theme: the native scrollbar, the date picker, a bare <select>'s option list,
       and the default canvas before any rule applies. A `.form-select`'s list is
       no longer one of them — base appearance puts it in reach of the stylesheet,
       and 49-form-select-base.css styles it. */
    --color-scheme: dark;

    /* The UA's date-picker glyph is drawn in a fixed dark colour we cannot reach.
       Inverting it is the only way to see it on a dark surface, so the amount of
       inversion moves with the theme: 1 on dark, 0 on light. A number, not a
       colour — there is nothing here to recolour. */
    --picker-invert: 1;

    /* ── Typography ─────────────────────────────────────────────────────── */
    --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
    --font-mono: "SF Mono", Menlo, Consolas, monospace;

    /* ── Surfaces, text, lines ──────────────────────────────────────────────
       Dark is the library's default and the identity is strongest here: Deep
       Space canvas, Navy Slate elevated surface, Ice White text, Dust Gray
       muted — the four guide colours used exactly as named. See
       docs/BRANDING.md §6. */
    --bg:              var(--slate-900);   /* Deep Space */
    --bg-elevated:     var(--slate-800);   /* Navy Slate */
    --bg-hover:        var(--slate-750);
    --fg:              var(--slate-50);    /* Ice White */
    --fg-soft:         var(--slate-300);         /* body copy on cards */
    --muted:           var(--slate-400);   /* Dust Gray */
    --border:          var(--slate-700);
    --border-strong:   var(--slate-600);
    --border-hover:    var(--slate-500);
    --divider:         rgba(255, 255, 255, 0.06);
    --card-bg:         var(--slate-800);
    --surface-soft:    rgba(255, 255, 255, 0.03);
    --surface-strong:  rgba(255, 255, 255, 0.05);
    /* ── Named surfaces ─────────────────────────────────────────────────
       Which surface a thing IS, rather than which colour it happens to take.
       An app asking "what colour is the navigation?" had to know that the
       answer was --sidebar-bg and that the topbar used the same value.

       These three are aliases on purpose: the value lives once, in the role
       the frame already paints with, so a light or colour-blind remap of that
       role carries here without a second remap to keep in step. */
    --surface-app:     var(--bg);
    --surface-chrome:  var(--sidebar-bg);
    --surface-content: var(--bg);
    /* ── Elevation ──────────────────────────────────────────────────────
       Three steps, so a surface inside a surface inside a surface is still
       three distinguishable surfaces. Level 1 is the card's own colour, which
       is why it is an alias and not a fourth value: a top-level card must not
       change appearance because a ladder exists above it.

       Levels 2 and 3 are ramp steps rather than a color-mix of level 1, for
       the reason docs/BRANDING.md gives for every other colour here — take
       the next step, do not adjust by eye. The consequence to know: an app
       that overrides --card-bg moves level 1 only, and should override these
       two as well.

       Why the ladder stops at three: on --surface-raised-3 the muted role is
       measurably too close to the surface (3.97:1 in dark), so 30-cards.css
       lifts --muted to --fg-soft at that level. A fourth step would need a
       second lift and would run out of ramp. */
    --surface-raised-1: var(--card-bg);
    --surface-raised-2: var(--slate-750);
    --surface-raised-3: var(--slate-700);
    /* Pure white, deliberately, and not the slate ramp's lightest step — that
       is Ice White (#f8fafc), which is measurably darker. This token is the
       "white" in "white on --brand" (4.55:1), one of the six pairs sitting
       within 0.3 of the AA floor in docs/BRANDING.md §7.1, and it is shared by
       every filled control. Ice White here would lower all six ratios at once,
       which is why --white exists in the palette as an absolute. */
    --on-solid:        var(--white);          /* text on any filled/solid button */
    /* Text on a series fill. The dark theme's series are light steps (cyan-400,
       teal-400), where white fails AA; the light theme's are 600 steps, and the
       light block swaps this to white. */
    --on-viz:          var(--slate-900);
    --redacted:        var(--crimson-400);

    /* ── Brand — THE app override point ─────────────────────────────────────
       An app redefines this family (and --accent / --sidebar-active) in its own
       override file, or supplies a whole theme; nothing else needs touching.
       The four ring tokens are separate because the alphas differ per control.

       --brand is NOT Sedna Red (coral-500) — Sedna Red is a display colour.
       White text on coral-500 is 2.82:1, far under the 4.5:1 a .btn label
       needs. coral-600 is the darkest-chroma point on the Sedna Red hue that
       still carries white text at AA (4.55:1): solved for maximum chroma at
       the contrast boundary, not "coral, darkened" — a naive darkening goes
       brick and dull. Sedna Red itself survives everywhere it can be seen at
       full strength: --brand-soft, tints, glows, the sidebar indicator. See
       docs/BRANDING.md §4.1. */
    --brand:            var(--coral-600);
    --brand-hover:      var(--coral-700);
    --brand-active:     var(--coral-800);
    /* --brand-soft is a DISPLAY role, never a text one: icons, spinners and focus
       borders, where 3:1 is the requirement. Its light value is the step solved for
       white text on it, which says nothing about reading it as text on the canvas —
       so text in brand colour takes --brand-text, the role measured against the
       surface it renders on. BrandTextContrastTests measures every brand-coloured
       rule in the sheet, in both variants, for every built-in and generated theme. */
    --brand-soft:       var(--coral-500);         /* icons/spinners/focus borders — Sedna Red at full strength */
    --brand-text:       var(--coral-400);         /* readable brand text on the theme bg */
    /* Mixed from --brand rather than restated as literals, so redefining --brand
       alone carries the tint and every focus ring with it. An app may still pin any
       of them.

       A derived colour serialises as `color(srgb 0.145098 0.388235 0.921569 / 0.14)`
       rather than `rgba(37, 99, 235, 0.14)` — the painted pixel is the same, only the
       string differs. A computed-style differ that compares strings therefore reports
       every brand-tinted surface as changed. */
    /* The one accent in a state illustration. A role rather than a literal use of
       --brand, because the block the drawing sits in re-points it — .empty-state--failed
       moves it into the danger ramp — and the sprite reads it through the <use> shadow
       tree without knowing which state it is drawing. */
    --state-accent:     var(--brand);
    /* Whether a drawing's moving part moves. `paused` here, `running` from
       .state-art--live or .empty-state--pending; the sprite's motion classes read it as
       their play state, and the reduced-motion block removes the animations outright. */
    --state-motion:     paused;

    /* ── Data visualisation ────────────────────────────────────────────────
       Six series colours for parts of a chart that are merely different from each
       other — the segments of a stack bar, the lines of a legend. The brand first,
       then hues ordered so neighbours differ in hue and in lightness. A part that
       MEANS something takes a semantic family instead; the `.series-*` modifiers in
       54-data-viz.css are the one place these are read. */
    --viz-1: var(--brand);
    --viz-2: var(--cyan-400);
    --viz-3: var(--orange-400);
    --viz-4: var(--teal-400);
    --viz-5: var(--violet-400);
    --viz-6: var(--indigo-300);
    /* The previous value behind a compared bar — last month behind this month. A
       neutral rather than a seventh hue, because it stands behind whichever series the
       fill is; the hatch `.bar-previous` draws it in is what tells it from the fill, so
       the pair holds in the colour-blind palette and in greyscale. At least 3:1 against
       the track and the card, the floor for a graphical object that carries a value. */
    --viz-previous: var(--slate-400);
    --brand-tint:       color-mix(in srgb, var(--brand) 14%, transparent);
    /* One step up from --brand-tint, for hovering something already tinted by it.
       Pointing at a chosen row must not replace its tint with the neutral hover:
       that erases the state under the pointer. Both move together — a tint remapped
       without its pair jumps between two unrelated strengths. */
    --brand-tint-strong: color-mix(in srgb, var(--brand) 24%, transparent);
    --brand-ring:       color-mix(in srgb, var(--brand) 50%, transparent);  /* filled primary focus ring */
    --brand-ring-soft:  color-mix(in srgb, var(--brand) 40%, transparent);  /* neutral button / card focus */
    --brand-ring-check: color-mix(in srgb, var(--brand) 35%, transparent);  /* checkbox focus */
    --brand-glow:       color-mix(in srgb, var(--brand) 18%, transparent);  /* text-input focus glow */
    --accent:           var(--orbit-400);         /* links, decorative icons — Orbit Blue, unmodified */

    /* ── Sidebar ───────────────────────────────────────────────────────── */
    --sidebar-bg:      var(--slate-900);
    --sidebar-border:  var(--slate-800);
    --sidebar-fg:      var(--slate-200);
    --sidebar-active:  var(--coral-600);
    /* The nav's hover surface has to be its own token rather than --bg-elevated,
       which is what it used to be. Elevation runs the other way in the light
       theme: there --sidebar-bg and --bg-elevated are both true white, so the
       hover resolved to the colour already underneath it and nothing happened.
       Same value as before in this theme, so nothing dark changes. */
    --sidebar-hover:   var(--slate-800);

    /* ── Semantic status families ───────────────────────────────────────────
       "go"     — the action that sends something outward (approve, apply, send)
       "warn"   — the action that changes who is in control (take over)
       "danger" — destructive / failed
       "info", "secret" — informational and sensitive-value surfaces
       [data-cvd="1"] swaps ONLY the go family to blue, so go-vs-danger reads as
       blue-vs-red for deuteranopia/protanopia. Amber and brand keep their roles. */
    /* The go and warn solids are one ramp step darker than the palette's own mid
       tone, because --on-solid is white: the 500/600 mid tones do not clear AA for
       the 13px/500 label a .btn renders, while step 700 does. White on every solid:
       go 4.83 · warn 5.31 (docs/BRANDING.md §6). Do not lighten them without
       re-running the contrast audit in docs/accessibility.md. */
    --go-solid:   var(--green-700);
    --go-hover:   var(--green-800);
    --go-active:  var(--green-900);
    --go-bg:      rgba(34, 197, 94, 0.12);   /* green-500 */
    --go-border:  var(--green-800);
    --go-fg:      var(--green-400);
    --go-ring:    rgba(34, 197, 94, 0.5);    /* green-500 */

    --warn-solid:      var(--amber-700);
    --warn-hover:      var(--amber-800);
    --warn-active:     var(--amber-900);
    --warn-bg:         rgba(245, 158, 11, 0.12);   /* amber-500 */
    --warn-bg-hover:   rgba(245, 158, 11, 0.2);    /* amber-500 */
    --warn-bg-active:  rgba(245, 158, 11, 0.28);   /* amber-500 */
    --warn-border:     var(--amber-800);
    --warn-fg:         var(--amber-300);
    --warn-ring:       rgba(245, 158, 11, 0.4);    /* amber-500 */
    --warn-ring-solid: rgba(245, 158, 11, 0.5);    /* amber-500 */

    /* crimson-700, not the old #dc2626: that red sits 7° from the Sedna Red brand
       in OKLCH — a Delete button and a Save button would read as the same colour
       at a glance. crimson-700 is 32° away and slightly darker, so the two separate
       on hue AND lightness (docs/BRANDING.md §4.3 — this one is forced, not
       cosmetic). It still carries white text at AA: 4.61:1, both themes. */
    --danger-solid:         var(--crimson-700);
    --danger-bg:            rgba(128, 0, 61, 0.15);    /* crimson-900 */
    --danger-bg-hover:      rgba(166, 6, 83, 0.25);    /* crimson-800 */
    --danger-bg-active:     rgba(166, 6, 83, 0.35);    /* crimson-800 */
    --danger-border:        rgba(166, 6, 83, 0.6);     /* crimson-800 */
    --danger-border-strong: var(--crimson-800);
    --danger-fg:            var(--crimson-400);
    --danger-ring:          rgba(244, 69, 136, 0.4);   /* crimson-500 — focus ring on a danger control */

    --info-bg:      rgba(89, 195, 255, 0.12);    /* orbit-400 */
    --info-border:  var(--orbit-800);
    --info-fg:      var(--orbit-300);
    --info-solid:   var(--orbit-700);                     /* a filled info dot or bar */
    --info-ring:    rgba(89, 195, 255, 0.5);     /* orbit-400 — its glow */

    --secret-bg:     color-mix(in srgb, var(--violet-600) 12%, transparent);
    --secret-border: var(--violet-800);
    --secret-fg:     var(--violet-300);

    /* The out-of-office presence mark. Not a semantic family: every other presence
       state borrows go, warn or danger, and this one means none of them. Violet-600
       clears 3:1 as a graphic against both the dark and the light page, so no theme
       remaps it; the arrow knocked out of it is what carries the state. */
    --presence-out-of-office: var(--violet-600);

    /* Extra badge hues — categorical only, no semantics attached. */
    --badge-cyan-bg:     rgba(34, 211, 238, 0.15);    /* cyan-400 */
    --badge-cyan-border: var(--cyan-800);
    --badge-cyan-fg:     var(--cyan-300);
    --badge-orange-bg:     rgba(245, 158, 75, 0.15);    /* orange-400 */
    --badge-orange-border: var(--orange-800);
    --badge-orange-fg:     var(--orange-300);
    --badge-teal-bg:     rgba(20, 184, 166, 0.15);    /* teal-500 */
    --badge-teal-border: var(--teal-800);
    --badge-teal-fg:     var(--teal-300);

    /* ── Severity — an ORDERED five-step ramp ───────────────────────────────
       For a scale whose steps rank: an incident priority P1–P5, a CVSS band, a
       triage tier. Not a sixth semantic family and not a categorical hue — the point
       of this one is that step 1 outranks step 2.

       THE ORDER IS CARRIED BY THE FILL, NOT BY THE HUE. Step 1 is an opaque
       fill; steps 2–5 are tints of decreasing strength (0.28 → 0.20 → 0.13 →
       0.08). That ladder survives what a hue rotation cannot: the colour-blind
       palette, a greyscale print, and a reader who simply does not read colour
       as ordered. Hue is identity on top of it — crimson, orange, amber, blue,
       grey — never the ranking itself. Same reasoning as `.steps`, where done
       is FILLED rather than green.

       Which means the label still has to say the rank. `.badge-sev-2` reading
       "P2" is the contract; a bare coloured pill is a colour, not a position.

       --sev-N-solid is a ramp of its own, stepping 800 → 700 → 600 → 500 → 400
       so it is ordered by construction on the palette's shared lightness curve.
       It is a fill for a bar or a dot, NOT a surface for --on-solid text: only
       steps 1 and 2 clear AA for white. Where there is a label, use the badge. */
    --sev-1-solid:  var(--crimson-800);
    --sev-1-bg:     var(--crimson-800);
    --sev-1-border: var(--crimson-700);
    --sev-1-fg:     var(--on-solid);

    --sev-2-solid:  var(--orange-700);
    --sev-2-bg:     rgba(245, 158, 75, 0.28);   /* orange-400 */
    --sev-2-border: var(--orange-700);
    --sev-2-fg:     var(--orange-300);

    --sev-3-solid:  var(--amber-600);
    --sev-3-bg:     rgba(245, 158, 11, 0.2);    /* amber-500 */
    --sev-3-border: var(--amber-800);
    --sev-3-fg:     var(--amber-300);

    --sev-4-solid:  var(--orbit-500);
    --sev-4-bg:     rgba(89, 195, 255, 0.13);   /* orbit-400 */
    --sev-4-border: var(--orbit-800);
    --sev-4-fg:     var(--orbit-300);

    --sev-5-solid:  var(--slate-400);
    --sev-5-bg:     rgba(148, 163, 184, 0.08);  /* slate-400 */
    --sev-5-border: var(--slate-700);
    --sev-5-fg:     var(--slate-400);

    /* ── Component surfaces ─────────────────────────────────────────────── */
    --badge-bg:      rgba(255, 255, 255, 0.07);
    --table-head-bg: rgba(255, 255, 255, 0.04);

    /* Feedback surfaces. Tinted from the theme rather than the brand: a loading
       placeholder is not a branded element, and a brand-tinted skeleton reads as
       content that is already there. */
    --skeleton-bg:    rgba(255, 255, 255, 0.06);
    --skeleton-sheen: rgba(255, 255, 255, 0.11);
    --progress-track: rgba(255, 255, 255, 0.08);
    --btn-active-bg: var(--slate-850);
    --code-fg:       var(--indigo-300);
    --code-bg:       rgba(133, 144, 253, 0.1);    /* indigo-500 */
    /* The three scrims. Each is a ramp step at an alpha, written as a color-mix
       rather than as the literal the step happens to be, because a theme replaces
       the ramp and a literal cannot follow it: the spotlight's dim stayed Sedna's
       own near-black navy under Graphite, over a canvas that had gone neutral grey.
       --backdrop is the exception that proves it — pure black belongs to no ramp,
       has no hue to drift, and is what a modal backdrop should be in every theme. */
    --backdrop:      rgba(0, 0, 0, 0.65);       /* modal backdrop */
    /* A video's letterbox, and the scrim under a label or a play glyph on a picture.
       Black in every theme for the same reason as --backdrop: a film is framed in
       black, and a label on a photograph cannot know what the photograph is. */
    --media-bg:      var(--black);
    --media-scrim:   color-mix(in srgb, var(--black) 62%, transparent);
    --overlay:       color-mix(in srgb, var(--slate-900) 66%, transparent);  /* in-card blocking overlay */
    --spotlight-dim: color-mix(in srgb, var(--slate-950) 62%, transparent);  /* guided-tour / spotlight dim */
    --tip-bg:        var(--slate-925);
    --tip-border:    var(--slate-600);
    --tip-fg:        var(--slate-50);
    --scrollbar:       var(--slate-700);
    --scrollbar-hover: var(--slate-600);
    --scroll-shade:    rgba(0, 0, 0, 0.3);      /* scroll-edge fade */

    /* ── Shadows (full box-shadow values — themes swap the colour) ─────────
       A shadow's own black has no palette step to trace to, so these carry
       literal alpha rather than a ramp step. 02-theme-light.css must remap
       EVERY ONE of them: an unremapped shadow silently keeps its dark alpha
       on a white surface, which is how .menu, the select panel and the
       search panel came to cast near-black in light mode. ShadowTokenTests
       asserts the two blocks list the same names. */
    --shadow-topbar:    0 3px 8px rgba(0, 0, 0, 0.28);
    --shadow-bottombar: 0 -3px 8px rgba(0, 0, 0, 0.28);
    --shadow-nav-tools: 0 -6px 12px 0 rgba(0, 0, 0, 0.18);
    --shadow-modal:     0 24px 64px rgba(0, 0, 0, 0.5);
    --shadow-tip:       0 8px 22px rgba(0, 0, 0, 0.42);
    --shadow-flyout:    0 6px 18px rgba(0, 0, 0, 0.38);
    --shadow-dropdown:  0 10px 30px rgba(0, 0, 0, 0.3);
    --shadow-float:     0 10px 30px rgba(0, 0, 0, 0.34);
    --shadow-pop:       0 14px 40px rgba(0, 0, 0, 0.45);
    /* A hairline ring plus a long drop, for a SMALL element that should read as
       lifted clean off the page — a logo tile, an icon plate over a hero. Every
       other shadow here is panel-sized: a 32px tile under --shadow-float shows a
       soft smudge and no edge, because the edge is what sells the lift at that
       size. The ring is inside the token so it cannot be forgotten. */
    --shadow-tile:      0 0 0 1px var(--border), 0 12px 32px rgba(0, 0, 0, 0.38);
    /* The edge of a horizontally scrolled region — a pinned table column, a
       sticky first cell. Every shadow above casts downwards; this pair casts
       sideways, which is the axis a sideways scroller needs and the only one the
       set had no answer for. Spread is negative so the shadow starts at the edge
       and does not bleed back over the pinned cell itself.

       start and end are the two PHYSICAL directions, named for the logical edge
       they belong to under dir="ltr"; 70-rtl.css swaps which one a pinned column
       reaches for, because box-shadow has no logical form. */
    --shadow-edge-start:  6px 0 8px -6px rgba(0, 0, 0, 0.45);
    --shadow-edge-end:   -6px 0 8px -6px rgba(0, 0, 0, 0.45);

    /* ── Blazor reconnect banner (deliberately dark in both themes) ───────
       Not covered by docs/BRANDING.md's per-token tables. The border and the
       text are the nearest available step in the amber/crimson ramps at the
       relative position (mid border, light fg) the old literals held.

       The FIELD is a mix rather than a step, because the bar spans the whole
       viewport at the top of the z scale and the darkest step in either ramp
       still read as a hot band across the page. The semantic ramps stop at 900
       by design (docs/BRANDING.md section 2), so a darker field cannot be a
       step without extending eight ramps and re-measuring every theme. Mixing
       the last step toward the base ramp's floor gets a darker field of the
       same hue out of tokens the theme already defines, so it follows a
       rebrand for free. Both pairs stay well clear of AA: 6.1:1 and 9.9:1. */
    --reconnect-warn-bg:     color-mix(in srgb, var(--amber-900) 72%, var(--slate-950));
    --reconnect-warn-border: var(--amber-600);
    --reconnect-warn-fg:     var(--amber-200);
    --reconnect-fail-bg:     color-mix(in srgb, var(--crimson-900) 72%, var(--slate-950));
    --reconnect-fail-border: var(--crimson-700);
    --reconnect-fail-fg:     var(--crimson-400);

    /* ── Spacing scale ──────────────────────────────────────────────────────
       Every padding, margin and gap in the library resolves through one of these.
       Eleven steps: 2px granularity where UI chrome lives, 4px above it, 8px at the
       top. Redefine the scale to make a whole app denser or roomier.

       Sizes are NOT on this scale. A 28px close button and a 30px logo are
       dimensions of a thing, not space between things, and forcing them onto a
       spacing ramp would make the ramp meaningless. */
    --space-1:  2px;
    --space-2:  4px;
    --space-3:  6px;
    --space-4:  8px;
    --space-5:  10px;
    --space-6:  12px;
    --space-7:  16px;
    --space-8:  20px;
    --space-9:  24px;
    --space-10: 32px;
    --space-11: 40px;

    /* ── Control height ─────────────────────────────────────────────────────
       Three heights, shared by everything that can sit in a row with another
       control: the button, the icon button, the text input, the select, the
       read-only value, the search box, the input group and the stepper. A control's
       own padding and line box stay UNDER its tier, so the tier is what decides the
       height rather than a floor the content walks past.

       The chip takes the small and normal tiers because it is interactive. A badge
       does not: it is a label, so it has its own scale in 31-badges.css that lines
       up with text instead of with a target. */
    --control-height-sm: 28px;
    --control-height:    36px;
    --control-height-lg: 44px;   /* the WCAG AAA target size */

    /* ── Choice inset ───────────────────────────────────────────────────────
       Where a choice control's label starts, measured from the control's inline
       edge: the box or track plus the gap after it. `.choice-dependent` indents by
       these, so what belongs to an option lines up with its label. The widths are
       the controls' own fixed sizes and the gaps ride the spacing scale, so a
       redefined scale moves both. `ChoiceInsetTests` measures each against the
       rendered control. */
    --choice-inset-check:  calc(15px + var(--space-4));   /* checkbox and radio */
    --choice-inset-switch: calc(34px + var(--space-5));

    /* How tall a clamped code block is before it scrolls. Raise it for docs, lower it
       for a dense admin page. */
    --code-clamp: 360px;

    /* ── Type scale ─────────────────────────────────────────────────────────
       Ten steps. 13px is the body size for content, 14px for the page. The three
       adjacent sizes in the middle are deliberate: a dense operations UI genuinely
       distinguishes a 13px cell from a 14px paragraph from a 15px panel title. */
    --text-1:  10px;   /* the smallest legible label — nav section headings */
    --text-2:  11px;
    --text-3:  12px;
    --text-4:  13px;
    --text-5:  14px;
    --text-6:  15px;
    --text-7:  16px;
    --text-8:  18px;
    --text-9:  22px;
    --text-10: 26px;
    --text-11: 40px;   /* display only — the empty-state glyph, .state-icon */

    /* ── Motion ─────────────────────────────────────────────────────────────
       Three durations for interaction, and one per looping animation. Set them all
       to 0s to stop every transition without touching a rule — though
       prefers-reduced-motion already does that properly. */
    --motion-fast: 0.12s;   /* hover, focus, colour changes */
    --motion-mid:  0.18s;   /* something entering or leaving — the drawer */
    --motion-slow: 0.25s;   /* a value animating to a new position — the progress bar */
    --spin-duration:     0.6s;
    --progress-duration: 1.1s;
    --skeleton-duration: 1.4s;
    --pulse-duration:    1.6s;

    /* ── Corner rounding ────────────────────────────────────────────────────
       Named by role rather than by size, because "square off the controls" is the
       actual intent and the sizes are not a scale — 4, 5 and 6 differ by a pixel
       each and mean different things. Redefine these to change how rounded a whole
       app is; every border-radius in the library except a literal 50% circle and
       `inherit` resolves through one of them. */
    --radius-control: 5px;   /* buttons, inputs, the input group */
    --radius-surface: 6px;   /* cards, panels, tips, flyouts, toasts */
    --radius-panel:   8px;   /* the modal — the largest surface */
    --radius-inner:   4px;   /* nested one border inside a control or surface */
    --radius-small:   3px;   /* inline code, the checkbox */
    --radius-pill:    999px; /* badges, count pills, the progress track, the switch */

    /* ── Cell padding ───────────────────────────────────────────────────────
       Table cells, remapped by [data-density="compact"]. A token so anything else
       that should tighten with the tables can opt in by using them. */
    --cell-pad-y: 8px;
    --cell-pad-x: 12px;

    /* ── Card padding ───────────────────────────────────────────────────────
       The gutter of `.card-head`, `.card-body` and `.card-foot`, remapped by [data-density="compact"] like the cell padding above. Everything
       that has to line up with a card's text reads the inline one — a list row or a
       group head meeting the card's edges, the first tab in a `.card-head--tabs` — so
       compact density moves them together. An app's own card part opts in the same
       way. */
    --card-pad-block:  var(--space-6);
    --card-pad-inline: var(--space-7);

    /* ── List rows ──────────────────────────────────────────────────────────
       The padding of every `.list-row` and the gap between the cards of a
       `.list--cards`, remapped by [data-density="compact"]. A row follows density
       because it is a row, not because a card contains it; inside a `.card` its
       inline padding is still the card's gutter. */
    --list-pad-block:  var(--space-5);
    --list-pad-inline: var(--space-6);
    --list-gap:        var(--space-4);

    /* How many lines `.list-title` and `.list-sub` show before the ellipsis — a
       number, or `none` to wrap freely. Set on a list, an <li> or a row it shapes
       every line inside and aligns the row's slots to the first line; set on one
       `.list-title` or `.list-sub` it shapes that line alone. */
    --list-lines: 1;

    /* ── Metrics ────────────────────────────────────────────────────────── */
    --page-max: 1800px;    /* content column cap on ultra-wide screens */

    /* ── Safe area ──────────────────────────────────────────────────────────
       How much of each viewport edge is covered by the device rather than by the
       page — a home indicator, a notch, a rounded corner. `env()` IS the device
       detection: it resolves to 0px wherever there is nothing in the way, so a
       rule that adds one of these serves a phone and a desktop unchanged and
       nothing has to ask what it is running on.

       They are 0px everywhere until the host page's viewport meta carries
       `viewport-fit=cover`, which is what tells the browser to lay the page out
       across the whole display in the first place. The block in
       docs/getting-started.md has it; an app that has not copied that block yet
       sees no change from any of these.

       A TOKEN rather than `env()` at each use site, for three reasons. The
       fallback is stated once instead of at every call. The inline pair can be
       mirrored for RTL, which `env()` cannot do on its own. And a value the
       browser synthesises from hardware is otherwise untestable — a headless
       Chromium reports 0 for all four and always will, whereas a token can be set
       to 34px and the rule that reads it measured.

       The block pair passes straight through: `dir` does not touch the block
       axis, and this library does not ship a vertical writing mode. */
    --safe-block-start:  env(safe-area-inset-top, 0px);
    --safe-block-end:    env(safe-area-inset-bottom, 0px);
    --safe-inline-start: env(safe-area-inset-left, 0px);
    --safe-inline-end:   env(safe-area-inset-right, 0px);
}

/* The inline pair is the one thing `env()` cannot express: its four values are
   physical, and the whole library is written in logical properties so that the
   layout mirrors from `dir="rtl"` with no rules at all. Swapping the two tokens
   here is what keeps every use site logical — `padding-inline-end:
   var(--safe-inline-end)` is then correct in both directions, and 70-rtl.css needs
   nothing. */
:root[dir="rtl"] {
    --safe-inline-start: env(safe-area-inset-right, 0px);
    --safe-inline-end:   env(safe-area-inset-left, 0px);
}
}

/* ── 02-theme-light.css → @layer sedna.tokens ──────────────────────────────────────────────── */
@layer sedna.tokens {
/* ═══ Light theme ═══════════════════════════════════════════════════════════
   Pure token remapping — no selector overrides. `data-variant` rides on <html>,
   orthogonal to `data-theme` (which theme; this is which variant of it); apply it
   before first paint (see README) so no dark flash occurs.

   Canvas is Ice White with white cards (docs/BRANDING.md §5). The canvas-to-card
   separation is deliberately quieter than the default theme's, because the guide
   specifies Ice White as *the* light surface — cards must therefore keep their
   border in light theme; it is doing the separation work that lightness alone no
   longer does. */
:root[data-variant="light"] {
    --color-scheme:   light;
    --picker-invert:  0;

    --bg:             var(--slate-50);    /* Ice White */
    /* No palette step is pure white — see --on-solid's comment in 01-tokens.css.
       --bg-elevated, --card-bg and --sidebar-bg all need true white here, one
       step brighter than Ice White's --bg, which the slate ramp has no step for. */
    --bg-elevated:    var(--white);
    --bg-hover:       var(--slate-100);
    --fg:             var(--slate-900);   /* Deep Space */
    --fg-soft:        var(--slate-700);
    /* slate-600, not slate-500: the lighter step measures 3.94:1 on the light
       --bg, which is under AA, and --muted is the single most-used text colour
       after --fg. slate-600 reaches 6.28:1. */
    --muted:          var(--slate-600);
    --border:         var(--slate-300);
    --border-strong:  var(--slate-400);
    /* The ladder's top step. Declared here even though the dark value happens to be
       the right one in this direction too — slate-500 is darker than light's
       --border-strong, so the three still ascend — because inheriting it was an
       accident, not a decision, and the next ramp edit would not know that. */
    --border-hover:   var(--slate-500);
    --divider:        rgba(15, 23, 42, 0.07);    /* slate-900 / Deep Space */
    --card-bg:        var(--white);
    --surface-soft:   rgba(15, 23, 42, 0.03);    /* slate-900 */
    --surface-strong: rgba(15, 23, 42, 0.055);   /* slate-900 */
    /* Elevation runs the other way here: level 1 is white, so a raised surface
       gets darker rather than lighter. --surface-app, --surface-chrome,
       --surface-content and --surface-raised-1 are aliases and follow --bg,
       --sidebar-bg and --card-bg above without a remap of their own. */
    --surface-raised-2: var(--slate-100);
    --surface-raised-3: var(--slate-200);
    --redacted:       var(--crimson-700);

    --brand-soft: var(--coral-600);
    /* Step 800, not 700. Brand text in this variant is read on the canvas — and on a
       brand tint over it, which is where it is dimmest — so the step has to clear AA
       there rather than against white. 700 does that for Sedna's own coral, whose
       steps were solved by hand, and for a GENERATED ramp it does not: the shared
       lightness curve puts step 700 at one lightness for every hue, and WCAG luminance
       weights hues differently, so a green brand measured 4.03:1 and a blue one 4.47:1
       on the tint. 800 clears it for both, and for the library's own brand. The same
       reasoning as --brand taking step 600 against white (docs/BRANDING.md §3.1), one
       step further along because the surface underneath is a tinted canvas rather
       than plain white. */
    --brand-text: var(--coral-800);
    --brand-tint: color-mix(in srgb, var(--brand) 10%, transparent);
    /* Lower than the dark variant's 24%: a tint darkens a light canvas, so the text on
       it — --brand-text — loses contrast as this rises. The strong tint is bounded by
       that pairing in either variant; BrandTextContrastTests is the measurement. */
    --brand-tint-strong: color-mix(in srgb, var(--brand) 20%, transparent);
    --accent:     var(--orbit-700);

    --sidebar-bg:     var(--white);
    --sidebar-border: var(--slate-200);
    --sidebar-fg:     var(--slate-700);
    /* One step off white, matching --bg-hover. The sidebar is white here, so the
       hover has to go darker; the dark theme's value goes lighter. */
    --sidebar-hover:  var(--slate-100);

    --go-bg:     rgba(34, 197, 94, 0.1);    /* green-500 */
    --go-border: var(--green-300);
    --go-fg:     var(--green-700);
    /* Two steps down: a 400 on a white canvas is a pastel, and a pastel bar is
       neither the brand's weight nor readable against the track. */
    --viz-2: var(--cyan-600);
    --viz-3: var(--orange-600);
    --viz-4: var(--teal-600);
    --viz-5: var(--violet-600);
    --viz-6: var(--indigo-600);
    --viz-previous: var(--slate-500);
    --on-viz: var(--white);

    --warn-bg:     rgba(245, 158, 11, 0.1);    /* amber-500 */
    --warn-border: var(--amber-300);
    --warn-fg:     var(--amber-700);

    /* The hover and active tints and the strong border take the same steps as the
       dark theme, scaled for a light surface. All four move together: a tint
       remapped without its hover pair jumps between two unrelated reds. */
    --danger-solid: var(--crimson-700);
    --danger-bg:            rgba(199, 34, 104, 0.08);    /* crimson-700 */
    --danger-bg-hover:      rgba(199, 34, 104, 0.14);    /* crimson-700 */
    --danger-bg-active:     rgba(199, 34, 104, 0.2);     /* crimson-700 */
    --danger-border:        var(--crimson-300);
    --danger-border-strong: var(--crimson-700);
    --danger-fg:            var(--crimson-700);
    --danger-ring:          rgba(199, 34, 104, 0.35);    /* crimson-700 */

    --info-bg:     rgba(89, 195, 255, 0.08);    /* orbit-400 */
    --info-border: var(--orbit-300);
    --info-fg:     var(--orbit-700);
    --info-solid:  var(--orbit-700);
    --info-ring:   rgba(89, 195, 255, 0.4);     /* orbit-400 */

    --secret-bg:     color-mix(in srgb, var(--violet-600) 8%, transparent);
    --secret-border: var(--violet-300);
    --secret-fg:     var(--violet-700);

    --badge-cyan-bg:     rgba(34, 211, 238, 0.08);    /* cyan-400 */
    --badge-cyan-border: var(--cyan-300);
    --badge-cyan-fg:     var(--cyan-700);
    --badge-orange-bg:     rgba(245, 158, 75, 0.08);    /* orange-400 */
    --badge-orange-border: var(--orange-300);
    --badge-orange-fg:     var(--orange-700);
    --badge-teal-bg:     rgba(20, 184, 166, 0.08);    /* teal-500 */
    --badge-teal-border: var(--teal-300);
    --badge-teal-fg:     var(--teal-700);

    /* The severity ramp, on a light canvas. The ladder is the same shape — an
       opaque step 1, then tints weakening 0.22 → 0.18 → 0.14 → 0.10 — carried
       at higher alphas because a tint has less room to read against white.
       Step 1 takes crimson-700 for the same reason --danger-solid does: 4.61:1
       for white in both variants. */
    --sev-1-solid:  var(--crimson-700);
    --sev-1-bg:     var(--crimson-700);
    --sev-1-border: var(--crimson-700);
    --sev-1-fg:     var(--on-solid);

    --sev-2-solid:  var(--orange-700);
    --sev-2-bg:     rgba(218, 133, 46, 0.22);   /* orange-500 */
    --sev-2-border: var(--orange-300);
    --sev-2-fg:     var(--orange-700);

    --sev-3-solid:  var(--amber-600);
    --sev-3-bg:     rgba(245, 158, 11, 0.18);   /* amber-500 */
    --sev-3-border: var(--amber-300);
    --sev-3-fg:     var(--amber-700);

    --sev-4-solid:  var(--orbit-600);
    --sev-4-bg:     rgba(89, 195, 255, 0.14);   /* orbit-400 */
    --sev-4-border: var(--orbit-300);
    --sev-4-fg:     var(--orbit-700);

    --sev-5-solid:  var(--slate-500);
    --sev-5-bg:     rgba(148, 163, 184, 0.1);   /* slate-400 */
    --sev-5-border: var(--slate-300);
    --sev-5-fg:     var(--slate-600);

    --badge-bg:      rgba(15, 23, 42, 0.04);    /* slate-900 */
    --table-head-bg: rgba(15, 23, 42, 0.03);    /* slate-900 */

    --skeleton-bg:    rgba(15, 23, 42, 0.06);    /* slate-900 */
    --skeleton-sheen: rgba(15, 23, 42, 0.11);    /* slate-900 */
    --progress-track: rgba(15, 23, 42, 0.08);    /* slate-900 */
    --btn-active-bg: var(--slate-300);
    --code-fg:       var(--indigo-700);
    --code-bg:       rgba(90, 97, 191, 0.08);    /* indigo-700 */
    --backdrop:      color-mix(in srgb, var(--slate-900) 35%, transparent);
    --overlay:       color-mix(in srgb, var(--slate-50) 66%, transparent);
    --spotlight-dim: color-mix(in srgb, var(--slate-900) 42%, transparent);
    /* A light surface, not an inverted dark bubble. All three move together: a
       hint that kept the dark background and took the light theme's foreground
       would be unreadable, so --tip-fg is remapped here rather than left to
       01-tokens.css. Separation from a white card comes from the border and
       --shadow-tip, the same two things .menu and .popover rely on in this
       variant. */
    --tip-bg:        var(--white);
    --tip-border:    var(--slate-300);
    --tip-fg:        var(--slate-900);
    --scrollbar:       var(--slate-300);
    --scrollbar-hover: var(--slate-400);
    --scroll-shade:    rgba(15, 23, 42, 0.16);    /* slate-900 */

    --shadow-topbar:    0 3px 8px rgba(15, 23, 42, 0.1);      /* slate-900 */
    --shadow-bottombar: 0 -3px 8px rgba(15, 23, 42, 0.1);     /* slate-900 */
    --shadow-nav-tools: 0 -6px 12px 0 rgba(15, 23, 42, 0.06); /* slate-900 */
    --shadow-modal:     0 24px 64px rgba(15, 23, 42, 0.22);   /* slate-900 */
    --shadow-tip:       0 8px 22px rgba(15, 23, 42, 0.22);    /* slate-900 */
    --shadow-flyout:    0 6px 18px rgba(15, 23, 42, 0.16);    /* slate-900 */
    --shadow-dropdown:  0 10px 30px rgba(15, 23, 42, 0.14);   /* slate-900 */
    --shadow-pop:       0 14px 40px rgba(15, 23, 42, 0.2);    /* slate-900 */
    --shadow-float:     0 10px 30px rgba(15, 23, 42, 0.16);   /* slate-900 */
    --shadow-tile:      0 0 0 1px var(--border), 0 12px 32px rgba(15, 23, 42, 0.18);   /* slate-900 */
    --shadow-edge-start:  6px 0 8px -6px rgba(15, 23, 42, 0.18);   /* slate-900 */
    --shadow-edge-end:   -6px 0 8px -6px rgba(15, 23, 42, 0.18);   /* slate-900 */
}
}

/* ── 03-theme-colour-blind.css → @layer sedna.tokens ──────────────────────────────────────────────── */
@layer sedna.tokens {
/* ═══ Colour-blind-safe palette ═════════════════════════════════════════════
   Independent toggle, composes with either theme. The library's own go → blue
   swap is retuned to Orbit Blue, so go-vs-danger reads as blue-vs-red for
   deuteranopia/protanopia.

   The rebrand adds a second collision the default palette did not have: under
   deuteranopia and protanopia, coral and crimson converge — brand and danger
   both desaturate toward a similar warm brown. Danger therefore also moves
   colder and darker here, so the pair separates on lightness when hue is
   unavailable (docs/BRANDING.md §9.2). */
:root[data-cvd="1"] {
    --go-solid:  var(--orbit-700);
    --go-hover:  var(--orbit-800);
    --go-active: var(--orbit-900);
    --go-bg:     rgba(89, 195, 255, 0.14);    /* orbit-400 */
    --go-border: var(--orbit-800);
    --go-fg:     var(--orbit-300);
    --go-ring:   rgba(89, 195, 255, 0.5);     /* orbit-400 */

    /* Added for SednaUI: separate danger from the coral brand by lightness. */
    --danger-solid: var(--crimson-800);
    --danger-fg:    var(--crimson-300);

    /* The severity ramp needs no rescue for its ORDER — that is carried by the
       fill strength, which no colour vision changes. Two of its five hues still
       move, for identity:

         step 1 darkens, the same trade --danger just made against the brand;
         step 2 leaves the warm band entirely, because orange sits between the
         coral brand and step 3's amber and under deuteranopia all three
         converge on one brown. Violet is the only remaining hue that is neither
         warm nor step 4's blue.

       Steps 3, 4 and 5 are amber, blue and grey, which already separate. */
    --sev-1-solid:  var(--crimson-900);
    --sev-1-bg:     var(--crimson-900);
    --sev-1-border: var(--crimson-800);

    --sev-2-solid:  var(--violet-700);
    --sev-2-bg:     rgba(162, 131, 255, 0.28);  /* violet-500 */
    --sev-2-border: var(--violet-700);
    --sev-2-fg:     var(--violet-300);
}
:root[data-variant="light"][data-cvd="1"] {
    --go-bg:     rgba(89, 195, 255, 0.08);    /* orbit-400 */
    --go-border: var(--orbit-300);
    --go-fg:     var(--orbit-700);
    --danger-fg: var(--crimson-800);

    --sev-2-bg:     rgba(117, 75, 211, 0.22);   /* violet-700 */
    --sev-2-border: var(--violet-300);
    --sev-2-fg:     var(--violet-700);
}
}

/* ── 04-theme-contrast.css → @layer sedna.tokens ──────────────────────────────────────────────── */
@layer sedna.tokens {
/* ═══ High contrast ═════════════════════════════════════════════════════════
   A pure token remap, like every other theme block, so CSS load order stays
   irrelevant and an app's own light/dark blocks keep working underneath.

   Two triggers, and both are needed:
     · `prefers-contrast: more`         — the OS setting, honoured automatically
     · `[data-contrast="more"]` on <html> — a setting inside the app, for the many
       people whose OS is not set but who want it here

   The attribute is deliberately NOT written by boot.js from the media query. A media
   query and an attribute would then both be live, and an app that wanted to offer
   "follow the system / always on / always off" could not express the third state.
   The app sets the attribute; the media query covers everybody who never opens
   settings.

   What changes, and what does not:
     · Borders go to full strength and dividers become real lines. Most of what makes
       this library low-contrast is not the text — it is the 3%-alpha surfaces and the
       6%-alpha dividers, which disappear entirely for anybody who needs this mode.
     · Muted text stops being muted. --muted is the lowest-contrast pair the light
       theme ships, at 4.39:1; here it goes to the body colour.
     · Translucent tints become opaque. A 12% tint over an unknown surface has an
       unknown contrast, which is the one thing this mode cannot have.
     · Shadows are NOT removed. They are the only depth cue left once every surface
       is the same lightness, so they matter more here, not less.
     · Semantic HUES are kept. Turning everything monochrome would destroy the
       go/warn/danger distinction, which is information. Forced colours does that,
       and it has to; this mode does not.

   docs/BRANDING.md §9.3 gives the brand-specific intent (brand text to step 300 dark /
   800 light, --brand-tint opaque) but no per-token table for this file the way §5 and
   §6 do for the two themes — the neutral, status and badge values below are the
   nearest available palette step at the same relative position the pre-rebrand
   literals held (grey → slate, blue → coral/orbit by role, red → crimson). Several
   support ramps have no step this dark or this light, which is what forces a
   "nearest available" choice here.
   ─────────────────────────────────────────────────────────────────────────── */

/* Dark, high contrast. */
:root[data-contrast="more"] {
    /* Pure black/white: no palette step reaches either extreme, and that is the
       point of this mode — see --on-solid's comment in 01-tokens.css. */
    --bg:              var(--black);
    --bg-elevated:     var(--slate-950);
    --bg-hover:        var(--slate-800);
    --fg:              var(--white);
    --fg-soft:         var(--white);
    --muted:           var(--slate-300);
    --border:          var(--slate-400);
    --border-strong:   var(--slate-300);
    --border-hover:    var(--white);
    --divider:         var(--slate-500);
    --card-bg:         var(--slate-950);
    --surface-soft:    var(--slate-900);
    --surface-strong:  var(--slate-800);

    --brand-soft:      var(--coral-400);
    --brand-text:      var(--coral-300);
    --brand-tint:      var(--coral-950);
    --brand-tint-strong: var(--coral-900);
    --accent:          var(--orbit-300);

    --sidebar-bg:      var(--black);
    --sidebar-border:  var(--slate-400);
    --sidebar-fg:      var(--white);
    /* A step stronger than the default theme's, for the same reason every border
       in this block is: the sidebar is pure black here, so the hover has further
       to travel before it registers as a hover at all. */
    --sidebar-hover:   var(--slate-700);

    --go-bg:      var(--green-900);
    --go-border:  var(--green-400);
    --go-fg:      var(--green-300);
    --warn-bg:     var(--amber-900);
    --warn-border: var(--amber-400);
    --warn-fg:     var(--amber-300);
    --danger-bg:            var(--crimson-900);
    --danger-border:        var(--crimson-400);
    --danger-border-strong: var(--crimson-300);
    --danger-fg:            var(--crimson-300);
    --info-bg:     var(--orbit-950);
    --info-border: var(--orbit-300);
    --info-fg:     var(--orbit-200);
    --info-solid:  var(--orbit-300);
    --secret-bg:     var(--violet-900);
    --secret-border: var(--violet-300);
    --secret-fg:     var(--violet-200);

    /* The severity ramp loses its ordering channel here, and that is the trade
       this mode makes everywhere: a tint whose strength carried the order has an
       unknown contrast over an unknown surface, which is the one thing high
       contrast cannot ship. Step 1 stays a filled pill — the single ordering cue
       that survives an opaque ramp — and 2–5 become opaque surfaces of their own
       hue. The rank is then entirely on the label, which is why the ramp's docs
       require one. */
    --sev-1-solid:  var(--crimson-800);
    --sev-1-bg:     var(--crimson-800);
    --sev-1-border: var(--crimson-300);
    --sev-1-fg:     var(--white);
    --sev-2-solid:  var(--orange-300);
    --sev-2-bg:     var(--orange-900);
    --sev-2-border: var(--orange-300);
    --sev-2-fg:     var(--orange-200);
    --sev-3-solid:  var(--amber-300);
    --sev-3-bg:     var(--amber-900);
    --sev-3-border: var(--amber-400);
    --sev-3-fg:     var(--amber-300);
    --sev-4-solid:  var(--orbit-300);
    --sev-4-bg:     var(--orbit-950);
    --sev-4-border: var(--orbit-300);
    --sev-4-fg:     var(--orbit-200);
    --sev-5-solid:  var(--slate-300);
    --sev-5-bg:     var(--slate-800);
    --sev-5-border: var(--slate-300);
    --sev-5-fg:     var(--slate-200);

    --badge-bg:      var(--slate-800);
    --table-head-bg: var(--slate-800);
    --badge-cyan-bg: var(--cyan-900);     --badge-cyan-border: var(--cyan-300);     --badge-cyan-fg: var(--cyan-200);
    --badge-orange-bg: var(--orange-900); --badge-orange-border: var(--orange-300); --badge-orange-fg: var(--orange-200);
    --badge-teal-bg: var(--teal-900);     --badge-teal-border: var(--teal-300);     --badge-teal-fg: var(--teal-200);

    --skeleton-bg:    var(--slate-800);
    --skeleton-sheen: var(--slate-700);
    --progress-track: var(--slate-700);
    --btn-active-bg:  var(--slate-700);
    --code-fg:        var(--indigo-200);
    --code-bg:        var(--indigo-900);
    --backdrop:       rgba(0, 0, 0, 0.85);
    --overlay:        rgba(0, 0, 0, 0.85);
    --scrollbar:       var(--slate-400);
    --scrollbar-hover: var(--slate-300);
}

/* Light, high contrast. Composes with the light variant by being more specific. */
:root[data-variant="light"][data-contrast="more"] {
    --bg:              var(--white);
    --bg-elevated:     var(--white);
    --bg-hover:        var(--slate-200);
    --fg:              var(--black);
    --fg-soft:         var(--black);
    --muted:           var(--slate-800);
    --border:          var(--slate-600);
    --border-strong:   var(--slate-800);
    --border-hover:    var(--black);
    --divider:         var(--slate-400);
    --card-bg:         var(--white);
    --surface-soft:    var(--slate-100);
    --surface-strong:  var(--slate-200);

    --brand-soft:      var(--coral-700);
    /* One step past the light variant's own --brand-text, which is 800. This mode has
       to be more contrast than the variant it composes with, not the same value
       restated — and a token that stopped moving here would look deliberate. */
    --brand-text:      var(--coral-900);
    --brand-tint:      var(--coral-200);
    --brand-tint-strong: var(--coral-300);
    --accent:          var(--orbit-800);

    --sidebar-bg:      var(--white);
    --sidebar-border:  var(--slate-600);
    --sidebar-fg:      var(--black);
    --sidebar-hover:   var(--slate-200);

    --go-bg:      var(--green-200);
    --go-border:  var(--green-800);
    --go-fg:      var(--green-900);
    --warn-bg:     var(--amber-200);
    --warn-border: var(--amber-800);
    --warn-fg:     var(--amber-900);
    --danger-bg:            var(--crimson-200);
    --danger-border:        var(--crimson-800);
    --danger-border-strong: var(--crimson-900);
    --danger-fg:            var(--crimson-900);
    --info-bg:     var(--orbit-100);
    --info-border: var(--orbit-800);
    --info-fg:     var(--orbit-900);
    --info-solid:  var(--orbit-900);
    --secret-bg:     var(--violet-200);
    --secret-border: var(--violet-800);
    --secret-fg:     var(--violet-900);

    --sev-1-solid:  var(--crimson-800);
    --sev-1-bg:     var(--crimson-800);
    --sev-1-border: var(--crimson-900);
    --sev-1-fg:     var(--white);
    --sev-2-solid:  var(--orange-900);
    --sev-2-bg:     var(--orange-200);
    --sev-2-border: var(--orange-800);
    --sev-2-fg:     var(--orange-900);
    --sev-3-solid:  var(--amber-900);
    --sev-3-bg:     var(--amber-200);
    --sev-3-border: var(--amber-800);
    --sev-3-fg:     var(--amber-900);
    --sev-4-solid:  var(--orbit-900);
    --sev-4-bg:     var(--orbit-100);
    --sev-4-border: var(--orbit-800);
    --sev-4-fg:     var(--orbit-900);
    --sev-5-solid:  var(--slate-700);
    --sev-5-bg:     var(--slate-200);
    --sev-5-border: var(--slate-600);
    --sev-5-fg:     var(--slate-900);

    --badge-bg:      var(--slate-200);
    --table-head-bg: var(--slate-200);
    --badge-cyan-bg: var(--cyan-200);     --badge-cyan-border: var(--cyan-800);     --badge-cyan-fg: var(--cyan-900);
    --badge-orange-bg: var(--orange-200); --badge-orange-border: var(--orange-800); --badge-orange-fg: var(--orange-900);
    --badge-teal-bg: var(--teal-200);     --badge-teal-border: var(--teal-800);     --badge-teal-fg: var(--teal-900);

    --skeleton-bg:    var(--slate-200);
    --skeleton-sheen: var(--slate-300);
    --progress-track: var(--slate-300);
    --viz-previous:   var(--slate-600);
    --btn-active-bg:  var(--slate-400);
    --code-fg:        var(--indigo-900);
    --code-bg:        var(--indigo-200);
    --backdrop:       rgba(0, 0, 0, 0.6);
    --overlay:        rgba(255, 255, 255, 0.9);
    /* Follows the light variant's hint rather than inheriting the dark bubble
       from 01-tokens.css, which would leave this the one light surface in the
       library still painted dark. The border goes to full strength, as every
       border in this block does. */
    --tip-bg:          var(--white);
    --tip-border:      var(--slate-800);
    --tip-fg:          var(--black);
    --scrollbar:       var(--slate-500);
    --scrollbar-hover: var(--slate-700);
    --redacted:        var(--crimson-900);
}

/* The OS setting, for anybody who never opens the app's own settings. Only :root
   remaps, so `Appearance_media_queries_only_remap_tokens` holds. */
@media (prefers-contrast: more) {
    :root {
        --border:         var(--slate-400);
        --border-strong:  var(--slate-300);
        --divider:        var(--slate-500);
        --fg-soft:        var(--white);
        --muted:          var(--slate-300);
        --surface-soft:   var(--slate-900);
        --surface-strong: var(--slate-800);
    }
    :root[data-variant="light"] {
        --border:         var(--slate-600);
        --border-strong:  var(--slate-800);
        --divider:        var(--slate-400);
        --fg-soft:        var(--black);
        --muted:          var(--slate-800);
        --surface-soft:   var(--slate-100);
        --surface-strong: var(--slate-200);
    }
}
}

/* ── 06-base-elements.css → @layer sedna.base ──────────────────────────────────────────────── */
@layer sedna.base {
/* ═══ Base elements ═════════════════════════════════════════════════════════ */
* { box-sizing: border-box; }

html, body { margin: 0; height: 100%; }

/* On <html>, not :root — the selectors are equivalent, but a :root block in this
   library is a token block and may hold custom properties only.

   `scrollbar-width` and `scrollbar-color` are inherited properties, so declaring them
   here reaches every scroll container in the document: a modal body, a drawer, the
   palette list, a code block, a table wrapper, and an app's own scrollers. Declare
   them here rather than on a list of the containers the library creates: that list is
   never complete, and one unthemed OS scrollbar inside a modal is what a gap in it
   looks like. Anything that wants a different bar overrides these two on itself,
   which is what `.breadcrumb` does to hide its own. */
html {
    color-scheme: var(--color-scheme);
    scrollbar-width: thin;
    scrollbar-color: var(--scrollbar) transparent;
}

body {
    background: var(--bg);
    color: var(--fg);
    font-family: var(--font-sans);
    font-size: var(--text-5);
    line-height: 1.5;
}

a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }

/* overflow-wrap: anywhere, because what goes in an inline <code> is exactly the kind
   of string that has no spaces to break at — an asset path, a class list, a token
   name. Without it a long one runs past its container and is clipped, which on a
   phone means the end of the path is simply not there. `anywhere` rather than
   `break-word`: it also shrinks the min-content width, so a <code> in a table cell or
   a flex item stops forcing the whole column wide.

   It does nothing to a code BLOCK. `white-space: pre` there means lines never wrap at
   all, so there is no wrap opportunity for this to take, and the block goes on
   scrolling instead. */
code {
    font-family: var(--font-mono);
    font-size: var(--text-4);
    color: var(--code-fg);
    background: var(--code-bg);
    padding: 1px var(--space-2);
    border-radius: var(--radius-small);
    overflow-wrap: anywhere;
}

h1 { font-size: var(--text-9); margin: 0 0 var(--space-4); }
h2 { font-size: var(--text-7); margin: var(--space-9) 0 var(--space-4); }
p.lede { color: var(--muted); margin: 0 0 var(--space-7); }

/* Keyboard focus keeps a ring; mouse/programmatic focus (e.g. focus restored on
   reload) does not — otherwise an auto-selected block shows a stray outline. */
:focus:not(:focus-visible) { outline: none; }

/* The case the rule above cannot reach: focus moved BY SCRIPT onto something that is
   not a control.

   `FocusOnNavigate` — the Blazor Web App template's own component, and what announces
   a client-side navigation to a screen reader — puts `tabindex="-1"` on the page's
   <h1> and calls focus() on it after every route change. Chromium then treats that as
   keyboard focus and paints its own square outline round the heading: its
   :focus-visible heuristic falls back to "keyboard" whenever the last interaction was
   a keypress OR the element that previously held focus is gone, and a route change
   re-renders the link that was clicked, so the fallback is the normal case rather than
   the edge one. `:focus:not(:focus-visible)` is no help, because :focus-visible
   matches.

   The seam is `tabindex="-1"` itself: it takes an element out of the tab order, so
   nothing carrying it can be reached by Tab, and nothing carrying it needs a ring to
   say where the keyboard is. The one exception is a roving-tabindex widget — a `.tab`,
   a menu item, a listbox option — which IS reached, by arrow key. Every one of those
   is a control, which is what the exclusions separate: an interactive tag or any ARIA
   role keeps its ring, while a heading, landmark or wrapper that only script can focus
   does not get one.

   This also silences the ring on a skip-link target (`<main id="main" tabindex="-1">`),
   deliberately: it is a viewport-sized box that tells the reader nothing, <main> is not
   keyboard-operable so WCAG 2.4.7 does not ask for one, and the skip link itself keeps
   its own ring while it is the focused control. An app that wants it back writes
   `main:focus-visible { outline: … }` in its own stylesheet, which is unlayered and
   beats this without a fight. */
[tabindex="-1"]:not(a, area, button, input, select, textarea, summary, [contenteditable], [role]):focus-visible {
    outline: none;
}

/* sedna-spin is NOT here: it is declared beside its only consumer in
   39-spinner.css. Two identical @keyframes of the same name is a silent-drift
   hazard — the later one wins for the whole document, so editing the earlier
   changes nothing. */
@keyframes sedna-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }


}

/* ── 10-frame-shell.css → @layer sedna.frame ──────────────────────────────────────────────── */
@layer sedna.frame {
/* ═══ TIER 1 — the frame ════════════════════════════════════════════════════
   Layout chrome that must be pixel-identical in every app. Do not restyle any
   of this per project; if it is wrong, it is wrong here. */

/* ── Shell ──────────────────────────────────────────────────────────────── */
/* 100dvh, not 100vh: dvh excludes the mobile browser's own chrome, so the shell is
   never partly hidden behind it. No 100vh fallback — dvh predates the supported
   browser floor, so the fallback would be a line that never runs.

   ── Safe area: the shell takes the top and the two sides ──
   Once the host page says `viewport-fit=cover`, the viewport reaches under the
   notch, the rounded corners and the home indicator, and something has to hold the
   content clear of them. The split is by construction rather than by taste: the
   shell's own children — the sidebar, the topbar, the page — are IN its flow, so one
   padding on the shell moves every one of them at once.

   Anything `position: fixed` is outside that padding and has to take the inset
   itself, which is also what lets each paint its OWN surface into the strip — shell
   padding there would leave a band of page background under a dark sheet, which is
   the artefact this is meant to prevent. Six of them: the sheet and the drawer, the
   toast stack, the FAB, the reconnect bar and the skip link, plus the modal, whose
   cap is what holds its footer off the edge. Each says so where it is declared. A
   NEW fixed overlay on any edge is the author's to handle; nothing here can.

   `height: 100dvh` with `box-sizing: border-box` means the padding eats into the
   shell rather than adding to it, so nothing overflows and no scrollbar appears.
   Every one of these resolves to 0px on a device with no inset, so this is the same
   rule it was on a desktop. */
.layout {
    display: flex;
    height: 100dvh;
    padding-block-start: var(--safe-block-start);
    padding-inline: var(--safe-inline-start) var(--safe-inline-end);
    overflow: hidden;
}
.content { flex: 1; display: flex; flex-direction: column; min-height: 0; overflow: hidden; }

/* Bare shell (unauthenticated / minimal pages): topbar + body, no sidebar. */
.bare-layout {
    display: flex;
    flex-direction: column;
    height: 100dvh;
    padding-block-start: var(--safe-block-start);
    padding-inline: var(--safe-inline-start) var(--safe-inline-end);
    overflow: hidden;
}
.bare-brand  { display: flex; align-items: center; gap: var(--space-5); padding: 0 var(--space-7); font-size: var(--text-6); }
.bare-brand .brand-logo { width: 28px; height: 28px; border-radius: var(--radius-surface); flex-shrink: 0; }

}

/* ── 11-frame-sidebar.css → @layer sedna.frame ──────────────────────────────────────────────── */
@layer sedna.frame {
/* ── Sidebar ────────────────────────────────────────────────────────────── */
/* The width is a knob, not a value baked into the class. The frame must look the
   same in every app, but how much room a nav needs depends on the labels in it —
   and the alternative was an app overriding `width` on a tier-1 class, which the
   override rules ask it not to do and which the library's own examples were doing.
   Both rungs are here so a rail that has to hold a wider icon moves with it. */
.sidebar {
    --sidebar-width: 220px;
    --sidebar-width-collapsed: 56px;
    width: var(--sidebar-width);
    --surface-here: var(--sidebar-bg);
    background: var(--sidebar-bg);
    color: var(--sidebar-fg);
    padding: 0;
    flex-shrink: 0;
    border-inline-end: 1px solid var(--sidebar-border);
    display: flex;
    flex-direction: column;
    /* Clipped, so that while the width moves the content keeps the layout it had
       and the edge covers or reveals it — rather than the labels re-wrapping to
       every width in between. The rail's flyout is `position: fixed` and is not
       clipped by this. */
    overflow: clip;
    transition: width var(--motion-mid) ease-out;
    /* The rail's layout snaps in AFTER the width has arrived, through a zero-length
       transition delayed by the width's own duration — see the collapsed rail. */
    --sidebar-snap: 0s;
    --snap: var(--sidebar-snap) var(--motion-mid);
}
/* The columns hold their expanded width while the sidebar narrows over them; the
   collapsed rules release it once the width has arrived. */
.sidebar > .brand,
.sidebar > .nav,
.sidebar > .nav-tools { min-width: var(--sidebar-width); }

/* flex-shrink: 0 because the sidebar is a flex column and the brand is the only
   item in it that can shrink. A nav taller than the viewport — a collapsed rail
   with more links than fit — would otherwise squeeze the header down onto the
   logo. A fixed-height header stays its height. */
.brand {
    display: flex;
    align-items: center;
    gap: var(--space-5);
    height: 44px;
    flex-shrink: 0;
    padding: 0 var(--space-7);
    border-bottom: 1px solid var(--sidebar-border);
    color: inherit;
    text-decoration: none;
}
.brand:hover { text-decoration: none; }
/* The tile takes a colour, a mark drawn in CSS, or an <img> — and an app's real logo
   is the third one, which nothing fitted: an image keeps its intrinsic size and its
   square corners, so every consuming app wrote the same four declarations for itself.
   The pair here is the one `.avatar` already uses. An image that is not square is
   cropped rather than squashed, so supply a square asset. */
.brand-logo { width: 30px; height: 30px; border-radius: var(--radius-surface); flex-shrink: 0; overflow: hidden; }
.brand-logo img { display: block; width: 100%; height: 100%; object-fit: cover; }
.brand-text { display: flex; flex-direction: column; line-height: 1.1; }
.brand-sub {
    display: block; font-size: var(--text-1); color: var(--muted);
    text-transform: uppercase; letter-spacing: 0.06em; margin-top: var(--space-1);
}

/* No margin above the nav. It would put the scroll container's top edge below
   the brand's divider, which reads as a gap the first time the nav is scrolled:
   the content slides under a strip of empty sidebar instead of under the
   divider, and the scrollbar and the scroll shadow both start late.

   --nav-indent is the inline depth every row in the nav reads, so one declaration
   sets the depth and everything at it follows: a link, a group's summary, and a
   group's sub-items, which derive their own indent from it. A .nav-section
   redefines it once.

   It replaces `.nav-section .nav-link`, which set the depth on links alone — so a
   .nav-group inside a section sat 8px to the left of its own siblings, and that
   group's sub-items indented from the outer depth rather than the section's. */
.nav { --nav-indent: var(--space-7); display: flex; flex-direction: column; flex: 1; overflow: hidden; }
.nav-scroll { flex: 1; overflow-y: auto; min-height: 0; }
/* Every element that can be first in the nav carries its own top padding, but that
   padding is INSIDE its background — so an active first link puts its tint hard
   against the brand's divider with nothing between them. A margin on whatever is
   first supplies the gap, scrolls away with the content, and leaves the scroll
   shadow's background box alone. Padding on .nav-scroll would not: `background-
   origin` is the padding box, so the cover gradient's origin would move off the
   top edge and content would show through the strip above it. 4px is the gap
   .nav-tools already leaves under its own border. */
.nav-scroll > :first-child { margin-block-start: var(--space-2); }
.nav-section { --nav-indent: var(--space-9); margin-bottom: var(--space-2); }
.nav-section-label {
    display: block;
    padding: var(--space-4) var(--space-7) var(--space-2);
    font-size: var(--text-1);
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.08em;
    color: var(--muted);
    pointer-events: none;
}
.nav-link {
    display: flex;
    align-items: center;
    gap: var(--space-4);
    padding: var(--space-4) var(--space-7);
    padding-inline-start: var(--nav-indent);
    color: var(--sidebar-fg);
    font-size: var(--text-4);
}
.nav-link i { font-size: var(--text-7); flex-shrink: 0; opacity: 0.6; }
.nav-link:hover { background: var(--sidebar-hover); text-decoration: none; }
.nav-link:hover i { opacity: 1; }
/* Tinted rather than a solid brand pill, so the count badge on the same row stays
   readable and any brand hue works underneath. */
.nav-link.active { background: var(--brand-tint); color: var(--brand-text); }
.nav-link.active i { opacity: 1; color: var(--brand-text); }
/* Specificity, not !important: `.nav-link i` (0-1-1) above would otherwise win
   over a bare `.nav-link-ext` (0-1-0). The library uses no !important anywhere —
   inside a cascade layer an !important declaration becomes harder for an app to
   override, not easier, because layer order inverts for important declarations. */
.nav-link .nav-link-ext { margin-inline-start: auto; font-size: var(--text-2); opacity: 0.35; }

/* Count pill on a nav link (pending items, unread, …). */
.nav-count {
    margin-inline-start: auto;
    background: var(--brand);
    color: var(--on-solid);
    border-radius: var(--radius-pill);
    font-size: var(--text-2); font-weight: 600; line-height: 1.6;
    padding: 0 var(--space-4); min-width: 18px; text-align: center;
}

.nav-tools {
    flex-shrink: 0;
    border-top: 1px solid var(--sidebar-border);
    padding: var(--space-2) 0 var(--space-4);
    box-shadow: var(--shadow-nav-tools);
}
/* Quietened by colour, never by opacity. `opacity: 0.55` is theme-blind: on the
   dark sidebar it lands around 4.6:1 and on the light theme's white sidebar around
   3:1, under the 4.5:1 floor for 12px text — and a control at half strength beside
   full-strength siblings reads as disabled rather than as secondary. --muted is the
   token each theme already tunes for exactly this job. */
.nav-link-tool { font-size: var(--text-3); color: var(--muted); }
.nav-link-tool i { opacity: 0.75; }
.nav-link-tool:hover { color: var(--sidebar-fg); }
.nav-link-tool:hover i { opacity: 1; }
.nav-tools-sep { border: none; border-top: 1px solid var(--sidebar-border); margin: var(--space-5) var(--space-8) var(--space-2); }

/* Sidebar status card (a monitored dependency: API reachable, DB connected, …).
   The --ok / --fail modifiers are commonly composed dynamically in the nav. */
.nav-status-card {
    display: flex;
    flex-direction: column;
    gap: var(--space-2);
    margin: var(--space-3) var(--space-4);
    padding: var(--space-4) var(--space-5);
    background: var(--surface-soft);
    border: 1px solid var(--border);
    border-inline-start-width: 3px;
    border-radius: var(--radius-surface);
    text-decoration: none;
    transition: background var(--motion-fast), border-color var(--motion-fast);
}
.nav-status-card:hover { background: var(--bg-hover); text-decoration: none; }
.nav-status-label {
    font-size: var(--text-1); font-weight: 600;
    text-transform: uppercase; letter-spacing: 0.08em;
    color: var(--muted);
}
.nav-status-row { display: flex; align-items: center; gap: var(--space-3); font-size: var(--text-3); font-weight: 500; }
.nav-status-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; background: var(--border-hover); }
.nav-status-state { flex: 1; }
.nav-status-card--ok   { border-inline-start-color: var(--go-solid); }
.nav-status-card--ok   .nav-status-dot   { background: var(--go-solid); box-shadow: 0 0 5px var(--go-ring); }
.nav-status-card--ok   .nav-status-state { color: var(--go-fg); }
.nav-status-card--fail { border-inline-start-color: var(--danger-solid); }
.nav-status-card--fail .nav-status-dot   { background: var(--danger-solid); }
.nav-status-card--fail .nav-status-state { color: var(--danger-fg); }

}

/* ── 12-frame-collapsed-rail.css → @layer sedna.frame ──────────────────────────────────────────────── */
@layer sedna.frame {
/* ── Collapsed sidebar rail ─────────────────────────────────────────────── */
/* The choreography. `.sidebar` transitions its width, and everything inside keeps
   the expanded layout while it moves — the sidebar clips, so the labels are
   covered by the edge rather than re-wrapped to every width in between — and
   takes the rail's layout only once the width has arrived. That is what the
   `transition` on each rule below is: a zero-length transition delayed by the
   width's own duration (`--snap`, from 11-frame-sidebar.css), `allow-discrete` so
   `position`, `overflow` and `clip-path` take part — and `width` and `height`,
   whose `auto` to `1px` cannot interpolate and would otherwise not transition at
   all, snapping to 1px at once while the rest waited; `display: none` needs no
   delay, since a discrete transition to `none` flips at its end by definition.

   Opening runs the other way and needs nothing here: a transition takes the
   values of the state being entered, and the expanded rules declare none, so the
   labels are back at once and the growing edge reveals them. Reduced motion turns
   all of it off with the width, in 95-reduced-motion.css. */
.sidebar.collapsed { width: var(--sidebar-width-collapsed); }
.sidebar.collapsed > .brand,
.sidebar.collapsed > .nav,
.sidebar.collapsed > .nav-tools { min-width: 0; transition: min-width var(--snap); }
.sidebar.collapsed .brand {
    justify-content: center; padding: 0; gap: 0;
    transition: justify-content var(--snap) allow-discrete, padding var(--snap), gap var(--snap);
}
.sidebar.collapsed .brand-text {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
    transition: position var(--snap) allow-discrete, width var(--snap) allow-discrete, height var(--snap) allow-discrete, overflow var(--snap) allow-discrete, clip-path var(--snap) allow-discrete;
}
.sidebar.collapsed .nav-section-label { display: none; transition: display var(--motion-mid) allow-discrete; }
.sidebar.collapsed .nav-section { margin-bottom: var(--space-4); transition: margin-bottom var(--snap); }
.sidebar.collapsed .nav-link {
    justify-content: center; padding: var(--space-4) 0; gap: 0; position: relative;
    transition: justify-content var(--snap) allow-discrete, padding var(--snap), gap var(--snap);
}
.sidebar.collapsed .nav-link > span:not(.nav-count) {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
    transition: position var(--snap) allow-discrete, width var(--snap) allow-discrete, height var(--snap) allow-discrete, overflow var(--snap) allow-discrete, clip-path var(--snap) allow-discrete;
}
.sidebar.collapsed .nav-link-ext { display: none; transition: display var(--motion-mid) allow-discrete; }
.sidebar.collapsed .nav-count {
    position: absolute; top: 2px; inset-inline-end: var(--space-3); margin-inline-start: 0;
    min-width: 15px; padding: 0 var(--space-2); font-size: var(--text-1); line-height: 1.6;
    transition: position var(--snap) allow-discrete, top var(--snap), inset-inline-end var(--snap), margin-inline-start var(--snap), min-width var(--snap), padding var(--snap), font-size var(--snap), line-height var(--snap);
}
.sidebar.collapsed .nav-status-card {
    align-items: center; padding: var(--space-4) 0; position: relative;
    transition: background var(--motion-fast), border-color var(--motion-fast), align-items var(--snap) allow-discrete, padding var(--snap);
}
.sidebar.collapsed .nav-status-label,
.sidebar.collapsed .nav-status-state {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
    transition: position var(--snap) allow-discrete, width var(--snap) allow-discrete, height var(--snap) allow-discrete, overflow var(--snap) allow-discrete, clip-path var(--snap) allow-discrete;
}
.sidebar.collapsed .nav-status-dot { width: 9px; height: 9px; transition: width var(--snap), height var(--snap); }
.sidebar.collapsed .nav-tools-sep { margin: var(--space-4) var(--space-7) var(--space-2); transition: margin var(--snap); }
/* A rail has no room to type in, so the nav filter goes with the labels. */
.sidebar.collapsed .nav-filter { display: none; transition: display var(--motion-mid) allow-discrete; }
/* A collapsed area rail is the rail alone: the panel goes, and the rail's own
   divider with it, since the sidebar's edge now stands in the same place. */
.sidebar.collapsed.sidebar--areas > .nav { display: none; transition: display var(--motion-mid) allow-discrete, min-width var(--snap); }
.sidebar.collapsed.sidebar--areas .nav-areas { border-inline-end: none; transition: border-inline-end var(--snap); }

/* Rail-only hover flyout. The JS tip engine skips .sidebar precisely so this
   pure-CSS variant owns the collapsed rail (no double tooltip).

   ANCHOR-POSITIONED, and it has to be. The rail scrolls: .nav-scroll keeps its
   `overflow-y: auto` when collapsed, and a scroll container clips both axes —
   there is no combination of overflow values that scrolls vertically and lets a
   child out sideways. So the flyout is `position: fixed`, which takes the
   viewport as its containing block and is therefore not clipped by any of it.

   That leaves the question of where to put it, and `position-area` answers it
   without a measurement: `inline-end` is the column beside the anchor,
   `align-self: anchor-center` lines it up with the anchor's middle. Both are
   logical, so this mirrors under dir="rtl" with no rule in 70-rtl.css.

   The name has to be scoped. Every rail item declares the SAME anchor-name, and
   without `anchor-scope` the last one in tree order would win for all of them —
   every flyout would appear beside the bottom item. `anchor-scope` confines each
   name to its own element's subtree, which is where its ::after lives. */
.sidebar.collapsed [data-tip] { anchor-name: --sedna-rail-tip; anchor-scope: --sedna-rail-tip; }
.sidebar.collapsed [data-tip]:hover::after {
    content: attr(data-tip);
    position: fixed;
    position-anchor: --sedna-rail-tip;
    position-area: inline-end;
    align-self: anchor-center;
    margin-inline-start: var(--space-5);
    background: var(--card-bg); color: var(--fg); border: 1px solid var(--border);
    padding: var(--space-2) var(--space-4); border-radius: var(--radius-surface); font-size: var(--text-3); font-weight: 500;
    white-space: nowrap; z-index: 400; box-shadow: var(--shadow-flyout);
    pointer-events: none;
}

}

/* ── 13-frame-topbar.css → @layer sedna.frame ──────────────────────────────────────────────── */
@layer sedna.frame {
/* ── Topbar ─────────────────────────────────────────────────────────────────
   z-index 60 keeps the header (and its own dropdowns) above page content,
   while staying far below the modal/spotlight/toast/tip chain documented at
   "Z-order" in CLAUDE.md. */
.topbar {
    display: flex;
    align-items: stretch;
    height: 44px;
    --surface-here: var(--bg-elevated);
    background: var(--bg-elevated);
    border-bottom: 1px solid var(--border);
    flex-shrink: 0;
    position: relative;
    z-index: 60;
    box-shadow: var(--shadow-topbar);
}
.topbar-spacer { flex: 1; }
.topbar-section {
    display: flex;
    align-items: center;
    gap: var(--space-3);
    padding: 0 var(--space-8);
    border-inline-start: 1px solid var(--border);
}

/* Square icon button living in the topbar. --start carries its divider on the
   right (for the leading sidebar toggle); the default divides on the left. */
.topbar-btn {
    display: inline-flex; align-items: center; justify-content: center;
    width: 44px; flex-shrink: 0;
    background: none; border: none; border-inline-start: 1px solid var(--border);
    color: var(--muted); cursor: pointer; font-size: var(--text-8);
    text-decoration: none;
}
.topbar-btn:hover { background: var(--bg-hover); color: var(--fg); text-decoration: none; }
.topbar-btn--start { border-inline-start: none; border-inline-end: 1px solid var(--border); }

/* ── Version tag ────────────────────────────────────────────────────────────
   The running version or build of the app, in the header's trailing cluster
   beside the status indicator and the user widget. `v0.9.0`, `build 1482`,
   `2026.09.1`.

   Quiet by construction: it is an identifier somebody reads once, when they file
   a bug or check whether a deploy landed. It is not a control and not a status,
   so it takes --muted and the smallest label size rather than competing with the
   pills next to it.

   Monospace and tabular, because a rolling build number that changes width as it
   counts up drags the whole cluster sideways on every deploy.

   `align-self: center` is not decoration. .topbar is `align-items: stretch`, so a
   loose child fills all 44px of the header — which nothing noticed while the only
   loose children were pills with no background, and which would have drawn this
   one as a 44px-tall capsule. */
.topbar-version {
    display: inline-flex;
    align-items: center;
    align-self: center;
    flex-shrink: 0;
    padding: var(--space-1) var(--space-4);
    background: var(--surface-soft);
    border: 1px solid var(--border);
    border-radius: var(--radius-pill);
    color: var(--muted);
    font-family: var(--font-mono);
    font-size: var(--text-2);
    font-variant-numeric: tabular-nums;
    line-height: 1.5;
    white-space: nowrap;
    text-decoration: none;
}
/* The <a> form — pointing at the release notes, the commit or a status page —
   keeps the same weight and only firms up under the pointer. */
a.topbar-version:hover {
    background: var(--surface-strong);
    border-color: var(--border-strong);
    color: var(--fg);
    text-decoration: none;
}

/* A build that is not the release: a preview deployment, a staging slot, a local
   run against production data. The reader has to know they are not looking at
   what they think they are looking at.

   The modifier colours it and does not name it — write the word as well
   (`v0.10.0-rc.2`, `staging 1482`), for the same reason the status indicator's
   dot is never alone. */
.topbar-version--pre {
    background: var(--warn-bg);
    border-color: var(--warn-border);
    color: var(--warn-fg);
}
a.topbar-version--pre:hover {
    background: var(--warn-bg-hover);
    border-color: var(--warn-fg);
    color: var(--warn-fg);
}

/* ── Loose items in the header ──────────────────────────────────────────────
   .topbar is a flex row with NO gap, because every child that belongs to it
   carries its own edges: .topbar-btn is a 44px box with a divider, and
   .topbar-section supplies its own padding and gap. Something dropped straight
   into the header instead of into a .topbar-section therefore has nothing at all
   between it and whatever comes next — a status pill sat hard against the user
   widget's avatar, with the two reading as one object.

   Margin rather than `gap` on .topbar: a gap would also prise the icon buttons
   apart, and their dividers are meant to touch. */
.topbar > .health-badge,
.topbar > .stale,
.topbar > .topbar-version { align-self: center; margin-inline: var(--space-6); }
}

/* ── 14-frame-nav-filter.css → @layer sedna.frame ──────────────────────────────────────────────── */
@layer sedna.frame {
/* ── Nav filter ─────────────────────────────────────────────────────────────
   A field above .nav-scroll that narrows a long nav to the links matching what was
   typed. Pair it with closed .nav-groups: the nav is short at rest, and a reader who
   knows the word never scrolls.

     <nav class="nav">
       <div class="nav-filter">
         <i class="ri-filter-3-line" aria-hidden="true"></i>
         <input class="nav-filter-input" type="search" placeholder="Filter pages"
                aria-label="Filter pages" data-nav-filter>
       </div>
       <div class="nav-scroll">
         … .nav-link, .nav-section and .nav-group, a link optionally data-keywords="…"
         <p class="nav-filter-empty">No page matches.</p>
       </div>
     </nav>

   The script sets data-filtering on .nav while the field holds text, and
   data-nav-miss on every .nav-scroll link that does not match. Everything below
   reads those two attributes, so nothing here moves an element or opens a group:
   a closed .nav-group shows its matching links through ::details-content without
   its `open` attribute changing, and clearing the field puts every group back as
   the reader left it.

   Its own input class rather than .form-input: that one lives in sedna.paint, which
   outranks this layer, so the icon's padding could not be added to it from here. */
.nav-filter {
    position: relative;
    display: flex;
    align-items: center;
    flex-shrink: 0;
    margin: var(--space-4) var(--space-5) var(--space-2);
}
.nav-filter > i {
    position: absolute;
    inset-inline-start: var(--space-4);
    font-size: var(--text-5);
    color: var(--muted);
    pointer-events: none;
}
.nav-filter-input {
    width: 100%;
    min-height: var(--control-height-sm);
    padding: var(--space-2) var(--space-4);
    padding-inline-start: calc(var(--space-4) + var(--text-5) + var(--space-3));
    background: var(--bg);
    border: 1px solid var(--border);
    border-radius: var(--radius-control);
    color: var(--fg);
    font-family: inherit;
    font-size: var(--text-3);
    line-height: 1.4;
    transition: border-color var(--motion-fast), box-shadow var(--motion-fast);
}
.nav-filter-input::placeholder { color: var(--muted); }
.nav-filter-input:focus {
    outline: none;
    border-color: var(--brand-soft);
    box-shadow: 0 0 0 2px var(--brand-glow);
}

.nav-filter-empty {
    display: none;
    margin: 0;
    padding: var(--space-4) var(--space-7);
    color: var(--muted);
    font-size: var(--text-3);
}

.nav[data-filtering] .nav-scroll .nav-link[data-nav-miss] { display: none; }
/* A section or a group with no match left in it goes, label and all. */
.nav[data-filtering] .nav-section:not(:has(.nav-link:not([data-nav-miss]))),
.nav[data-filtering] .nav-group:not(:has(.nav-link:not([data-nav-miss]))) { display: none; }
.nav[data-filtering] .nav-group::details-content { content-visibility: visible; }
.nav[data-filtering] .nav-group > summary::after { transform: rotate(-180deg); }
.nav[data-filtering] .nav-scroll:not(:has(.nav-link:not([data-nav-miss]))) .nav-filter-empty { display: block; }
}

/* ── 15-frame-user-widget.css → @layer sedna.frame ──────────────────────────────────────────────── */
@layer sedna.frame {
/* ── User widget ────────────────────────────────────────────────────────────
   Stretches to the full topbar height so the hover highlight covers the whole
   header area rather than a content strip inside it. */
.user-widget {
    position: relative;
    z-index: 200;
    display: flex;
    align-items: stretch;
    border-inline-start: 1px solid var(--border);
}
.user-trigger {
    display: flex; align-items: center; gap: var(--space-5);
    padding: 0 var(--space-7);
    background: none; border: none; cursor: pointer;
    color: var(--fg); white-space: nowrap;
}
.user-trigger:hover { background: var(--bg-hover); text-decoration: none; }
.user-info  { display: flex; flex-direction: column; align-items: flex-start; }
.user-name  { font-size: var(--text-4); font-weight: 500; line-height: 1.2; }
.user-email { font-size: var(--text-2); color: var(--muted); line-height: 1.2; }
.user-avatar {
    width: 28px; height: 28px; border-radius: 50%; flex-shrink: 0;
    display: inline-flex; align-items: center; justify-content: center;
    background: var(--brand-tint); color: var(--brand-text); font-size: var(--text-6);
}
.user-signout {
    display: inline-flex; align-items: center; justify-content: center;
    width: 46px; flex-shrink: 0;
    color: var(--muted); font-size: var(--text-8); text-decoration: none;
    transition: background var(--motion-fast), color var(--motion-fast);
}
.user-signout:hover, .user-signout:focus, .user-signout:hover:focus {
    background: var(--bg-hover); color: var(--fg); text-decoration: none;
}

/* ── The widget's dropdown ──────────────────────────────────────────────────
   The panel itself is the tier-2 `.menu` (58-menu.css) — there is one dropdown
   style in this library, not one per place that needs one. Only the anchoring is
   the frame's business, and it uses the same 4px gap as `.menu-anchor`. Flush
   against `top: 100%` the panel reads as welded to the topbar's bottom edge rather
   than floating below the trigger — the header's own border and the panel's top
   border land on the same line, so neither is legible as an edge.

   .user-widget is already position: relative, so this needs nothing measured.

   z-index 550 is the popover/dropdown rung. .user-widget also creates a stacking
   context at 200, so 550 orders the panel WITHIN the widget — which is exactly
   right: above everything else in the header, and still covered by a modal backdrop
   opened from inside it. */
.user-widget > .menu {
    position: absolute;
    top: calc(100% + var(--space-2));
    inset-inline-end: 0;
    z-index: 550;
}

}

/* ── 16-frame-page-scroll.css → @layer sedna.frame ──────────────────────────────────────────────── */
@layer sedna.frame {
/* ── Page surface + scrolling ───────────────────────────────────────────────
   .page is the scroll container. Its width stays 100% and the horizontal
   padding grows instead of capping the width, so the scrollbar stays pinned to
   the far right of the screen while the content is held to --page-max and
   centred. On narrow screens the padding floors at 24px.

   position: relative is load-bearing, not decoration. An `overflow` container
   only clips the absolutely positioned descendants it is the containing block
   for — so without this, a `.visually-hidden` span inside a page (an
   icon-only link's label, a table caption) is laid out against the initial
   containing block instead, escapes the clip, and extends the DOCUMENT's
   scrollable height by however far down the page it sits. The symptom is a
   second scrollbar on <html> that scrolls the whole 100dvh shell off the top
   and reveals empty space below it. `position: relative` with `z-index: auto`
   creates no stacking context, so nothing in the z-order changes. */
.page {
    --page-gap: var(--space-7);
    padding: var(--space-9);
    padding-inline: max(var(--space-9), calc((100% - var(--page-max)) / 2));
    /* The shell holds the top and the sides clear of the device; the bottom edge is
       every scroller's own, because the shell cannot know which of its descendants
       reaches it. Added to the gutter rather than replacing it, or the last row of a
       table would sit against the home indicator with no gutter at all on a phone and
       lose its 24px on a desktop. */
    padding-block-end: calc(var(--space-9) + var(--safe-block-end));
    flex: 1;
    overflow-y: auto;
    min-height: 0;
    position: relative;
}

/* ── The rhythm between page sections ───────────────────────────────────────
   A card, then another card. A toolbar, then the list it controls. This is the
   commonest arrangement on a page and the library had no answer for it, so every
   such seam in a consuming app was an inline `style="margin-top:1rem"` — and 1rem
   is --space-7 written by hand.

   The library had already TAKEN this decision; it had implemented it four times
   instead of once. `.page-head`, `.toolbar` and `.alert` each carry
   `margin-bottom: var(--space-7)`, which spaces the seam when a library component
   is the first of the pair and does nothing when the app's own card is. This is
   the same --space-7, stated once, for every pair.

   Adjacent siblings collapse their margins, so those three rules stay and are
   unaffected: max(16px, 16px) is 16px. `.breadcrumb` and `.back-link` carry
   --space-6, so their seam grows by 4px, which is the whole visible change to a
   page built out of library components.

   `gap` would be the better mechanism — it adds space BETWEEN without touching
   either element's margins — but it needs flex or grid, and `.page` is a block on
   purpose: it is the frame's scroll container, and blockifying its children
   stretches an inline-flex `.back-link` across the page and stops the collapsing
   the rule above depends on.

   Headings are excluded because they set a LARGER rhythm of their own on purpose
   (`h2` is --space-9), and this rule is in a later layer, so it would quietly
   shrink them. `.lede` is excluded because its `margin-top: 0` is deliberate: it
   sits tight under the heading it belongs to. An app's own deliberate zero needs
   no exclusion here — an app's stylesheet is unlayered and wins outright — and
   `.sedna-mt-0` is the per-element escape.

   `--page-gap: 0` on `.page` turns the whole thing off for a page that manages its
   own vertical rhythm. */
.page > * + *:not(h1, h2, h3, h4, h5, h6, .lede) { margin-block-start: var(--page-gap); }

/* ── A page whose last thing sits at the bottom ─────────────────────────────
   `.page--fill` makes the page a flex column, so a `.page-foot` — a footer, a
   sticky-looking action row — sits at the bottom of a short page rather than under
   its last paragraph, and a lone `.empty-state--page` (which is `flex: 1` already)
   centres itself between the top and that foot. Opt-in, because a flex column
   blockifies inline children and stops sibling margins collapsing; the rhythm rule
   above is one-sided, so it is unchanged. Not a percentage height: WebKit does not
   resolve `min-height: 100%` against a flexed scroller, and a column that measured
   itself that way sat short on an iPad. */
.page--fill { display: flex; flex-direction: column; }
.page--fill > .page-foot { margin-top: auto; }

.back-link {
    display: inline-flex; align-items: center; gap: var(--space-2);
    font-size: var(--text-3); color: var(--muted); text-decoration: none; margin-bottom: var(--space-6);
}
.back-link:hover { color: var(--fg); }

/* The scrollbar's SHAPE. Its colour and width are inherited from <html> in
   06-base-elements.css; these pseudo-elements are not inheritable, so the selector is
   the universal one for the same reason — every scroll container in the document, not
   a list of the ones the library creates.

   Thin, track-less thumb with breathing room from a transparent, clipped border. */
*::-webkit-scrollbar { width: 10px; height: 10px; }
*::-webkit-scrollbar-track { background: transparent; }
*::-webkit-scrollbar-thumb {
    background: var(--scrollbar); border-radius: var(--radius-pill);
    border: 2px solid transparent; background-clip: padding-box;
}
*::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-hover); }
*::-webkit-scrollbar-corner { background: transparent; }

/* Soft top/bottom scroll shadows so the scroll edge fades instead of cutting
   hard. The two solid cover gradients (background-attachment: local) ride the
   content and hide the shadow at the very top/bottom; the two radial shadows
   (attachment: scroll) are pinned to the box and only show when there IS more
   content that way. --scroll-bg MUST match the surface the scroller sits on, or
   the cover itself reads as a permanent dark band.

   It reads --surface-here, which every surface the library paints declares once
   (a card, a modal, a drawer, a menu, a popover, a toast). A scroller inside any
   of them is therefore right without being named here, and the fallback is the
   page. Only a scroller that can appear WITHOUT its surface names one below. */
.page, .nav-scroll, .sedna-scroll, .drawer-body, .palette-list, .form-combo-list,
.modal-body, .deck-body {
    --scroll-bg: var(--surface-here, var(--bg));
    background:
        linear-gradient(var(--scroll-bg) 30%, transparent) center top,
        linear-gradient(transparent, var(--scroll-bg) 70%) center bottom,
        radial-gradient(farthest-side at 50% 0, var(--scroll-shade), transparent) center top,
        radial-gradient(farthest-side at 50% 100%, var(--scroll-shade), transparent) center bottom;
    background-repeat: no-repeat;
    background-size: 100% 26px, 100% 26px, 100% 10px, 100% 10px;
    background-attachment: local, local, scroll, scroll;
}
/* The command list and the nav scroller are the two that stand on a surface their
   own markup need not contain — a palette rendered inline rather than as a
   <dialog>, a <nav> outside a .sidebar. Both state the surface they belong to.
   A modal body and a deck body are named above rather than left to `.sedna-scroll`
   because both scroll by construction: the panel is capped at the viewport, so the
   body is the part that gives way and it must say so at its edge. */
.drawer-body,
.palette-list,
.form-combo-list { --surface-here: var(--bg-elevated); }
.nav-scroll { --surface-here: var(--sidebar-bg); }

}

/* ── 17-frame-hover-hints.css → @layer sedna.frame ──────────────────────────────────────────────── */
@layer sedna.frame {
/* ── Hover hints ────────────────────────────────────────────────────────────
   One floating bubble, created and positioned by Sedna.UI.js (fixed and
   appended to <body>, so a card's or table's overflow never clips it).
   Put data-tip="plain-language what-it-does + consequence" on the control. */
.sedna-tip {
    position: fixed;
    /* rtl-ok: Sedna.UI.js writes `left` in pixels after measuring, so a
       logical property here would be overwritten by a physical one anyway. */
    top: 0; left: 0;
    z-index: 1000;
    max-width: 280px;
    padding: var(--space-3) var(--space-5);
    background: var(--tip-bg);
    color: var(--tip-fg);
    border: 1px solid var(--tip-border);
    border-radius: var(--radius-surface);
    font-size: var(--text-3);
    font-weight: 500;
    line-height: 1.45;
    box-shadow: var(--shadow-tip);
    pointer-events: none;      /* never intercept the hover it describes */
    opacity: 0;
    transform: translateY(2px);
    transition: opacity var(--motion-fast) ease, transform var(--motion-fast) ease;
}
.sedna-tip--visible { opacity: 1; transform: none; }
.btn[data-tip] { cursor: pointer; }
/* Press and hold shows the hint on touch, so the platform's own long-press
   behaviour on a trigger — the link preview, the text-selection loupe — would
   fight it. Off on triggers only. */
[data-tip] { -webkit-touch-callout: none; -webkit-user-select: none; user-select: none; }

}

/* ── 18-frame-status-bar.css → @layer sedna.frame ──────────────────────────────────────────────── */
@layer sedna.frame {
/* ── Status bar ─────────────────────────────────────────────────────────────
   One strip across the bottom of the window that says the app is not working
   normally, and ONE class family for every case of it. Five modifiers:

     --reconnecting   the circuit dropped and is being re-established   pulses
     --paused         the server released the circuit and kept the state
     --failed         reconnecting is over — only a reload helps
     --expired        the session is gone from the server
     --error          an unhandled error reached the browser

   This was two mechanisms: `.reconnect-*` rows for Blazor's connection UI, and a
   separate block of rules on `#blazor-error-ui` for the framework's error bar.
   Same strip, same position, same two severities, two vocabularies — and a reader
   had to know both to style one bar. There is one family now.

   WHERE IT MOUNTS. Two framework-owned ids carry it. Neither is a library class:
   the app writes the id because the framework looks for that id.

     #components-reconnect-modal   holds one .status-bar per state. Blazor sets a
                                   state class on the id and the rules below show
                                   the matching row.
     #blazor-error-ui              holds exactly one .status-bar--error. The
                                   framework reveals it by setting
                                   `display: block` inline.

   THE BAR IS A CHILD OF THE ID, never the id itself. The framework's inline
   `display: block` beats every rule in every layer, so putting `.status-bar` on
   `#blazor-error-ui` would lay that one bar out as inline text while the
   reconnect rows stayed flex rows — the exact drift this family exists to end.
   The id is the container; the strip inside it is a flex row in both cases.

   A `.status-bar` outside either id is visible on its own, which is how the
   catalogue shows all five at once.

   Blazor sets six state classes on the modal. They map onto four of the five
   rows — `--error` is not a connection state and belongs to the other id:

   | Blazor sets                          | The row that shows                          |
   |--------------------------------------|---------------------------------------------|
   | `components-reconnect-show`          | `.status-bar--reconnecting`                 |
   | `components-reconnect-retrying`      | `.status-bar--reconnecting`, plus its counter |
   | `components-reconnect-paused`        | `.status-bar--paused`, or reconnecting if absent |
   | `components-reconnect-failed`        | `.status-bar--failed`                       |
   | `components-reconnect-resume-failed` | `.status-bar--failed`                       |
   | `components-reconnect-rejected`      | `.status-bar--expired`                      |
   | `components-reconnect-hide`          | nothing — this is the connected state       |

   `retrying` is added alongside `show`; every other state replaces the last.
   `.status-bar--paused` is optional and falls back to the reconnecting row, so a
   host page written without it only misses the softer wording.

   The palette tokens are still named `--reconnect-*`. They are the two
   severities this bar has, and a token rename is its own break in its own
   release.
   ─────────────────────────────────────────────────────────────────────────── */
#components-reconnect-modal,
#blazor-error-ui {
    display: none;
    position: fixed;
    bottom: 0; inset-inline: 0;
    z-index: 1000;
}
#components-reconnect-modal.components-reconnect-show,
#components-reconnect-modal.components-reconnect-retrying,
#components-reconnect-modal.components-reconnect-paused,
#components-reconnect-modal.components-reconnect-failed,
#components-reconnect-modal.components-reconnect-resume-failed,
#components-reconnect-modal.components-reconnect-rejected { display: block; }

/* No shadow. Every shadow in the set casts downwards and this strip sits on the
   bottom edge, so one would fall off the screen and paint nothing. */
.status-bar {
    display: flex;
    align-items: center;
    gap: var(--space-5);
    padding: var(--space-5) var(--space-8);
    /* The bar sits ON the bottom edge, so the home indicator would otherwise cross
       its text. Padding rather than an offset: the inset has to be filled by the
       bar's own amber or crimson, or the strip below it shows the page through and
       the bar reads as floating. */
    padding-block-end: calc(var(--space-5) + var(--safe-block-end));
    font-size: var(--text-4);
    font-weight: 500;
}
/* The bar's own leading glyph, reached with `>` so it does not also resize the
   icons inside the action and the dismiss. */
.status-bar > i { font-size: var(--text-7); flex-shrink: 0; }

/* Amber: something is in progress or held, and nothing has been lost. */
.status-bar--reconnecting,
.status-bar--paused {
    background: var(--reconnect-warn-bg);
    border-top: 1px solid var(--reconnect-warn-border);
    color: var(--reconnect-warn-fg);
}
/* Only the reconnecting bar pulses. The other four are settled states, and a
   pulsing glyph on one of them promises work that is not happening. */
.status-bar--reconnecting > i { animation: sedna-pulse var(--pulse-duration) ease-in-out infinite; }

/* Crimson: over. A reload is the only way forward. */
.status-bar--failed,
.status-bar--expired,
.status-bar--error {
    background: var(--reconnect-fail-bg);
    border-top: 1px solid var(--reconnect-fail-border);
    color: var(--reconnect-fail-fg);
}

/* The attempt counter, hidden until Blazor has put a number in it — which it does
   from the second attempt on. Before that the markup's placeholder would read as
   a real count. Give one span the id `components-reconnect-current-attempt` and
   another `components-reconnect-max-retries` and the framework fills both in. */
.status-bar-attempt { opacity: 0.75; font-weight: 400; }
#components-reconnect-modal .status-bar-attempt { display: none; }
#components-reconnect-modal.components-reconnect-retrying .status-bar-attempt { display: inline; }

/* ── The bar's action ───────────────────────────────────────────────────────
   Reload, retry, sign in again. It is FILLED, and it is the bar's own control
   rather than a `.btn`.

   Both halves of that are the same finding. The markup carried
   `.btn.btn-sm.btn-danger`, and `.btn-danger` is a TINT — 15% crimson over the
   page surface — so on a bar that is already crimson-900 the fill vanished and
   the label landed on `--reconnect-fail-fg`, the bar's own text colour. The
   control read as an empty rectangle with a hairline around it. The tinted button
   was the fault, not the pairing — which is why the fix was a filled control here
   rather than a change to `--reconnect-fail-*`.

   And the frame cannot fix a `.btn`: `@layer sedna.paint` comes after
   `@layer sedna.frame`, so `.btn-danger` outranks anything written here whatever
   the selectors weigh. A control that belongs to the frame is a frame class —
   `.topbar-btn` and `.user-signout` already are.

   The fill is the semantic solid, which carries `--on-solid` at AA in every
   theme (white on warn 5.31, on danger 4.61 — docs/BRANDING.md §6). Hover is a
   ring in `currentColor` rather than a darker fill: the token set has no
   `--danger-hover`, because `.btn-danger` hovers on its tint, and a ring works
   on both palettes and in every theme without one. */
.status-bar-action {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: var(--space-2);
    flex-shrink: 0;
    margin-inline-start: auto;
    min-height: var(--control-height-sm);
    padding: var(--space-2) var(--space-5);
    border: 1px solid transparent;
    border-radius: var(--radius-control);
    color: var(--on-solid);
    font: inherit;
    font-size: var(--text-3);
    white-space: nowrap;
    text-decoration: none;
    cursor: pointer;
}
.status-bar-action i { font-size: var(--text-4); }
.status-bar-action:hover { border-color: currentColor; text-decoration: none; }
.status-bar-action:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--on-solid); }
.status-bar--reconnecting .status-bar-action,
.status-bar--paused .status-bar-action { background: var(--warn-solid); }
.status-bar--failed .status-bar-action,
.status-bar--expired .status-bar-action,
.status-bar--error .status-bar-action { background: var(--danger-solid); }

/* The dismiss, on the error bar. A <button> rather than the project template's
   <span>: it is a control, so it has to be focusable and announced as one, and a
   span is neither.

   `auto` when it is alone, a fixed gap when it follows an action — two autos
   would split the free space between them and leave the pair straddling the
   bar. */
.status-bar-dismiss {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    flex-shrink: 0;
    margin-inline-start: auto;
    padding: 0;
    background: none;
    border: none;
    color: inherit;
    font: inherit;
    font-size: var(--text-7);
    line-height: 1;
    opacity: 0.75;
    cursor: pointer;
}
.status-bar-action ~ .status-bar-dismiss { margin-inline-start: var(--space-3); }
.status-bar-dismiss:hover { opacity: 1; }
.status-bar-dismiss:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--on-solid); }

/* Inside the reconnect modal, one row at a time. The id carries the specificity,
   so there is no !important here and nothing for an app to fight. */
#components-reconnect-modal .status-bar { display: none; }
/* Blazor adds `retrying` alongside `show`, but the two are keyed separately so
   the row still appears if `retrying` ever arrives on its own. */
#components-reconnect-modal.components-reconnect-show     .status-bar--reconnecting { display: flex; }
#components-reconnect-modal.components-reconnect-retrying .status-bar--reconnecting { display: flex; }
#components-reconnect-modal.components-reconnect-paused   .status-bar--paused       { display: flex; }
#components-reconnect-modal.components-reconnect-failed   .status-bar--failed       { display: flex; }
#components-reconnect-modal.components-reconnect-rejected .status-bar--expired      { display: flex; }
/* A failed resume reads as a failed reconnect. */
#components-reconnect-modal.components-reconnect-resume-failed .status-bar--failed { display: flex; }
/* Paused with no row of its own falls back to reconnecting, rather than to an
   empty bar. */
#components-reconnect-modal.components-reconnect-paused:not(:has(.status-bar--paused))
    .status-bar--reconnecting { display: flex; }
}

/* ── 19-frame-responsive.css → @layer sedna.frame ──────────────────────────────────────────────── */
@layer sedna.frame {
/* ── Responsive frame — OPT IN ───────────────────────────────────────────────
   Below 900px the sidebar becomes the icon rail, whether or not `.collapsed` is
   set. Add `.layout--responsive` to `.layout` to turn it on.

   OPT-IN, NOT AUTOMATIC. A bare width query would change how an already-released app
   looks on a narrow screen with nobody editing anything. The modifier keeps it a new
   class an app chooses.

   The modifier also carries the specificity. An app that sets
   `.sidebar { width: 260px }` unconditionally beats a bare
   `@media .sidebar { width: 56px }` rule, because equal specificity plus later source
   order goes to the app's own stylesheet. `.layout--responsive .sidebar` is (0,2,0)
   and outranks it.

   Two things to know before switching it on:
     · Below 900px a collapse toggle does nothing, because the rail is forced.
       Hide the toggle at that width, or leave it and accept the no-op.
     · The rules below mirror 12-frame-collapsed-rail.css. CSS cannot alias a
       selector, so the list is repeated rather than shared, and
       `The_responsive_frame_mirrors_the_collapsed_rail` fails if the two drift.

   Geometry only. Nothing here changes a colour: appearance belongs to the token
   blocks, and a width query is the wrong place to decide what something looks
   like. `Layout_media_queries_only_change_geometry` enforces it.
   ─────────────────────────────────────────────────────────────────────────── */
@media (max-width: 900px) {
    .layout--responsive .sidebar { width: var(--sidebar-width-collapsed); }
    .layout--responsive .sidebar > .brand,
    .layout--responsive .sidebar > .nav,
    .layout--responsive .sidebar > .nav-tools { min-width: 0; }
    .layout--responsive .sidebar .brand { justify-content: center; padding: 0; gap: 0; }
    .layout--responsive .sidebar .brand-text {
        position: absolute;
        width: 1px;
        height: 1px;
        overflow: hidden;
        clip-path: inset(50%);
    }
    .layout--responsive .sidebar .nav-section-label { display: none; }
    .layout--responsive .sidebar .nav-section { margin-bottom: var(--space-4); }
    .layout--responsive .sidebar .nav-link { justify-content: center; padding: var(--space-4) 0; gap: 0; position: relative; }
    .layout--responsive .sidebar .nav-link > span:not(.nav-count) {
        position: absolute;
        width: 1px;
        height: 1px;
        overflow: hidden;
        clip-path: inset(50%);
    }
    .layout--responsive .sidebar .nav-link-ext { display: none; }
    .layout--responsive .sidebar .nav-count {
        position: absolute; top: 2px; inset-inline-end: var(--space-3); margin-inline-start: 0;
        min-width: 15px; padding: 0 var(--space-2); font-size: var(--text-1); line-height: 1.6;
    }
    .layout--responsive .sidebar .nav-status-card { align-items: center; padding: var(--space-4) 0; position: relative; }
    .layout--responsive .sidebar .nav-status-label,
    .layout--responsive .sidebar .nav-status-state {
        position: absolute;
        width: 1px;
        height: 1px;
        overflow: hidden;
        clip-path: inset(50%);
    }
    .layout--responsive .sidebar .nav-status-dot { width: 9px; height: 9px; }
    .layout--responsive .sidebar .nav-tools-sep { margin: var(--space-4) var(--space-7) var(--space-2); }
    .layout--responsive .sidebar .nav-filter { display: none; }
    .layout--responsive .sidebar.sidebar--areas > .nav { display: none; }
    .layout--responsive .sidebar.sidebar--areas .nav-areas { border-inline-end: none; }

    /* The rail's own flyout, which is what carries the label once the text is
       gone. Anchor-positioned for the reason spelled out in
       12-frame-collapsed-rail.css: the rail scrolls, and a scroll container
       clips both axes. Every value here is geometry; the surface comes from the
       tokens the expanded flyout already uses. */
    .layout--responsive .sidebar [data-tip] { anchor-name: --sedna-rail-tip; anchor-scope: --sedna-rail-tip; }
    .layout--responsive .sidebar [data-tip]:hover::after {
        content: attr(data-tip);
        position: fixed;
        position-anchor: --sedna-rail-tip;
        position-area: inline-end;
        align-self: anchor-center;
        margin-inline-start: var(--space-5);
        background: var(--card-bg); color: var(--fg); border: 1px solid var(--border);
        padding: var(--space-2) var(--space-4); border-radius: var(--radius-surface); font-size: var(--text-3); font-weight: 500;
        white-space: nowrap; z-index: 400; box-shadow: var(--shadow-flyout);
        pointer-events: none;
    }

    /* The user widget's second line is the first thing to go: it is the longest
       string in the header and the least load-bearing.

       `.topbar--responsive` reaches the same rules without a `.layout` around it, for
       a header in `.bare-layout`, in `.auth-layout`, or on its own. Same opt-in, same
       specificity — a topbar's own narrow behaviour must not depend on which shell it
       happens to be inside. */
    .layout--responsive .user-email,
    .topbar--responsive .user-email { display: none; }
}

/* Under ~560px the name goes too, leaving the avatar. The sign-out button and any
   header controls keep their full hit area, which is what actually matters on a
   touch screen.

   The version tag goes with it, and for the same reason: it is read once, when
   somebody files a bug, and a phone header has no room to carry a string nobody
   is looking for. Anything the reader has to act on stays. */
@media (max-width: 560px) {
    .layout--responsive .user-info,
    .topbar--responsive .user-info { display: none; }
    .layout--responsive .user-trigger,
    .topbar--responsive .user-trigger { padding: 0 var(--space-6); }
    .layout--responsive .topbar-version,
    .topbar--responsive .topbar-version { display: none; }
}

/* ── The document scrolls on a phone ─────────────────────────────────────────
   `.page` is the frame's only scroll container, and on a phone that is the one
   thing Safari cannot see: it collapses its own toolbar only while the DOCUMENT
   scrolls, so an app whose page scrolls inside a box keeps the full toolbar on
   screen for ever, and the reader loses a fifth of the height on every page.

   `.layout--flow` hands the scrolling to the document below 560px. The shell grows
   with its content instead of clipping to the viewport, the page is in the flow,
   the topbar sticks under the notch, a sidebar sticks to the top of its column and
   a bottom bar to the bottom of the viewport. Above 560px nothing changes — no
   desktop browser has a toolbar to collapse, and the frame keeps its one scroller.
   Opt-in, like the other two: an app with a page that must never move under its
   header — a full-bleed map, an editor — keeps the box. */
@media (max-width: 560px) {
    .layout--flow {
        height: auto;
        min-height: 100dvh;
        /* Clip, not visible, on the inline axis: a scroll container had given the
           column `min-width: 0` for free, and without it one wide table would widen
           the whole document and scroll the shell sideways under the toolbar. */
        overflow: clip visible;
    }
    .layout--flow > .content { overflow: visible; min-width: 0; }
    /* In the flow, and filling the column so a .page--fill foot still sits at the
       bottom; the scroll shadows go with the scrolling. */
    .layout--flow .page {
        flex: 1 0 auto;
        min-width: 0;
        overflow: visible;
        background: none;
    }
    .layout--flow .topbar {
        position: sticky;
        top: var(--safe-block-start);
    }
    .layout--flow > .sidebar {
        position: sticky;
        top: 0;
        height: 100dvh;
        align-self: flex-start;
    }
    .layout--flow .bottombar {
        position: sticky;
        bottom: 0;
    }
}
}

/* ── 20-frame-layouts.css → @layer sedna.frame ──────────────────────────────────────────────── */
@layer sedna.frame {
/* ── Auth and full-bleed layouts ────────────────────────────────────────────
   Two more shells beside .layout and .bare-layout. Both are tier 1: they must look
   the same in every app.
   ─────────────────────────────────────────────────────────────────────────── */

/* Sign-in, sign-out, "check your e-mail", an invitation. One centred card and
   nothing else — there is no navigation to offer to somebody who is not signed in,
   and a half-populated sidebar behind a sign-in form invites them to try it. */
.auth-layout {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: var(--space-8);
    min-height: 100dvh;
    padding: var(--space-9);
    background: var(--bg);
}
.auth-brand { display: flex; align-items: center; gap: var(--space-5); font-size: var(--text-7); font-weight: 600; }
.auth-brand img { width: 32px; height: 32px; border-radius: var(--radius-surface); }
/* Capped rather than stretched: a sign-in form the width of a desktop screen has
   inputs nobody can aim at, and reads as an unfinished page. */
.auth-card { width: 100%; max-width: 380px; }
.auth-foot { color: var(--muted); font-size: var(--text-3); text-align: center; }

/* Content that owns the whole area below the header — a map, a board, a diagram
   editor. `.page` is the frame's scroll container and pads its content; this
   replaces it, taking the padding off and handing scrolling to the child.

   Used INSTEAD of .page, never alongside it. */
.full-layout {
    flex: 1;
    min-height: 0;
    display: flex;
    flex-direction: column;
    overflow: hidden;
    padding: 0;
}
/* The child that takes whatever height is left — the map, the board, the canvas,
   with a toolbar above it. `min-height: 0` is the half worth having written down
   rather than rediscovered: `flex: 1` alone looks right and does nothing, because a
   flex child will not shrink below its content, so a tall canvas pushes the layout
   past the viewport instead of scrolling inside itself. Same trap as `.modal-body`.

   The library's own full-bleed snippet said `style="flex:1; min-height:0"`, which is
   the sign a class was missing rather than a number being app-specific — there is
   no other value either half could take. */
.full-layout-fill { flex: 1; min-height: 0; }

/* ── Nav sub-items ──────────────────────────────────────────────────────────
   A collapsible group inside the sidebar, for a section with more children than fits
   as a flat list.

   Built on <details>/<summary>: open state, keyboard operation and the announcement
   come from the platform, and the group stays usable with scripting blocked — which
   the frame must be. Add `open` in the markup for the group containing the current
   page, so the reader does not land on a page whose own nav entry is hidden.

   In the collapsed rail the whole group flattens to its icons; the summary's caret
   and label drop out with everything else, via the existing rail rules. */
.nav-group > summary {
    display: flex;
    align-items: center;
    gap: var(--space-4);
    padding: var(--space-4) var(--space-7);
    padding-inline-start: var(--nav-indent);
    color: var(--sidebar-fg);
    font-size: var(--text-4);
    cursor: pointer;
    list-style: none;
    user-select: none;
}
.nav-group > summary::-webkit-details-marker { display: none; }
.nav-group > summary:hover { background: var(--sidebar-hover); }
.nav-group > summary:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--brand-ring-soft); }
.nav-group > summary > i { font-size: var(--text-7); flex-shrink: 0; opacity: 0.6; }
.nav-group > summary::after {
    content: '';
    margin-inline-start: auto;
    width: 0;
    height: 0;
    border-inline: 4px solid transparent;
    border-top: 5px solid currentColor;
    opacity: 0.45;
    transition: transform var(--motion-fast) ease;
}
.nav-group[open] > summary::after { transform: rotate(-180deg); }
/* Sub-items indent to sit under the parent's label rather than its icon: the
   group's own depth plus the width of its icon and the gap after it. Written as that
   sum, off --nav-indent rather than off a literal rung, so the group stays aligned
   with its siblings at whatever depth it sits — inside a .nav-section as well as at
   the top of the nav — and when the spacing scale is redefined. */
.nav-group .nav-link {
    padding-inline-start: calc(var(--nav-indent) + var(--text-7) + var(--space-4));
    font-size: var(--text-3);
}
.sidebar.collapsed .nav-group > summary { justify-content: center; padding: var(--space-4) 0; gap: 0; }
.sidebar.collapsed .nav-group > summary::after { display: none; }
/* Clipped, not display:none — the group still needs its name in the rail. */
.sidebar.collapsed .nav-group > summary > span {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
}
.sidebar.collapsed .nav-group .nav-link { padding-inline-start: 0; }

/* ── Skip link ──────────────────────────────────────────────────────────────
   The first focusable thing on the page, jumping past the navigation to the content.
   Without it a keyboard user tabs through every nav item on every page.

   Hidden until focused, and then it must be visible ABOVE the header, hence the
   reconnect/tip rung at 1000: it is the one thing that has to be readable whatever
   else is on screen.

   Point it at the id on <main>, and give <main> tabindex="-1" so the jump actually
   moves focus rather than only scrolling. */
.skip-link {
    position: fixed;
    inset-block-start: 0;
    inset-inline-start: 0;
    z-index: 1000;
    padding: var(--space-5) var(--space-7);
    /* Fixed to the corner, so the shell's safe-area padding does not reach it and it
       would land under the notch in a standalone window. Grown rather than offset, so
       the brand fill still meets both edges — a skip link that floats away from the
       corner reads as a stray button. */
    padding-block-start: calc(var(--space-5) + var(--safe-block-start));
    padding-inline-start: calc(var(--space-7) + var(--safe-inline-start));
    background: var(--brand);
    border-end-end-radius: var(--radius-surface);
    color: var(--on-solid);
    font-size: var(--text-4);
    font-weight: 500;
    text-decoration: none;
    /* Off-screen rather than hidden: it must stay focusable, and it must be the first
       thing Tab reaches. */
    transform: translateY(-110%);
    transition: transform var(--motion-fast) ease;
}
.skip-link:focus {
    transform: none;
    text-decoration: none;
    outline: none;
    box-shadow: 0 0 0 3px var(--brand-ring);
}
}

/* ── 21-frame-search.css → @layer sedna.frame ──────────────────────────────────────────────── */
@layer sedna.frame {
/* ── Header search ──────────────────────────────────────────────────────────
   The free-text box that sits at the start of the topbar, and the result panel
   that drops out of it.

     <div class="search">
       <i class="ri-search-line search-icon"></i>
       <input class="search-input" type="search" data-search placeholder="Search…">
     </div>

   The clear button and the whole panel are built by sednaUi.search — see
   js-parts/25-search.js — so the classes below the input have no markup an app
   writes. An app that wants to render the results itself (a Blazor component
   over its own database, say) uses the same classes and skips data-search.

   The panel is appended to <body> and positioned with inline top/left/width, not
   nested in .search: .topbar creates a stacking context at z-index 60, so a panel
   inside it can never rise above the modal backdrop however high its own z-index,
   and Blazor owns the topbar's subtree while nothing but this library owns <body>'s
   tail. 550 is the popover and dropdown rung on the documented scale.
   ─────────────────────────────────────────────────────────────────────────── */
.search {
    position: relative;
    display: flex;
    align-items: center;
    /* Takes the space it is given up to --search-max and gives it back first: the
       status pills and the controls at the far end matter more than a wider box.

       480px is the ceiling for a topbar carrying little else. In one that also has
       a spacer and the user widget it reads as a second sidebar, so the ceiling is
       a knob — `style="--search-max: 340px"` on the `.search` box, which is what a
       consuming app was overriding `max-width` inline to get. */
    --search-max: 480px;
    flex: 0 1 var(--search-max);
    min-width: 130px;
    /* Logical, so the box keeps its breathing room from the nav toggles on its
       leading side under dir="rtl". A four-value `margin` shorthand would slip
       past the RTL guard and still be wrong. */
    margin-inline: var(--space-6) var(--space-7);
}

.search-icon {
    position: absolute;
    inset-inline-start: var(--space-6);
    color: var(--muted);
    font-size: var(--text-6);
    pointer-events: none;
}

.search-input {
    width: 100%;
    /* The same height as every other control, from the same token, so a .btn beside
       the box — an "advanced" toggle, a saved-search picker — lines up with it. */
    height: var(--control-height);
    /* Symmetric room for the icon at one end and the clear button at the other,
       both of which are positioned over the input rather than beside it. */
    padding: 0 var(--space-10);
    background: var(--surface-strong);
    border: 1px solid var(--border);
    border-radius: var(--radius-surface);
    color: var(--fg);
    font-family: inherit;
    font-size: var(--text-4);
    outline: none;
}
.search-input::placeholder { color: var(--muted); }
/* type="search" brings the platform's own ✕, which sits in the same place as
   .search-clear and does not follow the theme. */
.search-input::-webkit-search-cancel-button { display: none; }
.search-input:focus { border-color: var(--brand); background: var(--card-bg); }

.search-clear {
    position: absolute;
    inset-inline-end: var(--space-3);
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 22px;
    height: 22px;
    padding: 0;
    border: none;
    background: none;
    border-radius: var(--radius-control);
    color: var(--muted);
    font-size: var(--text-6);
    cursor: pointer;
}
.search-clear:hover { background: var(--bg-hover); color: var(--fg); }
/* Shown only when there is something to clear, decided by CSS rather than by
   script: the button is markup the app writes, so a rendered-and-hidden button
   is the one form that also works with scripting blocked. It needs the input to
   carry a placeholder — without one :placeholder-shown never matches and the
   button simply stays visible, which is the harmless failure. */
.search-input:placeholder-shown ~ .search-clear { display: none; }

.search-panel {
    position: fixed;
    z-index: 550;
    max-height: 360px;
    overflow-y: auto;
    padding: var(--space-2);
    background: var(--card-bg);
    border: 1px solid var(--border);
    border-radius: var(--radius-surface);
    box-shadow: var(--shadow-dropdown);
    --surface-here: var(--card-bg);
}

/* Searching, and nothing found. Both are a line of muted text rather than an
   empty box: a panel that opens on nothing reads as broken. */
.search-status {
    display: flex;
    align-items: center;
    gap: var(--space-4);
    padding: var(--space-6);
    color: var(--muted);
    font-size: var(--text-3);
}

.search-item {
    display: flex;
    flex-direction: column;
    gap: var(--space-1);
    width: 100%;
    padding: var(--space-4) var(--space-5);
    border: none;
    background: none;
    border-radius: var(--radius-surface);
    color: inherit;
    font-family: inherit;
    text-align: start;
    text-decoration: none;
    cursor: pointer;
}
/* Where the keyboard is, which is a different thing from where the pointer is.
   Both land on the same tint here because the two never disagree: the pointer
   moving over a result also moves the selection. */
.search-item--sel { background: var(--bg-hover); }
.search-item:hover { background: var(--bg-hover); text-decoration: none; }

.search-item-title {
    overflow: hidden;
    color: var(--fg);
    font-size: var(--text-4);
    font-weight: 600;
    line-height: 1.35;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.search-item-meta {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: var(--space-3);
    color: var(--muted);
    font-size: var(--text-2);
}

/* A pill inside a result's meta line — "closed", "needs approval". Not a
   .badge: that is a content-sized pill for a table cell, and at 11px inside a
   dropdown row it is the wrong weight. */
.search-tag {
    padding: 0 var(--space-3);
    background: var(--surface-strong);
    border: 1px solid var(--border);
    border-radius: var(--radius-pill);
    color: var(--muted);
    font-size: var(--text-1);
}
.search-tag--warn { border-color: var(--warn-border); color: var(--warn-fg); }

/* Below this the box would crowd the status pills and the controls beside it.
   An app that hides the search here needs another way in — its own list page
   usually already has one. */
@media (max-width: 720px) {
    .search { display: none; }
}

/* ── The panel, rendered by the app ─────────────────────────────────────────
   An app whose results come from a database renders the panel itself — the
   library's index is client-side and registered up front, so it has nothing to
   position. `.search-panel` alone cannot serve that: it is `position: fixed`
   with no top/left/width, because sednaUi.search sets those from the box it
   measured. Nested inside `.search` without this modifier it lands wherever the
   viewport's origin is.

   This anchors it under the box instead. `.search` is already
   `position: relative`, so nothing else is needed and no measuring happens at
   all — which is also why there is no `sednaUi.search.place()` to call: a panel
   the app rendered lives in the app's own subtree, and inline top/left written
   there by this library would be reverted the next time the framework
   re-rendered it.

   The trade, which is worth knowing before you take it: a nested panel joins
   `.topbar`'s stacking context at z-index 60, so it sits UNDER a modal backdrop
   however high its own z-index. For a header search that is almost always
   right — a modal opening should cover the results, not sit behind them. */
.search-panel--anchored {
    position: absolute;
    top: calc(100% + 6px);
    inset-inline: 0;
}
}

/* ── 22-frame-nav-areas.css → @layer sedna.frame ──────────────────────────────────────────────── */
@layer sedna.frame {
/* ── Area rail ──────────────────────────────────────────────────────────────
   A sidebar in two columns: a narrow rail of areas, and beside it one .nav panel
   holding only the current area's links. For an app whose nav has outgrown one
   list and splits cleanly into a handful of areas.

     <aside class="sidebar sidebar--areas">
       <div class="nav-areas">
         <a class="brand" href="/">…the usual .brand…</a>
         <nav class="nav-areas-list" aria-label="Areas">
           <button class="nav-area" type="button" aria-controls="area-orders"
                   aria-expanded="true" data-nav-area>
             <i class="ri-inbox-line"></i><span>Orders</span>
           </button>
           <button class="nav-area" type="button" aria-controls="area-stock"
                   aria-expanded="false" data-nav-area>…</button>
         </nav>
       </div>
       <nav class="nav" id="area-orders" aria-label="Orders">
         <div class="nav-area-title">Orders</div>
         <div class="nav-scroll">…</div>
       </nav>
       <nav class="nav" id="area-stock" aria-label="Stock" hidden>…</nav>
     </aside>

   Two forms of rail item, and the same class for both:
     · a <button data-nav-area> swaps panels in place, through the script;
     · an <a href> goes to the area's first page, and the app renders that area's
       panel. No script, and the form to use when the sidebar can collapse, because
       a collapsed .sidebar--areas keeps only the rail.

   The current area is `aria-expanded="true"` on a button and `.active` on a link —
   the same class ActiveLink appends — and both are styled alike.

   The rail's width is a knob beside the sidebar's own, and it is also the collapsed
   width: a collapsed area rail is the rail. */
.sidebar.sidebar--areas {
    --nav-areas-width: 64px;
    --sidebar-width: calc(var(--nav-areas-width) + 200px);
    --sidebar-width-collapsed: var(--nav-areas-width);
    flex-direction: row;
}

.nav-areas {
    width: var(--nav-areas-width);
    flex-shrink: 0;
    display: flex;
    flex-direction: column;
    border-inline-end: 1px solid var(--sidebar-border);
}
/* The brand drops to its tile, as it does in the collapsed rail; the text stays in
   the accessibility tree. */
.nav-areas .brand { justify-content: center; padding: 0; gap: 0; }
.nav-areas .brand-text {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
}

.nav-areas-list {
    flex: 1;
    min-height: 0;
    overflow-y: auto;
    scrollbar-width: none;
    display: flex;
    flex-direction: column;
    gap: var(--space-1);
    /* Almost no inline padding: the rail is 64px and a label is set at --text-1, so
       "Reference" needs every pixel — with 6px each side it broke as "Referenc e",
       and only in the expanded rail, whose 1px divider took the last pixel. */
    padding: var(--space-4) var(--space-1);
}
.nav-areas-list::-webkit-scrollbar { display: none; }

.nav-area {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: var(--space-1);
    padding: var(--space-4) 0;
    background: none;
    border: none;
    border-radius: var(--radius-surface);
    color: var(--muted);
    font-family: inherit;
    font-size: var(--text-1);
    font-weight: 500;
    line-height: 1.2;
    text-align: center;
    text-decoration: none;
    cursor: pointer;
}
.nav-area i { font-size: var(--text-8); line-height: 1; }
/* A long area name wraps inside the rail rather than widening it — at a hyphen
   where the language has one, and mid-word only as the last resort, which is what
   `anywhere` means beside `hyphens`. Give the page a `lang`, or there are no hyphens. */
.nav-area > span { max-width: 100%; hyphens: auto; overflow-wrap: anywhere; }
.nav-area:hover { background: var(--sidebar-hover); color: var(--sidebar-fg); text-decoration: none; }
.nav-area:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--brand-ring-soft); }
.nav-area[aria-expanded="true"],
.nav-area.active { background: var(--brand-tint); color: var(--brand-text); }

/* The panel. A .nav is display:flex, which outranks the UA's [hidden], so the
   hidden panels are put away explicitly. */
/* Its width is what the sidebar leaves beside the rail, and it holds that width
   while the sidebar closes over it (11-frame-sidebar.css). */
.sidebar--areas > .nav { flex: 1; min-width: calc(var(--sidebar-width) - var(--nav-areas-width)); }
.sidebar--areas > .nav[hidden] { display: none; }

/* The panel's heading sits on the brand's line, so the two columns share one
   divider across the top of the sidebar. */
.nav-area-title {
    display: flex;
    align-items: center;
    height: 44px;
    flex-shrink: 0;
    padding: 0 var(--space-7);
    border-bottom: 1px solid var(--sidebar-border);
    color: var(--sidebar-fg);
    font-size: var(--text-4);
    font-weight: 600;
}
}

/* ── 23-frame-topbar-nav.css → @layer sedna.frame ──────────────────────────────────────────────── */
@layer sedna.frame {
/* ── Areas in the topbar ────────────────────────────────────────────────────
   A row of links across the header naming the app's areas, with the sidebar
   holding only the current area's pages. The docs-site arrangement: the full
   width stays, and the sidebar is as short as one area.

     <header class="topbar">
       <nav class="topbar-nav" aria-label="Areas">
         <a class="topbar-nav-link active" href="/orders" aria-current="page">Orders</a>
         <a class="topbar-nav-link" href="/stock">Stock</a>
       </nav>
       <div class="topbar-spacer"></div>
       …
     </header>

   Links, never .tabs. An area is a place with an address, and .tabs swap a region
   of one page — a tab that changes the URL is a link wearing a control's clothes.

   The current area is `.active`, which is what ActiveLink.CssClass appends, and
   `aria-current="page"` is styled the same so either one on its own reads right.
   Give the area's link the address the area's pages share as a prefix, and
   ActiveLink's prefix match lights it up on every page inside it.

   The row scrolls sideways on a narrow header rather than wrapping: a second line
   would break the header's fixed 44px, which the sidebar's brand is aligned to. */
.topbar-nav {
    display: flex;
    align-items: stretch;
    gap: var(--space-1);
    min-width: 0;
    padding: 0 var(--space-5);
    overflow-x: auto;
    scrollbar-width: none;
}
.topbar-nav::-webkit-scrollbar { display: none; }

.topbar-nav-link {
    display: flex;
    align-items: center;
    gap: var(--space-3);
    flex-shrink: 0;
    padding: 0 var(--space-5);
    /* The indicator's 2px is reserved when it is not showing, so the label does not
       jump on becoming current, and a matching top edge keeps the label centred. */
    border-bottom: 2px solid transparent;
    border-top: 2px solid transparent;
    color: var(--muted);
    font-size: var(--text-4);
    white-space: nowrap;
    text-decoration: none;
}
.topbar-nav-link i { font-size: var(--text-6); }
.topbar-nav-link:hover { color: var(--fg); text-decoration: none; }
.topbar-nav-link:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--brand-ring-soft); }
.topbar-nav-link.active,
.topbar-nav-link[aria-current="page"] {
    border-bottom-color: var(--brand);
    color: var(--fg);
}
}

/* ── 24-frame-bottombar.css → @layer sedna.frame ──────────────────────────────────────────────── */
@layer sedna.frame {
/* ── Bottom bar ─────────────────────────────────────────────────────────────
   The phone's frame: a bar on the bottom edge for the three to five things a phone
   user does most — places, or the actions on what is on screen. It is the topbar's
   counterpart, and the last child of .content, so it sits in the flow: .page ends
   where it begins, and the page's last row is never under it.

     <div class="content">
       <header class="topbar">…</header>
       <main class="page">…</main>
       <nav class="bottombar" aria-label="Main">
         <div class="bottombar-accessory">…</div>                 optional
         <div class="bottombar-items">
           <a class="bottombar-item active" href="/orders" aria-current="page">
             <span class="bottombar-icon"><i class="ri-inbox-line"></i><span class="bottombar-count">3</span></span>
             <span class="bottombar-label">Orders</span>
           </a>
           …
           <button class="bottombar-item bottombar-item--end" type="button">
             <span class="bottombar-icon"><i class="ri-search-line"></i></span>
             <span class="bottombar-label">Search</span>
           </button>
         </div>
       </nav>
     </div>

   Icons only; the current item also shows its label. Every other label is visually
   hidden rather than removed, so each item keeps its accessible name with no
   aria-label to keep in step.

   THE COUNT sits on the icon's corner, ringed in the bar's own surface. On the current
   item — the one showing its label — .bottombar-icon dissolves and the count follows the
   label instead, so a wide "99+" never lands on text. The current item takes the width
   its label and count need; the others share what is left and never go under 44px.

   FOUR TREATMENTS, one markup. The default is the topbar turned over: its surface,
   its divider on the edge that meets the page, and .topbar-nav-link's brand line on the
   current item.
     .bottombar--tint    the sidebar's current link laid sideways: a tinted block
     .bottombar--card    floating, as a .card would: its border, radius and shadow
     .bottombar--icons   no label anywhere; a short brand mark under the current icon

   --bottombar-radius is how round the bar is, the surface radius by default. On --card it
   rounds both cards, and the current item's block and a set-apart cell's outer side follow
   it in by the card's padding, so the corners stay concentric; on --tint it rounds the
   current item's block. Neither goes squarer than a control. The line and --icons bars
   run edge to edge and have no corner to round. Set it on the bar, or on .bottombar in
   the app's stylesheet for every bar:

     <nav class="bottombar bottombar--card" style="--bottombar-radius: 16px" aria-label="Main">

   --end and --start are cells set apart by a short divider: search at the end, or a
   menu at the start that opens every page in a sheet — the bar holds a few places, the
   sheet holds the rest.

   It shows at every width. .layout--bottombar (below) is the opt-in that shows it on a
   phone only and hides the sidebar there instead. */
.bottombar {
    --bottombar-height: 46px;
    --bottombar-radius: var(--radius-surface);
    --bottombar-surface: var(--bg-elevated);
    --surface-here: var(--bottombar-surface);
    flex-shrink: 0;
    display: flex;
    flex-direction: column;
    background: var(--bottombar-surface);
    border-top: 1px solid var(--border);
    box-shadow: var(--shadow-bottombar);
    padding-block-end: var(--safe-block-end);
}

.bottombar-items {
    display: flex;
    align-items: stretch;
    height: var(--bottombar-height);
    overflow: hidden;
    transition: height var(--motion-mid) ease;
}

.bottombar-item {
    position: relative;
    flex: 1 1 0;
    min-width: 44px;
    display: flex;
    align-items: center;
    justify-content: center;
    gap: var(--space-3);
    padding: 0;
    background: none;
    border: none;
    /* Reserved on both edges, so the current item's line does not push its content off
       the row's centre. */
    border-top: 2px solid transparent;
    border-bottom: 2px solid transparent;
    color: var(--muted);
    font-family: inherit;
    font-size: var(--text-3);
    font-weight: 500;
    white-space: nowrap;
    text-decoration: none;
    cursor: pointer;
}
.bottombar-item:hover { background: var(--bg-hover); color: var(--fg); text-decoration: none; }
.bottombar-item:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--brand-ring-soft); }

.bottombar-icon { position: relative; display: inline-flex; }
.bottombar-icon i { font-size: var(--text-8); line-height: 1; }

.bottombar-label {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
    order: 1;
}

.bottombar-count {
    position: absolute;
    top: -5px;
    inset-inline-start: calc(100% - 7px);
    min-width: 16px;
    height: 16px;
    padding: 0 var(--space-2);
    border-radius: var(--radius-pill);
    background: var(--brand);
    color: var(--on-solid);
    font-size: var(--text-1);
    font-weight: 700;
    line-height: 16px;
    text-align: center;
    font-variant-numeric: tabular-nums;
    box-shadow: 0 0 0 2px var(--bottombar-surface);
    order: 2;
}

/* The current item: `.active` as ActiveLink writes it, or aria-current on its own. */
/* It grows to its label and count, and gives the label up to an ellipsis before it lets
   the row overflow — a long name on a narrow phone. */
.bottombar-item.active,
.bottombar-item[aria-current="page"] {
    flex: 0 1 auto;
    padding-inline: var(--space-6);
    border-top-color: var(--brand);
    color: var(--fg);
}
.bottombar-item.active i,
.bottombar-item[aria-current="page"] i { color: var(--brand-text); }
.bottombar-item.active .bottombar-label,
.bottombar-item[aria-current="page"] .bottombar-label {
    position: static;
    width: auto;
    height: auto;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    clip-path: none;
}
.bottombar-item.active .bottombar-icon,
.bottombar-item[aria-current="page"] .bottombar-icon { display: contents; }
.bottombar-item.active .bottombar-count,
.bottombar-item[aria-current="page"] .bottombar-count { position: static; box-shadow: none; }

/* A cell set apart: search at the end, a menu at the start. The divider stops short of
   both edges, so it separates the cell without cutting the bar in two. */
.bottombar-item--end,
.bottombar-item--start { flex: 0 0 var(--bottombar-height); }
.bottombar-item--end::before,
.bottombar-item--start::before {
    content: '';
    position: absolute;
    inset-block: var(--space-5);
    width: 1px;
    background: var(--border);
}
.bottombar-item--end::before { inset-inline-start: 0; }
.bottombar-item--start::before { inset-inline-end: 0; }

/* ── The accessory: something running in the background ── */
.bottombar-accessory {
    display: flex;
    align-items: center;
    gap: var(--space-4);
    min-height: 38px;
    padding: 0 var(--space-3) 0 var(--space-6);
    border-bottom: 1px solid var(--border);
    font-size: var(--text-3);
}
.bottombar-accessory > i { color: var(--brand-text); font-size: var(--text-6); }
.bottombar-accessory-text { flex: 1; min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
/* A fixed-width slot for a .progress, which fills whatever holds it. A column, so the
   bar inside is a block with a height whatever element it was written as. */
.bottombar-meter { flex: 0 0 48px; display: flex; flex-direction: column; }

/* data-away is written by the script for data-hide-on-scroll: the items step out of
   the way while the reader scrolls down, and the accessory stays. */
.bottombar[data-away] .bottombar-items { height: 0; }
.bottombar[data-away] .bottombar-accessory { border-bottom-color: transparent; }

/* ── B · Tint ── */
.bottombar--tint { --bottombar-height: 52px; }
.bottombar--tint .bottombar-items { gap: var(--space-2); padding: var(--space-3) var(--space-3); }
.bottombar--tint .bottombar-item { border-width: 0; border-radius: max(var(--radius-control), calc(var(--bottombar-radius) - var(--space-3))); }
.bottombar--tint .bottombar-item.active,
.bottombar--tint .bottombar-item[aria-current="page"] { background: var(--brand-tint); color: var(--brand-text); }
.bottombar--tint .bottombar-item--end,
.bottombar--tint .bottombar-item--start { flex-basis: 40px; border-radius: 0; }
.bottombar--tint .bottombar-item--end::before,
.bottombar--tint .bottombar-item--start::before { inset-block: var(--space-2); }
.bottombar--tint .bottombar-item--end { margin-inline-start: var(--space-2); }
.bottombar--tint .bottombar-item--start { margin-inline-end: var(--space-2); }
.bottombar--tint[data-away] .bottombar-items { padding-block: 0; }

/* ── C · Card ── */
.bottombar--card {
    --bottombar-height: 50px;
    --bottombar-surface: var(--card-bg);
    gap: var(--space-3);
    padding: 0 var(--space-5) calc(var(--space-5) + var(--safe-block-end));
    background: none;
    border-top: none;
    box-shadow: none;
}
.bottombar--card .bottombar-accessory,
.bottombar--card .bottombar-items {
    background: var(--card-bg);
    border: 1px solid var(--border);
    border-radius: var(--bottombar-radius);
    box-shadow: var(--shadow-float);
}
.bottombar--card .bottombar-items { gap: var(--space-1); padding: var(--space-2); }
/* Inset by the padding and the border, so the block's corner shares the card's centre. */
.bottombar--card .bottombar-item { border-width: 0; border-radius: max(var(--radius-control), calc(var(--bottombar-radius) - var(--space-2) - 1px)); }
.bottombar--card .bottombar-item.active,
.bottombar--card .bottombar-item[aria-current="page"] { background: var(--surface-strong); }
.bottombar--card .bottombar-item--end,
.bottombar--card .bottombar-item--start { flex-basis: 40px; border-radius: 0; }
/* Square on the divider's side, concentric on the card's, so a hover fills the corner. */
.bottombar--card .bottombar-item--end {
    border-start-end-radius: max(0px, calc(var(--bottombar-radius) - var(--space-2) - 1px));
    border-end-end-radius: max(0px, calc(var(--bottombar-radius) - var(--space-2) - 1px));
}
.bottombar--card .bottombar-item--start {
    border-start-start-radius: max(0px, calc(var(--bottombar-radius) - var(--space-2) - 1px));
    border-end-start-radius: max(0px, calc(var(--bottombar-radius) - var(--space-2) - 1px));
}
.bottombar--card .bottombar-item--end::before,
.bottombar--card .bottombar-item--start::before { inset-block: var(--space-3); }
.bottombar--card[data-away] .bottombar-items { padding-block: 0; border-width: 0; box-shadow: none; }

/* ── D · Icons only ── */
.bottombar--icons { --bottombar-height: 48px; }
.bottombar--icons .bottombar-item { border-width: 0; }
.bottombar--icons .bottombar-item.active,
.bottombar--icons .bottombar-item[aria-current="page"] { flex: 1 1 0; padding-inline: 0; }
.bottombar--icons .bottombar-item.active .bottombar-label,
.bottombar--icons .bottombar-item[aria-current="page"] .bottombar-label {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
}
.bottombar--icons .bottombar-item.active .bottombar-icon,
.bottombar--icons .bottombar-item[aria-current="page"] .bottombar-icon { display: inline-flex; }
.bottombar--icons .bottombar-item.active .bottombar-count,
.bottombar--icons .bottombar-item[aria-current="page"] .bottombar-count { position: absolute; box-shadow: 0 0 0 2px var(--bottombar-surface); }
.bottombar--icons .bottombar-item.active::after,
.bottombar--icons .bottombar-item[aria-current="page"]::after {
    content: '';
    position: absolute;
    bottom: var(--space-3);
    inset-inline-start: 50%;
    width: 16px;
    height: 2px;
    margin-inline-start: -8px;
    border-radius: 1px;
    background: var(--brand);
}

/* .page pads its own bottom edge clear of the home indicator, because it is normally
   what reaches the device's edge. With a bottom bar below it, the bar does that
   instead, and the page keeps only its gutter. */
.content:has(> .bottombar) > .page { padding-block-end: var(--space-9); }

/* ── On a phone only ────────────────────────────────────────────────────────
   .layout--bottombar on .layout shows the bottom bar below 560px and hides the sidebar
   there, and does the reverse above it. Opt-in, like .layout--responsive, because an
   app that writes a bottom bar and a sidebar may want both at every width. */
@media (max-width: 560px) {
    .layout--bottombar > .sidebar { display: none; }
}
@media (min-width: 561px) {
    .layout--bottombar .bottombar { display: none; }
    .layout--bottombar .content:has(> .bottombar) > .page { padding-block-end: calc(var(--space-9) + var(--safe-block-end)); }
}
}

/* ── 30-cards.css → @layer sedna.paint ──────────────────────────────────────────────── */
@layer sedna.paint {
/* ═══ TIER 2 — the paint ════════════════════════════════════════════════════
   Page content. Classes only, never components: pages write plain HTML and apply
   these, so the markup — and the spacing that emerges from it — stays visible in
   the .razor file. Copy-pasteable examples for every class below are on the
   catalogue at https://www.sedna-ui.com/. */

/* ── Cards ──────────────────────────────────────────────────────────────── */
.card {
    --surface-here: var(--surface-raised-1);
    background: var(--surface-raised-1);
    border: 1px solid var(--border);
    border-radius: var(--radius-surface);
    overflow: hidden;
}
/* A card inside a card takes the next step up the ladder, and the one inside
   that takes the third. Nothing in the markup says which level it is — the
   depth is the selector, so a card that gets moved into another card is right
   without being edited.

   Descendant selectors rather than a chain of custom properties: a chain has
   to be re-declared on every surface the library paints to stay in step, and
   the case this exists for is a card in a card. Four levels deep keeps level 3
   rather than inventing a fourth.

   The third level lifts --muted to --fg-soft, and that is not decoration:
   Dust Gray on --surface-raised-3 measures 3.97:1, under the AA floor, while
   --fg-soft measures 6.84:1. Both variants are measured in
   `SurfaceTokenTests.Muted_text_clears_AA_on_every_surface_level`. */
.card .card {
    --surface-here: var(--surface-raised-2);
    background: var(--surface-raised-2);
}
.card .card .card {
    --surface-here: var(--surface-raised-3);
    background: var(--surface-raised-3);
    --muted: var(--fg-soft);
}
.card-head {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: var(--card-pad-block) var(--card-pad-inline);
    border-bottom: 1px solid var(--border);
}
/* The same three declarations `.modal-header h3` and `.drawer-header h3` make, for the
   same reason: a bare <h3> in a head otherwise keeps the UA stylesheet's 1em block
   margin and 1.17em size, so the head measures half again as tall as one holding a
   <strong> and the heading is bigger than the card it titles. */
.card-head h3 { margin: 0; font-size: var(--text-6); font-weight: 600; }
.card-head-right { display: flex; align-items: center; gap: var(--space-4); }

/* ── A card head that holds a tab strip ─────────────────────────────────────
   `.card-head` pads all four sides and centres its children, which leaves a `.tabs`
   floating in it: the strip's own bottom border sits 12px above the head's, so the
   card draws two lines and the active tab's indicator lands nowhere near the content
   it selects.

   The modifier hands the head's padding to the strip. The block padding goes because
   the tab already carries its own, and the head's border becomes the strip's line — so
   the indicator, which is the tab's border pulled up 1px, meets the body edge. The
   inline padding goes because the tab carries 16px of its own, which is the body's
   gutter, so the first tab's label lines up with the body text.

   A modifier rather than a change to `.card-head`: a head holding a title and a badge
   wants its padding, and an app would not have asked for it to go.

   It reaches forward into 50-tabs.css, which is safe because every rule that does is
   two classes deep and wins on specificity rather than on order. The one rule that
   does depend on order is the padding reset — and it is in this file, below the
   `.card-head` it overrides, rather than in another part where the order is a
   filename. */
.card-head--tabs {
    align-items: stretch;
    padding: 0;
}
.card-head--tabs .tabs {
    flex: 1;
    min-width: 0;            /* or a long strip widens the card instead of scrolling */
    border-bottom: none;     /* the head draws the line the indicator sits on */
}
/* The tab's inline padding IS the body's gutter, and has to stay it under compact
   density too, or the first label drifts off the body text when the gutter narrows.
   Two classes deep, so it beats `.tab` on specificity rather than on file order. */
.card-head--tabs .tab { padding-inline: var(--card-pad-inline); }
/* Whatever sits beside the strip keeps the gutter the head gave up. */
.card-head--tabs .card-head-right { padding-inline-end: var(--card-pad-inline); }
/* The body's own padding is the gap under the strip; the panel's is a second one.
   Every panel, not the first — the selected tab is not always the first one, and
   scoping this to :first-child moves the content when a later tab is chosen. */
.card-head--tabs + .card-body > .tab-panel { padding-top: 0; }

/* The gutter is --card-pad-block / --card-pad-inline, which [data-density="compact"]
   remaps — so every card part tightens together and stays aligned. */
.card-body { padding: var(--card-pad-block) var(--card-pad-inline); }
/* Drops the padding, for content that is drawn edge to edge inside the card — a
   canvas, a chart, a map, a table that already has its own cell padding. The
   sibling of `.list-row--flush`, and the same reason: the gutter is a gap the
   content then paints over. How tall the content is stays the content's decision;
   this only takes the padding away. */
.card-body--flush { padding: 0; }

/* ── A section inside a card body ───────────────────────────────────────────
   Sub-sections stacked in ONE body — details, then attachments, then history —
   divided by a hairline with the card's own block gutter either side of it.

   The three things that were reached for instead, and why none of them is this:
   `.divider` is an <hr> with a page-sized margin, far too loose inside a card and an
   extra element per section; `.form-section` is a <fieldset> with a legend and no
   rule at all; and `.card-body + .card-body` splits the card into two bodies, which
   ends the first body's own layout — a gap, a column — at the seam.

   The rule is drawn only on a section with something before it, so a section that is
   not rendered takes its divider with it and the first one never doubles the line the
   `.card-head` above it already draws. `* + `, not `.card-section + `, so an intro
   paragraph before the first section is divided from it too.

   It reads the same --card-pad-block the body does, so sections tighten with
   [data-density="compact"] along with everything else in the card. */
.card-section { min-width: 0; }
* + .card-section {
    margin-block-start: var(--card-pad-block);
    padding-block-start: var(--card-pad-block);
    border-top: 1px solid var(--border);
}

/* The row a card ends with: the actions that decide what happens to the content
   above them. .card-head's mirror — same padding, the divider on top instead of
   underneath — with one difference that is deliberate. It is right-aligned rather
   than space-between, for the reason .form-actions is: that is where the button
   that commits is looked for, and a foot holding nothing but buttons would
   otherwise push them to the far side.

   Inside a card, use this rather than .card-body + .form-actions. That pair works,
   but it reads as two decisions instead of one and gives the row the body's
   padding rather than a foot's. */
.card-foot {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: flex-end;
    gap: var(--space-4);
    padding: var(--card-pad-block) var(--card-pad-inline);
    border-top: 1px solid var(--border);
}
/* Something on the far side from the commit button — a note about what the action
   does, a timestamp, a destructive action kept away from the primary one. Expects
   exactly two children; wrap a side in a <div> if it has more than one. */
.card-foot--split { justify-content: space-between; }
/* Read left to right instead of committed at the end: the primary action first and
   its secondaries after it, or a status line in place of the actions — "Claimed by
   …", "View only" — which is text, and right-aligned text reads as a mistake.
   One trailing control goes to the far end with `.sedna-push`.

   Not `--split`, which distributes the free space between every child: four buttons
   in a split foot are spread across the card rather than kept as a row. */
.card-foot--start { justify-content: flex-start; }

/* A caveat about the card's own content, pinned to its bottom edge — a stale
   figure, a degraded source, a partial result. It is part of the card, so it is
   not an .alert: an alert is about the page. */
.card-warning {
    display: flex;
    align-items: center;
    gap: var(--space-3);
    padding: var(--space-4) var(--card-pad-inline);
    border-top: 1px solid var(--warn-border);
    background: var(--warn-bg);
    color: var(--warn-fg);
    font-size: var(--text-3);
}
.card-warning i { font-size: var(--text-5); flex-shrink: 0; }

/* ── A card that fills its cell ─────────────────────────────────────────────
   Panels side by side in a dashboard row are stretched to the tallest one by the
   grid, but a plain card is a block: its body stays as tall as its content and the
   foot sits right under it, so two feet in one row end at different heights and the
   row looks ragged. `--fill` makes the card a column: the body takes the spare
   height, and whatever trails it — a foot, a caveat — sits on the bottom edge.

   The trailing run is pushed by its FIRST member rather than by each, because two
   auto margins would split the spare height and open a gap between a caveat and the
   foot under it. That is also what places the run when the card has no .card-body
   to grow — a `.list` straight in the card, a `.table`.

   The body scrolls when the card is given less height than its content — a fixed
   row of `.sedna-grid-24--rows` — so the head and the foot stay in view and the
   foot is never cut off by the card's own `overflow: hidden`. A card sized by its
   content never overflows, so nothing changes for one that is not constrained. */
.card--fill { display: flex; flex-direction: column; }
.card--fill > .card-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
.card--fill > :is(.card-foot, .card-warning):not(:is(.card-foot, .card-warning) + *) { margin-block-start: auto; }

/* Key/value row inside a card body. */
/* The gap is a MINIMUM separation, not the layout. `space-between` is what puts the
   value at the trailing edge in a card, where the row is full width and the gap never
   shows. It shows in a shrink-to-fit container — a `.popover`, which is
   `width: max-content` — where `space-between` has no free space to distribute and
   the key ran straight into the value with nothing between them. */
.kv { display: flex; justify-content: space-between; gap: var(--space-7); padding: var(--space-2) 0; }
.kv .k { color: var(--muted); }
.kv .v { font-family: var(--font-mono); }

}

/* ── 31-badges.css → @layer sedna.paint ──────────────────────────────────────────────── */
@layer sedna.paint {
/* ── Badges ─────────────────────────────────────────────────────────────────
   .badge is the neutral pill; the modifiers are the semantic families plus
   three categorical hues that carry no meaning of their own.

   A badge is a label, not a target, so its three sizes are a type scale rather than
   the control heights: it has to sit inside a line of text, a table cell and a card
   title without setting the line's height. That is why `.badge-lg` is 12px text and
   not a 36px pill. The chip, which is interactive, does take the control heights. */
.badge {
    display: inline-flex;
    align-items: center;
    gap: var(--space-2);
    background: var(--badge-bg);
    border: 1px solid var(--border);
    border-radius: var(--radius-pill);
    font-size: var(--text-2);
    padding: var(--space-1) var(--space-4);
    color: var(--muted);
    line-height: 1.45;
    white-space: nowrap;
    vertical-align: middle;
}
/* `line-height: 1`, or the glyph inherits the pill's 1.45 line box and its font's
   baseline offset drops it a pixel under the text on iOS. */
.badge i { font-size: var(--text-3); line-height: 1; }

.badge-sm   { font-size: var(--text-1); padding: 0 var(--space-3); }
.badge-sm i { font-size: var(--text-2); }
.badge-lg   { font-size: var(--text-3); padding: var(--space-2) var(--space-5); }
.badge-lg i { font-size: var(--text-4); }
.badge-go     { background: var(--go-bg);     border-color: var(--go-border);     color: var(--go-fg); }
.badge-warn   { background: var(--warn-bg);   border-color: var(--warn-border);   color: var(--warn-fg); }
.badge-danger { background: var(--danger-bg); border-color: var(--danger-border); color: var(--danger-fg); }
.badge-info   { background: var(--info-bg);   border-color: var(--info-border);   color: var(--info-fg); }
.badge-secret { background: var(--secret-bg); border-color: var(--secret-border); color: var(--secret-fg); }
.badge-cyan   { background: var(--badge-cyan-bg);   border-color: var(--badge-cyan-border);   color: var(--badge-cyan-fg); }
.badge-orange { background: var(--badge-orange-bg); border-color: var(--badge-orange-border); color: var(--badge-orange-fg); }
.badge-teal   { background: var(--badge-teal-bg);   border-color: var(--badge-teal-border);   color: var(--badge-teal-fg); }

/* ── Severity ───────────────────────────────────────────────────────────────
   An ORDERED five-step scale — P1–P5, a CVSS band, a triage tier. The four
   semantic families rank nothing and the three categorical hues say so out
   loud, which is why neither can serve a ranking.

   `.badge-sev-1` is a filled pill and 2–5 are tints that weaken as the rank
   falls. That ladder is the ordering, and it is the part that survives the
   colour-blind palette, a greyscale print and forced colours — see the token
   block in 01-tokens.css.

   THE LABEL MUST CARRY THE RANK. "P2", "High", "3 of 5" — not a bare pill.
   Colour is the reinforcement here, never the only channel. */
.badge-sev-1 { background: var(--sev-1-bg); border-color: var(--sev-1-border); color: var(--sev-1-fg); }
.badge-sev-2 { background: var(--sev-2-bg); border-color: var(--sev-2-border); color: var(--sev-2-fg); }
.badge-sev-3 { background: var(--sev-3-bg); border-color: var(--sev-3-border); color: var(--sev-3-fg); }
.badge-sev-4 { background: var(--sev-4-bg); border-color: var(--sev-4-border); color: var(--sev-4-fg); }
.badge-sev-5 { background: var(--sev-5-bg); border-color: var(--sev-5-border); color: var(--sev-5-fg); }
}

/* ── 32-tables.css → @layer sedna.paint ──────────────────────────────────────────────── */
@layer sedna.paint {
/* ── Tables ─────────────────────────────────────────────────────────────── */
.table { width: 100%; border-collapse: collapse; }
.table th, .table td {
    text-align: start;
    padding: var(--cell-pad-y) var(--cell-pad-x);
    border-bottom: 1px solid var(--border);
}
.table th {
    background: var(--table-head-bg);
    font-weight: 600;
    color: var(--muted);
    font-size: var(--text-3);
    text-transform: uppercase;
    letter-spacing: 0.04em;
}
.tr-clickable { cursor: pointer; user-select: none; }
.tr-clickable:hover > td { background: var(--bg-hover); }
.col-muted { color: var(--muted); font-size: var(--text-3); white-space: nowrap; }

}

/* ── 33-buttons.css → @layer sedna.paint ──────────────────────────────────────────────── */
@layer sedna.paint {
/* ── Buttons ────────────────────────────────────────────────────────────────
   Colour language — a panel's primary CTA is always a FILLED button in its
   semantic colour:
     .btn-primary    filled brand   — the neutral primary
     .btn-go         filled green   — sends something outward (approve, apply)
     .btn-warn-solid filled amber   — changes who is in control (take over)
     .btn-warn       tinted amber   — a SECONDARY of the above, beside a primary
     .btn-danger     tinted red     — destructive

   --control-height is shared with the text input, the select and the search box, so
   a button beside a field is the same height as the field. It also pins an icon-only
   button to the height of its text siblings: a bare icon's line box (15px/1) is
   shorter than the 1.4-line text next to it, so without it an icon-only button sits
   several pixels short in a mixed row. */
.btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: var(--space-2);
    min-height: var(--control-height);
    padding: var(--space-3) var(--space-7);
    border: 1px solid var(--border);
    border-radius: var(--radius-control);
    background: var(--bg-elevated);
    color: var(--fg);
    font-size: var(--text-4);
    font-family: inherit;
    font-weight: 500;
    cursor: pointer;
    line-height: 1.4;
    white-space: nowrap;
    text-decoration: none;
    transition: background var(--motion-fast), border-color var(--motion-fast), box-shadow var(--motion-fast);
}
.btn i { font-size: var(--text-6); line-height: 1; }
.btn:hover         { background: var(--bg-hover); border-color: var(--border-strong); text-decoration: none; }
.btn:active        { background: var(--btn-active-bg); }
.btn:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--brand-ring-soft); }
.btn:disabled      { opacity: 0.4; cursor: not-allowed; pointer-events: none; }

/* Three tiers. Each sets its height from the matching token and scales its padding,
   text and icon with it. */
.btn-sm   { min-height: var(--control-height-sm); padding: var(--space-2) var(--space-5); font-size: var(--text-3); }
.btn-sm i { font-size: var(--text-4); }
.btn-lg   { min-height: var(--control-height-lg); padding: var(--space-4) var(--space-9); font-size: var(--text-5); gap: var(--space-4); }
.btn-lg i { font-size: var(--text-8); }

.btn-primary        { background: var(--brand); border-color: var(--brand); color: var(--on-solid); }
.btn-primary:hover  { background: var(--brand-hover); border-color: var(--brand-hover); }
.btn-primary:active { background: var(--brand-active); border-color: var(--brand-active); }
.btn-primary:focus-visible { box-shadow: 0 0 0 2px var(--brand-ring); }

.btn-go        { background: var(--go-solid); border-color: var(--go-solid); color: var(--on-solid); }
.btn-go:hover  { background: var(--go-hover); border-color: var(--go-hover); }
.btn-go:active { background: var(--go-active); border-color: var(--go-active); }
.btn-go:focus-visible { box-shadow: 0 0 0 2px var(--go-ring); }

.btn-warn-solid        { background: var(--warn-solid); border-color: var(--warn-solid); color: var(--on-solid); }
.btn-warn-solid:hover  { background: var(--warn-hover); border-color: var(--warn-hover); }
.btn-warn-solid:active { background: var(--warn-active); border-color: var(--warn-active); }
.btn-warn-solid:focus-visible { box-shadow: 0 0 0 2px var(--warn-ring-solid); }

.btn-warn        { color: var(--warn-fg); border-color: var(--warn-border); background: var(--warn-bg); }
.btn-warn:hover  { background: var(--warn-bg-hover); border-color: var(--warn-border); }
.btn-warn:active { background: var(--warn-bg-active); }
.btn-warn:focus-visible { box-shadow: 0 0 0 2px var(--warn-ring); }

.btn-danger        { color: var(--danger-fg); border-color: var(--danger-border); background: var(--danger-bg); }
.btn-danger:hover  { background: var(--danger-bg-hover); border-color: var(--danger-border-strong); }
.btn-danger:active { background: var(--danger-bg-active); }
/* Its own ring, so focus is not a blue halo around a red destructive button. */
.btn-danger:focus-visible { box-shadow: 0 0 0 2px var(--danger-ring); }

/* ── In flight ──────────────────────────────────────────────────────────────
   `aria-busy="true"` on a button whose action is running — a save, a submit, an API
   call. Keyed off the attribute a screen reader announces, the way `.th-sort` keys
   off aria-sort, so the paint and the announcement cannot disagree.

   THE BUTTON KEEPS ITS MEASURED WIDTH. The label and icon stay in the layout and are
   only painted transparent, and the spinner is laid over the middle, so the box does
   not shrink under the cursor mid-click. `-webkit-text-fill-color` rather than
   `color: transparent`, because the ring is drawn in `currentColor` and has to keep
   the variant's own text colour — on `.btn-primary` that is the on-solid ink.

   Pointer clicks are stopped, so a double click cannot submit twice. The keyboard is
   not: leave `disabled` off, which would drop focus to <body> and stop the busy state
   being announced, and have the handler ignore a second activation while its work
   runs. */
.btn[aria-busy="true"] {
    position: relative;
    -webkit-text-fill-color: transparent;
    cursor: progress;
    pointer-events: none;
}
.btn[aria-busy="true"] > :is(svg, img) { visibility: hidden; }
.btn[aria-busy="true"]::after {
    content: '';
    position: absolute;
    inset: 0;
    width: 1em;
    height: 1em;
    margin: auto;
    border: 2px solid currentColor;
    border-inline-end-color: transparent;
    border-radius: 50%;
    animation: sedna-spin var(--spin-duration) linear infinite;
}

}

/* ── 34-forms.css → @layer sedna.paint ──────────────────────────────────────────────── */
@layer sedna.paint {
/* ── Forms ──────────────────────────────────────────────────────────────── */
.form-field { display: flex; flex-direction: column; gap: var(--space-3); }

.form-label {
    font-size: var(--text-2);
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.06em;
    color: var(--muted);
}

/* min-height, not height: a <textarea> and a <select multiple> are taller by nature
   and must stay so. It is the same token the button uses, which is what makes a
   button beside a field line up with it.

   The block padding matches the button's for the same reason, and has to stay small
   enough that the natural height — padding + line box + borders — is UNDER
   --control-height. A min-height is a floor: at 8px of block padding the field
   measures 36.2px, the token never binds, and the field sits a couple of pixels
   taller than everything beside it.

   `.form-select` IS IN THIS SELECTOR ON PURPOSE, and removing it is a regression
   that reaches consuming apps. The shape used to be `.form-input`'s alone, and
   45-form-controls.css only replaced the platform caret — so `.form-select` was a
   modifier that did nothing legible without `.form-input` beside it. A select
   carrying `.form-select` alone got `appearance: none` and a caret drawn over the
   UA's own `Field` background and `ButtonBorder` frame: a light, boxy, native
   control sitting next to a correctly themed input, with no error anywhere. The
   library's own examples and at least one app had copied exactly that. Declaring
   the shape once, for both selectors, is what makes either class list correct
   rather than making one of them a trap. */
.form-input,
.form-select {
    min-height: var(--control-height);
    padding: var(--space-3) var(--space-5);
    background: var(--bg);
    border: 1px solid var(--border);
    border-radius: var(--radius-control);
    color: var(--fg);
    font-size: var(--text-4);
    font-family: inherit;
    line-height: 1.4;
    width: 100%;
    transition: border-color var(--motion-fast), box-shadow var(--motion-fast);
}
.form-input:focus,
.form-select:focus {
    outline: none;
    border-color: var(--brand-soft);
    box-shadow: 0 0 0 2px var(--brand-glow);
}
.form-input::placeholder { color: var(--border-strong); }

/* ── A text area's resize grip ────────────────────────────────────
   Vertical is the DEFAULT rather than a class, because the failure is horizontal:
   the UA default is `resize: both`, and a textarea dragged wider than its
   .sedna-grid track breaks the row it sits in and every row below it. Taller is
   never the problem, so the safe axis is the one you get for free and only the two
   departures need a name.

   There is deliberately no --resize-y: it would be a second name for the default. */
textarea.form-input { resize: vertical; }
/* The element is in the modifier selectors too, and it is load-bearing: the rule above
   weighs (0,1,1) because only a textarea is resizable, so a bare `.form-input--resize-none`
   at (0,1,0) LOST to it and the two classes did nothing at all. Matching the weight and
   coming later is what makes them win — raising specificity rather than reaching for
   !important, which inside a cascade layer would make an app unable to override them. */
textarea.form-input--resize-none { resize: none; }
textarea.form-input--resize-both { resize: both; }

/* The grip itself. Chromium paints an unthemed white corner over a dark field
   otherwise — which was fixed for the Markdown editor and nowhere else, so every
   other textarea in the library had it. Same value as the .md-preview rule in
   44-markdown.css, which cannot share this selector: that pane is a div. */
textarea.form-input::-webkit-resizer {
    background: linear-gradient(-45deg, transparent 0 4px, var(--scrollbar) 4px 5px,
                transparent 5px 7px, var(--scrollbar) 7px 8px, transparent 8px);
}

/* The same three tiers as the button, so a field and the button beside it agree at
   every size. Padding stays under the tier so the tier is what sets the height. */
.form-input-sm { min-height: var(--control-height-sm); padding: var(--space-2) var(--space-4); font-size: var(--text-3); }
.form-input-lg { min-height: var(--control-height-lg); padding: var(--space-5) var(--space-7); font-size: var(--text-5); }

/* A read-only value rendered in the shape of an input — so it takes the input's
   height too, or a form of mixed editable and read-only rows steps up and down. */
/* display:flex, so min-height applies and the text sits centred in the box. min-height
   does nothing on an inline element, so a bare <span> here comes out several pixels
   shorter than the field above it. */
.form-value-display {
    display: flex;
    align-items: center;
    min-height: var(--control-height);
    padding: var(--space-3) var(--space-5);
    background: var(--surface-soft);
    border: 1px solid var(--border);
    border-radius: var(--radius-control);
    font-size: var(--text-4);
}

.form-check {
    display: inline-flex;
    align-items: center;
    gap: var(--space-4);
    font-size: var(--text-4);
    cursor: pointer;
    user-select: none;
}
/* `margin: 0` on every choice control: `appearance: none` keeps the browser's own
   margin — 3px and 4px on a checkbox, 5px on a radio — which pushed the label out past
   `--choice-inset-check` and set the box a pixel off centre. The library owns the
   geometry, so the tokens can state it. */
.form-check input[type="checkbox"] {
    appearance: none;
    margin: 0;
    width: 15px;
    height: 15px;
    flex-shrink: 0;
    cursor: pointer;
    background: var(--bg);
    border: 1px solid var(--border-strong);
    border-radius: var(--radius-small);
    position: relative;
    transition: background var(--motion-fast), border-color var(--motion-fast), box-shadow var(--motion-fast);
}
.form-check input[type="checkbox"]:hover { border-color: var(--border-hover); }
.form-check input[type="checkbox"]:checked {
    background: var(--brand);
    border-color: var(--brand);
}
.form-check input[type="checkbox"]:checked::after {
    content: '';
    position: absolute;
    /* rtl-ok: centring, with translate doing the offset — direction-neutral. */
    left: 50%; top: 50%;
    width: 4px; height: 8px;
    border-inline-end: 2px solid var(--on-solid);
    border-bottom: 2px solid var(--on-solid);
    transform: translate(-50%, -60%) rotate(45deg);
}
.form-check input[type="checkbox"]:focus-visible {
    outline: none;
    box-shadow: 0 0 0 2px var(--brand-ring-check);
}

/* Disabled reads as disabled. It applies to the whole label, not just the box:
   dimming the 15px control and leaving its sentence at full contrast makes the row
   look enabled with a faint tick, which is worse than either state.

   The `:has()` is what lets one rule cover the checkbox and the radio without either
   knowing about the other. */
.form-check:has(input:disabled) { color: var(--muted); cursor: not-allowed; }
.form-check input:disabled { opacity: 0.45; cursor: not-allowed; }

.form-input:disabled,
.form-select:disabled,
.form-input[readonly] { cursor: not-allowed; background: var(--surface-soft); color: var(--muted); }
/* readonly is not disabled: the value is still selectable and copyable, and still
   submitted, so it keeps the normal cursor and a full-contrast value. Only the
   surface says it cannot be edited. */
.form-input[readonly] { cursor: text; color: var(--fg); }

.form-hint { font-size: var(--text-3); color: var(--muted); margin: 0; }

}

/* ── 35-toolbar.css → @layer sedna.paint ──────────────────────────────────────────────── */
@layer sedna.paint {
/* ── Toolbar (the filter bar above a table or list) ─────────────────────── */
.toolbar {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-6);
    margin-bottom: var(--space-7);
    flex-wrap: wrap;
}
.toolbar-filters { display: flex; align-items: center; gap: var(--space-4); flex-wrap: wrap; }
/* The TRAILING group — the actions, the count, the pager. Same box as
   `.toolbar-filters`, and it exists because `.toolbar` is `space-between`: with two
   children that puts one group at each end, and with three it spreads all three and
   the middle one floats. `.sedna-push` does not rescue it either, because an auto
   margin and `space-between` are two answers to the same question.

   Its absence was not theoretical. This library's own examples had invented it three
   different ways — a second `.toolbar-filters` (those are not filters), a
   `.sedna-row sedna-gap-2`, and a `.sedna-push` on a bare span, which is the one
   that was visibly broken on the landing page. */
.toolbar-actions { display: flex; align-items: center; gap: var(--space-4); flex-wrap: wrap; }
/* 160px is right for a filter dropdown and short for a search box, so the width is
   a knob on the element rather than a value baked into the class — the same shape
   of fix as `--prose-measure` and `--deck-height`. Set it per instance
   (`style="--toolbar-input-width: 22rem"`) or reach for one of the two steps, which
   is what a search box wants: an app should not have to state a number to get the
   common case, and overriding `width` on a library class per instance is the thing
   the override rules ask an app not to do.

   `--grow` rather than a third fixed step, because a search box that is the only
   thing in the bar wants the room that is there, and a fixed width cannot know how
   much that is. It keeps a floor, so it still reads as a box on a narrow window. */
.toolbar-input { --toolbar-input-width: 160px; width: var(--toolbar-input-width); }
.toolbar-input--wide { --toolbar-input-width: 280px; }
.toolbar-input--grow { --toolbar-input-width: auto; flex: 1 1 12rem; min-width: 12rem; }
/* A filter that carries a leading icon is an `.input-group`, which is `width: 100%`
   for a form row — and 46-form-layout.css is a later part, so at equal specificity
   `.toolbar-input` lost its width silently and the group filled the bar. Stated as
   the collision it resolves, so it is order-independent. */
.input-group.toolbar-input { width: var(--toolbar-input-width); }
.toolbar-count { font-size: var(--text-3); color: var(--muted); white-space: nowrap; }

}

/* ── 36-modal.css → @layer sedna.paint ──────────────────────────────────────────────── */
@layer sedna.paint {
/* ── Modal ──────────────────────────────────────────────────────────────── */
.modal-backdrop {
    position: fixed;
    inset: 0;
    background: var(--backdrop);
    display: flex;
    align-items: center;
    justify-content: center;
    z-index: 500;
    backdrop-filter: blur(2px);
}
.modal {
    --surface-here: var(--bg-elevated);
    /* The panel never grows past the window, and the body is what gives way. A
       backdrop centres with align-items: center, so a panel taller than the
       viewport grows past BOTH edges — the header off the top and the footer off
       the bottom, which is where the primary action is. A tall dialog was
       therefore not submittable, with nothing on screen saying why, because no
       element had a scrollbar.

       dvh, not vh: on a phone vh is measured against the viewport with the
       address bar retracted, so a vh-capped panel is taller than what is visible
       exactly where the room is tightest.

       The safe-area insets come off the same cap. A modal is centred, so the
       shorter panel is what lifts its footer clear of the home indicator — and the
       footer is where the primary action is, which is the same reason the cap exists
       at all. Padding would be wrong here: the gap belongs OUTSIDE the panel, or the
       elevated surface is drawn across a strip the device owns.

       --modal-max-height is a knob rather than a fixed value because a panel
       that must not shrink at all sets it to `none`. */
    --modal-max-height: calc(100dvh - 2 * var(--space-8) - var(--safe-block-start) - var(--safe-block-end));
    background: var(--bg-elevated);
    border: 1px solid var(--border);
    border-radius: var(--radius-panel);
    width: 100%;
    max-width: 480px;
    max-height: var(--modal-max-height);
    display: flex;
    flex-direction: column;
    box-shadow: var(--shadow-modal);
}
/* Width only. A confirmation needs less room than a form, and a diff or a table
   needs more; nothing else about the panel changes. */
.modal-sm { max-width: 360px; }
.modal-lg { max-width: 760px; }

/* ── As a <dialog> ──────────────────────────────────────────────────────────
   `.modal` on a <dialog> opened with showModal() gets the top layer, a focus trap,
   Escape-to-close and inert content behind it — none of which a div-based backdrop
   can do without a lot of code that is usually subtly wrong. ShowModalAsync opens
   one, and an app should use it wherever its markup can be a <dialog>.

   No .modal-backdrop is involved: ::backdrop is the platform's own, so the
   dimming comes from the pseudo-element rather than from an element in the page.

   --dialog-backdrop is declared on the dialog itself rather than referencing
   var(--backdrop) from ::backdrop directly. ::backdrop inherits from its originating
   element, so the token has to be one inheritance step away rather than at the
   document root; an unresolved background is a transparent one, which removes the
   dimming without failing visibly. */
/* The entrance animation belongs on `dialog.modal` and never on `.modal`: only a
   dialog has an opening, going from display:none to shown, which is what starts the
   animation. A `.modal` panel sitting in the page has nothing to animate from.

   A resting appearance must never depend on an animation having run. An animation with
   no fill-mode still applies its 0% keyframe while it is PAUSED, and a document is
   paused whenever its tab is in the background — so the same animation on `.modal`
   leaves a panel in a background tab at `scale(0.97) translateY(-10px)` for good. */
dialog.modal {
    padding: 0;
    color: var(--fg);
    --dialog-backdrop: var(--backdrop);
    animation: sedna-modal-in var(--motion-fast) ease;
}
/* `.modal`'s own `display: flex` is gated on nothing, so on a <dialog> it applies
   while the dialog is CLOSED as well — and an author rule outranks the UA's
   `dialog:not([open]) { display: none }` whatever the layer. The panel then sits in
   the page before its button has ever been pressed, absolutely positioned at inset 0
   with the UA's `margin: auto`, so it lands over the content and behind whatever
   follows it in the source: a half-visible ghost with no backdrop, in the closed
   state both before the first open and after every close.

   Written as its own rule rather than by gating `.modal { display: flex }` on
   `[open]`, because the backdrop-div form has no `[open]` to gate on and needs the
   flex column unconditionally. `.drawer` and `.palette` each answer this on their own
   class; this is the modal's. */
dialog.modal:not([open]) { display: none; }
dialog.modal::backdrop {
    background: var(--dialog-backdrop);
    backdrop-filter: blur(2px);
}
/* The usual modal markup wraps header, body and footer in a <form> — a
   `method="dialog"` one on a <dialog>, an ordinary one in a backdrop div — so the
   panel's flex column has to reach THROUGH the form, or it has exactly one flex
   item and the body never becomes the part that scrolls. `display: contents`
   removes the form's box without touching its semantics: submission, validation
   and `method="dialog"` all still work.

   Written for `.modal` rather than `dialog.modal` because both markup forms need
   it, and because a closed <dialog> is `display: none` — its subtree does not
   render whatever this says about the form. */
.modal > form { display: contents; }

@keyframes sedna-modal-in {
    from { opacity: 0; transform: translateY(-10px) scale(0.97); }
    to   { opacity: 1; transform: none; }
}
/* The header and the footer keep their height whatever happens; only the body
   between them gives way. Without this a flex column shrinks all three, and the
   footer's padding is the first thing to go. */
.modal-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    flex-shrink: 0;
    padding: var(--space-7) var(--space-8);
    border-bottom: 1px solid var(--border);
}
.modal-header h3 { margin: 0; font-size: var(--text-6); font-weight: 600; }
.modal-close {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 28px; height: 28px;
    background: none;
    border: none;
    border-radius: var(--radius-inner);
    color: var(--muted);
    cursor: pointer;
    font-size: var(--text-8);
    padding: 0;
    transition: background var(--motion-fast), color var(--motion-fast);
}
.modal-close:hover { background: var(--bg-hover); color: var(--fg); }
/* showModal() moves focus to the first focusable thing in the dialog, which in this
   markup is the close button — so its focus ring is the FIRST thing a keyboard reader
   sees of a modal, and without this rule it was the UA's own outline: a hard square
   against every other control in the library, which rings with a soft brand halo. */
.modal-close:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--brand-ring-soft); }
/* The part that scrolls. `min-height: 0` is stated even though `overflow-y: auto`
   already suppresses a flex item's automatic minimum size, because it is the
   declaration that makes the intent survive: the obvious pair — max-height on the
   panel and a flex column — looks right and does nothing on its own, since a flex
   child refuses by default to shrink below its content height.

   The scroll-edge fade comes with it, from 16-frame-page-scroll.css, so a body
   with more content below says so. */
.modal-body {
    padding: var(--space-8);
    display: flex;
    flex-direction: column;
    gap: var(--space-7);
    overflow-y: auto;
    min-height: 0;
}
.modal-body p { margin: 0; }
.modal-footer {
    display: flex;
    justify-content: flex-end;
    align-items: center;
    flex-shrink: 0;
    gap: var(--space-4);
    padding: var(--space-7) var(--space-8);
    border-top: 1px solid var(--border);
}


/* ── Deck ───────────────────────────────────────────────────────────────────
   A short sequence of slides in a modal, with progress dots and Back/Next. The
   first-run half that `sednaUi.spotlight` cannot do: spotlight anchors to the
   page, and before a reader has seen the page there is nothing to anchor to.

   Sequencing stays the app's, exactly as it does for spotlight — which step is
   showing, what the buttons do, whether the deck has been seen before. What is
   here is the chrome plus the three things that are fiddly enough to get wrong
   once each:

     · --deck-height fixes the body's height, so the footer does not jump as
       slides of different lengths go past. Change it on `.deck` for a taller
       deck; it is not on the spacing scale, because it is the size of a thing.
     · `margin: auto 0` on the step centres a short slide vertically, and a tall
       one still starts at the top and scrolls — flex auto margins collapse to 0
       when there is no free space, which is exactly the behaviour wanted and
       the reason this is not `align-items: center`.
     · the entrance animation re-runs per step ONLY if the step element is
       actually replaced. In Blazor that means `@key="_step"` on the
       `.deck-step`; without it the framework reuses the node, the animation
       never restarts, and the deck goes still after slide one.

   A dot is a <span> when it only reports position, and a <button> when it also
   jumps to its step — which is the form to reach for, because a reader who has
   understood slide one should not have to press Next three times to get back to
   where they were. Either way the drawing is the same, and either way the
   position goes in words too — "3 of 5" in the footer, or aria-label on the
   dots' container — because a row of circles is not announced as a position by
   anything. Mark the current one with `aria-current="step"`; `.deck-dot--active`
   is then redundant on a button and is not needed. */
.deck { --deck-height: 320px; }
.deck-body {
    display: flex;
    height: var(--deck-height);
    overflow-y: auto;
    padding: var(--space-8);
}
.deck-step {
    width: 100%;
    margin: auto 0;
    animation: sedna-deck-in var(--motion-mid) ease;
}
@keyframes sedna-deck-in {
    from { opacity: 0; transform: translateY(8px); }
    to   { opacity: 1; transform: none; }
}
.deck-footer {
    display: flex;
    align-items: center;
    justify-content: space-between;
    flex-shrink: 0;
    gap: var(--space-6);
    padding: var(--space-7) var(--space-8);
    border-top: 1px solid var(--border);
}
.deck-dots { display: flex; align-items: center; gap: var(--space-3); }
.deck-dot {
    width: 6px;
    height: 6px;
    border-radius: 50%;
    background: var(--border-hover);
    transition: background var(--motion-fast), width var(--motion-fast);
}
/* Wider as well as brand-coloured: the current slide has to be findable without
   reading colour at all. */
.deck-dot--active,
.deck-dot[aria-current] { width: 18px; border-radius: var(--radius-pill); background: var(--brand); }

/* ── A dot that jumps ───────────────────────────────────────────────────────
   `<button class="deck-dot">` is a control, so it needs the button reset, a
   pointer, a hover state and the library's focus ring — the UA outline on a 6px
   circle is a square nearly three times its size.

   The hit target is the part that has to be got right: 6px is not one. It is
   grown by an absolutely positioned ::before rather than by padding, so a row of
   button dots occupies exactly the same space as a row of span dots and the two
   forms stay interchangeable. The insets are deliberately asymmetric — 9px in the
   block axis is free, while the inline axis may only take half of `.deck-dots`'
   6px gap, because two overlapping hit areas mean a press lands on the wrong
   step. */
button.deck-dot {
    position: relative;
    padding: 0;
    border: none;
    appearance: none;
    cursor: pointer;
}
button.deck-dot::before {
    content: '';
    position: absolute;
    inset-block: -9px;
    inset-inline: -3px;
}
button.deck-dot:hover { background: var(--muted); }
button.deck-dot[aria-current]:hover { background: var(--brand); }
button.deck-dot:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--brand-ring-soft); }
}

/* ── 37-alerts.css → @layer sedna.paint ──────────────────────────────────────────────── */
@layer sedna.paint {
/* ── Inline alert banner ────────────────────────────────────────────────── */
.alert {
    display: flex;
    align-items: center;
    gap: var(--space-4);
    padding: var(--space-5) var(--space-7);
    border-radius: var(--radius-surface);
    font-size: var(--text-4);
    margin-bottom: var(--space-7);
    border: 1px solid;
}
.alert i { font-size: var(--text-7); flex-shrink: 0; }
.alert-go     { background: var(--go-bg);     border-color: var(--go-hover);     color: var(--go-fg); }
.alert-warn   { background: var(--warn-bg);   border-color: var(--warn-hover);   color: var(--warn-fg); }
.alert-danger { background: var(--danger-bg); border-color: var(--danger-border-strong); color: var(--danger-fg); }
.alert-info   { background: var(--info-bg);   border-color: var(--info-border);   color: var(--info-fg); }

}

/* ── 38-toasts.css → @layer sedna.paint ──────────────────────────────────────────────── */
@layer sedna.paint {
/* ── Toasts ─────────────────────────────────────────────────────────────────
   Transient confirmation of something that already happened. Anything the user
   must act on is an .alert, which stays until the state changes.

   The stack is the only fixed element; each toast is a normal block inside it,
   so an app can render the list with @foreach and never position anything.

   THE STACK IS THE LIVE REGION. sednaUi.toast puts role="status" and
   aria-live="polite" on the stack it creates (assertive while a danger toast is
   showing). An app rendering its own stack has to write those itself — nothing
   on this path creates one — or its toasts are never announced. Never
   role="alert" on an individual toast: two regions over one message announce it
   twice, and assertive on every routine confirmation cuts across whatever is
   being read.

   Plain `column`, not `column-reverse`: because the bottom edge is pinned, the
   box grows upward on its own, so the newest toast is already nearest the corner
   without reversing anything — and the rendered order matches the source order,
   which a reader comparing the demo to the snippet depends on.

   The stack sednaUi.toast creates is a manual popover, so it reaches the top layer
   above an open modal <dialog> — 51-toast.js says why. The UA gives `[popover]` a
   centred, bordered, opaque box; the resets below take that back, and cost nothing on
   a stack an app writes without the attribute. z-index 600 still orders it wherever
   there is no top layer. */
.toast-stack {
    position: fixed;
    inset: auto;
    width: auto;
    height: auto;
    margin: 0;
    padding: 0;
    border: 0;
    overflow: visible;
    background: transparent;
    color: inherit;
    /* Pinned to a corner, so it is outside the shell's own safe-area padding and
       takes both insets itself. ADDED to the offset rather than replacing it: the
       20px is the gap the design wants from the edge, and the inset is how much of
       that edge the device has already taken. */
    inset-block-end: calc(var(--space-8) + var(--safe-block-end));
    inset-inline-end: calc(var(--space-8) + var(--safe-inline-end));
    z-index: 600;
    display: flex;
    flex-direction: column;
    gap: var(--space-4);
    /* The stack spans a corner region; only the toasts themselves take clicks,
       or an invisible column would swallow them for the whole page height. */
    pointer-events: none;
}

.toast {
    pointer-events: auto;
    display: flex;
    align-items: flex-start;
    gap: var(--space-5);
    /* 320px, capped by the viewport AND by whatever contains it. The stack is fixed
       to a corner, where `100%` is the viewport and the first term binds; a toast
       rendered inside a narrower box takes the second and shrinks rather than
       overflowing it. */
    width: 320px;
    max-width: min(calc(100vw - 40px), 100%);
    padding: var(--space-5) var(--space-6);
    --surface-here: var(--bg-elevated);
    background: var(--bg-elevated);
    border: 1px solid var(--border);
    border-inline-start: 3px solid var(--border-strong);
    border-radius: var(--radius-surface);
    box-shadow: var(--shadow-float);
    font-size: var(--text-4);
    color: var(--fg-soft);
}
/* A toast normally carries a title and a short description under it, and `flex-start`
   is right for that: the icon belongs beside the FIRST line, not beside the middle of
   two. A toast of one line is the exception, and there `flex-start` leaves the icon,
   the line and the close button on three different tops, because all three have
   different box heights.

   No extra class for it. The title IS a <strong> — that is what the markup says and
   what sednaUi.toast emits — so its absence is the condition, and a one-line toast
   centres whether an app rendered it or the library did. */
.toast:not(:has(strong)) { align-items: center; }

.toast i        { font-size: var(--text-7); flex-shrink: 0; color: var(--muted); }
.toast strong   { display: block; color: var(--fg); font-weight: 600; }
.toast-body     { flex: 1; min-width: 0; }
.toast-close {
    flex-shrink: 0;
    background: none; border: none; padding: 0;
    color: var(--muted); font-size: var(--text-6); cursor: pointer;
    line-height: 1;
}
.toast-close:hover { color: var(--fg); }

/* The semantic families again, carried on the leading rule and the icon. */
.toast-go     { border-inline-start-color: var(--go-solid); }
.toast-go i   { color: var(--go-fg); }
.toast-warn   { border-inline-start-color: var(--warn-solid); }
.toast-warn i { color: var(--warn-fg); }
.toast-danger   { border-inline-start-color: var(--danger-solid); }
.toast-danger i { color: var(--danger-fg); }
.toast-info   { border-inline-start-color: var(--info-border); }
.toast-info i { color: var(--info-fg); }

}

/* ── 39-spinner.css → @layer sedna.paint ──────────────────────────────────────────────── */
@layer sedna.paint {
/* ── Spinner ────────────────────────────────────────────────────────────────
   A ring with one coloured arc. Sized by font-size so it matches the text or
   the button it sits in without a second class. */
.spinner {
    display: inline-block;
    width: 1em;
    height: 1em;
    border: 2px solid var(--border);
    border-top-color: var(--brand-soft);
    border-radius: 50%;
    animation: sedna-spin var(--spin-duration) linear infinite;
    /* Keeps the ring on the text baseline instead of riding above it. */
    vertical-align: -0.125em;
}
.spinner-lg { width: 24px; height: 24px; border-width: 3px; }

@keyframes sedna-spin { to { transform: rotate(360deg); } }

}

/* ── 40-skeleton.css → @layer sedna.paint ──────────────────────────────────────────────── */
@layer sedna.paint {
/* ── Skeleton ───────────────────────────────────────────────────────────────
   A placeholder with the shape of the content that is coming, so the page does
   not reflow when it arrives. Give it the height of the real thing. */
.skeleton {
    /* A block whatever the element: a <span> in a table cell or a list row is the
       natural markup, and an inline box ignores the height that is the whole point. */
    display: block;
    background: var(--skeleton-bg);
    background-image: linear-gradient(
        90deg,
        var(--skeleton-bg) 0%,
        var(--skeleton-sheen) 50%,
        var(--skeleton-bg) 100%);
    background-size: 200% 100%;
    border-radius: var(--radius-inner);
    animation: sedna-skeleton var(--skeleton-duration) ease-in-out infinite;
}
/* A line of text. Two of these with different widths read as a paragraph. */
.skeleton-text   { height: 12px; margin: var(--space-3) 0; }
.skeleton-title  { height: 18px; margin: var(--space-4) 0; border-radius: var(--radius-control); }
.skeleton-avatar { width: 28px; height: 28px; border-radius: 50%; flex-shrink: 0; }
/* A SURFACE that has not arrived — a card of settings, a table of rows, a note's
   body — rather than a line of text. The three line-scale modifiers above are the
   shape of a paragraph, and every loading state on a list or a detail page is the
   shape of a block, which is why an app with no answer here writes
   `style="height:8rem"` once per loading state.

   The middle step is the bare class, as it is for `.modal`. --skeleton-height is a
   knob for the heights between the steps, so a placeholder can match the real thing
   it stands in for exactly — which is the point of a skeleton, since a height that
   is merely close reflows the page when the content lands. */
.skeleton-block {
    --skeleton-height: 8rem;
    height: var(--skeleton-height);
    border-radius: var(--radius-surface);
}
.skeleton-block--sm { --skeleton-height: 4rem; }
.skeleton-block--lg { --skeleton-height: 12rem; }

/* The other shapes a page is made of, each the size of the real thing so nothing
   moves when it lands: a field or a button (one control height), a pill, a circle
   of any size (an avatar in a different tier, a status dot), a picture. */
.skeleton-control { height: var(--control-height); border-radius: var(--radius-control); }
.skeleton-control--sm { height: var(--control-height-sm); }
.skeleton-control--lg { height: var(--control-height-lg); }
.skeleton-badge { width: 4rem; height: 18px; border-radius: var(--radius-pill); }
.skeleton-circle {
    --skeleton-size: 36px;
    width: var(--skeleton-size);
    height: var(--skeleton-size);
    border-radius: 50%;
    flex-shrink: 0;
}
.skeleton-image {
    aspect-ratio: 16 / 9;
    width: 100%;
    border-radius: var(--radius-surface);
}
/* A chart that has not arrived: a row of bars at different heights, so the box
   reads as a chart and not as a paragraph. Each bar's height is `--bar`. */
.skeleton-bars {
    --skeleton-height: 8rem;
    display: flex;
    align-items: flex-end;
    gap: var(--space-3);
    height: var(--skeleton-height);
}
.skeleton-bars > .skeleton {
    --bar: 50%;
    flex: 1;
    height: var(--bar);
    border-radius: var(--radius-inner) var(--radius-inner) 0 0;
}

@keyframes sedna-skeleton {
    from { background-position: 200% 0; }
    to   { background-position: -200% 0; }
}

}

/* ── 41-progress.css → @layer sedna.paint ──────────────────────────────────────────────── */
@layer sedna.paint {
/* ── Progress ───────────────────────────────────────────────────────────────
   Determinate: set the inline width on .progress-bar, which is the one value
   only the app knows. Indeterminate: omit the width and add the modifier. */
.progress {
    height: 6px;
    background: var(--progress-track);
    border-radius: var(--radius-pill);
    overflow: hidden;
}
.progress-bar {
    height: 100%;
    background: var(--brand);
    border-radius: inherit;
    transition: width var(--motion-slow) ease;
}
.progress-bar--go     { background: var(--go-solid); }
.progress-bar--warn   { background: var(--warn-solid); }
.progress-bar--danger { background: var(--danger-solid); }

/* Neutral. The base bar is var(--brand), which reads as an alarm wherever the
   brand is a red — and "nowhere near the threshold" is neither a warning nor a
   failure. Any bar measuring progress TOWARDS a threshold rather than the
   completion of a task wants this. */
.progress-bar--muted  { background: var(--muted); }

/* The ordered severity ramp, for a bar that measures a rank rather than a
   fraction — a P1–P5 mix, a CVSS band. --sev-N-solid is a fill only: steps 4
   and 5 do not carry --on-solid text at AA, and a bar has no label on it.
   Where there is one, use `.badge-sev-N`. */
.progress-bar--sev-1 { background: var(--sev-1-solid); }
.progress-bar--sev-2 { background: var(--sev-2-solid); }
.progress-bar--sev-3 { background: var(--sev-3-solid); }
.progress-bar--sev-4 { background: var(--sev-4-solid); }
.progress-bar--sev-5 { background: var(--sev-5-solid); }

.progress--indeterminate .progress-bar {
    width: 35%;
    animation: sedna-progress var(--progress-duration) ease-in-out infinite;
}
@keyframes sedna-progress {
    from { transform: translateX(-110%); }
    to   { transform: translateX(340%); }
}

}

/* ── 42-empty-state.css → @layer sedna.paint ──────────────────────────────────────────────── */
@layer sedna.paint {
/* ── Empty state ────────────────────────────────────────────────────────────
   No data, no permission, or nothing found. Say which of those it is, and give
   the one action that resolves it. */
.empty-state {
    display: flex;
    flex-direction: column;
    align-items: center;
    text-align: center;
    gap: var(--space-4);
    padding: var(--space-11) var(--space-9);
    color: var(--muted);
}
/* The drawing sits above the sentence with room of its own: an illustration and a
   13px line touching read as one object, and they are two. But only a little — and
   less than it was. `.empty-state` is a flex column, so this margin ADDS to the
   column's gap rather than collapsing into it, and 8px + 10px under a drawing that
   is already 96px of mostly air read as a hole between the picture and its own
   caption. 8px + 8px is the pair that reads as one block.
   The sprite, the ids and the three sizes are in 42-state-art.css, and so is
   `.state-icon`, the glyph that can stand in for the drawing. It takes the same room.

   Still no rule for a bare `<i>`: the glyph is opted in by its class. `.empty-state i`
   once reached the icon inside the action button too and rendered it at 40px. */
.state-art,
.state-icon { margin-bottom: var(--space-4); }

.empty-state-title { font-size: var(--text-5); font-weight: 600; color: var(--fg); }
.empty-state p     { margin: 0; font-size: var(--text-4); max-width: 44ch; }
/* The action is a separate decision from the sentence above it, so it gets more air
   than the lines of text get between themselves. */
.empty-state .btn  { margin-top: var(--space-3); }
/* Sits directly on the page rather than inside a card. */
.empty-state--bare { padding: var(--space-9) 0; }

/* ── Not empty yet, and empty because something broke ───────────────────────
   The same block, for the two states a live view spends most of its time in. Say
   which one it is: "nothing here" and "we could not load it" call for different
   actions, and showing the first when it is the second is how a broken page looks
   fine.

   `--pending` is for a wait with no progress to report — the first event of a stream,
   a long request. Where there IS progress, use `.progress`; where the shape of what is
   coming is known, use `.skeleton`. */

/* A spinner, not the `waiting` drawing. That drawing is for a queue the reader can be
   told about and then leave; this is a wait with nothing to report, and the one thing
   it has to say is that something is still happening — which a still picture cannot. */
.empty-state--pending > .spinner,
/* The spinner stands in for the drawing in the waiting state, so its margin tracks
   .state-art's. Left behind, the waiting state sits differently from every other. */
.empty-state--pending > .spinner-lg {
    width: 40px;
    height: 40px;
    margin-bottom: var(--space-4);
    border-width: 3px;
    color: var(--muted);
}

/* Failed moves the accent into the danger ramp and leaves the line work grey. A whole
   red panel reads as destructive — as though something was lost — rather than as
   unavailable, and what happened is that a request did not arrive.

   One declaration, inherited by the drawing through the `<use>` shadow tree. There is
   no second sprite for the error states and there must not be one. */
.empty-state--failed { --state-accent: var(--danger-fg); }
/* A wait is the one state where the drawing may move: the arrow nudging at the free
   slot says "still queued" the way the spinner says "still loading". */
.empty-state--pending { --state-motion: running; }
/* A glyph has no line work to leave grey, so the whole of it takes the accent. */
.empty-state--failed .state-icon { color: var(--danger-fg); }

/* The line under the title, for the detail that makes a failure actionable: the code,
   the endpoint, when it was last tried. It takes the paragraph's measure, and breaks
   anywhere, because what goes in it is the kind of string that has no spaces to break
   at — a path or a query — and without both it stretches the block wider than the
   text above it. */
.empty-state-detail {
    max-width: 44ch;
    font-family: var(--font-mono);
    font-size: var(--text-2);
    color: var(--muted);
    overflow-wrap: anywhere;
}

/* ── The whole page is the state ────────────────────────────────────────────
   Access denied, not found, the thing that threw, an expired session, a maintenance
   window. The same block, handed the page instead of a card.

   It is the page-level sibling of `.empty-state`, not a second idea: a 403 is a view
   with nothing in it for this reader, and the drawing, the sentence and the one action
   are the same three parts.

   Where it goes decides how it sits, and both are correct:

     · in `.auth-layout`, which is a flex column, it takes the free space and centres.
       That is the page for somebody who is not signed in or cannot get in — there is
       no navigation to offer them.
     · in `.page`, which is a block, `flex` is inert and it sits at the top under the
       sidebar. That is the page for a 404 inside the app, where the nav is still the
       way out and centring the message would push it below the fold on a laptop. */
.empty-state--page {
    flex: 1;
    min-height: 0;
    justify-content: center;
    gap: var(--space-5);
    padding-block: var(--space-11);
}
/* One step more than the card block, because the page block's own gap is a step
   larger and the title is --text-9 rather than --text-5. Same reduction. */
.empty-state--page .state-art,
.empty-state--page .state-icon { margin-bottom: var(--space-5); }
.empty-state--page .empty-state-title { font-size: var(--text-9); }
/* The measure grows with the type. 44ch of 13px is a comfortable column; 44ch of the
   same sentence under a 22px heading reads as a narrow strip. */
.empty-state--page p { font-size: var(--text-5); max-width: 52ch; }
.empty-state--page.empty-state--pending > .spinner,
.empty-state--page.empty-state--pending > .spinner-lg { width: 56px; height: 56px; border-width: 4px; }

/* The status code, above the title. What the reader needs it for is telling somebody
   else which page broke, so it is sized to be read once and repeated — not to be the
   picture. A 200px "404" is consumer-web decoration, and in a tool it competes with
   the sentence that says what to do next. */
.empty-state-code {
    font-family: var(--font-mono);
    font-size: var(--text-2);
    font-weight: 600;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--muted);
}

/* More than one way out — "Back to the dashboard" beside "Request access". Wraps,
   because two full labels do not fit a phone on one line.

   It also takes the top margin off the buttons and carries it once. `.empty-state
   .btn` gives every button --space-3 above it, which is right for the single action
   an empty panel has and stacks up into a stagger the moment there are two in a row
   that wraps. */
.empty-state-actions {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: center;
    gap: var(--space-5);
    margin-top: var(--space-3);
}
.empty-state-actions .btn { margin-top: 0; }
}

/* ── 42-state-art.css → @layer sedna.paint ──────────────────────────────────────────────── */
@layer sedna.paint {
/* ── State art ──────────────────────────────────────────────────────────────
   The drawing that says which state a view is in. One per state, from the sprite
   `SednaStateArt` writes into the page once, at the top of <body>:

     <SednaStateArt />
     …
     <svg class="state-art state-art--lg" aria-hidden="true">
       <use href="#no-access" />
     </svg>

   Thirteen symbols: nothing-yet, no-results, filtered-out, no-access, failed,
   waiting, not-found, server-error, session-expired, maintenance, all-done,
   first-run, offline.

   ── Why a sprite in the page, and not a file ──
   A `<use>` pointing at an external file is rendered as a document of its own in
   WebKit: no inherited `color`, no custom properties, so every line was black. A
   same-document reference inherits both in every engine, needs no second request, and
   is in the first server-rendered paint — which matters most on the pages that show
   when something is broken. The sprite ships inside the assembly, and a fix to a
   drawing still lands once, for every app.

   ── Why a drawing, and not an icon or markup ──
   One generic ornament on every empty state says only "this block is styled"; the
   reader still has to read the sentence to learn whether there is no data, no
   permission, or no connection. A drawing per state is the part that can be read
   before the text. It is not a second icon set: an icon set is a vocabulary an app
   draws from, and this is thirteen fixed pictures of thirteen fixed states.

   The icon is still available, as `.state-icon` below, for what the sprite cannot
   say: which kind of thing is missing — invoices, attachments, members. Pick the glyph
   for that thing. One glyph on every empty panel is the ornament described above.

   ── Colour ──
   The sprite writes no colour and no style. Every part carries one of the sprite
   classes declared here — the drawing IS these classes — so line work lands on
   whatever `color` the host sets (`--muted`) and the single accent is
   `var(--state-accent)`, declared in the token block and re-pointed by whatever the
   art sits in: `.empty-state--failed` swaps it for the danger colour, the modifiers
   below swap it for a semantic family, and the drawing follows with no second file
   to keep in step. Inherited properties and custom properties cross into a `<use>`
   shadow tree, and the class rules apply to the cloned parts as they apply to the
   originals, which together is the whole reason this works.

   ── Motion ──
   Seven of the drawings have a part that can move — the item arriving, the magnifier
   sweeping, the clock ticking — and each is a class on that part. All of them are
   paused until the page says otherwise, through `--state-motion`: `.state-art--live`
   runs one drawing, `.empty-state--pending` runs the drawing inside it, and the
   reduced-motion block switches every one of them off. Every animation's first frame is the
   resting pose, so a paused drawing is the still drawing and nothing depends on
   scripting or on the preference.

   ── Sizes ──
   Three, because the same state has to appear in three places. `--sm` is for a row
   inside a table or a narrow panel, where anything larger pushes the message out of
   view. The default is the card. `--lg` is the full page, where the block IS the page.
   Below 56px the 3-unit stroke fills in and the drawing reads as a smudge, which is
   why there is no smaller step — use the sentence alone.

   The block reserves its own space with `flex-shrink: 0`: dropped into a flex row it
   would otherwise be squeezed to nothing by the text beside it, silently.

   ── An app's own drawings ──
   Put a `<symbol>` of your own in a hidden `<svg class="sedna-sprite">` anywhere in
   the page, draw it on the same 120-unit grid with the classes below, and reference
   it exactly as a shipped one. The sizing, the colour, the accents and the motion
   gating are all the class's, so the drawing is the only thing you write.
   ─────────────────────────────────────────────────────────────────────────── */
.state-art {
    display: block;
    flex-shrink: 0;
    width: 96px;
    height: 96px;
    color: var(--muted);
}
.state-art--sm { width: 56px; height: 56px; }
.state-art--lg { width: 152px; height: 152px; }

/* The accent, by what the state means: the same families as badges and buttons. The
   line work stays `--muted` in all of them — a whole green drawing reads as an
   illustration of green, not of a cleared queue. */
.state-art--go     { --state-accent: var(--go-fg); }
.state-art--warn   { --state-accent: var(--warn-fg); }
.state-art--danger { --state-accent: var(--danger-fg); }
.state-art--info   { --state-accent: var(--info-fg); }

/* The drawing's moving part runs. Inherited, so it reaches the part through the
   `<use>` shadow tree like the accent does. */
.state-art--live { --state-motion: running; }

/* ── The sprite ─────────────────────────────────────────────────────────────
   Hidden without `display: none`: a symbol is never rendered in place either way,
   but WebKit drops the definitions inside a `display: none` subtree — gradients,
   masks, and on some versions the symbols themselves. Out of flow and zero-sized, it
   costs the page nothing and every `<use>` still resolves. */
.sedna-sprite {
    position: absolute;
    width: 0;
    height: 0;
    overflow: hidden;
}

/* ── The drawing classes ──────────────────────────────────────────────────────
   A 3-unit stroke on the 120-unit grid, round caps and round joins: the one line
   weight that makes thirteen scenes — and an app's fourteenth — read as one set. */
.sedna-art-line,
.sedna-art-soft,
.sedna-art-accent {
    fill: none;
    stroke: currentColor;
    stroke-width: 3;
    stroke-linecap: round;
    stroke-linejoin: round;
}
/* The part of the scene that is there but not the point: a list behind the
   magnifier, the panel the padlock sits on. */
.sedna-art-soft { opacity: 0.4; }
/* Something absent or expected — a slot not filled, the way a signpost pointed. */
.sedna-art-dash { stroke-dasharray: 6 7; }
/* The one part that says what happened. */
.sedna-art-accent { stroke: var(--state-accent); }
.sedna-art-accent-fill { fill: var(--state-accent); stroke: none; }
/* A faint wash behind a shape, so a filled area reads as a surface and not a hole. */
.sedna-art-tint { fill: currentColor; stroke: none; opacity: 0.07; }
/* A shape that has to cover what is behind it — the clock over the pass, the
   barrier across the panel. It takes the colour of the surface the drawing sits on,
   so it cuts the line behind it cleanly on a card, a modal or the page. */
.sedna-art-knockout { fill: var(--surface-here, var(--bg)); stroke: none; }

/* ── Motion ─────────────────────────────────────────────────────────────────
   Each class is one small, slow, looping movement whose first frame is the resting
   pose. `animation-play-state` reads `--state-motion`, which is `paused` at :root, so
   nothing moves until an ancestor says `running` — and the reduced-motion block
   removes the animations altogether, last. Durations are slow on purpose: a drawing beside a sentence is
   read, and anything quicker than a breath competes with the reading. */
.sedna-art-bob,
.sedna-art-sweep,
.sedna-art-nudge,
.sedna-art-blink,
.sedna-art-tick,
.sedna-art-twinkle {
    animation-play-state: var(--state-motion);
    animation-timing-function: ease-in-out;
    animation-iteration-count: infinite;
}
/* The item on its way into the tray. */
.sedna-art-bob     { animation-name: sedna-art-bob;     animation-duration: 2.4s; }
/* The magnifier passing over the list. */
.sedna-art-sweep   { animation-name: sedna-art-sweep;   animation-duration: 3.2s; }
/* The arrow pointing at the free slot. */
.sedna-art-nudge   { animation-name: sedna-art-nudge;   animation-duration: 1.8s; }
/* A signal, or a fault light. */
.sedna-art-blink   { animation-name: sedna-art-blink;   animation-duration: 1.6s; }
/* The clock's hands, once a minute in twelve seconds. Pivoted on the clock's
   centre in the sprite's own units, because the hand is not the centre of itself. */
.sedna-art-tick {
    animation-name: sedna-art-tick;
    animation-duration: 12s;
    animation-timing-function: linear;
    transform-box: view-box;
    transform-origin: 88px 80px;
}
/* A sparkle, breathing in place. */
.sedna-art-twinkle {
    animation-name: sedna-art-twinkle;
    animation-duration: 2s;
    transform-box: fill-box;
    transform-origin: center;
}

@keyframes sedna-art-bob     { 0%, 100% { transform: translateY(0); }   50% { transform: translateY(5px); } }
@keyframes sedna-art-sweep   { 0%, 100% { transform: translate(0, 0); } 50% { transform: translate(-7px, 3px); } }
@keyframes sedna-art-nudge   { 0%, 100% { transform: translateX(0); }   50% { transform: translateX(6px); } }
@keyframes sedna-art-blink   { 0%, 100% { opacity: 1; }                 50% { opacity: 0.2; } }
@keyframes sedna-art-tick    { to { transform: rotate(360deg); } }
@keyframes sedna-art-twinkle { 0%, 100% { transform: scale(1); opacity: 1; } 50% { transform: scale(0.7); opacity: 0.45; } }

/* ── State icon ───────────────────────────────────────────────────────────────
   A Remix Icon glyph in the drawing's place, with the same title, sentence and
   actions under it:
     <i class="ri-file-list-3-line state-icon" aria-hidden="true"></i>
   The same three places as the drawing, so the same three steps — `--sm` for a table
   row or a narrow panel, the bare class for a card, `--lg` for the page. They are
   font sizes because the glyph is type, and `line-height: 1` keeps the inherited
   leading out of its box, which would otherwise push the title down.

   `--border-strong`, a step lighter than the drawing's `--muted`: a line glyph this
   size has a heavier stroke than the drawing, and at the same colour it is the
   darkest thing in the panel. `.empty-state--failed` turns it to the danger colour. */
.state-icon {
    display: block;
    flex-shrink: 0;
    font-size: var(--text-11);
    line-height: 1;
    color: var(--border-strong);
}
.state-icon--sm { font-size: var(--text-10); }
.state-icon--lg { font-size: calc(var(--text-11) * 1.6); }
}

/* ── 43-content-grids.css → @layer sedna.paint ──────────────────────────────────────────────── */
@layer sedna.paint {
/* ── Grids and placement ────────────────────────────────────────────────────
   Three layout primitives for markup the app writes: a wrapping grid of peers, a
   24-column grid to place panels on, and a main column beside an aside — plus a
   stack, for a column of panels inside any of them. None names
   what goes in it — a card, a field, a figure and an app's own element are all
   children like any other.

   All three are breakpoint-free. They respond to the width they are GIVEN rather
   than to the viewport, so the same markup is right on a page, in a drawer and
   inside a narrow card.

   Every track minimum is `min(Npx, 100%)` rather than a bare `Npx`. A bare track
   minimum does not shrink, so a grid whose column floor exceeds the container
   overflows it instead of dropping to one column. */

/* ── .sedna-grid: peers in as many columns as fit ───────────────────────────
   Every child one track wide, as many tracks as `--grid-min` allows. auto-FILL keeps
   the track count stable, so three children in a wide grid stay three tracks wide
   instead of stretching across the row — right for a set of peers, a row of tiles.

   `--fit` switches to auto-FIT, which collapses the empty tracks so the children
   that ARE present stretch to fill the row: two fields in a form row, or a handful
   of cards whose count varies. Add `.sedna-start` when the children differ a lot in
   height, so each keeps its own instead of stretching to the tallest in its row.

   `--sm` / `--lg` move the floor for smaller children (form fields, 200px) and
   bigger ones (a card with a chart or a table in it, 340px); `--grid-min` takes any
   other length. */
.sedna-grid {
    --grid-min: 280px;
    --grid-gap: var(--space-7);
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(min(var(--grid-min), 100%), 1fr));
    gap: var(--grid-gap);
}
.sedna-grid--fit { grid-template-columns: repeat(auto-fit, minmax(min(var(--grid-min), 100%), 1fr)); }
.sedna-grid--sm  { --grid-min: 200px; }
.sedna-grid--lg  { --grid-min: 340px; }

/* ── .sedna-grid-24: panels placed on 24 columns ────────────────────────────
   A dashboard: a chart two-thirds wide beside a list, four tiles under it, a table
   the whole width. `.sedna-span-N` gives a child N of 24 columns; a child with no
   span takes the whole row. 24 rather than 12 so thirds, quarters, sixths AND
   eighths all divide evenly — and the even steps are a 12-column grid for anyone
   who thinks in twelfths (`.sedna-span-6` of 24 is 3 of 12).

   It collapses on its own, in two steps measured against its OWN width, so the same
   markup is right on a page, in a drawer and in a narrow card:

     narrower than 48rem  a span under 8 becomes 12 (tiles pair up two by two) and a
                          span of 8 or more becomes 24 (a panel a third wide or wider
                          needs the whole row)
     narrower than 30rem  every child takes the whole row

   That is a container query, so the grid is a size container. Two consequences:
   its width cannot come from its content — inside a flex row it needs `.sedna-fill`
   or a width of its own, or it shrinks to nothing — and it cannot restyle itself,
   which is why the steps are fixed lengths and not custom properties. A `.menu` or
   `.form-combo-panel` inside a panel still escapes the panel's edge:
   `LayoutGridTests` opens one and clicks its last item. */
.sedna-grid-24 {
    --grid-gap: var(--space-7);
    container: sedna-grid-24 / inline-size;
    display: grid;
    grid-template-columns: repeat(24, minmax(0, 1fr));
    gap: var(--grid-gap);
}
/* :where() keeps the defaults at zero specificity, so every span class — and an app's
   own class on the child — beats them without a specificity race. `min-width: 0`
   because a grid item's automatic minimum is its content, so a panel holding a wide
   table would force its track wider instead of scrolling inside itself. */
:where(.sedna-grid-24) > * {
    --span: 24;
    --span-md: 24;
    grid-column: span var(--span);
    min-width: 0;
}
@container sedna-grid-24 (width < 48rem) {
    :where(.sedna-grid-24) > * { grid-column: span var(--span-md); }
}
@container sedna-grid-24 (width < 30rem) {
    :where(.sedna-grid-24) > * { grid-column: span 24; }
}

.sedna-span-1  { --span: 1;  --span-md: 12; }
.sedna-span-2  { --span: 2;  --span-md: 12; }
.sedna-span-3  { --span: 3;  --span-md: 12; }
.sedna-span-4  { --span: 4;  --span-md: 12; }
.sedna-span-5  { --span: 5;  --span-md: 12; }
.sedna-span-6  { --span: 6;  --span-md: 12; }
.sedna-span-7  { --span: 7;  --span-md: 12; }
.sedna-span-8  { --span: 8;  --span-md: 24; }
.sedna-span-9  { --span: 9;  --span-md: 24; }
.sedna-span-10 { --span: 10; --span-md: 24; }
.sedna-span-11 { --span: 11; --span-md: 24; }
.sedna-span-12 { --span: 12; --span-md: 24; }
.sedna-span-13 { --span: 13; --span-md: 24; }
.sedna-span-14 { --span: 14; --span-md: 24; }
.sedna-span-15 { --span: 15; --span-md: 24; }
.sedna-span-16 { --span: 16; --span-md: 24; }
.sedna-span-17 { --span: 17; --span-md: 24; }
.sedna-span-18 { --span: 18; --span-md: 24; }
.sedna-span-19 { --span: 19; --span-md: 24; }
.sedna-span-20 { --span: 20; --span-md: 24; }
.sedna-span-21 { --span: 21; --span-md: 24; }
.sedna-span-22 { --span: 22; --span-md: 24; }
.sedna-span-23 { --span: 23; --span-md: 24; }
.sedna-span-24 { --span: 24; --span-md: 24; }

/* ── Keeping one tall panel from moving everything under it ─────────────────
   A row is as tall as its tallest child, so one long list beside three short tiles
   leaves holes beside the list and pushes the next row down by the difference. Four
   ways out, from the smallest change to the biggest:

   `.sedna-self-start` on the short ones keeps their own height. The row is still as
   tall as the list; the holes just stop being stretched cards.

   `.sedna-row-span-N` lets the tall panel take N rows, and the short ones fill the
   rows beside it — a list two rows tall beside two rows of tiles.

   `.sedna-stack` turns one span into an independent column of panels. Each column
   of a dashboard can then be as long as its own content without moving anything in
   the others; this is the answer when the columns are unrelated (the main chart and
   table on one side, a feed on the other).

   `--rows` gives every row the same height, `--grid-row` (9rem), whatever is in it.
   A panel's height is then its row span and nothing else, and what does not fit
   scrolls inside `.card--fill`'s body. That is a board of tiles, the layout
   monitoring dashboards use, and the only one in which nothing can move at all.

   `--dense` back-fills a hole with a later child that fits it. The DOM order is
   still the reading and tab order, so a dense grid reads out of visual order — use
   it for tiles that are peers, never for a sequence. */
.sedna-row-span-2 { grid-row: span 2; }
.sedna-row-span-3 { grid-row: span 3; }
.sedna-row-span-4 { grid-row: span 4; }

.sedna-grid-24--rows {
    --grid-row: 9rem;
    grid-auto-rows: var(--grid-row);
}
.sedna-grid-24--dense { grid-auto-flow: row dense; }

/* A column of panels with the grid's own gap, whichever grid it sits in —
   `--grid-gap` is inherited from it, so a stack inside a `.sedna-gap-3` grid is
   spaced like the grid. Outside a grid it falls back to the same 16px. */
.sedna-stack {
    display: flex;
    flex-direction: column;
    gap: var(--grid-gap, var(--space-7));
    min-width: 0;
}

/* ── A child that takes the whole row ───────────────────────────────────────
   Of any grid: `.sedna-grid`, `.sedna-grid-24`, `.stat-row`, or a grid of an app's own.
   A chart over a row of tiles, the odd card that would otherwise sit alone at one
   track wide, the long field among two-up fields: a description, a Markdown editor,
   a checkbox line.

   `1 / -1` rather than a `span` count, because the track COUNT of an auto-fill grid
   depends on its width, and `span 2` in a grid one track wide creates an implicit
   second column and overflows it. `1 / -1` is the whole row whatever the count.

   `min-width: 0` for the reason `.sedna-fill` carries it: a spanning card holding a
   wide table would otherwise force the whole grid wider. */
.sedna-span-full { grid-column: 1 / -1; min-width: 0; }

/* ── .sedna-split: a main column and an aside ───────────────────────────────
   A detail page with a side panel, a list beside a filter column, a dashboard's main
   chart beside its activity feed. The child marked `.sedna-split-aside` holds
   `--split-aside` (18rem); every other child shares what is left. Which side the
   aside is on is its place in the markup — first is the inline start, last is the
   inline end — so it mirrors under dir="rtl" with no rule.

   When the main column would drop below `--split-main` (60%) of the width, the two
   stack instead, aside where it is in the markup. That is flex-wrap doing the
   measuring: the main child grows 999 times faster than the aside, and its
   `min-inline-size` is what forces the wrap. No breakpoint, so a split inside a
   drawer stacks where the same split on a page does not.

   `--sm` / `--lg` are a 14rem and a 24rem aside. */
.sedna-split {
    --split-aside: 18rem;
    --split-main: 60%;
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-7);
}
.sedna-split > * {
    flex: 999 1 0;
    min-inline-size: min(var(--split-main), 100%);
}
.sedna-split > .sedna-split-aside {
    flex: 1 1 var(--split-aside);
    min-inline-size: 0;
}
.sedna-split--sm { --split-aside: 14rem; }
.sedna-split--lg { --split-aside: 24rem; }
}

/* ── 44-markdown.css → @layer sedna.paint ──────────────────────────────────────────────── */
@layer sedna.paint {
/* ── Markdown editor ────────────────────────────────────────────────────────
   Toolbar + textarea + rendered preview in one bordered box. The Write/Preview
   tabs flip [data-md-view] on the root (done by the JS). */
/* ── Editor chrome ──────────────────────────────────────────────────────────
   The frame, toolbar and body for a rich-text editor the APP brings — TipTap, Quill,
   ProseMirror, a plain contenteditable. Not an editor: no document model, no sanitiser,
   nothing that edits. The skin only, so a third-party editor's toolbar stops looking
   foreign beside .btn and .form-input.

     <div class="editor">
       <div class="editor-toolbar" role="toolbar" aria-label="Formatting">
         <button class="btn btn-ghost btn-icon btn-sm" type="button" aria-pressed="true" aria-label="Bold"><i class="ri-bold"></i></button>
         <span class="editor-sep" role="separator"></span>
         …
       </div>
       <div class="editor-body prose">the editor mounts here</div>
     </div>

   `.editor-body` is the scrolling content area; put `.prose` on it so what is typed looks
   like what is later rendered. A pressed formatting button is `aria-pressed="true"` — the
   attribute, not a class — which the toolbar colours. The frame takes the focus ring of a
   focused .form-input while anything inside it has focus. */
.editor {
    display: flex;
    flex-direction: column;
    background: var(--bg);
    border: 1px solid var(--border);
    border-radius: var(--radius-control);
    --surface-here: var(--bg);
    --editor-max-height: 480px;
    transition: border-color var(--motion-fast), box-shadow var(--motion-fast);
}
.editor:focus-within { border-color: var(--brand-soft); box-shadow: 0 0 0 2px var(--brand-glow); }
.editor-toolbar {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--space-1);
    padding: var(--space-2);
    border-bottom: 1px solid var(--divider);
}
.editor-toolbar [aria-pressed="true"] { background: var(--brand-tint); color: var(--brand-text); }
.editor-sep {
    align-self: stretch;
    inline-size: 1px;
    margin: var(--space-1) var(--space-2);
    background: var(--divider);
}
.editor-body {
    min-block-size: 160px;
    max-block-size: var(--editor-max-height);
    overflow-y: auto;
    padding: var(--space-5) var(--space-6);
    max-width: none;
}
/* The editable element the app's editor mounts. The frame already shows focus. */
.editor-body [contenteditable]:focus,
.editor-body[contenteditable]:focus { outline: none; }

.md-editor {
    border: 1px solid var(--border);
    border-radius: var(--radius-panel);
    overflow: hidden;
    --surface-here: var(--card-bg);
    background: var(--card-bg);
}
.md-toolbar {
    display: flex; align-items: center; gap: var(--space-1); flex-wrap: wrap;
    padding: var(--space-2) var(--space-3);
    border-bottom: 1px solid var(--border);
    background: var(--bg-elevated);
}
.md-tool {
    background: none; border: none; border-radius: var(--radius-control); cursor: pointer;
    color: var(--fg-soft); font-size: var(--text-6);
    width: 28px; height: 28px;
    display: inline-flex; align-items: center; justify-content: center;
}
.md-tool:hover { background: var(--bg-hover); color: var(--fg); }
/* The Write/Preview switch is the tier-2 .segmented control, not a private set of
   tab classes. It is exactly what a segmented control is for — two mutually
   exclusive options, applied immediately — and using it means the editor gets the
   radio group's keyboard behaviour and announcement for free instead of a pair of
   buttons with a toggled class. Only its position in the toolbar is the editor's
   business. */
/* The small tier, because `.md-tool` is 28px and the segmented control's default is
   now the standard 36px control height. Without this the switch stands 8px taller
   than every button beside it in the same row. */
.md-toolbar .segmented { margin-inline-start: auto; min-height: var(--control-height-sm); }
.md-toolbar .segmented-option { padding-block: var(--space-1); font-size: var(--text-3); }
.md-editor .md-input {
    border: none; border-radius: 0; background: transparent;
    width: 100%; display: block;
    font-family: var(--font-mono); font-size: var(--text-3); resize: vertical;
}
.md-editor .md-preview {
    display: none; box-sizing: border-box;
    padding: var(--space-6) var(--space-7); min-height: 140px;
    /* height mirrors the textarea (synced in JS); long content scrolls */
    overflow-y: auto; resize: vertical;
}
.md-editor[data-md-view="preview"] .md-input   { display: none; }
.md-editor[data-md-view="preview"] .md-preview { display: block; }

/* Theme the scroll + resize chrome on both panes so the browser-default white
   scrollbar corner and resizer grip don't show through. */
.md-editor .md-input,
.md-editor .md-preview { scrollbar-width: thin; scrollbar-color: var(--scrollbar) transparent; }
.md-editor .md-input::-webkit-scrollbar,
.md-editor .md-preview::-webkit-scrollbar { width: 10px; height: 10px; }
.md-editor .md-input::-webkit-scrollbar-thumb,
.md-editor .md-preview::-webkit-scrollbar-thumb { background: var(--scrollbar); border-radius: var(--radius-pill); }
.md-editor .md-input::-webkit-scrollbar-thumb:hover,
.md-editor .md-preview::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-hover); }
.md-editor .md-input::-webkit-scrollbar-track,
.md-editor .md-preview::-webkit-scrollbar-track,
.md-editor .md-input::-webkit-scrollbar-corner,
.md-editor .md-preview::-webkit-scrollbar-corner { background: transparent; }
.md-editor .md-input::-webkit-resizer,
.md-editor .md-preview::-webkit-resizer {
    background: linear-gradient(-45deg, transparent 0 4px, var(--scrollbar) 4px 5px,
                transparent 5px 7px, var(--scrollbar) 7px 8px, transparent 8px);
}

/* Rendered Markdown — the preview pane, and anywhere else Markdown is shown. */
.markdown-body { color: var(--fg-soft); font-size: var(--text-4); line-height: 1.6; word-break: break-word; }
.markdown-body > :first-child { margin-top: 0; }
.markdown-body > :last-child  { margin-bottom: 0; }
.markdown-body h1, .markdown-body h2, .markdown-body h3,
.markdown-body h4, .markdown-body h5, .markdown-body h6 {
    color: var(--fg); margin: 0.7em 0 0.3em; line-height: 1.3;
}
.markdown-body h1 { font-size: 1.4em; }
.markdown-body h2 { font-size: 1.25em; }
.markdown-body h3 { font-size: 1.1em; }
.markdown-body p  { margin: 0.5em 0; }
.markdown-body ul, .markdown-body ol { margin: 0.5em 0; padding-inline-start: 1.5em; }
.markdown-body li { margin: 0.15em 0; }
/* Underlined, not colour alone: a link inside a block of text must be
   distinguishable without seeing the hue (WCAG 1.4.1). .prose does the same.

   --brand-text, not --brand-soft. The soft role is the brand at display strength for
   icons and spinners on a tinted surface; it is solved against white, not against the
   canvas, so as body text on --bg it measured under AA in the light variant — 4.35:1
   for this library's own brand, and no better for a generated one. --brand-text is the
   role that IS measured against the canvas, in both variants. */
.markdown-body a  { color: var(--brand-text); text-decoration: underline; }
.markdown-body code {
    font-family: var(--font-mono); font-size: 0.9em;
    background: var(--bg-elevated); border: 1px solid var(--border);
    border-radius: var(--radius-inner); padding: 0.1em 0.35em;
}
.markdown-body pre {
    background: var(--bg-elevated); border: 1px solid var(--border); border-radius: var(--radius-surface);
    padding: var(--space-5) var(--space-6); overflow-x: auto; margin: 0.6em 0;
}
.markdown-body pre code { background: none; border: none; padding: 0; }
.markdown-body blockquote {
    border-inline-start: 3px solid var(--border);
    margin: 0.6em 0; padding: 0.2em 0 0.2em 0.8em;
    color: var(--muted);
}


}

/* ── 44-quill.css → @layer sedna.paint ──────────────────────────────────────────────── */
@layer sedna.paint {
/* ── Quill, inside the editor chrome ────────────────────────────────────────
   The one editor engine the library dresses: Quill 2, as the Blazor component
   Spillgebees.Blazor.RichTextEditor renders it. The library references neither — an
   app that wants the editor adds the package, and this part is what makes the result
   look like the rest of the app. CLAUDE.md names it as an exception to "no styling
   for a particular integration" and says why a second engine is not added beside it.

   The app writes the chrome's classes onto the component and its own toolbar:

     <RichTextEditor @bind-Content="content"
                     ContainerClass="editor"
                     ToolbarOptions="new() { ToolbarContainerClass = &quot;editor-toolbar&quot; }"
                     EditorContainerClass="editor-body prose"
                     Placeholder="Write the handover note">
       <ToolbarContent>
         <button class="btn btn-ghost btn-icon btn-sm ql-bold" type="button" aria-label="Bold"></button>
         <span class="editor-sep" role="separator"></span>
         …
       </ToolbarContent>
     </RichTextEditor>

   and does NOT link the package's own stylesheet. That file is unlayered, so every
   rule in it would outrank every rule here whatever the specificity; this part carries
   the few rules Quill needs to work as well as the look.

   Quill fills each `ql-*` button with its own 18-unit SVG and reports the state on
   `aria-pressed`, which `.editor-toolbar` already colours. Every rule is scoped to
   `.editor`, so an app using Quill with its own theme elsewhere is untouched.

   The words are the app's. The link tooltip's labels come from four custom properties
   the app sets on the editor or any ancestor, in its own language:

     --editor-link-open: "Open"   --editor-link-edit: "Edit"
     --editor-link-save: "Save"   --editor-link-remove: "Remove"

   and a picker item's label is the text of the `<option>` the app wrote, which Quill
   copies to `data-label`. They are declared empty on the root element rather than on
   `.editor`, where they would hide the value an ancestor set. */
:where(html) {
    --editor-link-open: "";
    --editor-link-edit: "";
    --editor-link-save: "";
    --editor-link-remove: "";
}

/* ── The editable area ──────────────────────────────────────────────────────
   The component puts `.editor-body` on Quill's container, which also holds the link
   tooltip. A container that scrolls would clip the tooltip at its edge, so the scroll
   moves one level in, to `.ql-editor`, and the container stays a positioned box. */
.editor .ql-container {
    position: relative;
    box-sizing: border-box;
    display: flex;
    flex-direction: column;
    margin: 0;
}
.editor-body.ql-container {
    min-block-size: 0;
    max-block-size: none;
    padding: 0;
    overflow: visible;
}
.editor .ql-editor {
    flex: 1;
    box-sizing: border-box;
    min-block-size: 160px;
    max-block-size: var(--editor-max-height);
    overflow-y: auto;
    padding: var(--space-5) var(--space-6);
    outline: none;
    tab-size: 4;
    text-align: start;
    white-space: pre-wrap;
    overflow-wrap: break-word;
    counter-reset: list-0 list-1 list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9;
}
.editor .ql-editor > * { cursor: text; }
.editor .ql-clipboard {
    position: absolute;
    inset-inline-start: -100000px;
    top: 50%;
    height: 1px;
    overflow-y: hidden;
}
.editor .ql-hidden { display: none; }
/* Quill's words for an empty editor are the component's `Placeholder`. */
.editor .ql-editor.ql-blank::before {
    content: attr(data-placeholder);
    position: absolute;
    inset-inline: var(--space-6);
    color: var(--border-strong);
    pointer-events: none;
}
.editor .ql-container.ql-disabled .ql-editor > * { cursor: default; }
.editor:has(.ql-container.ql-disabled) { background: var(--surface-soft); }
.editor:has(.ql-container.ql-disabled) .editor-toolbar { opacity: 0.45; pointer-events: none; }

/* ── Lists ──────────────────────────────────────────────────────────────────
   Quill 2 writes every list as an `<ol>` of `<li data-list="bullet|ordered|checked|
   unchecked">` and draws the marker itself, into a `.ql-ui` span, so the browser's own
   markers are off and the counters below number an ordered list through its indents:
   1. a. i. and round again, as a document editor does. */
.editor .ql-editor ol { padding-inline-start: 1.5em; }
.editor .ql-editor li {
    position: relative;
    list-style-type: none;
    padding-inline-start: 1.5em;
}
.editor .ql-editor .ql-ui { position: absolute; }
.editor .ql-editor li > .ql-ui::before {
    display: inline-block;
    width: 1.2em;
    margin-inline-start: -1.5em;
    margin-inline-end: 0.3em;
    text-align: end;
    white-space: nowrap;
    color: var(--muted);
}
.editor .ql-editor li[data-list="bullet"] > .ql-ui::before { content: "\2022"; }
/* A checklist item's box, drawn rather than a character, so it matches `.form-check`. */
.editor .ql-editor li:is([data-list="checked"], [data-list="unchecked"]) > .ql-ui::before {
    content: "";
    box-sizing: border-box;
    width: 1em;
    height: 1em;
    vertical-align: -0.15em;
    border: 1px solid var(--border-strong);
    border-radius: var(--radius-inner);
    background: var(--bg);
}
.editor .ql-editor li[data-list="checked"] > .ql-ui::before {
    content: "\2713";
    display: inline-grid;
    place-items: center;
    font-size: 0.8em;
    width: 1.25em;
    height: 1.25em;
    line-height: 1;
    border-color: var(--brand);
    background: var(--brand);
    color: var(--on-solid);
}
.editor .ql-editor li[data-list="checked"] { color: var(--muted); text-decoration: line-through; }
.editor .ql-container:not(.ql-disabled) li:is([data-list="checked"], [data-list="unchecked"]) > .ql-ui { cursor: pointer; }

.editor .ql-editor :is(p, h1, h2, h3, h4, h5, h6) { counter-set: list-0 list-1 list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9; }
.editor .ql-editor li[data-list] { counter-set: list-1 list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9; }
.editor .ql-editor li[data-list].ql-indent-1 { counter-set: list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9; }
.editor .ql-editor li[data-list].ql-indent-2 { counter-set: list-3 list-4 list-5 list-6 list-7 list-8 list-9; }
.editor .ql-editor li[data-list].ql-indent-3 { counter-set: list-4 list-5 list-6 list-7 list-8 list-9; }
.editor .ql-editor li[data-list].ql-indent-4 { counter-set: list-5 list-6 list-7 list-8 list-9; }
.editor .ql-editor li[data-list].ql-indent-5 { counter-set: list-6 list-7 list-8 list-9; }
.editor .ql-editor li[data-list].ql-indent-6 { counter-set: list-7 list-8 list-9; }
.editor .ql-editor li[data-list].ql-indent-7 { counter-set: list-8 list-9; }
.editor .ql-editor li[data-list].ql-indent-8 { counter-set: list-9; }

.editor .ql-editor li[data-list="ordered"] { counter-increment: list-0; }
.editor .ql-editor li[data-list="ordered"] > .ql-ui::before { content: counter(list-0, decimal) ". "; }
.editor .ql-editor li[data-list="ordered"].ql-indent-1 { counter-increment: list-1; }
.editor .ql-editor li[data-list="ordered"].ql-indent-1 > .ql-ui::before { content: counter(list-1, lower-alpha) ". "; }
.editor .ql-editor li[data-list="ordered"].ql-indent-2 { counter-increment: list-2; }
.editor .ql-editor li[data-list="ordered"].ql-indent-2 > .ql-ui::before { content: counter(list-2, lower-roman) ". "; }
.editor .ql-editor li[data-list="ordered"].ql-indent-3 { counter-increment: list-3; }
.editor .ql-editor li[data-list="ordered"].ql-indent-3 > .ql-ui::before { content: counter(list-3, decimal) ". "; }
.editor .ql-editor li[data-list="ordered"].ql-indent-4 { counter-increment: list-4; }
.editor .ql-editor li[data-list="ordered"].ql-indent-4 > .ql-ui::before { content: counter(list-4, lower-alpha) ". "; }
.editor .ql-editor li[data-list="ordered"].ql-indent-5 { counter-increment: list-5; }
.editor .ql-editor li[data-list="ordered"].ql-indent-5 > .ql-ui::before { content: counter(list-5, lower-roman) ". "; }
.editor .ql-editor li[data-list="ordered"].ql-indent-6 { counter-increment: list-6; }
.editor .ql-editor li[data-list="ordered"].ql-indent-6 > .ql-ui::before { content: counter(list-6, decimal) ". "; }
.editor .ql-editor li[data-list="ordered"].ql-indent-7 { counter-increment: list-7; }
.editor .ql-editor li[data-list="ordered"].ql-indent-7 > .ql-ui::before { content: counter(list-7, lower-alpha) ". "; }
.editor .ql-editor li[data-list="ordered"].ql-indent-8 { counter-increment: list-8; }
.editor .ql-editor li[data-list="ordered"].ql-indent-8 > .ql-ui::before { content: counter(list-8, lower-roman) ". "; }

/* Indents, as Quill steps them: three ems a level, a list item half a level more. */
.editor .ql-editor .ql-indent-1 { padding-inline-start: 3em; }
.editor .ql-editor li.ql-indent-1 { padding-inline-start: 4.5em; }
.editor .ql-editor .ql-indent-2 { padding-inline-start: 6em; }
.editor .ql-editor li.ql-indent-2 { padding-inline-start: 7.5em; }
.editor .ql-editor .ql-indent-3 { padding-inline-start: 9em; }
.editor .ql-editor li.ql-indent-3 { padding-inline-start: 10.5em; }
.editor .ql-editor .ql-indent-4 { padding-inline-start: 12em; }
.editor .ql-editor li.ql-indent-4 { padding-inline-start: 13.5em; }
.editor .ql-editor .ql-indent-5 { padding-inline-start: 15em; }
.editor .ql-editor li.ql-indent-5 { padding-inline-start: 16.5em; }
.editor .ql-editor .ql-indent-6 { padding-inline-start: 18em; }
.editor .ql-editor li.ql-indent-6 { padding-inline-start: 19.5em; }
.editor .ql-editor .ql-indent-7 { padding-inline-start: 21em; }
.editor .ql-editor li.ql-indent-7 { padding-inline-start: 22.5em; }
.editor .ql-editor .ql-indent-8 { padding-inline-start: 24em; }
.editor .ql-editor li.ql-indent-8 { padding-inline-start: 25.5em; }

/* ── What Quill marks up inside a line ──────────────────────────────────────
   Alignment and direction are classes Quill writes. `right` is Quill's word for the
   physical edge, so it stays physical. Sizes are relative to the text around them. */
.editor .ql-editor .ql-direction-rtl { direction: rtl; text-align: inherit; }
.editor .ql-editor .ql-align-center { text-align: center; }
.editor .ql-editor .ql-align-justify { text-align: justify; }
/* Quill's `right` is the physical edge whatever the direction, which is what it stores. */
.editor .ql-editor .ql-align-right { text-align: end; }
.editor .ql-editor .ql-direction-rtl.ql-align-right { text-align: start; }
.editor .ql-editor .ql-size-small { font-size: 0.75em; }
.editor .ql-editor .ql-size-large { font-size: 1.5em; }
.editor .ql-editor .ql-size-huge { font-size: 2.5em; }
.editor .ql-editor .ql-font-monospace { font-family: var(--font-mono); }
.editor .ql-editor .ql-video { display: block; max-width: 100%; }
.editor .ql-editor code {
    font-family: var(--font-mono);
    font-size: 0.9em;
    padding: 0.1em 0.35em;
    background: var(--bg-elevated);
    border: 1px solid var(--border);
    border-radius: var(--radius-inner);
}
.editor .ql-editor .ql-code-block-container {
    position: relative;
    margin: 0.8em 0;
    padding: var(--space-5) var(--space-6);
    font-family: var(--font-mono);
    font-size: 0.9em;
    background: var(--bg-elevated);
    border: 1px solid var(--border);
    border-radius: var(--radius-surface);
    overflow-x: auto;
}
.editor .ql-editor .ql-code-block-container .ql-ui { top: var(--space-2); inset-inline-end: var(--space-2); }
.editor .ql-editor a { color: var(--accent); text-decoration: underline; }

/* ── The toolbar ────────────────────────────────────────────────────────────
   Quill's icons are strokes and fills in its own classes; they take the button's
   colour, so a pressed, hovered or disabled button needs nothing more. */
.editor-toolbar button > svg {
    display: block;
    width: 1.25em;
    height: 1.25em;
}
.editor-toolbar .ql-stroke {
    fill: none;
    stroke: currentColor;
    stroke-width: 2;
    stroke-linecap: round;
    stroke-linejoin: round;
}
.editor-toolbar .ql-stroke-miter { fill: none; stroke: currentColor; stroke-width: 2; stroke-miterlimit: 10; }
.editor-toolbar :is(.ql-fill, .ql-stroke.ql-fill) { fill: currentColor; }
.editor-toolbar :is(.ql-thin, .ql-stroke.ql-thin) { stroke-width: 1; }
.editor-toolbar .ql-even { fill-rule: evenodd; }
.editor-toolbar .ql-transparent { opacity: 0.4; }
/* The direction button carries two icons and shows the one for the other way. */
.editor-toolbar .ql-direction svg:last-child { display: none; }
.editor-toolbar .ql-direction[aria-pressed="true"] svg:first-child { display: none; }
.editor-toolbar .ql-direction[aria-pressed="true"] svg:last-child { display: block; }

/* A `<select class="ql-header">` becomes a picker: Quill hides the select and builds a
   label and a list of items from its options. Here it is a ghost control with a
   dropdown panel, on the dropdown rung of the z-index scale. */
.editor-toolbar .ql-picker {
    position: relative;
    display: inline-flex;
    height: var(--control-height-sm);
    font-size: var(--text-3);
    color: var(--fg);
}
.editor-toolbar .ql-picker-label {
    display: inline-flex;
    align-items: center;
    gap: var(--space-2);
    height: 100%;
    padding: 0 var(--space-3) 0 var(--space-5);
    border-radius: var(--radius-control);
    cursor: pointer;
    white-space: nowrap;
}
.editor-toolbar .ql-picker-label:hover,
.editor-toolbar .ql-picker.ql-expanded .ql-picker-label { background: var(--bg-hover); }
.editor-toolbar .ql-picker-label > svg { width: 1.25em; height: 1.25em; }
.editor-toolbar :is(.ql-picker-label, .ql-picker-item)[data-label]::before { content: attr(data-label); }
.editor-toolbar .ql-picker-options {
    display: none;
    position: absolute;
    top: 100%;
    inset-inline-start: 0;
    z-index: 550;
    min-width: 100%;
    margin-top: var(--space-1);
    padding: var(--space-2);
    background: var(--bg-elevated);
    border: 1px solid var(--border);
    border-radius: var(--radius-surface);
    box-shadow: var(--shadow-dropdown);
    white-space: nowrap;
}
.editor-toolbar .ql-picker.ql-expanded .ql-picker-options { display: block; }
.editor-toolbar .ql-picker-item {
    display: block;
    padding: var(--space-3) var(--space-5);
    border-radius: var(--radius-inner);
    color: var(--fg);
    cursor: pointer;
}
.editor-toolbar .ql-picker-item:hover { background: var(--bg-hover); }
.editor-toolbar .ql-picker-item.ql-selected { background: var(--brand-tint); color: var(--brand-text); }
/* A colour picker's items are swatches Quill paints with the option's value. */
.editor-toolbar .ql-color-picker.ql-expanded .ql-picker-options {
    display: grid;
    grid-template-columns: repeat(7, var(--space-8));
    gap: var(--space-1);
}
.editor-toolbar .ql-color-picker .ql-picker-item {
    width: var(--space-8);
    height: var(--space-8);
    padding: 0;
    border: 1px solid var(--border);
}
.editor-toolbar .ql-color-picker .ql-picker-item.ql-selected { box-shadow: 0 0 0 2px var(--brand-ring-soft); }
.editor-toolbar .ql-icon-picker .ql-picker-item > svg { display: block; width: 1.25em; height: 1.25em; }

/* ── The link tooltip ───────────────────────────────────────────────────────
   Quill's own popup for a link: the address, and edit / save / remove. It is a
   dropdown panel on the same rung as a picker, and its words are the app's custom
   properties, declared empty at the top of this part. */
.editor .ql-tooltip {
    position: absolute;
    z-index: 550;
    display: flex;
    align-items: center;
    gap: var(--space-4);
    padding: var(--space-3) var(--space-4);
    background: var(--bg-elevated);
    border: 1px solid var(--border);
    border-radius: var(--radius-surface);
    box-shadow: var(--shadow-dropdown);
    color: var(--fg);
    font-size: var(--text-3);
    white-space: nowrap;
    transform: translateY(var(--space-4));
}
.editor .ql-tooltip.ql-flip { transform: translateY(calc(var(--space-4) * -1)); }
.editor .ql-tooltip.ql-hidden { display: none; }
.editor .ql-tooltip::before { content: var(--editor-link-open); color: var(--muted); }
.editor .ql-tooltip a { cursor: pointer; text-decoration: none; }
.editor .ql-tooltip a.ql-preview {
    max-width: 16rem;
    overflow: hidden;
    text-overflow: ellipsis;
    color: var(--accent);
}
.editor .ql-tooltip :is(a.ql-action, a.ql-remove) {
    color: var(--fg-soft);
    font-weight: 500;
}
.editor .ql-tooltip :is(a.ql-action, a.ql-remove):hover { color: var(--fg); }
.editor .ql-tooltip a.ql-action::after { content: var(--editor-link-edit); }
.editor .ql-tooltip a.ql-remove::before { content: var(--editor-link-remove); }
.editor .ql-tooltip input[type="text"] {
    display: none;
    box-sizing: border-box;
    width: 16rem;
    height: var(--control-height-sm);
    padding: 0 var(--space-4);
    font: inherit;
    color: var(--fg);
    background: var(--bg);
    border: 1px solid var(--border);
    border-radius: var(--radius-control);
    outline: none;
}
.editor .ql-tooltip input[type="text"]:focus { border-color: var(--brand-soft); box-shadow: 0 0 0 2px var(--brand-glow); }
.editor .ql-tooltip.ql-editing::before,
.editor .ql-tooltip.ql-editing :is(a.ql-preview, a.ql-remove) { display: none; }
.editor .ql-tooltip.ql-editing input[type="text"] { display: block; }
.editor .ql-tooltip.ql-editing a.ql-action::after { content: var(--editor-link-save); }
}

/* ── 45-form-controls.css → @layer sedna.paint ──────────────────────────────────────────────── */
@layer sedna.paint {
/* ── Form controls — select, radio, switch ──────────────────────────────────
   The rest of the family is in 34-forms.css. These three are here because each
   one resets the browser's native control and redraws it, which is most of the
   code in the family and none of the shape of it.

   This file must stay numbered ABOVE 34: `.form-select` and `.form-input` have
   equal specificity, so the caret's background-image survives the `background`
   shorthand in the shared field rule on source order alone. Renumber this below
   34 and the caret silently disappears.

   The select this file draws is the FALLBACK one — the control a browser without
   `appearance: base-select` renders, where the option list belongs to the platform
   and the only part CSS can reach is the closed box. 49-form-select-base.css draws
   the other one, and the two do not overlap: everything particular to the fallback
   sits inside the `@supports not` block below, so nothing there has to be undone
   later.
   ─────────────────────────────────────────────────────────────────────────── */

/* `class="form-select"` is the whole class list a dropdown needs. The field shape
   — height, padding, surface, border, radius, type and focus ring — comes from
   the shared `.form-input, .form-select` rule in 34-forms.css, and this file adds
   only what is particular to a select.

   `class="form-input form-select"` stays correct and renders identically, because
   the two names resolve to the same declarations rather than to two copies of
   them. Prefer the single class in new markup. */
.form-select { cursor: pointer; }

/* A list box is not a dropdown: there is nothing to drop, so a click on it picks
   a row rather than opening anything. */
.form-select[multiple],
.form-select[size]:not([size="1"]) { cursor: auto; }

/* ── The fallback caret ─────────────────────────────────────────────────────
   Only for a browser that cannot do base appearance. Where it can, the caret is
   the real `::picker-icon` pseudo-element in 49-form-select-base.css, and drawing
   both would put two arrows in the box.

   Two triangular gradients meeting at a point. Not an SVG: this stylesheet loads
   and inlines nothing, so neither url() nor a data: URI is available. Not a
   pseudo-element either — without base appearance, ::before / ::after do not
   render on a <select> at all, in any browser, which is the whole reason this is
   painted onto the background. */
@supports not (appearance: base-select) {
    .form-select {
        appearance: none;
        /* Room for the caret plus its 12px inset, so a long option label stops
           before it rather than running underneath. */
        padding-inline-end: var(--space-10);
        background-image:
            linear-gradient(45deg,  transparent 50%, var(--muted) 50%),
            linear-gradient(135deg, var(--muted) 50%, transparent 50%);
        background-position:
            calc(100% - 17px) calc(50% - 2px),
            calc(100% - 12px) calc(50% - 2px);
        background-size: 5px 5px;
        background-repeat: no-repeat;
    }

    /* The caret would sit over the first row of a list box. */
    .form-select[multiple],
    .form-select[size]:not([size="1"]) {
        background-image: none;
        padding-inline-end: var(--space-5);
    }
}

/* The same three tiers as .form-input, so a small filter field and the small
   dropdown beside it agree on height. Longhands rather than the `padding`
   shorthand .form-input-sm uses: the shorthand would reset the fallback caret's
   room above, which is the one thing that select cannot give up. --space-10
   therefore stays where the fallback set it, at every tier — and where base
   appearance is available there is no reserved room to protect, so
   49-form-select-base.css gives the trailing side back at each tier. */
.form-select-sm {
    min-height: var(--control-height-sm);
    padding-block: var(--space-2);
    padding-inline-start: var(--space-4);
    font-size: var(--text-3);
}
.form-select-lg {
    min-height: var(--control-height-lg);
    padding-block: var(--space-5);
    padding-inline-start: var(--space-7);
    font-size: var(--text-5);
}

/* ── Radio ──────────────────────────────────────────────────────────────────
   Same .form-check label wrapper as the checkbox. Deliberately a brand DOT in an
   unfilled circle rather than a filled disc: shape and fill then both differ from
   the checkbox, so which one is exclusive is readable at a glance and without
   colour. */
.form-check input[type="radio"] {
    appearance: none;
    margin: 0;               /* see the checkbox in 34-forms.css */
    width: 15px;
    height: 15px;
    flex-shrink: 0;
    cursor: pointer;
    background: var(--bg);
    border: 1px solid var(--border-strong);
    border-radius: 50%;
    position: relative;
    transition: border-color var(--motion-fast), box-shadow var(--motion-fast);
}
.form-check input[type="radio"]:hover { border-color: var(--border-hover); }
.form-check input[type="radio"]:checked { border-color: var(--brand); }
/* Centred by `inset: 0` and auto margins, not by `left: 50%` + a translate.
   Percentage inset and transform are resolved and rounded independently of each
   other, so at a fractional page zoom the two roundings can both land the same
   way and the dot sits visibly off-centre — measurably so at 110% and 125%.
   Auto margins are one layout computation that splits the leftover space, so the
   two sides are equal by construction at every zoom.

   rtl-ok: `inset` and auto margins are symmetrical, so there is no direction to
   flip. */
.form-check input[type="radio"]:checked::after {
    content: '';
    position: absolute;
    inset: 0;
    margin: auto;
    width: 7px; height: 7px;
    border-radius: 50%;
    background: var(--brand);
}
.form-check input[type="radio"]:focus-visible {
    outline: none;
    box-shadow: 0 0 0 2px var(--brand-ring-check);
}

/* ── Switch ─────────────────────────────────────────────────────────────────
   For a setting that takes effect immediately. A checkbox is for a value that is
   saved with the rest of the form — the difference is when it applies, not how it
   looks, and picking the wrong one misleads about whether Save is still needed.

   Still an <input type="checkbox">, so it is keyboard- and screen-reader-correct
   with no ARIA. */
.switch {
    display: inline-flex;
    align-items: center;
    gap: var(--space-5);
    font-size: var(--text-4);
    cursor: pointer;
    user-select: none;
}
.switch input[type="checkbox"] {
    appearance: none;
    margin: 0;               /* see the checkbox in 34-forms.css */
    position: relative;
    flex-shrink: 0;
    width: 34px;
    height: 19px;
    background: var(--border-strong);
    border: 1px solid var(--border-strong);
    border-radius: var(--radius-pill);
    cursor: pointer;
    transition: background var(--motion-fast), border-color var(--motion-fast), box-shadow var(--motion-fast);
}
.switch input[type="checkbox"]::after {
    content: '';
    position: absolute;
    /* Not on the spacing scale, deliberately: these two, the 34×19 track and the
       13px knob are one interlocking geometry. Rescaling the spacing ramp must not
       move the knob out of its track. */
    top: 2px;
    inset-inline-start: 2px;
    width: 13px;
    height: 13px;
    background: var(--on-solid);
    border-radius: 50%;
    transition: transform var(--motion-fast) ease;
}
.switch input[type="checkbox"]:hover { border-color: var(--border-hover); }
.switch input[type="checkbox"]:checked {
    background: var(--brand);
    border-color: var(--brand);
}
.switch input[type="checkbox"]:checked::after { transform: translateX(15px); }
.switch input[type="checkbox"]:focus-visible {
    outline: none;
    box-shadow: 0 0 0 2px var(--brand-ring-check);
}
.switch input[type="checkbox"]:disabled { cursor: not-allowed; opacity: 0.45; }
.switch:has(input:disabled) { cursor: not-allowed; color: var(--muted); }

/* ── Label and hint ─────────────────────────────────────────────────────────
   Room for a sentence under the label: what the option does, or what flipping it
   costs. One slot for the checkbox, the radio and the switch, so turning one into
   another keeps its markup. It takes the place of the label text, so the hint
   starts where the label starts with nothing to measure. */
.choice-text { display: flex; flex-direction: column; gap: var(--space-1); }
.choice-hint { font-size: var(--text-3); color: var(--muted); }

/* ── Colour ─────────────────────────────────────────────────────────────────
   `<input type="color" class="form-color">`. The native input IS the picker, so there
   is no picker logic here — only its chrome, which otherwise is a small system-styled
   box that sits badly beside a .form-input in every engine. It takes the shared control
   heights, the field border and the field focus ring; `.form-input-sm` and
   `.form-input-lg` size it like any other field.

   The swatch inside is the engine's own pseudo-element, which is why it is reached
   with the prefixed selectors: the wrapper's padding is what left the colour floating
   in a frame, and its border drew a second one. */
.form-color {
    inline-size: var(--control-height);
    block-size: var(--control-height);
    padding: var(--space-1);
    background: var(--bg);
    border: 1px solid var(--border);
    border-radius: var(--radius-control);
    cursor: pointer;
    transition: border-color var(--motion-fast), box-shadow var(--motion-fast);
}
.form-color.form-input-sm { inline-size: var(--control-height-sm); block-size: var(--control-height-sm); }
.form-color.form-input-lg { inline-size: var(--control-height-lg); block-size: var(--control-height-lg); }
.form-color:hover { border-color: var(--border-hover); }
.form-color:focus-visible { outline: none; border-color: var(--brand-soft); box-shadow: 0 0 0 2px var(--brand-glow); }
.form-color:disabled { cursor: not-allowed; opacity: 0.45; }
.form-color::-webkit-color-swatch-wrapper { padding: 0; }
.form-color::-webkit-color-swatch { border: none; border-radius: calc(var(--radius-control) - 2px); }
.form-color::-moz-color-swatch { border: none; border-radius: calc(var(--radius-control) - 2px); }

/* ── Swatches ───────────────────────────────────────────────────────────────
   A preset palette to pick from — a label's colour, a calendar group's — as a row of
   buttons. Each swatch carries its colour as `--swatch` in its own style attribute,
   because the colour is the app's data, and the chosen one is `aria-pressed="true"`:
   the attribute a screen reader announces, and a ring rather than a tint, so the
   choice is visible whatever the swatch's own colour is. Name each one with
   `aria-label` — a colour is not its own accessible name.

     <div class="swatch-grid" role="group" aria-label="Label colour">
       <button class="swatch" type="button" style="--swatch: var(--coral-500)" aria-label="Coral" aria-pressed="true"></button>
       …
     </div> */
.swatch-grid { display: flex; flex-wrap: wrap; gap: var(--space-3); }
.swatch {
    --swatch: transparent;
    inline-size: var(--control-height-sm);
    block-size: var(--control-height-sm);
    padding: 0;
    background: var(--swatch);
    border: 1px solid var(--border);
    border-radius: var(--radius-control);
    cursor: pointer;
}
.swatch:hover { border-color: var(--border-strong); }
.swatch:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--brand-glow); }
.swatch[aria-pressed="true"] { box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--fg); }
.swatch[aria-pressed="true"]:focus-visible { box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--brand-soft); }
}

/* ── 46-form-layout.css → @layer sedna.paint ──────────────────────────────────────────────── */
@layer sedna.paint {
/* ── Form layout and validation ──────────────────────────────────────────────
   How fields are grouped, affixed and closed off, and how a rejected value is
   shown. Numbered above 34 for the same reason as 45: these rules override
   .form-input's own border and ring at equal specificity.
   ─────────────────────────────────────────────────────────────────────────── */

/* ── Validation ─────────────────────────────────────────────────────────────
   Keyed off aria-invalid, not a class. The attribute is what a screen reader
   announces, so if the styling hung off a separate class the two could disagree,
   and the reader who most needs the message would be the one who does not get
   it. Set aria-invalid and the border follows.

   `.form-select` is in the selector for the same reason it is in the shared shape
   rule in 34-forms.css: a select carrying `.form-select` alone is a complete,
   correct class list, so a rule that names only `.form-input` leaves the one
   control a form most often rejects — an empty required dropdown — reporting
   nothing at all, while the screen reader announces it as invalid. */
.form-input[aria-invalid="true"],
.form-select[aria-invalid="true"] { border-color: var(--danger-border-strong); }
.form-input[aria-invalid="true"]:focus,
.form-select[aria-invalid="true"]:focus {
    border-color: var(--danger-solid);
    box-shadow: 0 0 0 2px var(--danger-ring);
}

/* The message itself. Put it after the control and point at it with
   aria-describedby, so it is read as part of the field rather than found by
   accident. */
.form-error,
.form-warning {
    display: flex;
    align-items: flex-start;
    gap: var(--space-2);
    margin: 0;
    font-size: var(--text-3);
}
.form-error   { color: var(--danger-fg); }
.form-warning { color: var(--warn-fg); }
.form-error i,
.form-warning i { font-size: var(--text-5); flex-shrink: 0; line-height: 1.35; }

/* A required field's label. The marker is decoration — put `required` on the
   control, which is what is actually announced and enforced, and aria-hidden on
   any marker you write in the markup instead. */
.form-label--required::after {
    content: '*';
    margin-inline-start: var(--space-2);
    color: var(--danger-fg);
}

/* ── Input group ────────────────────────────────────────────────────────────
   One border around a control and its affixes: a leading icon, a trailing unit,
   a button that acts on the value.

   The affixes are real siblings rather than padding plus a background trick, so a
   button stays clickable and a unit stays selectable. The ring moves out to the
   group with :focus-within and the inner control gives up its own border and
   ring — otherwise the group draws two of each, nested. */
/* The GROUP owns the height, and its children give theirs up. The group draws the
   border, so a child still holding min-height: var(--control-height) makes the group
   that height PLUS its own two border pixels. */
.input-group {
    display: flex;
    align-items: stretch;
    width: 100%;
    min-height: var(--control-height);
    background: var(--bg);
    border: 1px solid var(--border);
    border-radius: var(--radius-control);
    transition: border-color var(--motion-fast), box-shadow var(--motion-fast);
}
/* The group carries the tier, because it is the group that draws the border. Its
   children have already given up their own height.

   A min-height is a floor, not a cap, so the small tier also has to bring its
   children's block padding and text down — otherwise the field's own 6px + 18.2px line
   box + the group's two borders comes to 32px and the 28px tier never binds. */
.input-group--sm { min-height: var(--control-height-sm); }
.input-group--sm .form-input,
.input-group--sm .btn,
.input-group--sm .input-affix { padding-block: var(--space-1); font-size: var(--text-3); }
.input-group--lg { min-height: var(--control-height-lg); }
.input-group--lg .form-input,
.input-group--lg .btn,
.input-group--lg .input-affix { font-size: var(--text-5); }
.input-group:focus-within {
    border-color: var(--brand-soft);
    box-shadow: 0 0 0 2px var(--brand-glow);
}
/* `.form-select` is named alongside `.form-input` for the reason 34-forms.css gives
   for naming both in the shared shape rule: `.form-select` alone is a complete class
   list. Without it here, a select inside a group kept its own border and radius over
   the group's, and drew a second focus ring inside the group's — two frames and two
   rings around one control. */
.input-group .form-input,
.input-group .form-select {
    flex: 1;
    min-width: 0;              /* or a long value pushes the affixes out */
    min-height: 0;             /* the group holds the height — see above */
    border: none;
    border-radius: 0;
    background: none;
}
.input-group .form-input:focus,
.input-group .form-select:focus { box-shadow: none; }
/* --radius-inner, not --radius-control: the inner corner sits one border-width
   inside the outer one. */
.input-group > :first-child { border-start-start-radius: var(--radius-inner); border-end-start-radius: var(--radius-inner); }
.input-group > :last-child  { border-start-end-radius: var(--radius-inner);   border-end-end-radius: var(--radius-inner); }

/* A static affix — an icon, a unit, a currency symbol, a fixed prefix. */
.input-affix {
    display: flex;
    align-items: center;
    gap: var(--space-2);
    flex-shrink: 0;
    padding: 0 var(--space-5);
    color: var(--muted);
    font-size: var(--text-4);
    white-space: nowrap;
}
/* `line-height: 1`, as `.btn i` has: at the inherited line height the glyph's own line box
   is taller than the small tier's room, and a search icon made a 28px group 28.5px. */
.input-affix i { font-size: var(--text-6); line-height: 1; }
/* The rule goes between the affix and the control, so it is drawn on whichever
   side the control is on. */
.input-affix:first-child { border-inline-end: 1px solid var(--border); }
.input-affix:last-child  { border-inline-start: 1px solid var(--border); }

/* A .btn inside the group gives up its outer edge and keeps its ring inside, so
   focus on the button is still distinguishable from focus in the field. */
.input-group .btn {
    min-height: 0;             /* the group holds the height — see above */
    border: none;
    border-radius: 0;
    background: var(--surface-strong);
}
.input-group .btn:hover { background: var(--bg-hover); }
.input-group .btn:not(:first-child) { border-inline-start: 1px solid var(--border); }
.input-group .btn:not(:last-child)  { border-inline-end:   1px solid var(--border); }
.input-group .btn:focus-visible { box-shadow: inset 0 0 0 2px var(--brand-ring-soft); }

/* ── Clear button ───────────────────────────────────────────────────────────
   A button that empties the field beside it, drawn inside the group rather than as
   a segment of it — a search box's ✕, a filter's reset:

     <div class="input-group input-group--sm">
       <span class="input-affix"><i class="ri-search-line"></i></span>
       <input class="form-input" type="search" placeholder="Host" aria-label="Filter by host">
       <button class="input-clear" type="button" aria-label="Clear the host filter"><i class="ri-close-line"></i></button>
     </div>

   Shown only while there is something to clear, decided by `:placeholder-shown`, so
   the field needs a placeholder — without one the button simply stays visible, which
   is the harmless failure. The app still empties the value on click.

   The platform's own ✕ on `type="search"` sits in the same place and does not follow
   the theme, so a group holding this button hides it. */
.input-clear {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    flex-shrink: 0;
    align-self: center;
    width: 22px;
    height: 22px;
    margin-inline: var(--space-1);
    padding: 0;
    background: none;
    border: none;
    border-radius: var(--radius-small);
    color: var(--muted);
    font-size: var(--text-5);
    line-height: 1;
    cursor: pointer;
}
.input-clear:hover { background: var(--bg-hover); color: var(--fg); }
.input-clear:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--brand-ring-soft); }
.input-group:has(> .form-input:placeholder-shown) > .input-clear { display: none; }
.input-group:has(> .input-clear) > .form-input::-webkit-search-cancel-button { display: none; }

/* ── Trigger field ──────────────────────────────────────────────────────────
   A <button> in the shape of a field, for a value that is chosen in a panel and
   shown here as a summary — "2 selected" from a popover of checkboxes, "1–15 Sep"
   from a `.datepicker`. It is worn WITH `.form-input`, which gives it the border,
   the focus ring and the three size tiers, so it lines up with the text fields
   beside it at every size:

     <button class="form-input form-input-sm form-trigger" type="button" popovertarget="region-filter">
       <i class="ri-map-pin-line"></i><span>2 selected</span>
     </button>

   The chevron is drawn by CSS, and turns while the popover it opens is showing —
   read from `:popover-open` on the NEXT sibling, so put the popover straight after
   the button. The value truncates rather than pushing the chevron out. */
.form-trigger {
    display: flex;
    align-items: center;
    gap: var(--space-3);
    text-align: start;
    white-space: nowrap;
    cursor: pointer;
}
.form-trigger > i { flex-shrink: 0; color: var(--muted); font-size: var(--text-5); line-height: 1; }
.form-trigger > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.form-trigger::after {
    content: '';
    flex-shrink: 0;
    margin-inline-start: auto;
    width: 0;
    height: 0;
    border-inline: 4px solid transparent;
    border-top: 5px solid currentColor;
    opacity: 0.5;
    transition: transform var(--motion-fast);
}
.form-trigger:hover { border-color: var(--border-hover); }
.form-trigger:hover::after { opacity: 1; }
.form-trigger[aria-expanded="true"]::after,
.form-trigger:has(+ :popover-open)::after { transform: rotate(180deg); }

/* ── Form actions ───────────────────────────────────────────────────────────
   The row a form ends with. Right-aligned, because that is where the button that
   commits is looked for. Wraps instead of overflowing, so a three-button row on a
   phone stacks rather than pushing the primary action off-screen. */
.form-actions {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: flex-end;
    gap: var(--space-4);
    margin-top: var(--space-7);
}
/* Two groups: something destructive or tangential on the far side from the
   commit button, so they cannot be hit by accident. Expects exactly two
   children — wrap each side in a <div> if either has more than one button. */
.form-actions--split { justify-content: space-between; }
.form-actions--start { justify-content: flex-start; }

/* ── Form section ───────────────────────────────────────────────────────────
   A titled group of fields. Use <fieldset> + <legend>: the legend is then
   announced together with every control inside the group, which a heading above
   a <div> is not.

   The fieldset itself is left as a block and the fields go in .form-section-body.
   A <legend> is laid out specially and is not a flex or grid item, so making the
   fieldset the flex container puts the legend somewhere different in every
   engine. min-width: 0 undoes the fieldset's intrinsic minimum width, which
   otherwise refuses to shrink inside a flex or grid parent. */
.form-section {
    margin: 0 0 var(--space-8);
    padding: 0;
    border: none;
    min-width: 0;
}
.form-section > legend,
.form-section-title {
    width: 100%;
    padding: 0 0 var(--space-3);
    margin-bottom: var(--space-6);
    border-bottom: 1px solid var(--border);
    font-size: var(--text-4);
    font-weight: 600;
    color: var(--fg);
}
.form-section-hint {
    margin: -6px 0 var(--space-6);
    font-size: var(--text-3);
    color: var(--muted);
}
.form-section-body {
    display: flex;
    flex-direction: column;
    gap: var(--space-7);
}

/* ── Dependent options ──────────────────────────────────────────────────────
   Fields that only matter while the option above them is on: the recipients under
   "E-mail notifications", a threshold under "Close stale topics". Put the
   `.choice-dependent` straight after the `.form-check` or `.switch` it belongs to and
   it indents by that control's inset, so its content starts where the label does.
   A dependent inside a dependent indents again, measured from the edge it is already
   on.

   Only the block IMMEDIATELY after the control is indented. A hint between the two
   breaks the pairing and the block sits flush — put the hint in the control's own
   `.choice-hint` instead, where it lines up with the label anyway. */
.choice-dependent {
    display: flex;
    flex-direction: column;
    gap: var(--space-6);
    min-width: 0;
}
.form-check + .choice-dependent { margin-inline-start: var(--choice-inset-check); }
.switch + .choice-dependent     { margin-inline-start: var(--choice-inset-switch); }
/* `display: flex` above outranks the browser's own `[hidden]`. */
.choice-dependent[hidden] { display: none; }

/* Inactive while the option is off. Opt-in, because whether an unused setting
   vanishes, fades or stays put is the page's call. Both follow the control's live
   `:checked` state, so they need no round trip and work on a page with no
   interactivity at all; a radio is off while another in its set is chosen.

   `--dim` is also inert: a block that looks unavailable and still takes focus and
   typing is the worse of both. It is not `disabled` — the values still submit with
   the form. */
:is(.form-check, .switch):has(> input:not(:checked)) + .choice-dependent--hide { display: none; }
:is(.form-check, .switch):has(> input:not(:checked)) + .choice-dependent--dim {
    opacity: 0.45;
    interactivity: inert;
}
/* Opacity multiplies, so a faded block inside a faded one would drop to 0.2. The
   inner one keeps full opacity and is faded once, with its parent. */
:is(.form-check, .switch):has(> input:not(:checked)) + .choice-dependent--dim .choice-dependent--dim { opacity: 1; }

/* ── Transfer ───────────────────────────────────────────────────────────────
   Two list boxes with the buttons that move rows between them: pick on the left,
   the row appears on the right. For choosing a SET out of a long list, where a
   column of checkboxes would be unreadable and a `multiple` select alone gives no
   answer to "what have I chosen so far" — which is the whole reason this shape
   exists, and why the right-hand box is not decoration.

   Layout only. `sednaUi.transfer` does the moving, delegated from `document`, and
   nothing here depends on it: with scripting blocked the two lists are still two
   list boxes a form can post.

   The middle column is `auto`, so it is exactly as wide as its buttons and the two
   lists split what is left evenly. `align-self` on the actions rather than
   `align-items` on the grid: the fields have to stretch, or a label above one list
   pushes that list out of line with the other. */
.transfer {
    display: grid;
    grid-template-columns: 1fr auto 1fr;
    gap: var(--space-6);
}
.transfer-actions {
    align-self: center;
    display: flex;
    flex-direction: column;
    gap: var(--space-3);
}

/* Under the two-list width the buttons go between the lists as a row, and the
   arrows then point the wrong way — which is why the markup uses up/down glyphs at
   this width and not rotated chevrons: a rotation is a lie about what the button
   does. */
@media (max-width: 640px) {
    .transfer { grid-template-columns: 1fr; }
    .transfer-actions { flex-direction: row; justify-content: center; }
}
}

/* ── 47-form-file.css → @layer sedna.paint ──────────────────────────────────────────────── */
@layer sedna.paint {
/* ── File input, dropzone and file list ─────────────────────────────────────
   The native `<input type="file">` cannot be restyled: its button is a shadow-DOM
   part with only `::file-selector-button` exposed, and its "No file chosen" text is
   not addressable at all. So the input is hidden behind a label styled as a button,
   which is the one approach that keeps the real control — a <label> for a file input
   opens the picker on click and on Enter, with no JavaScript and no ARIA.

   `::file-selector-button` is styled too, for anyone who uses the input bare.
   ─────────────────────────────────────────────────────────────────────────── */
.form-file { display: inline-flex; align-items: center; gap: var(--space-5); }
/* Hidden, not display:none — the input must stay focusable and in the accessibility
   tree, or the label becomes a button that a keyboard cannot reach. */
.form-file input[type="file"] {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip-path: inset(50%);
    border: 0;
}
.form-file-name { color: var(--muted); font-size: var(--text-3); }
/* The focus ring has to be drawn on the label, because the focused element is the
   input the reader cannot see. */
.form-file:has(input:focus-visible) .btn { box-shadow: 0 0 0 2px var(--brand-ring-soft); }

/* Almost no block padding. A file input's content is the UA's own button, which is
   taller than a line of text, so `.form-input`'s padding on top of it pushes a bare
   file input past every other control in the row. The button supplies the height, and
   --control-height binds as it does everywhere else. */
/* `align-content` centres a BLOCK container's in-flow content, which is what this
   box is — the engine lays a file input out around its own button, so there is no
   flex line to centre and `align-items` reaches nothing. Without it the button's
   line box sits at the top of the 36px the control tier reserves and the whole
   control looks lifted: measurably 2px more space above the button than below. */
.form-input[type="file"] {
    padding-block: var(--space-1);
    align-content: center;
}

.form-input::file-selector-button {
    margin-inline-end: var(--space-5);
    padding: var(--space-2) var(--space-5);
    background: var(--bg-elevated);
    border: 1px solid var(--border);
    border-radius: var(--radius-inner);
    color: var(--fg);
    font-family: inherit;
    font-size: var(--text-3);
    cursor: pointer;
}
.form-input::file-selector-button:hover { background: var(--bg-hover); }

/* ── Dropzone ───────────────────────────────────────────────────────────────
   A drop target that is ALSO a click target. Drag and drop alone is unusable by
   keyboard and awkward on touch, so the zone wraps a real file input and the drag
   handling is an enhancement on top.

   .dropzone--over says "something is being dragged over me", for which there is no
   CSS pseudo-class. Add `data-dropzone` and Sedna.UI.js maintains it, and hands
   a dropped file to the zone's own <input type="file"> as a `change` event; an app
   that would rather own the drag handling leaves the attribute off and sets the
   class itself. */
.dropzone {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: var(--space-3);
    padding: var(--space-9);
    background: var(--surface-soft);
    border: 1px dashed var(--border-strong);
    border-radius: var(--radius-surface);
    color: var(--muted);
    font-size: var(--text-4);
    text-align: center;
    cursor: pointer;
    transition: background var(--motion-fast), border-color var(--motion-fast);
}
.dropzone i { font-size: var(--text-10); color: var(--border-hover); }
.dropzone:hover { border-color: var(--border-hover); }
.dropzone--over {
    background: var(--brand-tint);
    border-color: var(--brand-soft);
    border-style: solid;
    color: var(--brand-text);
}
.dropzone--over i { color: var(--brand-soft); }
/* :focus-within rather than :has(input:focus-visible): the zone IS the label, and
   focus-within says exactly what is meant with less machinery. */
.dropzone:focus-within { box-shadow: 0 0 0 2px var(--brand-ring-soft); }

/* ── File list ──────────────────────────────────────────────────────────────
   What was attached, and how to remove it. A real <ul>, so the count is announced.
   The size is monospace so a column of them lines up. */
.file-list { list-style: none; margin: var(--space-4) 0 0; padding: 0; display: flex; flex-direction: column; gap: var(--space-2); }
.file-item {
    display: flex;
    align-items: center;
    gap: var(--space-4);
    padding: var(--space-3) var(--space-4);
    background: var(--surface-soft);
    border: 1px solid var(--border);
    border-radius: var(--radius-inner);
    font-size: var(--text-3);
}
.file-item > i { font-size: var(--text-7); color: var(--muted); flex-shrink: 0; }
.file-item-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-item-size { color: var(--muted); font-family: var(--font-mono); flex-shrink: 0; }
/* Failed upload or a rejected type. The icon carries it too, so it is not colour
   alone. */
.file-item--error { border-color: var(--danger-border); color: var(--danger-fg); }
.file-item--error > i { color: var(--danger-fg); }

/* ── What happened to it ────────────────────────────────────────────────────
   An item that says more than its name: `.file-item-main` stacks the name over a
   `.file-meta` line or a `.progress` bar, `.file-item-status` is the glyph for how it
   went, and `.file-item-actions` holds its buttons — retry, cancel, remove, download.

     <li class="file-item">
       <span class="file-icon file-icon--sheet"><i class="ri-file-excel-2-line"></i></span>
       <span class="file-item-main">
         <span class="file-item-name">stock-check.xlsx</span>
         <span class="progress" role="progressbar" aria-valuenow="64" aria-label="Uploading stock-check.xlsx"><span class="progress-bar" style="width:64%"></span></span>
       </span>
       <span class="file-item-size">64%</span>
       <span class="file-item-actions"><button class="btn btn-ghost btn-icon btn-sm" type="button" aria-label="Cancel">…</button></span>
     </li>

   Uploading needs no modifier — the bar is the state. `--done` and `--error` colour
   the status glyph, and the glyph's shape says it without the colour. The same item
   is a file attached to a record, or to a chat message, as an `<a>` that downloads. */
.file-item-main {
    flex: 1;
    min-width: 0;
    display: flex;
    flex-direction: column;
    gap: var(--space-2);
}
.file-item-main > .file-item-name { flex: none; }
.file-item-main > .progress { height: 3px; }
.file-item-status { flex-shrink: 0; font-size: var(--text-6); color: var(--muted); }
.file-item--done .file-item-status { color: var(--go-fg); }
.file-item--error .file-item-status { color: var(--danger-fg); }
.file-item--error .file-meta { color: var(--danger-fg); }
.file-item-actions { display: flex; align-items: center; gap: var(--space-1); flex-shrink: 0; }
a.file-item { color: inherit; text-decoration: none; }
a.file-item:hover { border-color: var(--border-strong); text-decoration: none; }
a.file-item:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--brand-ring-soft); }
}

/* ── 48-form-numeric.css → @layer sedna.paint ──────────────────────────────────────────────── */
@layer sedna.paint {
/* ── Range, stepper and date/time controls ──────────────────────────────────
   The three native controls whose internals need naming per engine. Everything here
   is a restatement of the same design in different vendor pseudo-elements — the
   duplication is the platform's, not a choice.
   ─────────────────────────────────────────────────────────────────────────── */

/* ── Range ──────────────────────────────────────────────────────────────────
   `appearance: none` removes the whole native widget, track included, so the track
   and thumb are both drawn here. The two engines expose different pseudo-elements
   and CANNOT be combined into one selector list: an unknown pseudo-element
   invalidates the entire rule, so a shared `::-webkit-…, ::-moz-…` list would apply
   in neither browser. Hence two blocks that look copy-pasted.

   A range gives no readout. Put the value in text beside it — the thumb position is
   not a number, and it is unreadable to a screen reader on its own. */
.form-range {
    appearance: none;
    width: 100%;
    height: 18px;
    background: none;
    cursor: pointer;
}
.form-range::-webkit-slider-runnable-track {
    height: 4px;
    background: var(--progress-track);
    border-radius: var(--radius-pill);
}
.form-range::-webkit-slider-thumb {
    appearance: none;
    width: 14px;
    height: 14px;
    margin-top: -5px;              /* centres the thumb on the 4px track */
    background: var(--brand);
    border: none;
    border-radius: 50%;
}
.form-range::-moz-range-track {
    height: 4px;
    background: var(--progress-track);
    border-radius: var(--radius-pill);
}
.form-range::-moz-range-thumb {
    width: 14px;
    height: 14px;
    background: var(--brand);
    border: none;
    border-radius: 50%;
}
.form-range:focus-visible { outline: none; }
.form-range:focus-visible::-webkit-slider-thumb { box-shadow: 0 0 0 3px var(--brand-ring-check); }
.form-range:focus-visible::-moz-range-thumb { box-shadow: 0 0 0 3px var(--brand-ring-check); }
.form-range:disabled { opacity: 0.45; cursor: not-allowed; }

/* The value readout beside a range. Tabular and fixed-width, so the track does not
   shift as the number's width changes while dragging. */
.form-range-value {
    min-width: 4ch;
    color: var(--fg);
    font-size: var(--text-4);
    font-variant-numeric: tabular-nums;
    text-align: end;
}

/* ── Stepper ────────────────────────────────────────────────────────────────
   A number input with its own buttons, for a value adjusted by one at a time. The
   native spinner is tiny, inconsistent between engines, and invisible until hover in
   some — so it is removed and replaced with two real buttons, which are also a
   usable size on touch.

   Give each button an aria-label: "+" and "−" are announced as "plus" and "minus"
   without saying what of. */
.stepper { display: inline-flex; align-items: stretch; }
.stepper .form-input {
    width: 5ch;
    text-align: center;
    border-radius: 0;
    font-variant-numeric: tabular-nums;
    /* Hides the native spinner. Firefox needs the property, WebKit needs the
       pseudo-element; neither implies the other. */
    appearance: textfield;
}
.stepper .form-input::-webkit-outer-spin-button,
.stepper .form-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.stepper .btn { border-radius: 0; }
.stepper > :first-child { border-start-start-radius: var(--radius-control); border-end-start-radius: var(--radius-control); }
.stepper > :last-child  { border-start-end-radius: var(--radius-control);   border-end-end-radius: var(--radius-control); }
/* One shared border between neighbours rather than two adjacent ones. */
.stepper > * + * { margin-inline-start: -1px; }
/* The focused control has to paint above its neighbour, or half its ring is
   covered. 1 is local stacking, not a rung on the documented scale. */
.stepper .form-input:focus,
.stepper .btn:focus-visible { position: relative; z-index: 1; }

/* ── Date and time ──────────────────────────────────────────────────────────
   `.form-input` already covers the box. What needs naming is the calendar picker
   icon, which is a UA glyph in its own colour and is invisible on a dark surface
   until it is inverted. `color-scheme` (set on <html> from --color-scheme) is what
   makes the picker panel itself dark; this is only the trigger glyph. */
.form-input::-webkit-calendar-picker-indicator {
    cursor: pointer;
    opacity: 0.55;
    /* Not a colour: a filter on a UA glyph we cannot recolour. It flips with the
       theme through the token below rather than being hard-coded to one direction. */
    filter: invert(var(--picker-invert));
}
.form-input::-webkit-calendar-picker-indicator:hover { opacity: 1; }

/* iOS gives a date or time input an intrinsic width and centres its value, and
   both ignore the class: the field ran past its column and its text sat in the
   middle. `min-width: 0` lets it shrink to the column, `appearance: none` hands
   the box to the class, and the value pseudo-element is told to start where every
   other field's text does. The picker still opens on tap; only the box changes. */
.form-input:is([type="date"], [type="time"], [type="datetime-local"], [type="month"], [type="week"]) {
    min-width: 0;
    appearance: none;
}
.form-input::-webkit-date-and-time-value {
    text-align: start;
    min-height: 1.4em;
}
}

/* ── 49-form-combo.css → @layer sedna.paint ──────────────────────────────────────────────── */
@layer sedna.paint {
/* ── Combo field ─────────────────────────────────────────────────────────────
   A field you type into to pick something: a searchable single select, a
   multiselect whose picks are chips, a list the app searches on the server, and
   free entry that turns what is typed into chips.

   Not `.form-select`. A <select> cannot hold a text input or a chip, even with
   `appearance: base-select`, so this is a box of plain markup:

     .form-combo                      the field; carries the anchor scope
       .form-combo-box                the bordered box, shaped like .form-input
         .chip …                      each pick, for multi and free entry
         .form-combo-value            the current value, for a single select
         .form-combo-input            the text input — role="combobox"
         .form-combo-actions          clear, busy spinner, chevron
       .form-combo-panel              the drop-down
         .form-combo-head             optional count or "Clear all" line
         .form-combo-list             role="listbox"
           .form-combo-option         role="option", aria-selected
         .form-combo-empty            shown by CSS when no option is visible

   OPEN IS `aria-expanded="true"` ON THE INPUT, and nothing else. The attribute is
   what a screen reader announces, so it is also what shows the panel; a class
   beside it could disagree with it. 23-combo.js sets it. With scripting blocked the
   panel never opens and the box is still a labelled text input.

   Chips and options are the page's markup. In a Blazor app they render from the
   app's own state, which is the only way a pick that is not in the loaded page of
   results — a tag already on a note, say — can still be shown.
   ─────────────────────────────────────────────────────────────────────────── */
.form-combo {
    position: relative;
    display: block;
    width: 100%;
    anchor-scope: --sedna-combo;
}

.form-combo-box {
    position: relative;
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--space-2);
    min-height: var(--control-height);
    /* A single row of small-tier chips is centred by `align-items` in a box that is
       exactly one control tall, so a field with chips is as tall as one without. The
       block padding only matters once they wrap, where it keeps the rows off the
       border. */
    padding: var(--space-1) var(--space-2);
    background: var(--bg);
    border: 1px solid var(--border);
    border-radius: var(--radius-control);
    color: var(--fg);
    cursor: text;
    anchor-name: --sedna-combo;
    transition: border-color var(--motion-fast), box-shadow var(--motion-fast);
}
.form-combo-box:hover { border-color: var(--border-hover); }
.form-combo-box:focus-within {
    border-color: var(--brand-soft);
    box-shadow: 0 0 0 2px var(--brand-glow);
}
/* Invalid and disabled are the INPUT's attributes, which is where a screen reader
   reads them. The box follows. */
.form-combo-box:has(> .form-combo-input[aria-invalid="true"]) { border-color: var(--danger-border-strong); }
.form-combo-box:has(> .form-combo-input[aria-invalid="true"]):focus-within {
    box-shadow: 0 0 0 2px var(--danger-ring);
}
.form-combo-box:has(> .form-combo-input:disabled) {
    background: var(--surface-soft);
    color: var(--muted);
    cursor: not-allowed;
}

/* A leading icon — an envelope on an address field. */
.form-combo-icon {
    flex-shrink: 0;
    padding-inline-start: var(--space-3);
    color: var(--muted);
    font-size: var(--text-5);
}

.form-combo-input {
    flex: 1 1 8ch;
    min-width: 8ch;
    min-height: var(--control-height-sm);
    padding: 0 var(--space-3);
    background: none;
    border: 0;
    outline: none;
    color: var(--fg);
    font: inherit;
    font-size: var(--text-4);
}
.form-combo-input::placeholder { color: var(--border-strong); }
.form-combo-input:disabled { cursor: not-allowed; }

/* ── Single select: the value under the input ─────────────────────────────
   The chosen value and the input share one grid cell, and the input is on top
   with a transparent ground. A click on the value is a click on the input, with
   no script, and typing hides the value so the query reads on its own.

   `:placeholder-shown` is the only test for "nothing typed" a stylesheet has, so
   a single select's input needs a placeholder attribute — `placeholder=" "` will
   do. Without one the value is never shown. */
.form-combo-box:has(> .form-combo-value) {
    display: grid;
    grid-template-columns: minmax(0, 1fr) auto;
    align-items: center;
}
.form-combo-box > .form-combo-value,
.form-combo-box:has(> .form-combo-value) > .form-combo-input {
    grid-row: 1;
    grid-column: 1;
}
.form-combo-box:has(> .form-combo-value) > .form-combo-actions { grid-row: 1; grid-column: 2; }
.form-combo-value {
    overflow: hidden;
    padding: 0 var(--space-3);
    font-size: var(--text-4);
    text-overflow: ellipsis;
    white-space: nowrap;
    pointer-events: none;
}
.form-combo-value:not(:empty) ~ .form-combo-input::placeholder { color: transparent; }
.form-combo-box:has(> .form-combo-input:not(:placeholder-shown)) > .form-combo-value { visibility: hidden; }
.form-combo-box:has(> .form-combo-input[aria-expanded="true"]) > .form-combo-value { color: var(--muted); }

/* ── Actions ──────────────────────────────────────────────────────────────── */
.form-combo-actions {
    display: flex;
    align-items: center;
    gap: var(--space-1);
    margin-inline-start: auto;
    padding-inline-start: var(--space-2);
}
.form-combo-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: var(--control-height-sm);
    height: var(--control-height-sm);
    padding: 0;
    background: none;
    border: 0;
    border-radius: var(--radius-small);
    color: var(--muted);
    font-size: var(--text-6);
    line-height: 1;
    cursor: pointer;
}
.form-combo-btn:hover { background: var(--bg-hover); color: var(--fg); }
.form-combo-btn:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--brand-ring-soft); }
.form-combo-toggle i { transition: transform var(--motion-fast); }
.form-combo-box:has(> .form-combo-input[aria-expanded="true"]) .form-combo-toggle i { transform: rotate(180deg); }
/* Clear appears once there is something to clear. */
.form-combo-box:not(:has(> .chip, > .form-combo-value:not(:empty))) .form-combo-clear { display: none; }
.form-combo-box:has(> .form-combo-input:disabled) .form-combo-btn { display: none; }
/* A spinner in the actions, shown while the list is busy — the app's server
   search sets aria-busy on the list, and this follows it. */
.form-combo-busy { margin-inline: var(--space-3); }
.form-combo:not(:has(.form-combo-list[aria-busy="true"])) .form-combo-busy { display: none; }

/* The chip Backspace will remove next. 23-combo.js marks it on the first press
   and clicks its dismiss button on the second, so holding Backspace to fix a typo
   never eats the picks in front of it. */
.form-combo-box > .chip[data-armed] {
    border-color: var(--brand);
    box-shadow: 0 0 0 2px var(--brand-ring-soft);
    color: var(--fg);
}

/* ── Collapsed while not in use ───────────────────────────────────────────
   `.form-combo--collapse` keeps a long set of picks to one row until the field is
   focused: the first three chips, then a count of the rest. Focus it and every
   chip wraps back in, and 23-combo.js opens the list. The count is a CSS counter
   over the chips that are hidden, so nothing measures and nothing is written.

   The count is the BOX's ::after, moved back beside the last visible chip with
   `order`. It cannot be a pseudo-element of the third chip, where it would sit
   naturally: a counter is read in tree order, and there it would come before the
   chips it counts and always say +0. */
.form-combo--collapse:not(:focus-within) > .form-combo-box {
    flex-wrap: nowrap;
    overflow: hidden;
    counter-reset: sedna-combo-more;
}
.form-combo--collapse:not(:focus-within) > .form-combo-box > .chip { flex-shrink: 1; min-width: 0; }
.form-combo--collapse:not(:focus-within) > .form-combo-box > .form-combo-input { flex-basis: 0; }
.form-combo--collapse:not(:focus-within) > .form-combo-box > .chip:nth-child(n + 4 of .chip) {
    position: absolute;
    visibility: hidden;
    counter-increment: sedna-combo-more;
}
.form-combo--collapse:not(:focus-within) > .form-combo-box > .form-combo-input,
.form-combo--collapse:not(:focus-within) > .form-combo-box > .form-combo-actions { order: 2; }
.form-combo--collapse:not(:focus-within) > .form-combo-box:has(> .chip:nth-child(4 of .chip))::after {
    content: "+" counter(sedna-combo-more);
    order: 1;
    flex-shrink: 0;
    display: inline-flex;
    align-items: center;
    min-height: var(--control-height-sm);
    padding: 0 var(--space-4);
    border: 1px dashed var(--border-strong);
    border-radius: var(--radius-pill);
    color: var(--muted);
    font-size: var(--text-3);
    font-variant-numeric: tabular-nums;
    white-space: nowrap;
}

/* ── The panel ────────────────────────────────────────────────────────────
   Anchored to the box and as wide as it, on the dropdown rung of the z-index
   scale. Fixed rather than absolute for the reason `.menu` is: an absolutely
   positioned panel is clipped by, and scrolls, the nearest scroll container —
   a combo in a drawer or a scrolling form lost the bottom of its list.
   22-anchored.js closes it on a scroll that moves the box. */
.form-combo-panel {
    display: none;
    position: fixed;
    position-anchor: --sedna-combo;
    position-area: block-end span-inline-end;
    justify-self: start;
    align-self: start;
    width: anchor-size(width);
    min-width: 14rem;
    position-try-fallbacks: flip-block;
    position-visibility: anchors-visible;
    margin-block-start: var(--space-2);
    padding: var(--space-2);
    --surface-here: var(--bg-elevated);
    background: var(--bg-elevated);
    border: 1px solid var(--border);
    border-radius: var(--radius-surface);
    box-shadow: var(--shadow-dropdown);
    z-index: 550;
}
.form-combo:has(> .form-combo-box > .form-combo-input[aria-expanded="true"]) > .form-combo-panel { display: block; }

/* `.form-combo--inline` keeps the list open under the field, in the flow — for a
   picker that is the whole content of a drawer or a popover, where a drop-down
   over it would cover nothing but itself. */
.form-combo--inline > .form-combo-panel {
    display: block;
    position: static;
    /* The anchored panel's `start` alignment shrinks an in-flow block to its
       content; an inline list is as wide as its field. */
    justify-self: stretch;
    align-self: auto;
    width: auto;
    min-width: 0;
    box-shadow: none;
}

/* The list scrolls on its own, with the edge fade every scroller in the frame has
   (16-frame-page-scroll.css). Do not add `.sedna-scroll` to it: that utility is a
   later layer, and its `max-height: var(--scroll-max)` of `none` would let a long
   list run off the screen. */
.form-combo-list {
    display: flex;
    flex-direction: column;
    gap: 1px;
    max-height: 16rem;
    overflow-y: auto;
    overscroll-behavior: contain;
}
/* Busy keeps the rows that are there, dimmed, until the new ones land: a list
   that empties on every keystroke flickers, and reads as "no results" between. */
.form-combo-list[aria-busy="true"] > .form-combo-option { opacity: 0.5; }

.form-combo-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-4);
    padding: var(--space-2) var(--space-4) var(--space-3);
    margin-block-end: var(--space-2);
    border-bottom: 1px solid var(--border);
    color: var(--muted);
    font-size: var(--text-2);
    font-variant-numeric: tabular-nums;
}
.form-combo-head button {
    padding: 0;
    background: none;
    border: 0;
    color: var(--brand-text);
    font: inherit;
    cursor: pointer;
}
.form-combo-head button:hover { text-decoration: underline; }

/* ── Options ──────────────────────────────────────────────────────────────── */
.form-combo-option {
    display: flex;
    align-items: center;
    gap: var(--space-4);
    min-height: calc(var(--control-height-sm) + var(--space-2));
    padding: var(--space-2) var(--space-4);
    border-radius: var(--radius-inner);
    color: var(--fg);
    font-size: var(--text-4);
    line-height: 1.35;
    cursor: pointer;
    user-select: none;
}
/* A filtered row. `display: flex` above outranks the browser's own `[hidden]`
   rule, which is a user-agent style, so the attribute alone left every row on
   screen. */
.form-combo-option[hidden],
.form-combo-group[hidden] { display: none; }
/* Where the keyboard is. The pointer lands on the same tint, because the two never
   disagree for long enough to matter. `data-active` rather than a class: the script
   sets it on markup Blazor renders, and Blazor rewrites a class attribute whole. */
.form-combo-option:hover,
.form-combo-option[data-active] { background: var(--bg-hover); }
.form-combo-option[aria-disabled="true"] { color: var(--muted); cursor: default; }
.form-combo-option[aria-disabled="true"]:hover { background: none; }

/* The marker is drawn, so an option's markup is its text and nothing else. A
   single select ticks the chosen row; a multiselectable list draws a checkbox. */
.form-combo-option { position: relative; }
.form-combo-option::before {
    content: "";
    flex-shrink: 0;
    width: 15px;
    height: 15px;
}
.form-combo-option[aria-selected="true"]::after,
[aria-multiselectable="true"] > .form-combo-option[aria-selected="true"]::after {
    content: "";
    position: absolute;
    /* The tick sits over the ::before slot. Offset from the inline start, so it
       follows the slot in RTL. */
    inset-inline-start: calc(var(--space-4) + 5px);
    top: 50%;
    width: 5px;
    height: 9px;
    border-inline-end: 2px solid var(--brand-text);
    border-bottom: 2px solid var(--brand-text);
    transform: translateY(-60%) rotate(45deg);
}
/* The same box `.form-check` draws, so a multiselect reads as a list of checkboxes. */
[aria-multiselectable="true"] > .form-combo-option::before {
    background: var(--bg);
    border: 1px solid var(--border-strong);
    border-radius: var(--radius-small);
}
[aria-multiselectable="true"] > .form-combo-option[aria-selected="true"]::before {
    background: var(--brand);
    border-color: var(--brand);
}
[aria-multiselectable="true"] > .form-combo-option[aria-selected="true"]::after {
    width: 4px;
    height: 8px;
    border-color: var(--on-solid);
}

.form-combo-option-text { overflow: hidden; min-width: 0; text-overflow: ellipsis; white-space: nowrap; }
.form-combo-option-meta {
    flex-shrink: 0;
    margin-inline-start: auto;
    color: var(--muted);
    font-family: var(--font-mono);
    font-size: var(--text-2);
    white-space: nowrap;
}
.form-combo-group {
    padding: var(--space-4) var(--space-4) var(--space-2);
    color: var(--muted);
    font-size: var(--text-1);
    font-weight: 600;
    letter-spacing: 0.06em;
    text-transform: uppercase;
}

/* What the query matched, painted through the CSS Custom Highlight API — the
   script registers ranges and never touches the text nodes, which Blazor holds
   references to. */
::highlight(sedna-combo-match) {
    background-color: var(--brand-tint-strong);
    color: var(--fg);
}

/* ── States inside the panel ──────────────────────────────────────────────── */
.form-combo-empty,
.form-combo-status {
    display: flex;
    align-items: center;
    gap: var(--space-4);
    padding: var(--space-5) var(--space-4);
    color: var(--muted);
    font-size: var(--text-3);
}
.form-combo-empty i,
.form-combo-status i { font-size: var(--text-6); }
/* Shown whenever no option is visible and nothing is loading — after filtering,
   and for a server result that came back empty — so neither the script nor the
   app has to toggle it. */
.form-combo-empty { display: none; }
.form-combo-panel:not(:has(.form-combo-option:not([hidden]))):not(:has([aria-busy="true"])) > .form-combo-empty { display: flex; }
.form-combo-status--error { color: var(--danger-fg); }
.form-combo-status .btn { margin-inline-start: auto; }

/* A row the height of an option, for the first page of a server list. */
.form-combo-skeleton {
    display: flex;
    align-items: center;
    gap: var(--space-4);
    min-height: calc(var(--control-height-sm) + var(--space-2));
    padding: var(--space-2) var(--space-4);
}
.form-combo-skeleton > .skeleton { margin: 0; }

/* The next page of a server list, straight after `.form-combo-list` — not inside
   it, because a listbox may hold only options. A real button, so a pointer or a
   keyboard can ask for it; the script also clicks it once the list is scrolled to
   its end. */
.form-combo-more {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: var(--space-4);
    width: 100%;
    min-height: calc(var(--control-height-sm) + var(--space-2));
    padding: var(--space-2) var(--space-4);
    background: none;
    border: 0;
    border-radius: var(--radius-inner);
    color: var(--muted);
    font: inherit;
    font-size: var(--text-2);
    cursor: pointer;
}
.form-combo-more:hover { background: var(--bg-hover); color: var(--fg); }
}

/* ── 49-form-select-base.css → @layer sedna.paint ──────────────────────────────────────────────── */
@layer sedna.paint {
/* ── The customizable select ────────────────────────────────────────────────
   `appearance: base-select` takes the whole control out of the platform's hands:
   the closed box, the drop-down and every row in it become real CSS boxes in this
   document, so the option list finally answers to the theme like everything else
   the library paints. Before it, `--color-scheme` was the only thing that reached
   an option list at all, and it could say no more than "dark".

   Opting in is `.form-select`'s own doing, not a modifier. An app that already
   writes `<select class="form-select">` gets the themed drop-down by upgrading,
   which is the point — a class nobody knows to add would leave every existing
   select looking like the operating system.

   ── What the markup may now be ──
   Nothing has to change. A bare `<select class="form-select">` keeps working and
   the browser draws the closed box for it.

   Where the options need more than a word, they may hold real markup, and the
   closed box shows the chosen one through `<selectedcontent>`:

       <select class="form-select">
         <button><selectedcontent></selectedcontent></button>
         <option value="prod">
           <i class="ri-server-line" aria-hidden="true"></i>
           <span class="option-text">Production<span class="option-hint">eu-west-1</span></span>
         </option>
       </select>

   The leading `<button>` is what makes `<selectedcontent>` available; without one
   the browser supplies its own and the closed box shows the option's text — every
   text node in it, run together, so an `.option-hint` lands there unseparated.

   **`<selectedcontent>` needs the script.** Blazor's render does not trigger the
   copy the browser is supposed to make, so the closed box comes out blank with
   nothing in the console. `20-select.js` repairs it, on load and after every
   render. Nothing in THIS file depends on that: with scripting blocked a select
   still has its theme, its drop-down and its caret, and only the copy is missing.

   ── Why almost none of this is inside @supports ──
   `::picker(select)`, `::picker-icon` and `::checkmark` are unknown selectors to a
   browser that cannot do base appearance, and an unknown pseudo-element drops the
   whole rule. So those rules gate themselves, and only the two `appearance`
   declarations — which are valid property/value pairs either way — need asking.
   Keeping the transitions outside the block matters for a second reason: an
   `@supports` body is opaque to the reduced-motion guard, so motion declared in
   one could ship with no off switch.

   The fallback caret lives in 45-form-controls.css behind `@supports not`, so
   nothing here has to undo it.
   ─────────────────────────────────────────────────────────────────────────── */

/* Both halves, or neither. A picker only takes base appearance when its select
   already has it, so opting the select in alone gets a themed box above a
   platform-drawn list — which is worse than not opting in at all. */
@supports (appearance: base-select) {
    .form-select { appearance: base-select; }
    .form-select::picker(select) { appearance: base-select; }
}

/* ── The closed box ─────────────────────────────────────────────────────────
   The field shape is already right: height, surface, border, radius, type and the
   focus ring all come from the `.form-input, .form-select` rule in 34-forms.css,
   and this control is the same field it has always been. What base appearance
   changes is what is INSIDE the box — the select becomes a flex container holding
   the value and the caret, so it needs the layout the platform used to do.

   `white-space: nowrap` with `overflow: hidden` is not a nicety. The base
   appearance box wraps by default, so one long option label makes a 36px field
   50px tall and every control in the row stops lining up. It is set on the select
   rather than on `<selectedcontent>` because a select with no `<button>` of its
   own has no `<selectedcontent>` to reach — the browser's is out of the document.

   `padding-inline-end` goes back to the leading side's value: the caret is a real
   child here, so the 32px the fallback reserves for a painted one would be 32px of
   empty space beyond it. */
@supports (appearance: base-select) {
    .form-select {
        align-items: center;
        gap: var(--space-4);
        padding-inline-end: var(--space-5);
        white-space: nowrap;
        overflow: hidden;
        text-align: start;
    }

    /* The tiers give the trailing side back too, or a small select keeps the
       normal one's inline-end padding and comes out lopsided. */
    .form-select-sm { padding-inline-end: var(--space-4); }
    .form-select-lg { padding-inline-end: var(--space-7); }
}

/* The value, when the markup supplies its own button. A clone of the chosen
   option, so it is laid out the same way an option is — and the min-inline-size
   is what lets a long one shrink instead of pushing the caret out of the box. */
.form-select selectedcontent {
    display: flex;
    align-items: center;
    gap: var(--space-4);
    min-inline-size: 0;
    overflow: hidden;
}

/* A hint is an aside about an option, and the closed box is one line tall. The
   clone drops it rather than being allowed to grow. */
.form-select selectedcontent .option-hint { display: none; }

/* The base-appearance select lays the label and the hint out itself, so the
   separator between them is noise here and goes. It exists for the fallback
   below, where an <option> is one text node and the two would otherwise run
   together — "Frankfurteu-central-1" — in the list and in the closed box alike. */
@supports (appearance: base-select) {
    .form-select .option-sep { display: none; }
}

/* ── The caret ──────────────────────────────────────────────────────────────
   The same arrow the fallback paints, drawn once as a box instead of twice as a
   background: two 5px triangles side by side. `margin-inline-start: auto` is what
   holds it at the trailing edge — the flex layout is logical, so this is the whole
   of the RTL story for the base-appearance select.

   It turns over when the list is open, which the painted caret could never do. */
.form-select::picker-icon {
    content: '';
    width: 10px;
    height: 5px;
    flex-shrink: 0;
    margin-inline-start: auto;
    background-image:
        linear-gradient(45deg,  transparent 50%, var(--muted) 50%),
        linear-gradient(135deg, var(--muted) 50%, transparent 50%);
    /* The two halves of one 10px glyph, measured inside the caret's own box. The
       box itself is placed logically by the auto margin above, so there is nothing
       to mirror and 70-rtl.css has no rule for this. */
    background-position: 0 0, 5px 0;
    background-size: 5px 5px;
    background-repeat: no-repeat;
    transition: rotate var(--motion-fast);
}
.form-select:open::picker-icon { rotate: 180deg; }

/* The box says it is open, the same way it says it is focused. */
.form-select:open { border-color: var(--brand-soft); }

/* ── The drop-down ──────────────────────────────────────────────────────────
   Deliberately the `.menu` panel, to the token: an app that opens a menu from one
   toolbar button and a select from the next must not get two different panels.
   58-menu.css owns that look; this is the second place it appears and there is not
   going to be a third.

   It IS positioned here, and it did not used to be. Left to the UA the picker opens
   above the select as soon as the select passes the middle of the viewport, whether
   or not there was room below it — so a select two thirds down a page covered itself
   with its own list while 300px sat empty underneath. That is a reasonable default
   for a platform list and wrong for a panel the page draws.

   `position-area: block-end span-inline-end` is the row below the select, starting
   at its inline-start edge — the same construction as `.menu`, logical on both axes,
   which is why 70-rtl.css still has no rule for this. The picker is in the top layer
   with an implicit anchor on its own select, so it needs neither an anchor-name in
   the markup nor a z-index off the scale.

   ── `position-try-fallbacks: none`, and it has to be said out loud ──
   The UA declares its own list — `self-start span-self-end, self-end
   span-self-start, self-start span-self-start` — whose FIRST entry is above the
   select. Declaring `position-area` alone does not remove it, so the panel still
   flipped: that list is the midpoint behaviour, and `none` is what suppresses it.

   `flip-block` is not the answer either, although it is what `.menu` and
   `.popover` use and what this appears to want. On a `::picker(select)` it flips
   UNCONDITIONALLY — measured opening upwards with 314px free below a panel 80px
   tall, at every `max-height` from 100px through `none`, on a freshly loaded page.
   The UA's `max-height: stretch` is the likely cause, a stretched box being exactly
   the size of its position area so that "does it overflow" has no answer, but
   `max-height: none` flips too, so that is not the whole story. Re-test it when
   Chromium changes.

   Nothing is clipped by having no fallback, because `max-height: stretch` stays:
   the panel is capped to the room below and scrolls inside itself. The trade is a
   short scrolling panel for a select very near the viewport's bottom edge, which is
   the lesser fault — it is still under its select, and still shows every option.

   `--surface-here` is the colour a scrollbar inside the panel paints itself
   against — a long option list scrolls, and 16-frame-page-scroll.css reads this. */
.form-select::picker(select) {
    position-area: block-end span-inline-end;
    position-try-fallbacks: none;
    padding: var(--space-2);
    --surface-here: var(--bg-elevated);
    background: var(--bg-elevated);
    border: 1px solid var(--border);
    border-radius: var(--radius-surface);
    box-shadow: var(--shadow-dropdown);
    color: var(--fg);
    font-size: var(--text-4);
    /* One edge, because there is only one placement now. `margin-block` on both was
       the workaround for the UA's fallback list, which flipped the panel above the
       select without carrying the margin with it and left it sitting flush. */
    margin-block-start: var(--space-2);
    opacity: 0;
    /* `display` and `overlay` are discrete, and without allow-discrete the panel
       is simply gone before the fade has anywhere to run. */
    transition:
        opacity var(--motion-fast),
        display var(--motion-fast) allow-discrete,
        overlay var(--motion-fast) allow-discrete;
}
.form-select:open::picker(select) { opacity: 1; }
@starting-style {
    .form-select:open::picker(select) { opacity: 0; }
}

/* ── A row in it ────────────────────────────────────────────────────────────
   `.menu-item`'s box, for the reason above. An option is a real element, so it
   takes hover and disabled from the platform and needs no ARIA and no script. */
.form-select option {
    display: flex;
    align-items: center;
    gap: var(--space-4);
    padding: var(--space-4) var(--space-5);
    border-radius: var(--radius-inner);
    color: var(--fg);
    font-size: var(--text-4);
    cursor: pointer;
}
/* A hairline of panel between rows, so the rounded hover and selected fills read as
   separate rows rather than as one block.

   A margin between adjacent rows, and NOT `display: flex` with a `gap` on the picker,
   which is the obvious way to write it: forcing a display on `::picker(select)`
   CRASHES the renderer in Chromium 141 — the whole tab, not the control. Fixed by
   152, but 141 is a version consuming apps are still on. The margin needs no display
   change and reaches the rows inside an `<optgroup>` for free, which the picker's own
   gap would not have done anyway. */
.form-select option + option { margin-block-start: var(--space-1); }

/* :focus is the option the keyboard is on, which is a different thing from the one
   already chosen — arrowing down a list moves it without selecting anything. */
.form-select option:hover,
.form-select option:focus { background: var(--bg-hover); }
/* Chosen is a TINT and a colour, not a colour alone. Brand text on the field's own
   background is the shape of an error rather than of a selection, it carries nothing
   at all for a reader going by lightness, and in a list box — where several rows are
   chosen at once — there was no way to see the set of them. Same construction as
   `.nav-link.active` and `.palette-item[aria-selected]`, so the three read alike. */
.form-select option:checked { background: var(--brand-tint); color: var(--brand-text); }
/* Pointing at a chosen row deepens it instead of replacing the tint with the neutral
   hover, which erased the very thing being pointed at. */
.form-select option:checked:hover,
.form-select option:checked:focus { background: var(--brand-tint-strong); }
.form-select option:disabled { color: var(--muted); cursor: not-allowed; }
/* The clone in the closed box is not inside the <option> any more, so it has to be
   named again or the same icon comes out two sizes in the two places it appears. */
.form-select option i,
.form-select selectedcontent i { font-size: var(--text-7); color: var(--muted); flex-shrink: 0; }

/* The tick on the chosen row. Moved to the trailing edge rather than left where
   the browser puts it: a leading tick exists only on the chosen option, so every
   other label in the list is indented past nothing and the column of text is
   ragged. At the end it lines up with `.menu-item-note` instead.

   The glyph is the browser's own — it is not in the accessibility tree, which is
   correct, because `option:checked` already carries that meaning. */
.form-select option::checkmark {
    order: 2;
    margin-inline-start: auto;
    color: var(--brand-text);
}

/* ── Rich option content ────────────────────────────────────────────────────
   Two classes, and both are scoped to `.form-select` so an app that already owns
   a `.option-hint` somewhere else keeps it.

   `.option-hint` reads its own position: a direct child of the option is a
   trailing note, like a shortcut beside a menu item; inside `.option-text` it is a
   second line under the label. One name, because it is one idea — the quiet half
   of the row — and the markup already says which shape it is in. */
.form-select .option-text {
    display: flex;
    flex-direction: column;
    gap: var(--space-1);
    min-inline-size: 0;
    overflow: hidden;
}
.form-select .option-hint {
    color: var(--muted);
    font-size: var(--text-3);
}
/* The trailing form. It takes the free space, so the tick that would otherwise
   have taken it gets a fixed gap instead — two `auto` margins on one line split
   the space between them and leave the note stranded in the middle. */
.form-select option > .option-hint { order: 1; margin-inline-start: auto; }
.form-select option:has(> .option-hint)::checkmark { margin-inline-start: var(--space-5); }

/* ── Groups and separators ──────────────────────────────────────────────────
   Use a `<legend>` for a group heading, not the `label` attribute on its own. The
   attribute's heading is drawn by the browser outside this document and cannot be
   themed — the two lines below would do nothing to it. Keep the attribute as well:
   it is what a browser without base appearance shows.

       <optgroup label="Regions"><legend>Regions</legend> … </optgroup>

   `<hr>` between groups is a separator the picker renders like any other box. */
.form-select optgroup > legend {
    padding: var(--space-4) var(--space-5) var(--space-2);
    font-size: var(--text-1);
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.08em;
    color: var(--muted);
}
.form-select hr {
    margin: var(--space-2) 0;
    border: 0;
    border-block-start: 1px solid var(--divider);
}

/* ── The list box ───────────────────────────────────────────────────────────
   `multiple`, or `size` above 1. Base appearance reaches this one too, and it is
   the simpler half: the rows are already styled by the rules above, there is no
   picker to place and no caret to draw. What it needs is `border-box` — base
   appearance hands a list box `content-box`, so a width set on it comes out wider
   than everything beside it — and the row arithmetic below. */
@supports (appearance: base-select) {
    .form-select[multiple],
    .form-select[size]:not([size="1"]) {
        box-sizing: border-box;
        --surface-here: var(--bg);
        white-space: normal;
        overflow: auto;
        /* ── The box is measured here, not left to `size` ──
           A list box the browser sizes is exactly `size` times its own LINE-HEIGHT
           tall — border and padding included, because everything here is
           `border-box` — and it decides that before it knows anything about the rows
           inside. So any block padding came straight out of the rows: `size="4"` drew
           two and a half of the four rows it had asked for, above a scrollbar it
           should not have had, and the panel therefore had to keep none. Which is
           what left every list box's first row flush against its own top border,
           alone among the panels in this library.

           `attr()` is what breaks that: the height is declared as the row budget PLUS
           the padding, so the padding no longer competes with the rows. The row is
           still built to fit — `2.4`, which lands it on the height a `.menu-item` has
           — and the arithmetic below is exact rather than approximate.

           `max(2, …)` is a floor, not a preference: `size="0"` is legal to write and
           would otherwise compute a box with no height at all. The fallback `4` is for
           `multiple` with no `size`, which is the number the browser uses too.

           A browser without typed `attr()` drops the declaration and gets `auto`,
           which is the browser-sized box above — the rows lose the padding again and
           the box scrolls one row early. Nothing else changes, and the floor is
           current Chromium, which has had it since 133. */
        block-size: calc(max(2, attr(size type(<number>), 4)) * 2.4em + var(--space-2) * 2);
        /* Both axes now, so a row's rounded hover and `:checked` fill sit at the same
           inset from the box's border as a `::picker(select)` row does from the
           panel's. */
        padding: var(--space-2);
        /* Only the fallback path reads this — `block-size` above has already decided
           the height. It is the same 2.4, so the two cannot disagree. */
        line-height: 2.4;
    }
    /* Two pixels per row out of the budget, for the gap below it. The box is `size`
       rows plus the padding, so what has to come out of the rows is exactly the gaps
       between them — and there is one fewer of those than there are rows, which is
       what pays for the box's own 2px border. `size` rows in a `size` box therefore
       fit exactly, with no scrollbar and no sliver of panel under the last one. */
    .form-select[multiple] option,
    .form-select[size]:not([size="1"]) option {
        padding-block: 0;
        line-height: calc(2.4em - 2px);
    }
    /* The same gap a `::picker(select)` row gets, and for the same reason now that a
       chosen row is filled: without it two chosen rows next to each other merged
       into one tinted block. It has to be paid for out of the `size` budget, which
       is what the three pixels above are — this is the one measurement in the file
       where the row height and the gap are a single sum. */
    .form-select[multiple] option + option,
    .form-select[size]:not([size="1"]) option + option {
        margin-block-start: var(--space-1);
    }

    /* A row carrying `.option-text` is two lines and cannot live inside the
       single-line budget above — squeezed into it, the second line was clipped by
       the row below. It gets its block padding back and measures itself.

       The cost is that `size` stops being the number of rows on show: the box is
       still `size` times the box's own line-height, and these rows are taller than
       that. Which is the honest outcome — a list of two-line rows has no single row
       height to divide by — and the box scrolls, as it already does for a list
       longer than `size`. Set `size` to about half the rows you want visible. */
    .form-select[multiple] option:has(.option-text),
    .form-select[size]:not([size="1"]) option:has(.option-text) {
        padding-block: var(--space-3);
        line-height: 1.4;
    }
}
}

/* ── 50-tabs.css → @layer sedna.paint ──────────────────────────────────────────────── */
@layer sedna.paint {
/* ── Tabs ───────────────────────────────────────────────────────────────────
   Switching which view of one thing is shown, where the tabs belong to the page.
   Not navigation: a tab that changes the URL is a link wearing a control's
   clothes, and the sidebar is where going somewhere belongs.

   Tabs vs .segmented: tabs swap a region of *content*, a segmented control changes
   a *setting*. If the thing below it is a panel, use tabs.

   The classes do not imply the ARIA and cannot. Write it:
     .tabs        role="tablist"
     .tab         role="tab" aria-selected id="…" aria-controls="panel-id"
     .tab-panel   role="tabpanel" id="panel-id" aria-labelledby="tab-id"
   Without it a screen reader hears a row of unrelated buttons, and arrow-key
   movement between tabs does not exist.
   ─────────────────────────────────────────────────────────────────────────── */
.tabs {
    display: flex;
    align-items: stretch;
    gap: var(--space-1);
    border-bottom: 1px solid var(--border);
    /* Overflow scrolls rather than wraps. A wrapped second row pushes the content
       down and the underline stops reading as one continuous strip. The scrollbar
       is hidden because the tabs themselves are the affordance. */
    overflow-x: auto;
    scrollbar-width: none;
}
.tabs::-webkit-scrollbar { display: none; }

.tab {
    flex-shrink: 0;
    display: inline-flex;
    align-items: center;
    gap: var(--space-3);
    padding: var(--space-4) var(--space-7);
    background: none;
    border: none;
    /* The indicator is a transparent border that gains a colour, not a border that
       appears — otherwise the label jumps 2px when it is selected. */
    border-bottom: 2px solid transparent;
    margin-bottom: -1px;              /* sit the indicator on the container's line */
    color: var(--muted);
    font-family: inherit;
    font-size: var(--text-4);
    font-weight: 500;
    white-space: nowrap;
    cursor: pointer;
    text-decoration: none;
    transition: color var(--motion-fast), border-color var(--motion-fast), background var(--motion-fast);
}
.tab i { font-size: var(--text-6); }
.tab:hover { color: var(--fg); background: var(--surface-soft); text-decoration: none; }
.tab:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--brand-ring-soft); }
.tab:disabled,
.tab[aria-disabled="true"] { opacity: 0.4; cursor: not-allowed; }

.tab--active,
.tab[aria-selected="true"] { color: var(--brand-text); border-bottom-color: var(--brand); }
.tab--active:hover,
.tab[aria-selected="true"]:hover { color: var(--brand-text); background: none; }

/* A count beside the label — how many rows the tab's panel holds. */
.tab-count {
    padding: 0 var(--space-3);
    background: var(--badge-bg);
    border-radius: var(--radius-pill);
    color: var(--fg-soft);
    font-size: var(--text-2);
    font-weight: 600;
}
.tab--active .tab-count,
.tab[aria-selected="true"] .tab-count { background: var(--brand-tint); color: var(--brand-text); }

.tab-panel { padding-top: var(--space-7); }

/* A strip inside a card head is `.card-head--tabs`, declared beside `.card-head` in
   30-cards.css because it is that head's modifier. It drops this padding, the strip's
   own bottom border and the head's block padding, so the indicator meets the body
   edge. Do not write a second answer here. */
}

/* ── 51-segmented.css → @layer sedna.paint ──────────────────────────────────────────────── */
@layer sedna.paint {
/* ── Segmented control ──────────────────────────────────────────────────────
   Two to four mutually exclusive options, all of them visible, applied the moment
   one is chosen. Prefer it over a <select> when the options are few and short
   enough to read at a glance — the cost of a select is one click before you learn
   what the choices are.

   Built from radios, so it is keyboard- and screen-reader-correct with no ARIA at
   all: arrow keys move between options, the group is announced as a group, and the
   chosen one is announced as chosen. Give every input the same `name`, and wrap the
   control in a <fieldset> or point at a label with aria-labelledby so the group has
   a name.

   The input is a transparent overlay rather than display:none, because display:none
   takes it out of the tab order and out of the accessibility tree — which would leave
   a control that looks operable and is not.

   The chosen state is `:has(input:checked)` on the label, so the whole label — which
   is also the whole hit area — is what gets painted, and the markup stays a label
   wrapping an input and its text.

   When measuring this state from a test, switch transitions off first: everything here
   has a `transition`, and getComputedStyle immediately after the change returns the
   value at t=0, which is the one being transitioned away from.
   ─────────────────────────────────────────────────────────────────────────── */
/* The TRACK owns the height, the way `.input-group` does, and for the same reason: it
   is the track that draws the border, so an option carrying its own min-height would
   make the track that height PLUS its two borders and its own padding. The options
   stretch to fill the tier — `align-items` is stated rather than left to the flex
   default because it is load-bearing here.

   --control-height is the token the button, the text input, the select and the search
   box take, and it is what makes a segmented control in a toolbar the same height as
   the field and the button beside it. Without it the track measured 32px next to a
   36px field, and 35px when its options carried icons — so two segmented controls in
   one bar did not even agree with each other. */
.segmented {
    display: inline-flex;
    align-items: stretch;
    min-height: var(--control-height);
    padding: var(--space-1);
    background: var(--surface-strong);
    border: 1px solid var(--border);
    border-radius: var(--radius-control);
}

/* line-height pins the option's box, so an option with an icon in it is the same
   height as one without. The icon takes `line-height: 1` for the reason `.btn i`
   does: an icon nested in a <span> beside the label shares a line box with it, and at
   the inherited 1.5 its taller box is what set the option's height. */
.segmented-option {
    position: relative;
    display: inline-flex;
    align-items: center;
    gap: var(--space-2);
    padding: var(--space-2) var(--space-6);
    border-radius: var(--radius-inner);
    color: var(--muted);
    font-size: var(--text-3);
    font-weight: 500;
    line-height: 1.4;
    white-space: nowrap;
    cursor: pointer;
    user-select: none;
    transition: background var(--motion-fast), color var(--motion-fast);
}
.segmented-option i { font-size: var(--text-5); line-height: 1; }
.segmented-option input {
    position: absolute;
    inset: 0;
    margin: 0;
    appearance: none;
    opacity: 0;
    cursor: pointer;
}
/* The disabled option is excluded from the hover state, and the input overlay gives up
   its pointer cursor. The overlay is the element actually under the pointer, so its
   cursor wins over the label's `not-allowed` — without both rules a disabled option
   shows a hand and lights up on hover while refusing to be chosen. */
.segmented-option:hover:not(:has(input:disabled)) { color: var(--fg); }
.segmented-option input:disabled { cursor: not-allowed; }

/* Flat, not raised: a shadow inside a 2px track reads as a rendering artefact at
   this size. The elevated surface against the inset track is enough. */
.segmented-option:has(input:checked) { background: var(--bg-elevated); color: var(--fg); }
.segmented-option:has(input:focus-visible) { box-shadow: 0 0 0 2px var(--brand-ring-soft); }
.segmented-option:has(input:disabled) { opacity: 0.45; cursor: not-allowed; }

/* The same three tiers as the button and the text input, off the same tokens, so a
   toolbar row agrees at every size.

   min-height is a floor, so a tier also has to bring the option's block padding and
   text UNDER it — exactly what `.input-group--sm` does. At the base padding an option
   is 25px, which clears the 28px tier's 22px of room and would leave the small track
   measuring 31px. */
.segmented--sm { min-height: var(--control-height-sm); }
.segmented--sm .segmented-option   { padding-block: var(--space-1); font-size: var(--text-2); }
.segmented--sm .segmented-option i { font-size: var(--text-4); }
.segmented--lg { min-height: var(--control-height-lg); }
.segmented--lg .segmented-option   { padding-block: var(--space-4); font-size: var(--text-4); }
.segmented--lg .segmented-option i { font-size: var(--text-6); }
}

/* ── 52-avatar.css → @layer sedna.paint ──────────────────────────────────────────────── */
@layer sedna.paint {
/* ── Avatar ─────────────────────────────────────────────────────────────────
   A person or a system actor, in page content. The header's own avatar is
   `.user-avatar` and stays part of the frame; this is the one for tables, lists and
   cards.

   Takes an <img>, initials, or a Remix Icon — in that order of preference, and the
   icon is the honest fallback rather than a generated colour block, which invents a
   distinction that is not in the data.

   An avatar is never the only carrier of a name. Put the name in text beside it, or
   in an <img alt>, or in a .visually-hidden span: initials are not a name, and a
   hover title is not reachable by keyboard or touch.
   ─────────────────────────────────────────────────────────────────────────── */
.avatar {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    flex-shrink: 0;
    /* One source for the diameter, because the group's overlap and its cut-out are
       both derived from it. A size variant sets these two numbers and the geometry
       below follows.

       --avatar-cut-x and --avatar-cut-y are the centre of the hole the group masks
       out: the next avatar's centre, measured from this one's own box. Past the
       right edge in LTR; 70-rtl.css mirrors it, because a radial-gradient position is
       physical and margin-inline-start is not. */
    --avatar-size: 28px;
    --avatar-overlap: 8px;
    --avatar-gap: 2px;
    --avatar-cut-x: calc(100% + var(--avatar-size) / 2 - var(--avatar-overlap));
    --avatar-cut-y: 50%;
    width: var(--avatar-size);
    height: var(--avatar-size);
    border-radius: 50%;
    overflow: hidden;
    background: var(--brand-tint);
    color: var(--brand-text);
    font-size: var(--text-3);
    font-weight: 600;
    line-height: 1;
    text-transform: uppercase;
    user-select: none;
}
.avatar img { width: 100%; height: 100%; object-fit: cover; }
.avatar i { font-size: var(--text-6); }

.avatar-sm { --avatar-size: 22px; --avatar-overlap: 6px; font-size: var(--text-1); }
/* An even size in the 22px circle: an odd one sits on a half pixel and the glyph
   rounds off-centre. */
.avatar-sm i { font-size: var(--text-5); }
.avatar-lg { --avatar-size: 40px; --avatar-overlap: 11px; font-size: var(--text-6); }
.avatar-lg i { font-size: var(--text-9); }

/* Neutral, for an actor with no brand meaning — a system account, an unknown
   sender. Keeps the brand tint for people, so a page of rows does not read as
   uniformly branded. */
.avatar--muted { background: var(--surface-strong); color: var(--muted); }
/* One person one colour, from the series tokens, so four people in a thread are
   four hues and none of them is a semantic one. The initials take `--on-viz`, the
   ink measured against the series fills in each theme: the brand text on one was
   unreadable, and white fails on the dark theme's light steps. */
/* The first series colour is the brand, whose measured ink is white (4.55:1);
   the dark ink lands under the floor on it. */
.avatar--viz-1 { background: var(--viz-1); color: var(--on-solid); }
.avatar--viz-2 { background: var(--viz-2); color: var(--on-viz); }
.avatar--viz-3 { background: var(--viz-3); color: var(--on-viz); }
.avatar--viz-4 { background: var(--viz-4); color: var(--on-viz); }
.avatar--viz-5 { background: var(--viz-5); color: var(--on-viz); }
.avatar--viz-6 { background: var(--viz-6); color: var(--on-viz); }

/* ── Ring ───────────────────────────────────────────────────────────────────
   A ring around an avatar, standing a gap off it, for a state of the person rather
   than of their availability:

     .avatar--ring        selected, or the one speaking — the brand colour
     .avatar--ring-warn   an administrator is acting as this person

   An outline, not a box-shadow. The gap between the avatar and the ring is then
   transparent — the surface shows through it on a card, on the page, in a toast and
   over a selected row — where a shadow would have to paint the gap in a guessed
   surface colour. An outline follows the border radius, so the ring is concentric at
   every size, and forced colours still paint it, which they do not do for a shadow.

   It also works on the topbar's `.user-avatar`, which is how the header says whose
   session this is while somebody else is driving it. The ring is never the only
   carrier: say "Acting as" in text beside it.

   A ring on an avatar inside an `.avatar-group` is clipped by the group's cut-out,
   which is drawn for a plain avatar; ring a single avatar or an `.avatar-pair`. */
.avatar--ring,
.avatar--ring-warn {
    --avatar-ring: var(--brand);
    --avatar-ring-width: 2px;
    --avatar-ring-offset: 2px;
    outline: var(--avatar-ring-width) solid var(--avatar-ring);
    outline-offset: var(--avatar-ring-offset);
}
.avatar--ring-warn { --avatar-ring: var(--warn-solid); }

/* ── Presence ───────────────────────────────────────────────────────────────
   Whether the person can be reached, as a mark on the avatar's corner. A wrapper
   rather than a pseudo-element on the avatar itself, which clips to its circle:

     <span class="avatar-presence avatar-presence--online" role="img" aria-label="Alex Fischer, online">
       <span class="avatar">AF</span>
     </span>

     --online          a disc with a tick                go
     --away            a disc with clock hands           warn
     --busy            a plain disc                      danger
     --do-not-disturb  a disc with a bar                 danger
     --meeting         a disc with a hole                danger   in a meeting or a call
     --presenting      a disc with a play mark           danger
     --out-of-office   a disc with an arrow back         --presence-out-of-office
     --offline         a ring                            muted
     --unknown         a broken ring                     muted

   EVERY STATE IS A SHAPE FIRST and a colour second. Several share the danger
   colour, the colour-blind palette moves go and danger closer, and forced colours
   collapse all of them to one ink — so the colour is a second channel and the shape
   is the one that has to work alone. `AvatarPresenceTests` renders every state in forced
   colours and fails when two of them draw the same pixels.

   The marks are knocked out of the disc in `--presence-knockout`, the surface the
   avatar sits on (`--surface-here`, the page otherwise), which is also the colour of
   the separating ring around the dot — so a tick reads as a hole through to the card,
   not as a white stroke drawn on it. All of it is gradients on the one pseudo-element:
   the icon font cannot be used here, because `font-family` always rides a token.

   The size is even, so a centred stroke lands on whole pixels, and it steps up with a
   large avatar, where a fixed dot was a speck. The dot's colours are the content, so
   it opts out of forced colours (71-forced-colors.css) and the tokens it reads are
   remapped there instead.

   `--label` puts the same mark in front of text, for a user widget, a list row or a
   people picker, where the state is written out:

     <span class="avatar-presence avatar-presence--label avatar-presence--away">Away · back at 14:00</span> */
.avatar-presence {
    --presence-size: 10px;
    --presence-stroke: 2px;
    --presence-knockout: var(--surface-here, var(--bg));
    position: relative;
    display: inline-flex;
    flex-shrink: 0;
}
.avatar-presence:has(> .avatar-lg) { --presence-size: 12px; }
.avatar-presence::after {
    content: '';
    position: absolute;
    inset-block-end: 0;
    inset-inline-end: 0;
    flex-shrink: 0;
    width: var(--presence-size);
    height: var(--presence-size);
    border-radius: 50%;
    background: var(--muted);
    box-shadow: 0 0 0 2px var(--presence-knockout);
}

/* The glyphs. Each layer is a small positioned gradient with `no-repeat`, measured in
   fractions of the dot, so it scales with `--presence-size`. The layers are
   physical on purpose: a tick, a clock and a play mark do not mirror in RTL. */
.avatar-presence--online::after {
    background:
        linear-gradient(45deg, transparent calc(50% - var(--presence-stroke) / 2), var(--presence-knockout) 0 calc(50% + var(--presence-stroke) / 2), transparent 0)
            calc(var(--presence-size) * 0.2) calc(var(--presence-size) * 0.42) / calc(var(--presence-size) * 0.3) calc(var(--presence-size) * 0.3) no-repeat,
        linear-gradient(-45deg, transparent calc(50% - var(--presence-stroke) / 2), var(--presence-knockout) 0 calc(50% + var(--presence-stroke) / 2), transparent 0)
            calc(var(--presence-size) * 0.38) calc(var(--presence-size) * 0.24) / calc(var(--presence-size) * 0.44) calc(var(--presence-size) * 0.44) no-repeat,
        var(--go-solid);
}
.avatar-presence--away::after {
    background:
        linear-gradient(var(--presence-knockout), var(--presence-knockout))
            calc(var(--presence-size) / 2 - var(--presence-stroke) / 2) calc(var(--presence-size) * 0.2)
            / var(--presence-stroke) calc(var(--presence-size) * 0.3 + var(--presence-stroke) / 2) no-repeat,
        linear-gradient(var(--presence-knockout), var(--presence-knockout))
            calc(var(--presence-size) / 2 - var(--presence-stroke) / 2) calc(var(--presence-size) / 2 - var(--presence-stroke) / 2)
            / calc(var(--presence-size) * 0.28 + var(--presence-stroke) / 2) var(--presence-stroke) no-repeat,
        var(--warn-solid);
}
.avatar-presence--busy::after { background: var(--danger-solid); }
.avatar-presence--do-not-disturb::after {
    background:
        linear-gradient(var(--presence-knockout), var(--presence-knockout))
            center / calc(var(--presence-size) * 0.6) var(--presence-stroke) no-repeat,
        var(--danger-solid);
}
.avatar-presence--meeting::after {
    background:
        radial-gradient(circle calc(var(--presence-size) * 0.2), var(--presence-knockout) 100%, transparent 100%),
        var(--danger-solid);
}
/* A 60° wedge from a point on the right, cut off at the left: a play mark whose
   centroid sits on the dot's centre. */
.avatar-presence--presenting::after {
    background:
        conic-gradient(from 240deg at 100% 50%, var(--presence-knockout) 0 60deg, transparent 0)
            calc(var(--presence-size) * 0.34) calc(var(--presence-size) * 0.27)
            / calc(var(--presence-size) * 0.42) calc(var(--presence-size) * 0.46) no-repeat,
        var(--danger-solid);
}
/* An arrowhead — a right-angled wedge opening to the right — and a shaft behind it. */
.avatar-presence--out-of-office::after {
    background:
        conic-gradient(from 45deg at 0 50%, var(--presence-knockout) 0 90deg, transparent 0)
            calc(var(--presence-size) * 0.18) calc(var(--presence-size) * 0.2)
            / calc(var(--presence-size) * 0.3) calc(var(--presence-size) * 0.6) no-repeat,
        linear-gradient(var(--presence-knockout), var(--presence-knockout))
            calc(var(--presence-size) * 0.4) calc(var(--presence-size) / 2 - var(--presence-stroke) / 2)
            / calc(var(--presence-size) * 0.42) var(--presence-stroke) no-repeat,
        var(--presence-out-of-office);
}
.avatar-presence--offline::after {
    background: var(--bg-elevated);
    box-shadow: 0 0 0 2px var(--presence-knockout), inset 0 0 0 var(--presence-stroke) var(--muted);
}
/* The ring of --offline, broken into dashes: not known to be away, just not known. */
.avatar-presence--unknown::after {
    background:
        radial-gradient(circle closest-side, var(--bg-elevated) calc(100% - var(--presence-stroke)), transparent 0),
        repeating-conic-gradient(var(--muted) 0 36deg, var(--bg-elevated) 0 60deg);
}

/* The mark in front of text. The pseudo-element is a flex item, so `order` puts it
   first in either direction; the surface ring is dropped, there being no avatar to
   separate it from. */
.avatar-presence--label {
    align-items: center;
    gap: var(--space-3);
    vertical-align: middle;
}
.avatar-presence--label::after {
    position: static;
    order: -1;
    box-shadow: none;
}
.avatar-presence--label.avatar-presence--offline::after { box-shadow: inset 0 0 0 var(--presence-stroke) var(--muted); }

/* ── Avatar group ───────────────────────────────────────────────────────────
   Overlapping avatars for "these several people".

   The separating gap is a hole cut out of the avatar BEHIND, not a ring painted on the
   one in front. A ring has to be drawn in the colour of whatever is underneath, and a
   shared component cannot know what that is — `box-shadow: 0 0 0 2px var(--card-bg)`
   is right inside a card and a grey halo on the page background, in a toast and over a
   selected table row. Masking takes colour out of the problem, so the group is correct
   on every surface and in every theme.

   Paint order is plain source order: each avatar overlaps the one before it, the LAST
   one sits on top, and the counter goes last and unoverlapped. Do not reverse it with
   a negative z-index step or with `flex-direction: row-reverse` — the second fixes the
   painting and breaks the reading order. */
/* The default is declared on the group as well as on .avatar, so the counter — which
   is not an .avatar and inherits from here — resolves it too. */
.avatar-group { display: inline-flex; align-items: center; --avatar-overlap: 8px; --avatar-gap: 2px; }
.avatar-group > * + * { margin-inline-start: calc(var(--avatar-overlap) * -1); }

/* :has(+ .avatar), not :not(:last-child) — the last thing in a group is usually the
   .avatar-more counter, which overlaps nothing, so the avatar before it must not be
   cut either. Only an avatar with an avatar after it is.

   The same rule cuts the back avatar of an `.avatar-pair` below, which only moves the
   centre of the hole: one mechanism, so the gap is the same width in both. */
.avatar-group > .avatar:has(+ .avatar),
.avatar-pair > .avatar:first-child:not(:last-child) {
    mask-image: radial-gradient(
        circle calc(var(--avatar-size) / 2 + var(--avatar-gap))
        at var(--avatar-cut-x) var(--avatar-cut-y),
        transparent 100%, currentColor 100%);
}

/* The overflow counter. Not an .avatar: it is a number, not an actor, and giving it
   the brand tint would make it look like one more person. */
/* The gap is the group's OWN rhythm, not a step off the spacing scale. The counter is
   a child of .avatar-group, so it already carries the negative overlap margin from the
   rule above — and `margin-inline-start: var(--space-2)` overrode it at identical
   specificity, later in the file. That left the ink 12px past the last avatar's edge
   where its neighbours sit 2px apart, so the number read as separate content rather
   than as the tail of the stack. 2px + 4px puts it 6px out.

   --avatar-gap has to be on .avatar-group for this to resolve at all: it is declared
   on .avatar, and the counter is deliberately not one. That is the same reason
   --avatar-overlap is declared in both places, and the same blind spot as the shadow
   tokens — TokenDeclarationTests sees a declared name, not whether it is in scope
   HERE, so an out-of-scope var() is guaranteed-invalid and the margin quietly
   computes to 0 with nothing failing. */
.avatar-more {
    display: inline-flex;
    align-items: center;
    margin-inline-start: var(--avatar-gap);
    padding-inline: var(--space-2) var(--space-4);
    color: var(--muted);
    font-size: var(--text-3);
}

/* ── Avatar pair ────────────────────────────────────────────────────────────
   Two people in one avatar's square, stacked corner to corner: a conversation, a
   hand-over, a shared owner. An `.avatar-group` is as wide as its count, so in a list
   a group's name starts further in than a person's and the column of names is
   crooked; a pair keeps the leading column one avatar wide whoever is in it. Chat's
   conversation list is the case it was made for.

     <span class="avatar-pair" role="img" aria-label="Priya Nair and Tomás Ferreira">
       <span class="avatar avatar--viz-2" aria-hidden="true">PN</span>
       <span class="avatar avatar--viz-4" aria-hidden="true">TF</span>
     </span>

   The size class goes on the pair — `.avatar-pair.avatar-lg` is a pair the size of a
   large avatar — and the two inside take a step down from it, rounded to an even
   pixel so the initials centre. The avatar in front is cut out of the one behind by
   the group's own rule above, with the hole moved to the front one's centre.

   Both sit in one grid cell, so the pair is sized by its content and needs no
   positioning; the front one is pushed to the far corner by its margins, which are
   logical and so mirror in RTL. The hole is a radial-gradient position and does not,
   which is why 70-rtl.css restates --avatar-cut-x for it. */
.avatar-pair {
    --avatar-pair-size: var(--avatar-size, 28px);
    display: inline-grid;
    flex-shrink: 0;
    width: var(--avatar-pair-size);
    height: var(--avatar-pair-size);
    /* Round, so a ring on the pair is a circle and a presence mark sits on its curve
       like on any avatar. Nothing inside is clipped by it. */
    border-radius: 50%;
    vertical-align: middle;
}
.avatar-pair > .avatar {
    --avatar-size: round(down, calc(var(--avatar-pair-size) * 0.72), 2px);
    grid-area: 1 / 1;
    font-size: var(--text-1);
}
.avatar-pair > .avatar i { font-size: var(--text-3); }
.avatar-pair.avatar-lg > .avatar { font-size: var(--text-3); }
.avatar-pair.avatar-lg > .avatar i { font-size: var(--text-6); }
.avatar-pair > .avatar:first-child:not(:last-child) {
    --avatar-cut-x: calc(var(--avatar-pair-size) - var(--avatar-size) / 2);
    --avatar-cut-y: calc(var(--avatar-pair-size) - var(--avatar-size) / 2);
}
.avatar-pair > .avatar:last-child:not(:first-child) {
    margin-block-start: calc(var(--avatar-pair-size) - var(--avatar-size));
    margin-inline-start: calc(var(--avatar-pair-size) - var(--avatar-size));
}
/* The two avatars reach past the pair's inscribed circle at their outer corners, so a
   ring on the pair stands off by that overshoot as well as its own gap: the gap is
   then measured from the avatars, as it is on a single one. */
.avatar-pair:is(.avatar--ring, .avatar--ring-warn) {
    --avatar-ring-offset: calc(2px
        + (var(--avatar-pair-size) - round(down, calc(var(--avatar-pair-size) * 0.72), 2px)) / 2 * 1.4142
        + round(down, calc(var(--avatar-pair-size) * 0.72), 2px) / 2
        - var(--avatar-pair-size) / 2);
}
}

/* ── 53-chip.css → @layer sedna.paint ──────────────────────────────────────────────── */
@layer sedna.paint {
/* ── Chip ───────────────────────────────────────────────────────────────────
   A chip is a *thing*: an applied filter, a selected recipient, a label attached to
   a record. A .badge is a *state*: read-only, describing what something is.

   The test is whether it can be removed. If the user put it there and can take it
   away, it is a chip. If it reflects data, it is a badge. Getting this the wrong way
   round produces a page of pills where nobody can tell which ones are clickable.

   `.chip` rather than `.tag`: a plain name is a claim on a shared namespace, and
   `.tag` is commonly already taken in a consuming app.
   ─────────────────────────────────────────────────────────────────────────── */
/* A chip is interactive, so it takes the control heights rather than a type scale.
   The default is the SMALL tier: a chip's home is a filter bar beside a `.btn-sm`,
   and that is the pairing it has to line up with. `.chip-lg` is the normal tier, for
   a chip beside a full-size button. */
.chip {
    display: inline-flex;
    align-items: center;
    gap: var(--space-3);
    max-width: 100%;
    min-height: var(--control-height-sm);
    padding: var(--space-1) var(--space-2) var(--space-1) var(--space-5);
    background: var(--surface-strong);
    border: 1px solid var(--border);
    border-radius: var(--radius-pill);
    color: var(--fg-soft);
    font-size: var(--text-3);
    line-height: 1.6;
}
.chip-lg { min-height: var(--control-height); font-size: var(--text-4); padding-inline-start: var(--space-6); }
.chip-lg .chip-dismiss { width: 22px; height: 22px; }
/* A chip with no dismiss button does not need the trailing room for one. */
.chip:not(:has(.chip-dismiss)) { padding-inline-end: var(--space-5); }
/* Child, not descendant. `.chip i` weighs the same as `.avatar i` in 52-avatar.css
   and is the later file, so an icon avatar inside a chip silently took the chip's
   14px and --muted instead of its own 15px — and .chip--active then recoloured it. */
.chip > i { font-size: var(--text-5); color: var(--muted); }
.chip-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* A 22px .avatar-sm in a 28px chip leaves EXACTLY 3px of clearance — the chip's
   min-height is the small control tier, its block padding is 2px and its border 1px,
   and 22 + 4 + 2 is 28 with nothing over. 3 CSS px on a 1.5x display is 4.5 device
   pixels, which cannot split evenly, so the top and the bottom differ by a device
   pixel and which one is larger flips with the scroll offset. That is the "bottom is
   more, it feels like" — it is real and it is not a measurable asymmetry.

   20px makes the clearance 4px, which does split. Scoped off .chip-lg, whose 36px box
   already has 7px and needs nothing. */
.chip:not(.chip-lg) > .avatar { --avatar-size: 20px; }

/* A status dot inside a chip, for a filter whose value carries a state. Colour it
   with a semantic token from the markup, or it says nothing. */
.chip-dot {
    width: 7px;
    height: 7px;
    border-radius: 50%;
    flex-shrink: 0;
    background: var(--border-hover);
}

/* The remove button. A real <button> with an accessible name — "×" alone is
   announced as "times" and gives no clue what it removes, so write
   aria-label="Remove <the value>". */
.chip-dismiss {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    flex-shrink: 0;
    width: 18px;
    height: 18px;
    padding: 0;
    background: none;
    border: none;
    border-radius: 50%;
    color: var(--muted);
    font-size: var(--text-5);
    line-height: 1;
    cursor: pointer;
}
.chip-dismiss:hover { background: var(--bg-hover); color: var(--fg); }
.chip-dismiss:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--brand-ring-soft); }

/* Chosen — a filter that is on, rather than one offered. */
.chip--active {
    background: var(--brand-tint);
    border-color: var(--brand-soft);
    color: var(--brand-text);
}
.chip--active i,
.chip--active .chip-dismiss { color: var(--brand-text); }

/* Not accepted — an address that is not one, a value the app rejected. Kept as a
   chip rather than thrown away, so the reader can see the typo and fix it. Pair it
   with a .form-error that names the value: colour alone says nothing to a reader
   going by lightness. */
.chip--invalid {
    background: var(--danger-bg);
    border-color: var(--danger-border);
    color: var(--danger-fg);
}
.chip--invalid > i,
.chip--invalid .chip-dismiss { color: var(--danger-fg); }

/* A chip that is a <button>: a filter offered and toggled in place, such as a log
   level. `aria-pressed` carries the state for a screen reader and `.chip--active` the
   look, so the two are set together. A button does not inherit the page's font. */
button.chip { font-family: inherit; cursor: pointer; }
button.chip:hover { border-color: var(--border-hover); }
button.chip:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--brand-ring-soft); }

/* A whole row of them, wrapping. */
.chip-set { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-3); }
}

/* ── 54-data-viz.css → @layer sedna.paint ──────────────────────────────────────────────── */
@layer sedna.paint {
/* ── Data visualisation ──────────────────────────────────────────────────────
   The small charts a page of figures is made of: a list of bars to compare a
   handful of values, one bar split into parts, a value against a scale with the
   thresholds marked, and a sparkline under a number. Each is markup and classes;
   the one number only the app knows is an inline custom property.

   ── Colour is one family, for every chart ──
   Every filled part paints `currentColor`, so which colour it is comes from ONE set
   of modifiers, `.series-*`, that any of them takes: a bar, a segment, a meter fill,
   a sparkline, a legend swatch. `.series-1` … `.series-6` are the categorical run —
   the brand first, then hues ordered so neighbours differ in both hue and lightness
   — for parts that are merely different from each other. `.series-go`, `--warn`,
   `--danger`, `--info` and `--muted` are the semantic families, for a part that
   MEANS something. Never colour a category with a semantic family: a red slice says
   "danger" whether or not the data does.

   The brand is the default — every fill starts at `--viz-1`, which is the brand —
   so a chart with one series needs no modifier at all.

   ── Nothing here is a chart library ──
   A line with more than a few dozen points, an axis, a tooltip on hover, a legend
   that toggles — those are a chart component's job, and the app's. These are the
   shapes that appear beside a number in a card, and they read without JavaScript.
   ─────────────────────────────────────────────────────────────────────────── */

/* ── Bar list ───────────────────────────────────────────────────────────────
   Rows of label, bar and value, for comparing a handful of things — carriers by
   volume, regions by open orders. The bar is `--value`, a percentage, on the fill:

     <div class="bar-list">
       <div class="bar-list-row">
         <span class="bar-list-label">EU-Central</span>
         <span class="bar-list-track"><span class="bar-list-fill" style="--value: 62%"></span></span>
         <span class="bar-list-value">1 240</span>
       </div>
     </div>

   The label column is `--bar-list-label`, 9rem by default, set on the list where the
   names are longer. The value is what the reader takes away, so it is text and not
   only a bar; the bar is for the comparison down the column.

   The columns belong to the list and each row is a subgrid of them, so the widest
   value sets the value column for every row. Sized row by row, `1 240` took its width
   out of its own track, and the longest bar was drawn shorter than a shorter one. */
.bar-list {
    --bar-list-label: 9rem;
    display: grid;
    grid-template-columns: minmax(0, var(--bar-list-label)) 1fr auto;
    gap: var(--space-4) var(--space-5);
}
.bar-list-row {
    display: grid;
    grid-column: span 3;   /* the list's three columns: label, track, value */
    grid-template-columns: subgrid;
    align-items: center;
}
.bar-list-label {
    font-size: var(--text-3);
    color: var(--fg-soft);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}
.bar-list-track {
    display: block;
    height: 10px;
    background: var(--progress-track);
    border-radius: var(--radius-pill);
    overflow: hidden;
}
.bar-list-fill {
    --value: 0%;   /* the app's one number, set inline */
    display: block;
    height: 100%;
    width: var(--value);
    background: currentColor;
    color: var(--viz-1);
    border-radius: inherit;
    transition: width var(--motion-slow) ease;
}
.bar-list-value {
    font-size: var(--text-3);
    color: var(--muted);
    font-variant-numeric: tabular-nums;
    min-width: 3ch;
    text-align: end;
}
/* A row the reader is on, or the one the figure above is about. */
.bar-list-row[aria-current="true"] .bar-list-label,
.bar-list-row[aria-current="true"] .bar-list-value { color: var(--fg); font-weight: 600; }

/* ── Stack bar ──────────────────────────────────────────────────────────────
   One bar in parts that add up — the states an order book is in, the share each
   carrier has. Each segment is `--value`, its share as a percentage; the parts
   should sum to 100% and the track shows through where they do not, which is the
   honest picture of a total that is not fully accounted for.

     <div class="stack-bar" role="img" aria-label="Orders by state: 62% decided, …">
       <span class="stack-bar-segment series-go" style="--value: 62%"></span>
       <span class="stack-bar-segment series-warn" style="--value: 28%"></span>
       <span class="stack-bar-segment series-muted" style="--value: 10%"></span>
     </div>

   The bar is one picture, so it takes `role="img"` and a label that says the
   numbers; the legend under it is the same numbers for the eye. */
.stack-bar {
    display: flex;
    gap: var(--space-1);
    height: 10px;
    background: var(--progress-track);
    border-radius: var(--radius-pill);
    overflow: hidden;
}
.stack-bar--lg { height: 18px; }
.stack-bar-segment {
    --value: 0%;
    flex: 0 0 var(--value);
    min-width: 2px;
    background: currentColor;
    color: var(--viz-1);
    transition: flex-basis var(--motion-slow) ease;
}
.stack-bar-legend {
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-3) var(--space-7);
    margin-top: var(--space-4);
    font-size: var(--text-3);
    color: var(--muted);
}
.stack-bar-key {
    display: inline-flex;
    align-items: center;
    gap: var(--space-3);
    font-variant-numeric: tabular-nums;
}
.stack-bar-swatch {
    width: 10px;
    height: 10px;
    flex-shrink: 0;
    border-radius: var(--radius-inner);
    background: currentColor;
    color: var(--viz-1);
}

/* ── Meter ──────────────────────────────────────────────────────────────────
   A value against a scale with the thresholds marked — disk at 71% with the alert
   line at 80%, a queue against its cap. It differs from `.progress` in what the
   marks say: progress ends at 100% and is done, a meter has a line the reader does
   not want the fill to cross.

     <div class="meter meter--labelled">
       <span class="meter-fill series-warn" style="--value: 71%"></span>
       <span class="meter-mark" style="--at: 80%"><span class="meter-mark-label">alert 80%</span></span>
     </div>

   The fill's colour is the app's judgement of the value, through a series
   modifier; the marks are neutral. `--labelled` reserves the line under the track
   for the mark labels, and `.meter-scale` is the pair of end values under that.

   Two marks close together would print their labels over each other, so
   `.meter-mark--above` moves one label over the track, and `--labelled` reserves that
   line as well once a mark asks for it. */
.meter {
    position: relative;
    height: 10px;
    background: var(--progress-track);
    border-radius: var(--radius-pill);
}
.meter--lg { height: 18px; }
.meter--labelled { margin-bottom: calc(var(--text-2) + var(--space-5)); }
.meter--labelled:has(.meter-mark--above) { margin-top: calc(var(--text-2) + var(--space-5)); }
.meter-fill {
    --value: 0%;
    display: block;
    height: 100%;
    width: var(--value);
    background: currentColor;
    color: var(--viz-1);
    border-radius: inherit;
    transition: width var(--motion-slow) ease;
}
.meter-mark {
    --at: 0%;
    position: absolute;
    top: -3px;
    bottom: -3px;
    inset-inline-start: var(--at);
    width: 2px;
    margin-inline-start: -1px;
    border-radius: 1px;
    background: var(--fg-soft);
}
.meter-mark-label {
    position: absolute;
    top: 100%;
    inset-inline-start: 50%;
    transform: translateX(-50%);
    margin-top: var(--space-2);
    font-size: var(--text-2);
    color: var(--muted);
    white-space: nowrap;
    font-variant-numeric: tabular-nums;
}
.meter-mark--above .meter-mark-label {
    top: auto;
    bottom: 100%;
    margin-top: 0;
    margin-bottom: var(--space-2);
}
.meter-scale {
    display: flex;
    justify-content: space-between;
    margin-top: var(--space-2);
    font-size: var(--text-2);
    color: var(--muted);
    font-variant-numeric: tabular-nums;
}

/* ── Sparkline ──────────────────────────────────────────────────────────────
   The last few dozen readings as one line, under a figure or in a table cell,
   with no axis and no numbers: it says which way things are going, and the figure
   beside it says where they are. An inline <svg> whose viewBox is the data's own
   range and which stretches to its box:

     <svg class="sparkline" viewBox="0 0 100 32" preserveAspectRatio="none" aria-hidden="true">
       <path class="sparkline-area" d="M0 20 L8 18 … L100 6 V32 H0 Z" />
       <polyline class="sparkline-line" points="0,20 8,18 … 100,6" />
     </svg>

   `preserveAspectRatio="none"` is what lets one viewBox fit any box, and
   `vector-effect: non-scaling-stroke` is what keeps the line 2px when it does. The
   colour is the series modifier, the brand by default; `aria-hidden`, because the
   figure beside it is the accessible answer — a line has none. */
.sparkline {
    display: block;
    width: 100%;
    height: 32px;
    color: var(--viz-1);
    overflow: visible;
}
.sparkline--sm { height: 20px; }
.sparkline--lg { height: 56px; }
.sparkline-line {
    fill: none;
    stroke: currentColor;
    stroke-width: 2;
    stroke-linejoin: round;
    stroke-linecap: round;
    vector-effect: non-scaling-stroke;
}
.sparkline-area { fill: currentColor; opacity: 0.12; stroke: none; }
/* A reference: the target, the average, the same day last week. */
.sparkline-ref {
    stroke: currentColor;
    stroke-width: 1;
    stroke-dasharray: 3 3;
    opacity: 0.5;
    vector-effect: non-scaling-stroke;
}
/* Under a stat's value, and in a table cell, where the row's height is the box. */
.stat > .sparkline { margin-top: var(--space-2); }
.table td > .sparkline { width: 96px; height: 24px; }

/* ── Against a previous value ───────────────────────────────────────────────
   This month over last month, this year over last: one bar carrying two values. The
   fill is the current value, as everywhere above; `.bar-previous` is the previous one,
   a hatch behind the fill with a line at its end, both in one colour so they read as
   one thing and not as a threshold `.meter-mark`.

     <span class="bar-list-track">
       <span class="bar-previous" style="--value: 64%"></span>
       <span class="bar-list-fill" style="--value: 72%"></span>
     </span>

   It goes first, inside a `.bar-list-track`, a `.meter` or a `.progress`, so the fill
   after it paints over it. Where the previous value is higher the hatch sticks out past
   the fill; where it is lower the fill covers the hatch and the line is what is left,
   which is why the line reaches past the track on both sides and sits over the fill.

   A hatch rather than a lighter tint of the fill: a tint of the brand is the track's
   lightness in dark mode, and a neutral tint is the brand's lightness under the
   colour-blind palette. The texture differs from a solid fill in every palette. It is
   `--viz-previous` by default and takes a `.series-*` modifier where the previous value
   should wear the fill's hue.

   In the value column, `.bar-list-previous` is the previous figure, smaller and muted
   beside the current one; a `.stat-delta` in the same place says the change instead.
   In a legend, `.stack-bar-swatch--previous` is the hatch and the line as a key. */
.bar-list-track:has(> .bar-previous),
.meter:has(> .bar-previous),
.progress:has(> .bar-previous) {
    position: relative;
    isolation: isolate;   /* keeps the line's z-index inside the track */
    overflow: visible;    /* the line reaches past the track */
}
.bar-previous {
    --value: 0%;
    position: absolute;
    inset-block: 0;
    inset-inline-start: 0;
    width: var(--value);
    border-radius: inherit;
    color: var(--viz-previous);
    background: repeating-linear-gradient(-45deg, currentColor 0 2px, transparent 2px 5px);
    transition: width var(--motion-slow) ease;
}
.bar-previous::after {
    content: "";
    position: absolute;
    z-index: 1;   /* over the fill that follows, so a lower previous value still shows */
    top: -3px;
    bottom: -3px;
    inset-inline-end: -1px;
    width: 2px;
    border-radius: 1px;
    background: currentColor;
}
/* Positioned, so it paints over the hatch before it rather than under it. */
.bar-previous ~ :is(.bar-list-fill, .meter-fill, .progress-bar) { position: relative; }

.bar-list-value:has(> .bar-list-previous, > .stat-delta) { color: var(--fg-soft); }
.bar-list-previous {
    margin-inline-start: var(--space-2);
    font-size: var(--text-2);
    color: var(--muted);
}
.bar-list-value > .stat-delta { margin-inline-start: var(--space-3); }

.stack-bar-swatch--previous {
    color: var(--viz-previous);
    background: repeating-linear-gradient(-45deg, currentColor 0 2px, transparent 2px 4px);
    border-inline-end: 2px solid currentColor;
}

/* ── Series colours ─────────────────────────────────────────────────────────
   Last in the part on purpose. Every fill above sets its default `color`, and a
   modifier at the same specificity wins only by coming later — so this block stays
   at the end, or every segment paints the brand whatever class it carries. */
.series-1 { color: var(--viz-1); }
.series-2 { color: var(--viz-2); }
.series-3 { color: var(--viz-3); }
.series-4 { color: var(--viz-4); }
.series-5 { color: var(--viz-5); }
.series-6 { color: var(--viz-6); }
.series-go     { color: var(--go-solid); }
.series-warn   { color: var(--warn-solid); }
.series-danger { color: var(--danger-solid); }
.series-info   { color: var(--info-solid); }
.series-muted  { color: var(--border-strong); }
}

/* ── 54-stat.css → @layer sedna.paint ──────────────────────────────────────────────── */
@layer sedna.paint {
/* ── Stat tile ──────────────────────────────────────────────────────────────
   One number that matters, with what it is and — optionally — which way it moved.

   The label goes ABOVE the value. A number read before its label is a number the
   reader has to hold in their head while they find out what it means, and on a row
   of four tiles they do that four times.

   The value is tabular-nums: a row of tiles whose digits are different widths
   reflows as the numbers update, and on a polling dashboard that is a page that
   never sits still.
   ─────────────────────────────────────────────────────────────────────────── */
.stat { display: flex; flex-direction: column; gap: var(--space-1); }
.stat-label {
    font-size: var(--text-2);
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.06em;
    color: var(--muted);
}
.stat-value {
    font-size: var(--text-9);
    font-weight: 600;
    line-height: 1.2;
    color: var(--fg);
    font-variant-numeric: tabular-nums;
}
/* A smaller value, for a stat whose value is wide — a timestamp, a date range, a name —
   in a row of stats the reader compares. The label, note and unit keep their size:
   only the figure steps down, so the tile still lines up with its neighbours. */
.stat--sm .stat-value { font-size: var(--text-6); }

/* A unit or denominator that should not compete with the number. */
.stat-unit { font-size: var(--text-4); font-weight: 500; color: var(--muted); }
.stat-note { font-size: var(--text-3); color: var(--muted); }

/* ── Delta ──────────────────────────────────────────────────────────────────
   Which way it moved. Colour alone does not say — put a Remix Icon arrow inside
   (ri-arrow-up-line / ri-arrow-down-line), so the direction survives a
   colour-blind palette, a greyscale print and a forced-colors mode.

   --up and --down are direction, not judgement: more open incidents is up and bad,
   more resolved is up and good. Choose the semantic modifier by whether the reader
   should be pleased, and let the arrow carry the direction. */
.stat-delta {
    display: inline-flex;
    align-items: center;
    gap: var(--space-2);
    font-size: var(--text-3);
    font-weight: 500;
    color: var(--muted);
    font-variant-numeric: tabular-nums;
}
.stat-delta i { font-size: var(--text-5); }
.stat-delta--good { color: var(--go-fg); }
.stat-delta--bad  { color: var(--danger-fg); }
.stat-delta--warn { color: var(--warn-fg); }

/* ── Against a target ───────────────────────────────────────────────────────
   A number is rarely interesting on its own. `.stat-target` is the line that says what
   it is being measured against — "62 of 80", "of a 120 cap" — and `.stat-bar` is the
   optional filled track under it.

   The bar is a `.progress` with no extra rules: a stat against a target IS progress
   towards it, and a second track class would drift from the first. */
.stat-target { font-size: var(--text-3); color: var(--muted); font-variant-numeric: tabular-nums; }

/* ── A stat that opens something ────────────────────────────────────────────
   For a tile that leads to the thing it counts. An <a>, so it is a real link. The
   whole tile is the target, which is why the padding and the hover are on it rather
   than on the value. */
a.stat {
    padding: var(--space-4) var(--space-5);
    margin: calc(var(--space-4) * -1) calc(var(--space-5) * -1);
    border-radius: var(--radius-surface);
    color: inherit;
    text-decoration: none;
    transition: background var(--motion-fast);
}
a.stat:hover { background: var(--bg-hover); text-decoration: none; }
a.stat:hover .stat-value { color: var(--brand-text); }
a.stat:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--brand-ring-soft); }

/* ── Unavailable ────────────────────────────────────────────────────────────
   The number could not be read. An em dash, not a zero: zero is a measurement and
   this is the absence of one, and a dashboard that shows 0 for "the endpoint did not
   answer" is a dashboard that lies. */
.stat--unavailable .stat-value { color: var(--border-strong); font-weight: 500; }
.stat--unavailable .stat-delta,
.stat--unavailable .stat-target { visibility: hidden; }

/* A row of tiles. Breakpoint-free, like the other grids: it responds to the space
   it is given, so it behaves correctly inside a narrow column too. */
.stat-row {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(min(150px, 100%), 1fr));
    gap: var(--space-7);
}
/* Dividers between tiles outside a card too, for a row that sits straight on the
   page and still has to read as one object. */
.stat-row--divided { gap: 0; }
.stat-row--divided > .stat { padding-inline: var(--space-7); border-inline-start: 1px solid var(--divider); }
.stat-row--divided > .stat:first-child { padding-inline-start: 0; border-inline-start: none; }
/* Tiles inside a card get a dividing rule between them rather than a gap, so the
   card reads as one object. */
.card-body > .stat-row { gap: 0; }
.card-body > .stat-row > .stat { padding: var(--space-2) var(--space-7); border-inline-start: 1px solid var(--divider); }
.card-body > .stat-row > .stat:first-child { padding-inline-start: 0; border-inline-start: none; }
}

/* ── 55-page-structure.css → @layer sedna.paint ──────────────────────────────────────────────── */
@layer sedna.paint {
/* ── Page head ──────────────────────────────────────────────────────────────
   The title row a page opens with: heading, optional supporting line, and the
   actions that apply to the whole page.

   Wraps rather than shrinking the title. On a narrow screen the actions drop below
   the heading, which keeps the heading readable — the alternative is a truncated
   title next to two buttons, and the title is what tells you where you are.
   ─────────────────────────────────────────────────────────────────────────── */
.page-head {
    display: flex;
    flex-wrap: wrap;
    align-items: flex-start;
    justify-content: space-between;
    gap: var(--space-6);
    margin-bottom: var(--space-7);
}
.page-head-text { min-width: 0; }
/* Zero the heading's own margins. 06-base-elements gives h1 and h2 a margin, and margins
   do not collapse inside a flex container: with align-items: flex-start the actions sit at
   the top of the line while the heading's text is pushed down by its own top margin, which
   reads as the actions being on a line above the title. Matched on the head rather than on
   .page-head-text, and for every level a page head can carry: a sub-page opening on an h2
   is as ordinary as a top-level page opening on an h1. */
.page-head :is(h1, h2, h3) { margin: 0; }
.page-head-text > p { margin: var(--space-2) 0 0; color: var(--muted); font-size: var(--text-4); }
.page-head-actions { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-4); }

/* ── Divider ────────────────────────────────────────────────────────────────
   A rule between sections. Use it when two blocks are related but distinct; use
   whitespace when they are simply sequential, and a .card when one of them is an
   object in its own right.

   With a label it becomes a section break: the text sits in a gap in the rule,
   made with a flex row rather than a background trick, so it works on any surface
   and needs no colour to match. */
.divider { border: none; border-top: 1px solid var(--border); margin: var(--space-8) 0; }

.divider-labelled {
    display: flex;
    align-items: center;
    gap: var(--space-6);
    margin: var(--space-8) 0;
    color: var(--muted);
    font-size: var(--text-2);
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.06em;
}
.divider-labelled::before,
.divider-labelled::after {
    content: '';
    flex: 1;
    border-top: 1px solid var(--border);
}
/* Label at the start instead of centred, for a run of sections where a centred
   label makes the page look like a series of title cards. */
.divider-labelled--start::before { flex: 0 0 0; }

/* ── Callout ────────────────────────────────────────────────────────────────
   An editorial aside inside prose: a note, a caveat, a worked example. It is part
   of the content and stays there for good.

   Three neighbours, and picking the wrong one misleads:
     .callout       permanent, about the content     — "Note: times are UTC."
     .alert         about the page right now         — "Could not reach the API."
     .card-warning  about one card's own data        — "These counts may lag."
   An .alert that never goes away trains the reader to ignore alerts. */
.callout {
    display: flex;
    gap: var(--space-5);
    padding: var(--space-6) var(--space-7);
    margin: var(--space-7) 0;
    background: var(--surface-soft);
    border-inline-start: 3px solid var(--border-strong);
    border-radius: var(--radius-inner);
    color: var(--fg-soft);
    font-size: var(--text-4);
}
.callout > i { font-size: var(--text-8); flex-shrink: 0; color: var(--muted); line-height: 1.4; }
.callout-body { min-width: 0; }
.callout-body > :first-child { margin-top: 0; }
.callout-body > :last-child { margin-bottom: 0; }
.callout-title { display: block; font-weight: 600; color: var(--fg); margin-bottom: var(--space-1); }

/* The icon is --text-8 against --text-4 prose, so its line box is the taller of the
   two and it — not the text — sets the row's height. A callout with no title came
   out icon-tall with its single line parked against the top edge and a gap beneath.
   Centre the row in that case, which is what .alert does with the same shape.

   A title is what makes centring wrong: then the callout is two blocks, and the
   icon belongs against the first line rather than floating to the middle of both. */
.callout:not(:has(.callout-title)) { align-items: center; }

/* A title with nothing under it IS the content, so it is the one-line case again
   and its gap has nothing left to separate. */
.callout-title:only-child { margin-bottom: 0; }
.callout:has(.callout-title:only-child) { align-items: center; }

/* The same semantic families as buttons, badges and alerts. Tinted more lightly
   than an .alert: a callout is read as part of the prose, not scanned as a state. */
.callout-info   { background: var(--info-bg);   border-inline-start-color: var(--info-border); }
.callout-info > i   { color: var(--info-fg); }
.callout-warn   { background: var(--warn-bg);   border-inline-start-color: var(--warn-solid); }
.callout-warn > i   { color: var(--warn-fg); }
.callout-danger { background: var(--danger-bg); border-inline-start-color: var(--danger-solid); }
.callout-danger > i { color: var(--danger-fg); }
.callout-go     { background: var(--go-bg);     border-inline-start-color: var(--go-solid); }
.callout-go > i     { color: var(--go-fg); }
}

/* ── 56-code-block.css → @layer sedna.paint ──────────────────────────────────────────────── */
@layer sedna.paint {
/* ── Code block ─────────────────────────────────────────────────────────────
   A block of code or output with a copy button. For a command, a payload, a stack
   trace — anything the reader is going to want verbatim.

   No syntax highlighting, and there will not be any: it needs a tokeniser per
   language, which is either a runtime download or a large bundle, and neither is
   allowed here. Monospace with real contrast reads well enough for the payloads and
   commands this is for.

   The block scrolls horizontally rather than wrapping. A wrapped command line is a
   command that no longer copies correctly by eye, and a wrapped stack trace loses
   the frame boundaries.
   ─────────────────────────────────────────────────────────────────────────── */
.code-block {
    position: relative;
    background: var(--bg);
    border: 1px solid var(--border);
    border-radius: var(--radius-surface);
    overflow: hidden;
}

/* Inside a surface that already draws the frame — a card whose tabs pick between
   several files. The block gives up its own border and corners so the card's are the
   only ones; everything else about it is unchanged. */
.code-block--flush { border: none; border-radius: 0; }

/* An optional header naming the file or the language. */
.code-block-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-4);
    padding: var(--space-3) var(--space-6);
    background: var(--surface-soft);
    border-bottom: 1px solid var(--border);
    color: var(--muted);
    font-size: var(--text-2);
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.06em;
}

.code-block pre {
    margin: 0;
    padding: var(--space-6) var(--space-7);
    overflow-x: auto;
    font-family: var(--font-mono);
    font-size: var(--text-3);
    line-height: 1.55;
    color: var(--fg-soft);
    /* tab-size 4, because a stack trace or a YAML fragment pasted in with tabs
       otherwise indents by eight and wraps sooner than it has to. */
    tab-size: 4;
}
/* The inline `code` rule gives every <code> a tint and a padding; inside a block
   that would draw a box around the whole thing. */
.code-block pre code {
    background: none;
    border: none;
    padding: 0;
    color: inherit;
    font-size: inherit;
}

/* Floats over the top-right corner. Only visible on hover or keyboard focus at a
   pointer-capable size — a button permanently sitting over the first line is in the
   way of the thing it copies. It stays visible where hover does not exist. */
.code-block-copy {
    position: absolute;
    top: 8px;
    inset-inline-end: var(--space-4);
    z-index: 1;
    display: inline-flex;
    align-items: center;
    gap: var(--space-2);
    padding: var(--space-2) var(--space-4);
    background: var(--bg-elevated);
    border: 1px solid var(--border);
    border-radius: var(--radius-inner);
    color: var(--muted);
    font-family: inherit;
    font-size: var(--text-2);
    cursor: pointer;
    opacity: 0;
    transition: opacity var(--motion-fast), color var(--motion-fast), background var(--motion-fast);
}
.code-block:hover .code-block-copy,
.code-block-copy:focus-visible { opacity: 1; }
.code-block-copy:hover { background: var(--bg-hover); color: var(--fg); }
.code-block-copy:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--brand-ring-soft); }
/* Touch and other hover-less inputs: there is no hover to reveal it. */
@media (hover: none) {
    .code-block-copy { opacity: 1; }
}
/* With a head, the button sits in the head row instead of over the code. */
.code-block-head .code-block-copy { position: static; opacity: 1; }

/* ── Clamped ────────────────────────────────────────────────────────────────
   A long block bounded to a readable height, scrolling inside, with a fading lip and
   a control that opens it in full. Forty lines of markup between two paragraphs is a
   page nobody scrolls past.

   `--clamped` caps it; the expand control removes the class. The height is a token so
   an app can raise it, and the fade is a gradient over the lip rather than a shorter
   box, so the reader can see there is more without a scrollbar being the only clue.

   The pane keeps `tabindex="0"` in both states: a scroll container that a keyboard
   cannot reach is a part of the page a keyboard cannot read. */
.code-block--clamped pre {
    max-height: var(--code-clamp);
    overflow-y: auto;
}
/* The lip. `sticky` inside the scroller, so it sits over the last visible line at
   whatever scroll position rather than at the bottom of the content. */
.code-block--clamped .code-block-lip {
    position: sticky;
    bottom: 0;
    display: flex;
    align-items: flex-end;
    justify-content: center;
    height: 68px;
    margin-top: -68px;
    padding-bottom: var(--space-4);
    background: linear-gradient(transparent, var(--bg) 70%);
    pointer-events: none;
}
/* Not clamped: no fade, and the control still has to be clickable. */
.code-block-lip {
    display: flex;
    justify-content: center;
    padding: var(--space-3) 0 var(--space-4);
}
.code-block-expand {
    pointer-events: auto;
    display: inline-flex;
    align-items: center;
    gap: var(--space-2);
    min-height: var(--control-height-sm);
    padding: var(--space-2) var(--space-6);
    background: var(--bg-elevated);
    border: 1px solid var(--border);
    border-radius: var(--radius-pill);
    color: var(--fg-soft);
    font-family: inherit;
    font-size: var(--text-2);
    cursor: pointer;
    transition: background var(--motion-fast), color var(--motion-fast);
}
.code-block-expand:hover { background: var(--bg-hover); color: var(--fg); }
.code-block-expand:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--brand-ring-soft); }
.code-block-expand i { font-size: var(--text-4); }
}

/* ── 57-navigation-aids.css → @layer sedna.paint ──────────────────────────────────────────────── */
@layer sedna.paint {
/* ── Pagination ─────────────────────────────────────────────────────────────
   Page-by-page movement through a long result set.

   Use <a href> for every page that exists, not <button>. A page of results is an
   address: it should be linkable, openable in a new tab, and reachable by the back
   button. Only the disabled ends are <button disabled> or plain spans.

   Mark the list up as <nav aria-label="Pagination"> with the current page carrying
   aria-current="page". The class colours the current page; aria-current is what
   announces it.
   ─────────────────────────────────────────────────────────────────────────── */
.pagination {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--space-2);
}
.page-link {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: var(--space-2);
    min-width: 32px;
    min-height: 32px;
    padding: 0 var(--space-4);
    background: none;
    border: 1px solid transparent;
    border-radius: var(--radius-inner);
    color: var(--fg-soft);
    font-family: inherit;
    font-size: var(--text-4);
    font-variant-numeric: tabular-nums;
    text-decoration: none;
    cursor: pointer;
}
.page-link:hover { background: var(--bg-hover); border-color: var(--border); text-decoration: none; }
.page-link:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--brand-ring-soft); }
.page-link[aria-current="page"] {
    background: var(--brand-tint);
    border-color: var(--brand-soft);
    color: var(--brand-text);
    font-weight: 600;
}
.page-link:disabled,
.page-link[aria-disabled="true"] {
    opacity: 0.35;
    cursor: not-allowed;
    pointer-events: none;
}
/* The gap standing in for skipped pages. Not a link, and not focusable. */
.page-gap {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-width: 24px;
    color: var(--muted);
    user-select: none;
}
/* "121–140 of 1,204" beside the controls. */
.page-count { margin-inline-start: auto; color: var(--muted); font-size: var(--text-3); }

/* ── Breadcrumb ─────────────────────────────────────────────────────────────
   Where this page sits, when the sidebar cannot show it — a record three levels
   inside a section. If the sidebar already highlights the page, a breadcrumb repeats
   it and earns nothing.

   The separator is a pseudo-element on the item, so it is never selected when the
   trail is copied and is never read out. The last item is the current page: mark it
   aria-current="page" and leave it as a <span>, not a link to where you already are.

   Long trails scroll rather than wrap, and the leading edge is what gets cut,
   because the levels nearest the current page are the useful ones. */
.breadcrumb {
    display: flex;
    align-items: center;
    gap: var(--space-3);
    margin-bottom: var(--space-6);
    overflow-x: auto;
    scrollbar-width: none;
    font-size: var(--text-3);
    white-space: nowrap;
}
.breadcrumb::-webkit-scrollbar { display: none; }
.breadcrumb-item {
    display: inline-flex;
    align-items: center;
    gap: var(--space-3);
    flex-shrink: 0;
    color: var(--muted);
    text-decoration: none;
}
.breadcrumb-item i { font-size: var(--text-5); }
/* Colour, not an underline. `text-decoration` propagates to every descendant and
   cannot be cancelled by one, so an underline here also underlines the item's icon and
   the `/` separator after it. */
a.breadcrumb-item:hover { color: var(--fg); text-decoration: none; }
.breadcrumb-item + .breadcrumb-item::before {
    content: '/';
    color: var(--border-strong);
    /* Not a font glyph choice: a slash is one character wide in any typeface, so
       the trail does not reflow when the font falls back. */
}
.breadcrumb-item[aria-current="page"] { color: var(--fg); }
}

/* ── 58-menu.css → @layer sedna.paint ──────────────────────────────────────────────── */
@layer sedna.paint {
/* ── Menu / dropdown panel ──────────────────────────────────────────────────
   A short list of actions, opened from a control. This is the one dropdown panel
   in the library: the user widget's menu is this class, anchored differently (see
   15-frame-user-widget.css). Two near-identical panel styles is how a design
   system starts to rot, so there is only one.

   A menu is for *actions*. A list of values to pick one of is a <select> or a
   .segmented; a list of places to go is the sidebar.

   Deliberately NOT role="menu". That role promises arrow-key navigation and a
   roving tabindex, and a menu that claims it without implementing it is worse for
   a screen-reader user than one that claims nothing: the items stop being reachable
   the way they appear to be. These are ordinary links and buttons that tab. Put
   aria-expanded on the trigger and leave the panel unlabelled by role.

   Dismissal is the app's: click the scrim, press Escape. There is deliberately no
   JavaScript behind this — the frame must work with scripting blocked.
   ─────────────────────────────────────────────────────────────────────────── */
/* `width: max-content` is what makes the panel as wide as its widest row, and the
   220px is a floor rather than the width. Shrink-to-fit was the width before, and
   for an out-of-flow box that is measured against the CONTAINING BLOCK, not the
   content: anchored inside a narrow table cell the available width was the cell,
   so the panel came out at the floor with its rows wrapped or clipped inside it.
   A dropdown sizes to what is in it.

   The cap keeps a long item from drawing a panel wider than the viewport; past it
   the rows wrap, which is the right failure for a label nobody can shorten. */
.menu {
    width: max-content;
    min-width: 220px;
    max-width: min(20rem, calc(100vw - 32px));
    padding: var(--space-2);
    --surface-here: var(--bg-elevated);
    background: var(--bg-elevated);
    border: 1px solid var(--border);
    border-radius: var(--radius-surface);
    box-shadow: var(--shadow-dropdown);
}

/* Wrap trigger + panel in this to anchor the panel under the trigger.
   ─── Why this is anchor positioning and no longer `position: absolute` ─────
   An absolutely positioned panel is laid out INSIDE the nearest scroll container
   and counts towards its scrollable overflow. So a menu opened from a toolbar, a
   `.sedna-scroll-x` around a table or any app container with an `overflow` other
   than `visible` did two wrong things at once: it grew that container a scrollbar,
   and it was clipped at the container's edge — the last item of a three-item menu
   simply was not there. Three separate reports, one cause, and it reproduced in a
   consuming app.

   `position: fixed` takes the viewport as its containing block, so no ancestor's
   `overflow` clips it and none of them can be made to scroll by it. That is the
   same reason the collapsed rail's flyout is fixed (12-frame-collapsed-rail.css).
   Fixed positioning then needs somewhere to go, and the alternative to anchor
   positioning here is measuring the trigger in JavaScript on every open, scroll
   and resize — which is exactly the case the repo rules name as earning it.

   `position-area` places the panel with no measurement and no anchor-name in the
   markup: `block-end` is the row below the trigger, and the inline span plus the
   explicit `justify-self` set which of the trigger's edges the panel lines up
   with. Both axes are logical, so this mirrors under dir="rtl" with no rule in
   70-rtl.css — which is why 70-rtl.css has none.

   The name has to be scoped. Every anchor on the page declares the SAME
   anchor-name, and without `anchor-scope` the last one in tree order would win for
   all of them: every menu on a page would open under the bottom-most trigger.
   `anchor-scope` confines each name to its own element's subtree, which is where
   its panel lives.

   `position-visibility: anchors-visible` is the price of being fixed. A fixed
   panel does not scroll away with its trigger, so without this a menu left open
   while the reader scrolls a table detaches and floats over unrelated chrome.
   With it, the panel goes when its trigger goes.

   `position: relative` stays on the anchor. It is no longer what positions the
   panel, but it is the containing block an app's own absolutely positioned
   children inside a trigger already rely on. */
/* align-self: start alongside the inline display, because the display alone does not
   hold. A flex or grid item is blockified and then stretched by the default
   `align-items: stretch`, so an `inline-flex` anchor inside a flex column becomes
   full-width — and the panel, which hangs off the anchor's inline-END edge, detaches
   from its trigger and appears at the far side of the container. An app that genuinely
   wants a full-width anchor sets `align-self: stretch` back. */
.menu-anchor {
    position: relative;
    display: inline-flex;
    align-self: start;
    anchor-name: --sedna-menu-anchor;
    anchor-scope: --sedna-menu-anchor;
}
.menu-anchor > .menu {
    position: fixed;
    position-anchor: --sedna-menu-anchor;
    /* The row below the trigger, spanning back towards the inline start — so the
       region ends at the trigger's inline-END edge, which `justify-self: end` then
       lines the panel up with. */
    position-area: block-end span-inline-start;
    justify-self: end;
    align-self: start;
    /* Flips above the trigger when there is no room below, and to the trigger's
       other edge when there is none beside it. A try tactic takes the margin and
       the self-alignment with it, so the gap stays on the side facing the trigger
       and the panel stays flush with one of its edges. */
    position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline;
    position-visibility: anchors-visible;
    margin-block-start: var(--space-2);
    z-index: 550;
}
/* This offset is computed when the panel becomes visible and Chromium does not
   recompute it while the anchor scrolls, so an open panel goes stale rather than
   merely moving — 22-anchored.js closes it on a scroll that moved the trigger. The
   panel is correct every time it opens, which is why the fix is not in this file. */

/* The panel lines up with the anchor's inline-END edge, so a trigger narrower than
   the panel's 220px overhangs on the leading side. That is right for a trigger at the
   trailing end of a row — a table row's actions, a toolbar's overflow — and wrong for
   one near the leading edge, where the overhang leaves the container. `--start` aligns
   the panel to the anchor's inline-START edge instead. Put it on the panel, so the
   choice sits with the panel that moves. */
.menu-anchor > .menu--start {
    position-area: block-end span-inline-end;
    justify-self: start;
}

/* ── Value trigger ──────────────────────────────────────────────────────────
   A control that shows the CURRENT value of something and opens a menu to change it —
   a status, an assignee, a queue, a time range. It sits inside a line of text or a
   table cell, so it is quieter than a button: no border and no fill until hovered.

   Use it where a <select> would be wrong because the options need icons, colour or a
   description. Where a plain list of values would do, use a <select>: it is one
   element and the platform gives you the keyboard.

   The chevron is drawn by CSS, so the markup is the value and nothing else. Pair it
   with data-menu-toggle and a sibling .menu.

   `--value-trigger-measure` is how much of the value the control keeps room for —
   a knob on the element, the same shape as `--toolbar-input-width` and
   `--col-control-min`. Set it per instance for a column whose values are longer
   (`style="--value-trigger-measure: 16ch"`). See the note on the value span below
   for what it is holding open. */
.value-trigger {
    --value-trigger-measure: 10ch;
    /* The measure PLUS the chevron's own room, on the TRIGGER rather than on the
       value inside it. The chevron is an ::after, and `.menu-anchor`'s shrink-to-fit
       does not count a pseudo-element's contribution — measured: an anchor 12px
       narrower than its own trigger's max-content, exactly the gap plus the
       chevron — so a floor on the value alone sized the trigger to the value and the
       chevron was drawn outside it and clipped. A min-width here is a definite
       length, which the parent does count.

       The four terms are the gap, the chevron's 8px of border, the inline padding
       and the border, so the value still gets its full measure inside them. */
    min-width: calc(var(--value-trigger-measure) + var(--space-2) + 8px
                    + var(--space-3) * 2 + 2px);
    display: inline-flex;
    align-items: center;
    gap: var(--space-2);
    max-width: 100%;
    padding: var(--space-1) var(--space-3);
    margin-inline: calc(var(--space-3) * -1);   /* so the text still lines up with its column */
    background: none;
    border: 1px solid transparent;
    border-radius: var(--radius-inner);
    color: inherit;
    font: inherit;
    text-align: start;
    cursor: pointer;
}
.value-trigger::after {
    content: '';
    flex-shrink: 0;
    /* Held at the trailing edge, like `.accordion summary::after` and the nav group's.
       The trigger has a min-width that reserves the measure, so without this the
       chevron sat against the end of the VALUE with the reserved room stranded
       after it — two triggers in a column drew their chevrons at two different
       places, and neither was the edge of the control the hover fill draws. */
    margin-inline-start: auto;
    width: 0;
    height: 0;
    border-inline: 4px solid transparent;
    border-top: 5px solid currentColor;
    opacity: 0.5;
}
.value-trigger:hover { background: var(--bg-hover); border-color: var(--border); }
.value-trigger:hover::after { opacity: 1; }
.value-trigger:focus-visible { outline: none; border-color: var(--brand-soft); box-shadow: 0 0 0 2px var(--brand-ring-soft); }
.value-trigger[aria-expanded="true"] { background: var(--bg-hover); border-color: var(--border); }
.value-trigger[aria-expanded="true"]::after { transform: rotate(180deg); }
.value-trigger[aria-disabled="true"] { color: var(--muted); pointer-events: none; }
.value-trigger[aria-disabled="true"]::after { display: none; }
/* The value itself, so a long one truncates instead of pushing the chevron out.

   `min-width: 0` rather than the measure. `overflow: hidden` on a flex item already
   sets its automatic minimum size to zero — that is what makes the ellipsis possible
   — and the floor that stops it truncating ALWAYS is the trigger's own min-width
   above. It has to be there and not here: in a table cell a zero minimum is also the
   column's minimum contribution, so auto table layout squeezes the column down to
   the padding and every value in it renders as two characters and an ellipsis. The
   trigger's min-width is what the column now asks for, and it reserves the chevron's
   room in the same number. */
.value-trigger > span {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    min-width: 0;
}

/* Works on an <a> or a <button>, so an item can navigate or act. */
.menu-item {
    display: flex;
    align-items: center;
    gap: var(--space-4);
    width: 100%;
    padding: var(--space-4) var(--space-5);
    background: none;
    border: none;
    border-radius: var(--radius-inner);
    color: var(--fg);
    font-family: inherit;
    font-size: var(--text-4);
    text-align: start;
    text-decoration: none;
    cursor: pointer;
}
.menu-item i { font-size: var(--text-7); color: var(--muted); }
.menu-item:hover { background: var(--bg-hover); text-decoration: none; }
.menu-item:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--brand-ring-soft); }
.menu-item:disabled,
.menu-item[aria-disabled="true"] { opacity: 0.45; cursor: not-allowed; pointer-events: none; }
.menu-item--danger,
.menu-item--danger i { color: var(--danger-fg); }
/* A trailing shortcut hint or count. */
.menu-item-note { margin-inline-start: auto; color: var(--muted); font-size: var(--text-2); }

.menu-label {
    display: block;
    padding: var(--space-4) var(--space-5) var(--space-2);
    font-size: var(--text-1);
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.08em;
    color: var(--muted);
}
.menu-sep { border: none; border-top: 1px solid var(--divider); margin: var(--space-2) var(--space-1); }

/* Catches the click that dismisses the panel. Transparent and undecorated: this is
   a menu, not a modal, so it must not dim the page. Put it immediately BEFORE the
   panel in the markup — the panel then paints above it on source order, and the
   scrim needs no z-index of its own. */
.menu-scrim { position: fixed; inset: 0; cursor: default; }
}

/* ── 59-table-extensions.css → @layer sedna.paint ──────────────────────────────────────────────── */
@layer sedna.paint {
/* ── Table extensions ───────────────────────────────────────────────────────
   Everything beyond "one class on the table". Each is opt-in, because each costs
   something: a sticky header needs a scroll container, zebra stripes fight with row
   selection, and the stacked layout throws away column alignment.
   ─────────────────────────────────────────────────────────────────────────── */

/* ── Sticky header ──────────────────────────────────────────────────────────
   The header stays put while the body scrolls. Two things this needs from the
   markup, and it silently does nothing without them:
     1. A scroll container around the table — `.sedna-scroll` with a max-height. The
        header sticks to the nearest scrolling ancestor, and with none it sticks to
        the page, where the topbar is already.
     2. `border-collapse: separate` — a collapsed table's borders belong to the
        table, not the cell, so the header's bottom border scrolls away and leaves
        the rows running into the header text. That is why the border is drawn with
        a box-shadow here rather than a border.
   z-index 2 is local stacking inside the table, not a rung on the documented scale;
   the band is described where the pinned column sets the rest of it.

   The `<thead>` sticks, not each cell. Pinned cell by cell, every header row after the
   first needs the height of the rows above it as a number, and a number that is a pixel
   off is a pixel of body rows showing through between two header rows. Pinned as one
   box, the rows of a header cannot come apart.

   The cells' second shadow paints a pixel of the header's own surface above it, over
   the seam a fractional device-pixel ratio can leave between the header and the top
   of its scroller. The scroller clips it everywhere else. */
.table--sticky { border-collapse: separate; border-spacing: 0; }
.table--sticky thead {
    position: sticky;
    top: 0;
    z-index: 2;
}
.table--sticky thead th {
    /* Opaque, not the translucent --table-head-bg: rows scrolling underneath would
       show through it. */
    background: var(--bg-elevated);
    box-shadow: inset 0 -1px 0 var(--border), 0 -1px 0 var(--bg-elevated);
}
.table--sticky td { border-bottom: 1px solid var(--border); }

/* ── Zebra ──────────────────────────────────────────────────────────────────
   For wide tables where the eye loses the row between the first and last column.
   Avoid combining it with row selection: two faint background states are hard to
   tell apart, and the reader cannot see which row is chosen.

   The stripe is (0,2,3) — `:nth-child` counts as a class — which is higher than the
   hover highlight in 32-tables.css at (0,2,1). Without the second rule below,
   striped rows would silently stop responding to hover, which reads as a broken
   table rather than as a styling choice. */
.table--zebra tbody tr:nth-child(even) > td { background: var(--surface-soft); }
.table--zebra .tr-clickable:hover > td { background: var(--bg-hover); }

/* ── Sortable header ────────────────────────────────────────────────────────
   The whole header cell is the control, so put a <button> inside it filling the
   cell — a click target that is only the label text is a smaller target than it
   looks. The arrow is keyed off aria-sort, which is what a screen reader announces,
   so the two cannot disagree. */
.table th[aria-sort] { padding: 0; }
.th-sort {
    display: flex;
    align-items: center;
    gap: var(--space-2);
    width: 100%;
    padding: var(--cell-pad-y) var(--cell-pad-x);
    background: none;
    border: none;
    color: inherit;
    font: inherit;
    letter-spacing: inherit;
    text-transform: inherit;
    text-align: start;
    cursor: pointer;
}
.th-sort:hover { color: var(--fg); background: var(--surface-strong); }
.th-sort:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--brand-ring-soft); }
/* The caret is drawn with borders rather than taken from the icon font. Two
   reasons: `font-family` in this library always rides a token and a test enforces
   it, and pinning a Remix Icon codepoint here would silently point at a different
   glyph the next time the font is re-vendored.

   It reserves its 8px even when unsorted, so the header does not shift sideways as
   the reader sorts by one column after another. */
.th-sort::after {
    content: '';
    flex-shrink: 0;
    width: 0;
    height: 0;
    border-inline: 4px solid transparent;
    opacity: 0;
}
.table th[aria-sort="none"] .th-sort:hover::after { border-top: 5px solid currentColor; opacity: 0.4; }
.table th[aria-sort="ascending"] .th-sort::after { border-bottom: 5px solid currentColor; opacity: 1; }
.table th[aria-sort="descending"] .th-sort::after { border-top: 5px solid currentColor; opacity: 1; }
.table th[aria-sort="ascending"],
.table th[aria-sort="descending"] { color: var(--brand-text); }

/* ── Numeric columns ────────────────────────────────────────────────────────
   Right-aligned and tabular, so digits line up by place value down the column and
   two numbers can be compared by their shape. Put it on the th AND the td.

   Scoped to `.table .col-num`, not bare `.col-num`. A single-purpose class is (0,1,0)
   and `.table th, .table td` is (0,1,1), so the bare form loses the text-align it
   exists to set, silently. A class in `@layer sedna.utilities` wins that collision on
   layer order instead; this file is in sedna.paint, so it needs the specificity. */
.table .col-num { text-align: end; font-variant-numeric: tabular-nums; }

/* ── Row selection ──────────────────────────────────────────────────────────
   aria-selected on the <tr>, not a class, so the state the reader is told matches
   the state that is drawn. The leading rule is what survives a colour-blind palette
   and a greyscale print.

   `tbody` is in the selector only to reach (0,2,3) and tie with the zebra stripe
   above, which then loses on source order. Without it selection is (0,2,2) and an
   even striped row would show the stripe instead of the selection — the more
   important of the two signals, silently dropped. */
.table tbody tr[aria-selected="true"] > td { background: var(--brand-tint); }

/* The marker goes on the FIRST cell, not on every one. An inset shadow is drawn
   per box, so `> td` gave each cell its own 3px rule and the row read as a set of
   vertical dividers rather than one leading edge. It cannot go on the <tr>: a
   row's box is not painted when the table's borders are collapsed. */
.table tbody tr[aria-selected="true"] > td:first-child {
    box-shadow: inset 3px 0 0 var(--brand);
}

/* ── Pinned columns ─────────────────────────────────────────────────────────
   `.table--sticky` pins the header for a reader scrolling DOWN. This is the same
   problem on the other axis: a table too wide for its column scrolls sideways in
   a `.sedna-scroll-x`, and a few columns in the reader can no longer tell which
   record a row belongs to. `--pin-start` holds the identifying column still;
   `--pin-end` holds the last one, which is where the row's actions are.

   Three things beyond `position: sticky`, each of which fails silently on its own:

     1. `border-collapse: separate`. A collapsed table's borders belong to the
        table, so a sticky cell does not stick at all in Chromium. Same reason
        `.table--sticky` sets it.
     2. An OPAQUE background. A transparent sticky cell shows the columns sliding
        underneath it — `10020266` followed by the `.com` of an e-mail four columns
        away, reading as one value. Two layers are needed, because every state a
        row can be in paints a TRANSLUCENT tint: `--pin-bg` is the opaque surface
        underneath (`--surface-here`, the colour every library surface declares for
        its own children), and `--pin-tint` is the row's own state, inherited from
        the <tr> and laid over the base as a gradient. Each tint rule below mirrors
        one background rule above it, selector for selector, so the two cannot
        disagree about which state wins.
     3. The edge, and only while there is something behind the pinned column. The
        shadow says "columns are hidden this way"; painted on a table that fits, or
        on one sitting at the start of its scroll, it says it about nothing. A
        scroll-state container query answers exactly that question —
        `scrollable: inline-start` is true only while the scroller CAN be scrolled
        that way — so `--pin-shadow` is switched to a transparent no-op rather than
        to `none`, which would be invalid inside the two rules that compose it with
        an inset hairline and take the whole declaration down with them.

        The container is `.sedna-scroll-x`. An app scrolling the table in a wrapper
        of its own is not one, and a container query with no container never
        matches: the shadow then stays painted, which is what it did before. There
        is nothing to undo and nothing that fails silently — the app opts in by
        using the library's scroller.

        `--shadow-edge-start` / `--shadow-edge-end` are the token pair for a
        horizontally scrolled edge, held in `--pin-edge-start` / `--pin-edge-end` so
        that 70-rtl.css can swap which one a side uses — box-shadow has no logical
        form — without touching the `--pin-shadow` the container query sets.

   z-index stays local to the table — 1, 2 and 3, the local-stacking band rather
   than a rung of the overlay scale, and a sticky pinned table is what makes that
   band this wide. Each level is load-bearing:

     · 1  the pinned body cells, above the static cells they slide across.
     · 2  `.table--sticky`'s header row, above those.
     · 3  the corner cell, which is sticky on BOTH axes. It has to beat the other
          header cells as well as the column beneath it, and it is first in DOM
          order, so at an equal z-index every other header cell paints over it — the
          symptom being a header cell sliding across the pinned identity column.

   The pinned body cells start at 1 and not at 0, which is what they were, because
   0 is not above `auto`: a POSITIONED child of an ordinary cell — a
   `.segmented-option`, a `.switch`, a `.menu-anchor` — paints in the same step of
   the stacking order as a z-index: 0 stacking context, and the tie is broken by
   tree order. Every such control in a column to the right of the pinned one
   therefore slid over the top of it while the table scrolled, which reads as the
   pinned cell being transparent. Anything a cell contains is unpositioned or
   `auto`, so 1 clears the lot.

   A cell with `colspan` is never the pinned column — a detail row's body cell
   spans the table — so it is excluded rather than dragged sideways.

   Not for `.table--stack`: below 640px the rows become blocks and there is no
   sideways scroll to pin against. */
.table--pin-start,
.table--pin-end { border-collapse: separate; border-spacing: 0; --pin-bg: var(--surface-here, var(--bg)); }
.table--pin-start { --pin-edge-start: var(--shadow-edge-start); }
.table--pin-end   { --pin-edge-end:   var(--shadow-edge-end); }

.table--pin-start tbody tr > td:first-child:not([colspan]),
.table--pin-start tfoot tr > td:first-child:not([colspan]) {
    --pin-shadow: var(--pin-edge-start);
    position: sticky;
    inset-inline-start: 0;
    z-index: 1;
    background-color: var(--pin-bg);
    background-image: linear-gradient(var(--pin-tint, transparent), var(--pin-tint, transparent));
    box-shadow: var(--pin-shadow);
}
.table--pin-end tbody tr > td:last-child:not([colspan]),
.table--pin-end tfoot tr > td:last-child:not([colspan]) {
    --pin-shadow: var(--pin-edge-end);
    position: sticky;
    inset-inline-end: 0;
    z-index: 1;
    background-color: var(--pin-bg);
    background-image: linear-gradient(var(--pin-tint, transparent), var(--pin-tint, transparent));
    box-shadow: var(--pin-shadow);
}
/* The header cell of a pinned column, sticky on the inline axis whether or not the
   header is also sticky vertically. It carries the head tint over the same opaque
   base as the body cells. */
.table--pin-start thead th:first-child:not([colspan]) {
    --pin-shadow: var(--pin-edge-start);
    position: sticky;
    inset-inline-start: 0;
    z-index: 3;
    background-color: var(--pin-bg);
    background-image: linear-gradient(var(--table-head-bg), var(--table-head-bg));
    box-shadow: var(--pin-shadow);
}
.table--pin-end thead th:last-child:not([colspan]) {
    --pin-shadow: var(--pin-edge-end);
    position: sticky;
    inset-inline-end: 0;
    z-index: 3;
    background-color: var(--pin-bg);
    background-image: linear-gradient(var(--table-head-bg), var(--table-head-bg));
    box-shadow: var(--pin-shadow);
}
/* The corner cell. A sticky header is opaque `--bg-elevated` rather than tinted, so
   the corner matches the rest of the header row instead of being the one cell in it
   with a different surface — and it keeps the header's bottom hairline, which is a
   box-shadow here and would otherwise be replaced by the edge shadow. */
.table--sticky.table--pin-start thead th:first-child:not([colspan]),
.table--sticky.table--pin-end thead th:last-child:not([colspan]) {
    background-color: var(--bg-elevated);
    background-image: none;
    box-shadow: inset 0 -1px 0 var(--border), 0 -1px 0 var(--bg-elevated), var(--pin-shadow);
}

/* The edge, off until there is a column behind it. `.sedna-scroll-x` is the
   scroll-state container; the query is true only while the scroller can still be
   scrolled that way, so a table that fits and a table sitting at the start of its
   scroll both paint nothing. Same specificity as the rules above and later in the
   file, and set on the CELL rather than the table, so neither source order nor
   70-rtl.css's `[dir="rtl"] .table--pin-start` can outrank it. */
@container not scroll-state(scrollable: inline-start) {
    .table--pin-start thead th:first-child:not([colspan]),
    .table--pin-start tbody tr > td:first-child:not([colspan]),
    .table--pin-start tfoot tr > td:first-child:not([colspan]) { --pin-shadow: 0 0 0 transparent; }
}
@container not scroll-state(scrollable: inline-end) {
    .table--pin-end thead th:last-child:not([colspan]),
    .table--pin-end tbody tr > td:last-child:not([colspan]),
    .table--pin-end tfoot tr > td:last-child:not([colspan]) { --pin-shadow: 0 0 0 transparent; }
}
/* The same two facts as attributes, written by 22-scroll-edge.js, for the engine
   without scroll-state() — WebKit, where a table that fit still drew the end
   shadow. Where the query works the two agree. */
.sedna-scroll-x[data-scroll-start] .table--pin-start thead th:first-child:not([colspan]),
.sedna-scroll-x[data-scroll-start] .table--pin-start tbody tr > td:first-child:not([colspan]),
.sedna-scroll-x[data-scroll-start] .table--pin-start tfoot tr > td:first-child:not([colspan]),
.sedna-scroll-x[data-scroll-end] .table--pin-end thead th:last-child:not([colspan]),
.sedna-scroll-x[data-scroll-end] .table--pin-end tbody tr > td:last-child:not([colspan]),
.sedna-scroll-x[data-scroll-end] .table--pin-end tfoot tr > td:last-child:not([colspan]) { --pin-shadow: 0 0 0 transparent; }

/* ── Group rows ─────────────────────────────────────────────────────────────
   A row that heads the rows after it — a state, a day, a customer — written as
   one cell spanning the table:

     <tr class="tr-group">
       <th colspan="4" scope="rowgroup">
         <div class="tr-group-body">
           Waiting on you <span class="badge badge-warn">3</span>
           <span class="chip">EU-Central <button class="chip-dismiss" …></button></span>
           <span class="tr-group-count">3 of 128</span>
         </div>
       </th>
     </tr>

   A <th> with `scope="rowgroup"`, so a screen reader announces the group with each
   row under it; the header's own uppercase treatment is undone here, because the
   cell holds a sentence and the chips and badges that belong to it, not a column
   name. The flex row is a child rather than the cell — `display: flex` on a cell
   takes it out of the table and the colspan with it. `.tr-group-count` sits at
   the far end. */
.tr-group > th,
.tr-group > td {
    background: var(--surface-soft);
    padding-block: var(--space-3);
    font-size: var(--text-3);
    font-weight: 600;
    color: var(--fg-soft);
    text-transform: none;
    letter-spacing: 0;
}
.tr-group-body {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--space-4);
}
.tr-group-count {
    margin-inline-start: auto;
    color: var(--muted);
    font-weight: 500;
    font-variant-numeric: tabular-nums;
}
/* Under a sticky header the group row is a header too, and pins beneath it. The
   header's height is not knowable here, so it is the app's one number: set
   `--table-head-height` on the table where its header is not the default 35px.

   The group row pins a pixel higher than the two heights add up to. It lies below the
   header in the stacking order, so a pixel too high is a pixel hidden under the header,
   and a pixel too low was a strip of rows showing through. */
.table--sticky { --table-head-height: 35px; --table-filter-height: 0px; }
.table--sticky .tr-group > th {
    position: sticky;
    top: calc(var(--table-head-height) + var(--table-filter-height) - 1px);
    z-index: 1;
}

/* ── Filter row ─────────────────────────────────────────────────────────────
   A second header row whose cells hold a filter for their column — a
   `.form-input.form-input-sm` or a `.form-select`. For an admin table that is
   filtered per column; the toolbar above a table is still the default.

     <thead>
       <tr><th>Order</th><th>Status</th></tr>
       <tr class="tr-filter">
         <th><input class="form-input form-input-sm" type="search" aria-label="Filter by order"></th>
         <th><select class="form-input form-select form-input-sm" aria-label="Filter by status">…</select></th>
       </tr>
     </thead>

   The header's uppercase treatment is undone, because the cell holds a control, not a
   column name. Every filter needs an aria-label: the column heading above it is not
   its label.

   Under `.table--sticky` it pins with the header row, because the whole `<thead>` is
   what sticks. Its height is added to where a group row pins, through
   `--table-filter-height`. */
.tr-filter > th {
    padding-block: var(--space-2);
    font-weight: 400;
    text-transform: none;
    letter-spacing: 0;
    color: var(--fg);
}
.tr-filter :is(.form-input, .form-select) { width: 100%; min-width: 0; }
.table--sticky:has(.tr-filter) { --table-filter-height: 37px; }

/* One filter per type of column, each built from a control that already exists and
   each at the SMALL tier, which is what keeps the row one height — 37px is the
   control plus the cell's block padding and its rule, and a single default-size
   control in any cell makes the whole row taller than the sticky offset above says:

     · text          `.input-group--sm` with a search `.input-affix` and an `.input-clear`
     · one choice    `.form-select.form-select-sm`, with `<selectedcontent>` for icons or badges
     · several       `.form-trigger` reading "2 selected", opening a `.popover` of `.form-check`s
     · number range  `.input-group--sm` holding two number fields and an `.input-affix` between
     · date range    `.form-trigger` opening a `.datepicker`
     · yes / no      `.segmented--sm` with an Any option

   Inside a cell an icon affix gives up its dividing rule and most of its padding: it
   labels the field rather than being a value beside it, and a narrow column cannot
   spare 20px around a glyph. A number field gives up its spin buttons for the same
   room. The group and the segmented control fill the cell like the fields do. */
.tr-filter :is(.input-group, .form-trigger) { width: 100%; min-width: 0; }
.tr-filter .segmented { display: flex; width: 100%; }
.tr-filter .segmented-option { flex: 1 1 0; justify-content: center; padding-inline: var(--space-3); }
.tr-filter .input-affix { padding-inline: var(--space-3); border-inline: 0; }
.tr-filter .input-affix:first-child + .form-input { padding-inline-start: 0; }
.tr-filter .input-affix:not(:first-child, :last-child) { padding-inline: 0; }
.tr-filter .form-input::-webkit-inner-spin-button,
.tr-filter .form-input::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }

/* ── A filtered column ──────────────────────────────────────────────────────
   `.col-filtered` on the header cell of a column whose filter is set, and on its
   cell in the filter row. The heading takes the brand colour and a rule under it,
   and the control in the filter row takes the brand border, so a reader scanning
   a wide table finds the columns that are narrowing it without reading every
   field. Colour and a rule are not a label: say it in the heading's accessible
   name too, with a `.visually-hidden` "filtered".

   A rule, not a colour alone, because a sorted column's heading is already the
   brand colour; the two states have to be told apart when both are on. Drawn as an
   inset shadow so it does not change the header's height, and restated for
   `.table--sticky`, whose header already draws its bottom hairline the same way. */
.table th.col-filtered { color: var(--brand-text); }
.table thead tr:not(.tr-filter) > th.col-filtered { box-shadow: inset 0 -2px 0 var(--brand); }
.table--sticky thead tr:not(.tr-filter) > th.col-filtered {
    box-shadow: inset 0 -2px 0 var(--brand), 0 -1px 0 var(--bg-elevated);
}
.tr-filter > th.col-filtered :is(.form-input, .form-select, .input-group, .segmented) {
    border-color: var(--brand-soft);
}

/* ── Filter from the header ─────────────────────────────────────────────────
   The alternative to a filter row, for a table too narrow to give every column a
   field: a small button at the end of the header cell opens the column's filter in
   a `.popover`.

     <th>
       <div class="sedna-row sedna-gap-0">
         Status
         <button class="th-filter th-filter--active" type="button" popovertarget="status-filter"
                 aria-label="Filter by status, 2 chosen"><i class="ri-filter-3-line"></i></button>
         <div class="popover" id="status-filter" popover>…</div>
       </div>
     </th>

   `.th-filter--active` is the column's filter being set: the button fills and
   carries a dot, which is what an admin grid's funnel does. Say so in the
   aria-label as well.

   The button keeps the header row its height: it is taller than the heading's line
   box, and the negative block margin takes back the difference. Next to a
   `.th-sort`, which fills a cell with no padding, it keeps its own distance from
   the cell's edge. */
.th-filter {
    position: relative;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    flex-shrink: 0;
    width: 22px;
    height: 22px;
    margin-block: -3px;
    margin-inline-start: auto;
    padding: 0;
    background: none;
    border: none;
    border-radius: var(--radius-small);
    color: var(--muted);
    font-size: var(--text-4);
    line-height: 1;
    cursor: pointer;
}
.th-filter:hover { background: var(--bg-hover); color: var(--fg); }
.th-filter:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--brand-ring-soft); }
.table th[aria-sort] .th-filter { margin-inline-end: var(--space-3); }
.th-filter--active,
.th-filter--active:hover { background: var(--brand-tint); color: var(--brand-text); }
.th-filter--active::after {
    content: '';
    position: absolute;
    inset-block-start: 2px;
    inset-inline-end: 2px;
    width: 6px;
    height: 6px;
    border-radius: 50%;
    background: var(--brand);
}

/* ── Nothing here, and still loading ────────────────────────────────────────
   The table or list is present — framed by its toolbar and its count — and this
   query returned nothing. The page-level "nothing here" is `.empty-state`; this is
   the commoner case inside it.

     <tr class="tr-empty"><td colspan="999">No order matches these filters.</td></tr>

   `colspan="999"`, never the real count: a browser clamps a colspan to the columns
   the table has, so the cell spans them all and does not go stale the next time a
   column is added. A `.state-art` drawing in the cell sits centred above the text.

   `.tr-busy` is the same row for a refresh in flight, holding a `.spinner`; the first
   load is `.skeleton` rows instead. `.list-empty` is an `<li>` in a `.list`. */
.tr-empty > td,
.tr-busy > td,
.list-empty {
    padding: var(--space-10) var(--space-7);
    color: var(--muted);
    font-size: var(--text-4);
    text-align: center;
}
.tr-empty:hover > td,
.tr-busy:hover > td { background: none; }
.tr-busy .spinner { margin-inline-end: var(--space-4); }
:is(.tr-empty, .list-empty) .state-art { display: block; margin: 0 auto var(--space-5); }

/* The row states, mirroring the background rules above one for one. Set on the
   <tr> so a custom property carries them into the pinned cell by inheritance;
   ordering between them is the ordering the `> td` rules already have. */
.table--zebra tbody tr:nth-child(even) { --pin-tint: var(--surface-soft); }
.table tbody tr[aria-selected="true"] { --pin-tint: var(--brand-tint); }
.tr-clickable:hover { --pin-tint: var(--bg-hover); }
.table--zebra .tr-clickable:hover { --pin-tint: var(--bg-hover); }
.table tfoot tr { --pin-tint: var(--surface-soft); }
/* The selection marker is an inset shadow, and the pinned cell's box-shadow would
   replace it — on the very column where it is most visible. Both, in one list. */
.table--pin-start tbody tr[aria-selected="true"] > td:first-child:not([colspan]) {
    box-shadow: inset 3px 0 0 var(--brand), var(--pin-shadow);
}

/* ── Column width ───────────────────────────────────────────────────────────
   `.table` leaves column widths to the browser, which is right nearly always. It
   goes wrong when the cells in one row are not all short values, because the
   browser gives an unbounded column the room it asks for and squeezes the rest to
   their padding. Three shapes cover it, each with its own knob:

     · `.col-control` — a cell holding an <input>, a <select> or a
       `.value-trigger`. Squeezed to its padding a select renders as a caret and
       two clipped characters, so the column has a floor sized from the longest
       value the control can hold, and the control fills the column so the two
       agree on an edge. A legible table with unusable controls in it is the
       failure this avoids. A `.value-trigger` is reached through its
       `.menu-anchor` wrapper, which is the direct child of the cell.
     · `.col-prose` — a sentence, a description, a distinguished name. Gives way
       instead of taking the table, and breaks inside a word because an identifier
       has nowhere else to break.
     · `.col-fit` — the opposite: an icon, a status dot, a row of buttons. Takes the
       width of its content and no more. `width: 1%` is the idiom — a table at
       `width: 100%` distributes the slack to the columns that asked for room, so a
       column asking for almost none keeps almost none.

   Put the class on the th AND the td, the way `.col-num` is used. Each is scoped
   to `.table` for the reason `.col-num` is: a bare single-purpose class is (0,1,0)
   and `.table th, .table td` is (0,1,1), so the bare form would lose its padding
   and width to the cell rule, silently. */
.table .col-control {
    --col-control-min: 14rem;
    --col-control-max: none;
    min-width: var(--col-control-min);
    max-width: var(--col-control-max);
}
/* A field fills the column, because a field's box IS the affordance and a short one
   in a wide cell reads as broken. A `.value-trigger` is the opposite — it is
   deliberately quieter than a field, and its whole shape is "as wide as the value" —
   so it is NOT in this list and neither is its `.menu-anchor`.

   Stretching the anchor was doubly wrong. `.menu-anchor > .menu` hangs off the
   anchor's inline-END edge, so a full-width anchor put the panel at the far side of
   the cell rather than under the trigger — which is the exact detachment
   58-menu.css warns about. Both rules went together.

   A field that should also be content-width takes `.sedna-w-fit`, which is in the
   utilities layer and so beats this rule without a specificity race. */
.table .col-control > .form-input,
.table .col-control > .form-select { width: 100%; }
.table .col-prose {
    --col-prose-min: 12rem;
    --col-prose-max: 30rem;
    min-width: var(--col-prose-min);
    max-width: var(--col-prose-max);
    white-space: normal;
    overflow-wrap: anywhere;
}
.table .col-fit { width: 1%; white-space: nowrap; }

/* ── Expandable and nested rows ─────────────────────────────────────────────
   The detail row is a real <tr> after its parent, so it moves with it when the
   table is sorted and is read after it. Its cell spans every column.

   The inset rule on the left is the only thing saying it belongs to the row above;
   a plain indent reads as an unrelated row that happens to start further in. */
.tr-detail > td {
    padding: 0;
    background: var(--surface-soft);
    box-shadow: inset 3px 0 0 var(--border-strong);
}
.tr-detail-body { padding: var(--space-6) var(--space-7); }

/* A grouped header spanning several columns. */
.table th[colspan] { text-align: center; border-bottom-color: var(--border-strong); }

/* ── Footer totals ──────────────────────────────────────────────────────────
   In a real <tfoot>, so it is announced as a footer and prints on every page. */
.table tfoot td,
.table tfoot th {
    background: var(--surface-soft);
    border-top: 1px solid var(--border-strong);
    border-bottom: none;
    font-weight: 600;
    color: var(--fg);
}

/* ── Stacked layout ────────────────────────────────────────────────────────
   Below 640px each row becomes a card and each cell a label/value pair, taking its
   label from `data-label`. Opt-in, and worth understanding before switching on:
   this throws away column alignment, so comparing a value across rows stops being
   possible. It is right for a list of records to read one at a time, and wrong for
   anything the reader scans down a column of.

   The header is hidden rather than removed, because the cells still reference it
   for screen readers.

   Only geometry and a token-valued surface here — a layout query decides how much
   room there is, never what something looks like. */
@media (max-width: 640px) {
    .table--stack thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
    .table--stack tr { display: block; margin-bottom: var(--space-5); border: 1px solid var(--border); border-radius: var(--radius-surface); overflow: hidden; }
    .table--stack td { display: flex; justify-content: space-between; gap: var(--space-6); border-bottom: 1px solid var(--divider); }
    .table--stack tr > td:last-child { border-bottom: none; }
    .table--stack td::before {
        content: attr(data-label);
        flex-shrink: 0;
        color: var(--muted);
        font-size: var(--text-2);
        font-weight: 600;
        text-transform: uppercase;
        letter-spacing: 0.06em;
    }
    /* A cell with no data-label — an actions cell — keeps the full width instead of
       leaving an empty gutter where the label would be. */
    .table--stack td:not([data-label])::before { content: none; }
    .table--stack .col-num { text-align: end; }
}
}

/* ── 60-lists.css → @layer sedna.paint ──────────────────────────────────────────────── */
@layer sedna.paint {
/* ── List ───────────────────────────────────────────────────────────────────
   Rows of records where a table would be wrong: when the fields differ per row, when
   there is one dominant field and the rest is supporting, or when each row is
   something you open rather than compare.

   A real <ul>/<li>, so the count is announced and the rows are navigable as a list.
   A row that navigates is an <a> filling the <li>, not a click handler on the <li>:
   the whole row is then a real link, openable in a new tab and reachable by keyboard.
   A row that acts is a <button> filling it the same way, and `.list-row` resets the
   platform's button chrome so the two rows look alike.
   ─────────────────────────────────────────────────────────────────────────── */
.list { list-style: none; margin: 0; padding: 0; }
.list > li + li { border-top: 1px solid var(--divider); }

.list-row {
    display: flex;
    align-items: center;
    gap: var(--space-6);
    padding: var(--list-pad-block) var(--list-pad-inline);
    color: inherit;
    text-decoration: none;
}
/* A row that ACTS rather than navigates is a <button>, and a <button> arrives
   carrying the platform's own chrome: a `ButtonFace` fill, a system border, centred
   text and the UA's ~13px Arial. `.list-row` declares none of background, border or
   font — it was written for an <li>, a <div> or an <a> — so a button row rendered as
   a grey slab with its label centred, and `.list-title`, which sets only
   `font-weight`, inherited the UA font size while `.list-sub` kept `var(--text-3)`:
   the two lines came out the same size and the hierarchy was gone. The drawer's
   action sheet was three of those.

   The reset is scoped to `button.list-row` rather than declared on `.list-row`
   itself, because `width: 100%` and `text-align: start` are INHERITED CONTEXT on a
   <span> or a <div>. Put them on the bare class and a centred cell or a right-aligned
   column silently re-aligns every row inside it. On a <button> both are the UA's own
   values, so the scoped rule reproduces exactly what the other elements already do
   and changes none of them.

   `font: inherit` for the same reason: the row takes the surrounding list's font, as
   a <span> row does, rather than pinning a size of its own. */
button.list-row {
    width: 100%;
    background: none;
    border: none;
    font: inherit;
    text-align: start;
    cursor: pointer;
}
/* Hover and focus were scoped to `a.list-row`, so a button row had no affordance and
   no focus ring at all. Both interactive elements, one rule — and a <span> row has to
   keep getting neither, which is why the element scope stays rather than going away.
   The forced-colours outline in 71-forced-colors.css is scoped the same way. */
:is(a, button).list-row:hover { background: var(--bg-hover); text-decoration: none; }
:is(a, button).list-row:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--brand-ring-soft); }
/* Pressed, which on a touch screen is the only feedback there is — a hover state a
   finger never enters says nothing. The same `--bg-hover` / `--btn-active-bg` pair
   `.btn-ghost` uses, so a quiet control being held reads the same everywhere and a
   theme remaps both at once.

   Pressed and disabled stay on `button.` rather than joining the `:is(a, button)`
   above, so that everything this fix adds lands only on the element that was broken
   and no shipped <a> row changes appearance. Giving a link row a press state is a
   separate decision; do not fold it in here on the way past. */
button.list-row:active { background: var(--btn-active-bg); }
/* The treatment `.menu-item` and `.btn` use. `pointer-events: none` is what stops a
   disabled row still taking the hover fill. */
button.list-row:disabled,
button.list-row[aria-disabled="true"] { opacity: 0.45; cursor: not-allowed; pointer-events: none; }
.list-row[aria-current="true"],
.list-row[aria-selected="true"] {
    background: var(--brand-tint);
    box-shadow: inset 3px 0 0 var(--brand);
}

/* The dominant field and its supporting line. min-width: 0 is what lets the
   ellipsis work — a flex item defaults to min-content width and refuses to shrink. */
.list-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--space-1); }
/* Both lines are cut after `--list-lines` lines: 1 by default, `none` to wrap, or any
   count. One knob rather than a `--wrap` modifier, so "two lines, then clamp" is a
   value and not a release. `-webkit-line-clamp` needs `display: -webkit-box` and has
   no unprefixed form that works. Chromium still lays inline children out as text, so
   a badge inside a title flows on the line rather than becoming a line of its own.
   `overflow-wrap: anywhere` is for the one-word line — an e-mail address — which
   would otherwise run past the edge unclamped instead of breaking into the ellipsis. */
.list-title,
.list-sub {
    display: -webkit-box;
    -webkit-box-orient: vertical;
    -webkit-line-clamp: var(--list-lines);
    overflow: hidden;
    overflow-wrap: anywhere;
}
.list-title { color: var(--fg); font-weight: 500; }
.list-sub { color: var(--muted); font-size: var(--text-3); }
/* Metadata — a timestamp, a badge, a chevron. Trailing, as a child of the row; or
   as a line of its own above the title, as the first child of `.list-main`, where it
   wraps rather than pushing the row wider than its container. */
.list-meta { display: flex; align-items: center; gap: var(--space-4); flex-shrink: 0; color: var(--muted); font-size: var(--text-3); }
.list-main > .list-meta { flex-wrap: wrap; row-gap: var(--space-1); }

/* A row taller than one line — lines set to wrap or clamp at more than one, or a
   meta line above the title — aligns its leading visual and trailing slot to the top
   rather than centring them on the whole height, so the avatar sits beside the title
   it belongs to. The container is the row's parent, so this sees `--list-lines` set
   on the row or anything above it, and not a value set on one line alone. */
@container not style(--list-lines: 1) {
    .list-row > * { align-self: flex-start; }
}
.list-row:has(> .list-main > .list-meta:first-child) > * { align-self: flex-start; }

/* Inside a card the rows meet the card's edges, so the divider spans the full
   width instead of floating with a gap at each end. */
.card > .list > li > .list-row { padding-inline: var(--card-pad-inline); }

/* ── Cards: the same rows, presented apart ──────────────────────────────────
   `.list--cards` is a presentation of the same markup, not a second component: a gap
   instead of dividers, and each row a card — surface, border, radius. Everything else
   is the row's own: the button reset, hover, pressed, focus, disabled, density and
   sticky group heads between the cards.

   The surface follows the card ladder by depth, so a card list inside a `.card` takes
   the next step up, as a nested `.card` would. Each rung sets `--surface-here` only
   and the base rule paints it, so no rung outranks a state below.

   Selection reads as strongly as a bordered card: the brand border doubled by an
   inset ring, over the tint. Inset rather than outside the border, because the usual
   parent is a scroll container and an outer ring on the first or last card is
   clipped at its edge. The tint is a layer over the opaque surface, not the
   background itself — `--brand-tint` is translucent and would show the page through
   the card. */
.list--cards { display: flex; flex-direction: column; gap: var(--list-gap); }
.list--cards > li + li { border-top: none; }
.list--cards > li > .list-row {
    --surface-here: var(--surface-raised-1);
    background-color: var(--surface-here);
    border: 1px solid var(--border);
    border-radius: var(--radius-surface);
}
.card .list--cards > li > .list-row { --surface-here: var(--surface-raised-2); }
.card .card .list--cards > li > .list-row { --surface-here: var(--surface-raised-3); --muted: var(--fg-soft); }

.list--cards > li > :is(a, button).list-row:hover { background-color: var(--bg-hover); border-color: var(--border-strong); }
.list--cards > li > button.list-row:active { background-color: var(--btn-active-bg); }
.list--cards > li > .list-row:is([aria-current="true"], [aria-selected="true"]),
.list--cards > li > .list-row:is([aria-current="true"], [aria-selected="true"]):hover {
    background-color: var(--surface-here);
    background-image: linear-gradient(var(--brand-tint), var(--brand-tint));
    border-color: var(--brand);
    box-shadow: inset 0 0 0 1px var(--brand);
}
.list--cards > li > :is(a, button).list-row:is([aria-current="true"], [aria-selected="true"]):focus-visible {
    box-shadow: inset 0 0 0 1px var(--brand), inset 0 0 0 3px var(--brand-ring-soft);
}

/* In a card the list keeps the card's gutter round the whole stack, and the rows go
   back to their own padding — the card-edge rule above is for rows that meet the
   card's border, and these do not. */
.card > .list--cards { padding: var(--card-pad-block) var(--card-pad-inline); }
.card > .list--cards > li > .list-row { padding-inline: var(--list-pad-inline); }
/* A group head between cards puts its label on the cards' text: the row's padding
   plus the card's 1px border. */
.list--cards > .sticky-group-head,
.card > .list--cards > .sticky-group-head { padding-inline: calc(var(--list-pad-inline) + 1px); }

/* ── The row as a lockup ────────────────────────────────────────────────────
   `.list-row` + `.list-main` + `.list-title` + `.list-sub` + `.list-meta` is a leading
   visual, two stacked lines and a trailing slot. Put an `.avatar` first and it is a
   person; put an icon there and it is a device, a file or a queue. Nothing in it names
   what the thing is, which is why it serves for all of them.

   `--flush` drops the row's own padding, for the places where the surrounding cell
   already provides it: a table cell, a card header, a form row. */
.list-row--flush { padding: 0; gap: var(--space-5); }

/* ── Steps ──────────────────────────────────────────────────────────────────
   Progress through a sequence that has an order — a wizard, an approval chain.

   State is `data-state` on the <li> rather than a modifier class, because a step has
   exactly one of three states and an attribute cannot accidentally carry two. The
   number is drawn in CSS from a counter, so renumbering after inserting a step is
   not a markup edit.

   The connector is the <li>'s own ::after, so the last step has none without a
   modifier. Colour alone does not say which steps are done: the done marker shows a
   tick, not just a fill. */
.steps {
    /* One number for the marker's diameter, because the connector's own offset is
       half of it. Typed separately they disagreed: the marker rendered 28px across
       and the connector sat at 13px, because `* { box-sizing: border-box }` in the
       base layer does not reach a pseudo-element and the 1px ring was added to the
       26px width rather than taken out of it. */
    --step-marker: 28px;
    list-style: none;
    margin: 0 0 var(--space-7);
    padding: 0;
    display: flex;
    counter-reset: sedna-step;
}
.steps > li {
    position: relative;
    flex: 1;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: var(--space-3);
    padding: 0 var(--space-4);
    color: var(--muted);
    font-size: var(--text-3);
    text-align: center;
    counter-increment: sedna-step;
}
.steps > li::before {
    content: counter(sedna-step);
    box-sizing: border-box;
    display: flex;
    align-items: center;
    justify-content: center;
    width: var(--step-marker);
    height: var(--step-marker);
    border: 1px solid var(--border-strong);
    border-radius: 50%;
    background: var(--bg);
    color: var(--muted);
    font-weight: 600;
    /* Above the connector, which runs behind it. Local stacking. */
    position: relative;
    z-index: 1;
}
/* The connector runs from this step's marker to the next one's. */
.steps > li:not(:last-child)::after {
    content: '';
    position: absolute;
    top: calc(var(--step-marker) / 2);
    inset-inline-start: 50%;
    width: 100%;
    border-top: 1px solid var(--border);
}
/* Done is FILLED, pending is outlined. That difference survives a colour-blind
   palette, a greyscale print and forced colours, which a green-vs-grey fill would
   not — so no markup has to be added to carry a tick. */
.steps > li[data-state="done"] { color: var(--fg-soft); }
.steps > li[data-state="done"]::before {
    background: var(--go-solid);
    border-color: var(--go-solid);
    color: var(--on-solid);
}
.steps > li[data-state="done"]:not(:last-child)::after { border-top-color: var(--go-solid); }
.steps > li[data-state="current"] { color: var(--fg); font-weight: 600; }
.steps > li[data-state="current"]::before {
    border-color: var(--brand);
    color: var(--brand-text);
    box-shadow: 0 0 0 3px var(--brand-glow);
}

/* ── Stacking group headers ─────────────────────────────────────────────────
   A grouped list in a scroll container where each group's header pins BELOW the
   one above it instead of replacing it, so the reader keeps the whole path to
   where they are.

   The app supplies one number — the header's rendered index — and nothing else:

     <li class="sticky-group-head" style="--group-head-index: 2">Waiting on a person</li>

   Everything that made this a bug four times over is here rather than in the
   app. `top` is computed from the index so no app does the arithmetic. The
   height is a variable so the two agree by construction. The background is
   opaque, because rows scroll UNDER a header. `flex-shrink: 0` stops a flex
   scroll container squashing a pinned header to nothing. And the height in the
   `top` multiplication is one pixel short, which overlaps each header on the
   one above by 1px — without it a sub-pixel sliver of a scrolling row shows
   between two stacked headers on a fractional device pixel ratio.

   z-index 1 is local stacking inside the list, not a rung on the scale.

   BECAUSE the background is opaque it has to be the colour of the surface the
   list is pinned over, and a header that picks one surface is a lighter box
   floating on every other one. So it is `--group-head-bg`, which reads
   `--surface-here` — the colour every library surface declares for its own
   contents — and falls back to the page. A list in a card, a modal, a drawer or
   a popover is therefore right with no help. Only on a surface the app itself
   paints does the app name it, and it never restates the rule:

     <ul class="list" style="--group-head-bg: var(--surface-strong)">

   `--group-head-fg` and `--group-head-height` are the same escape hatch for the
   label colour and the pinned height. */
.sticky-group-head {
    --group-head-index: 0;
    --group-head-height: 32px;
    --group-head-bg: var(--surface-here, var(--bg));
    --group-head-fg: var(--muted);
    position: sticky;
    top: calc(var(--group-head-index) * (var(--group-head-height) - 1px));
    z-index: 1;
    display: flex;
    align-items: center;
    gap: var(--space-4);
    flex-shrink: 0;
    height: var(--group-head-height);
    padding: 0 var(--space-6);
    background: var(--group-head-bg);
    border-bottom: 1px solid var(--border);
    color: var(--group-head-fg);
    font-size: var(--text-2);
    font-weight: 600;
    letter-spacing: 0.04em;
    text-transform: uppercase;
}
/* Inside a card the header meets the card's edges, like `.list-row` does. */
.card > .list > .sticky-group-head,
.card > .sticky-group-head { padding-inline: var(--card-pad-inline); }

/* `--rule` trades the full-width bottom border for a hairline running from the
   label to the end of the row. Same pinning, but the header reads as a caption
   over the rows rather than a bar across them — which is what a list wants
   where the groups are a state the reader scans past, not a section they land
   in.

   The rule is the header's own ::after, so the markup stays a label and, where
   there is one, a count. Ordering is why the count needs a class at all: a
   pseudo-element is always the last child, and `order` is what puts the rule
   between the two. */
.sticky-group-head--rule { border-bottom: none; }
.sticky-group-head--rule::after {
    content: '';
    order: 1;
    flex: 1;
    border-top: 1px solid var(--border);
}
/* Auto margin for the plain header, absorbed by the rule's flex where there is
   one, so the count sits at the end either way. */
.sticky-group-count {
    order: 2;
    flex-shrink: 0;
    margin-inline-start: auto;
    color: var(--fg-soft);
    font-variant-numeric: tabular-nums;
}

/* ── Accordion ──────────────────────────────────────────────────────────────
   Built on <details>/<summary>, which is the whole point: open and closed state,
   keyboard operation, and the announcement all come from the platform, so there is
   no ARIA to get wrong and nothing to wire.

   Give every <details> the same `name` to make them mutually exclusive — that is a
   platform feature too, and it needs no JavaScript.

   The one thing to wire carefully is the opposite direction. If code has to OPEN a
   section, bind `open` one way and never write it back: `toggle` fires whenever the
   attribute changes, the framework's own diff included, so a handler mirroring the
   state back flips it, the next diff removes the attribute, and the two fight. It is
   deterministic, not a race, and it appears only once something other than a click
   opens the disclosure.

   The marker is drawn as a rotating caret. `list-style: none` plus
   `::-webkit-details-marker` is needed because the two engines expose the default
   triangle differently, and neither alone removes it everywhere. */
.accordion { border: 1px solid var(--border); border-radius: var(--radius-surface); overflow: hidden; }
.accordion > details + details { border-top: 1px solid var(--border); }
.accordion summary {
    display: flex;
    align-items: center;
    gap: var(--space-4);
    padding: var(--space-5) var(--space-7);
    background: var(--surface-soft);
    color: var(--fg);
    font-size: var(--text-4);
    font-weight: 500;
    cursor: pointer;
    list-style: none;
    user-select: none;
}
.accordion summary::-webkit-details-marker { display: none; }
.accordion summary:hover { background: var(--bg-hover); }
.accordion summary:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--brand-ring-soft); }
/* The caret goes last so the label starts at the same place whatever the state. */
.accordion summary::after {
    content: '';
    margin-inline-start: auto;
    width: 0;
    height: 0;
    border-inline: 4px solid transparent;
    border-top: 5px solid currentColor;
    opacity: 0.5;
    transition: transform var(--motion-fast) ease;
}
.accordion details[open] > summary::after { transform: rotate(-180deg); }
.accordion details[open] > summary { background: var(--surface-strong); }
.accordion-body { padding: var(--space-6) var(--space-7); font-size: var(--text-4); color: var(--fg-soft); }
.accordion-body > :first-child { margin-top: 0; }
.accordion-body > :last-child { margin-bottom: 0; }
}

/* ── 61-buttons-extra.css → @layer sedna.paint ──────────────────────────────────────────────── */
@layer sedna.paint {
/* ── Button group and split button ──────────────────────────────────────────
   Adjacent buttons that act on the same thing. The group shares one border between
   neighbours rather than drawing two adjacent ones, which is what makes it read as
   one control instead of a row.

   A group is NOT a way to pick one of several — that is `.segmented`, and it is
   built from radios so it is announced as a choice. A group is several actions.
   Wrap it in `role="group"` with an `aria-label` saying what they act on.
   ─────────────────────────────────────────────────────────────────────────── */
.btn-group { display: inline-flex; align-items: stretch; }
.btn-group > .btn { border-radius: 0; }
.btn-group > :first-child { border-start-start-radius: var(--radius-control); border-end-start-radius: var(--radius-control); }
.btn-group > :last-child  { border-start-end-radius: var(--radius-control);   border-end-end-radius: var(--radius-control); }
.btn-group > * + * { margin-inline-start: -1px; }
/* The focused or hovered button paints above its neighbour, or half its ring and
   its darker border edge are covered. 1 is local stacking, not a documented rung. */
.btn-group > .btn:hover,
.btn-group > .btn:focus-visible { position: relative; z-index: 1; }

/* ── Split button ───────────────────────────────────────────────────────────
   A primary action plus a menu of its variants. Two real buttons, not one with a
   hit-test: the main action must be operable without ever opening the menu, and the
   caret needs its own accessible name ("More send options") and its own
   aria-expanded. */
.split-btn { display: inline-flex; align-items: stretch; }
.split-btn > .btn:first-child { border-start-end-radius: 0; border-end-end-radius: 0; }
.split-btn > .btn:last-child {
    border-start-start-radius: 0;
    border-end-start-radius: 0;
    margin-inline-start: -1px;
    padding-inline: var(--space-4);
}
.split-btn > .btn:hover,
.split-btn > .btn:focus-visible { position: relative; z-index: 1; }
/* Between two filled buttons of the same colour the shared edge disappears, so the
   split is drawn as a lighter inset line instead. */
.split-btn > .btn-primary + .btn-primary,
.split-btn > .btn-go + .btn-go,
.split-btn > .btn-warn-solid + .btn-warn-solid { box-shadow: inset 1px 0 0 var(--surface-strong); }

/* ── Icon-only and floating buttons ─────────────────────────────────────────
   .btn-icon is a square button with no label. It still needs an aria-label, and
   `.btn`'s min-height already matches it to text buttons beside it — this only
   squares the padding so it is not a wide box around one glyph.

   Square by construction, from the same token as the height. Do not hard-code the
   width: it drifts from the height the moment either tier changes. */
.btn-icon { padding-inline: 0; width: var(--control-height); }
.btn-icon.btn-sm { width: var(--control-height-sm); }
.btn-icon.btn-lg { width: var(--control-height-lg); }

/* .btn-ghost carries no border or fill until hovered, for a dense row of secondary
   actions — a table row's actions, a toolbar. Everything the button does is still
   there; only the chrome is deferred. */
.btn-ghost { background: none; border-color: transparent; color: var(--muted); }
.btn-ghost:hover { background: var(--bg-hover); border-color: transparent; color: var(--fg); }
.btn-ghost:active { background: var(--btn-active-bg); }

/* ── A button with no chrome ────────────────────────────────────────────────
   For an identifier in running text that DOES something: a record number that opens
   the record, a key with a copy icon beside it, a chevron that folds a section away.
   It acts, so it is a <button> and not a link — and every other button here carries
   padding, a height and a hover fill, which is chrome a sentence has no room for.

   Used WITHOUT `.btn`. It is the absence of the button's chrome rather than a variant
   of it, so writing both brings the fill and the height back: `.btn:hover` and
   `.btn:focus-visible` win on specificity.

   `font: inherit` rather than a type token, because the point is to match whatever
   text it is sitting in. Inline-flex with a small gap so a trailing icon rides along,
   and the container's baseline is still the label's, so the line of text is not
   disturbed.

   The focus ring is the library's own, and it is the declaration an inline reset
   always forgets: `border: none` on a <button> takes the browser's own focus outline
   box with it, and a keyboard reader is then left with nothing at all. */
.btn-bare {
    display: inline-flex;
    align-items: center;
    gap: var(--space-2);
    padding: 0;
    border: none;
    border-radius: var(--radius-small);
    background: none;
    color: inherit;
    font: inherit;
    text-align: inherit;
    cursor: pointer;
}
/* No size of its own: the glyph rides the surrounding text. line-height: 1 keeps it
   from growing the line box it is in. */
.btn-bare i { line-height: 1; }
.btn-bare:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--brand-ring-soft); }
.btn-bare:disabled { opacity: 0.4; cursor: not-allowed; }
/* Link-coloured, for an identifier that goes somewhere. It underlines on hover
   because that is where a reader looks for the affordance an <a> would have. */
.btn-bare--link { color: var(--accent); }
.btn-bare--link:hover { text-decoration: underline; }

/* ── Floating action button ─────────────────────────────────────────────────
   One page-level action, pinned to a corner on a screen too small to show a toolbar.
   Deliberately not a general pattern: a second FAB has no obvious place, and the
   first one already covers whatever is under it.

   z-index 60 is the topbar rung — it is chrome at the same level, and it must stay
   below every overlay so a modal covers it. */
.fab {
    position: fixed;
    /* The toast stack's corner and the same arithmetic — see 38-toasts.css. A FAB
       lands on a phone by definition, so this is the one control in the library
       that would be under a thumb-height home indicator on every device that has
       one. */
    inset-block-end: calc(var(--space-8) + var(--safe-block-end));
    inset-inline-end: calc(var(--space-8) + var(--safe-inline-end));
    z-index: 60;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: var(--space-4);
    min-height: 48px;
    padding: 0 var(--space-8);
    background: var(--brand);
    border: none;
    border-radius: var(--radius-pill);
    color: var(--on-solid);
    font-family: inherit;
    font-size: var(--text-5);
    font-weight: 500;
    box-shadow: var(--shadow-float);
    cursor: pointer;
}
.fab i { font-size: var(--text-8); }
.fab:hover { background: var(--brand-hover); }
.fab:focus-visible { outline: none; box-shadow: var(--shadow-float), 0 0 0 3px var(--brand-ring); }
/* Icon-only: circular, and the label goes in an aria-label. */
.fab--icon { width: 48px; padding: 0; }
}

/* ── 62-media-and-prose.css → @layer sedna.paint ──────────────────────────────────────────────── */
@layer sedna.paint {
/* ── Media frame ────────────────────────────────────────────────────────────
   A bounded box for an image or an embed whose size you do not control — a
   screenshot, an attachment preview, a chart from elsewhere.

   `aspect-ratio` plus `object-fit: contain` is what stops the page reflowing as
   images arrive: the box has its height before the image loads. `contain` rather
   than `cover` because these are documents, and cropping a screenshot removes the
   part somebody needed.
   ─────────────────────────────────────────────────────────────────────────── */
.media-frame {
    display: flex;
    align-items: center;
    justify-content: center;
    overflow: hidden;
    aspect-ratio: 16 / 10;
    background: var(--surface-soft);
    border: 1px solid var(--border);
    border-radius: var(--radius-surface);
}
.media-frame > img,
.media-frame > video,
.media-frame > iframe { max-width: 100%; max-height: 100%; object-fit: contain; border: none; }
.media-frame--square { aspect-ratio: 1; }
.media-frame--wide { aspect-ratio: 21 / 9; }
/* Fills and crops — right for a decorative banner, wrong for a document. */
.media-frame--cover > img { width: 100%; height: 100%; object-fit: cover; }

/* A caption belongs to the figure, so use <figure>/<figcaption> and this styles the
   caption. An image whose meaning is in the caption still needs a real alt. */
.media-figure { margin: 0; }
.media-figure > figcaption { margin-top: var(--space-3); color: var(--muted); font-size: var(--text-3); }

/* ── Gallery ────────────────────────────────────────────────────────────────
   Thumbnails, breakpoint-free like the other grids. Each tile is a link or a button
   opening the full item, never a bare div with a click handler. */
.gallery {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(min(140px, 100%), 1fr));
    gap: var(--space-5);
}
.gallery > * { position: relative; display: block; text-decoration: none; }
.gallery .media-frame { aspect-ratio: 1; }
.gallery > a:hover .media-frame,
.gallery > button:hover .media-frame { border-color: var(--border-hover); }
.gallery > *:focus-visible { outline: none; }
.gallery > *:focus-visible .media-frame { box-shadow: 0 0 0 2px var(--brand-ring-soft); }
/* Pictures that share one frame — the photos in a message, the ones on a card — two
   across with a hairline between them. The frame they sit in has the corners, so the
   pictures have none of their own. */
.gallery--tight {
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: var(--space-1);
    width: min(100%, 320px);
}
.gallery--tight > *,
.gallery--tight .media-frame { border-radius: 0; }

/* ── Link preview ───────────────────────────────────────────────────────────
   A link unfurled — the picture, the title, a line and the host — in a message, a
   comment or a record's description. The app fetches the preview; this is the card.

     <a class="link-preview" href="https://ops.example.com/docks">
       <img src="…" width="88" height="88" alt="">
       <span class="link-preview-body">
         <strong>Dock schedule — week 38</strong>
         <span>Inbound and outbound slots for all four docks.</span>
         <small>ops.example.com</small>
       </span>
     </a> */
.link-preview {
    display: grid;
    grid-template-columns: auto minmax(0, 1fr);
    align-items: stretch;
    max-width: 100%;
    border: 1px solid var(--border);
    border-radius: var(--radius-inner);
    background: var(--surface-here);
    color: inherit;
    text-decoration: none;
    overflow: hidden;
}
.link-preview:hover { border-color: var(--border-strong); text-decoration: none; }
.link-preview:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--brand-ring-soft); }
.link-preview > img { width: 88px; height: 100%; object-fit: cover; }
.link-preview-body {
    display: flex;
    flex-direction: column;
    gap: var(--space-1);
    min-width: 0;
    padding: var(--space-3) var(--space-4);
    font-size: var(--text-3);
}
.link-preview-body > strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.link-preview-body > span { color: var(--fg-soft); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.link-preview-body > small { color: var(--muted); font-size: var(--text-2); }

/* ── Prose ──────────────────────────────────────────────────────────────────
   Long-form text an app renders from a source it does not control — a release note,
   a knowledge-base article, an e-mail body.

   `.markdown-body` already styles rendered Markdown; `.prose` is for the same job
   where the HTML did not come from Markdown. They deliberately look the same: two
   typographic voices in one app is worse than one shared rule set.

   The measure is capped at 72 characters. Beyond about that the eye loses the line
   it is returning to, which no amount of line-height fixes.

   The cap sits on the same element as any border and background the page gives
   `.prose`, so where the prose IS a panel the cap shrinks the PANEL rather than
   the text. `--prose-measure` is the way out — set it on the box, and either
   widen the measure or hand the job to an inner wrapper:

     <div class="card-body prose" style="--prose-measure: none">
   ─────────────────────────────────────────────────────────────────────────── */
.prose {
    --prose-measure: 72ch;
    color: var(--fg-soft);
    font-size: var(--text-5);
    line-height: 1.65;
    max-width: var(--prose-measure);
}
.prose > :first-child { margin-top: 0; }
.prose > :last-child { margin-bottom: 0; }
.prose h1, .prose h2, .prose h3, .prose h4 { color: var(--fg); line-height: 1.3; margin: 1.4em 0 0.4em; }
.prose h1 { font-size: 1.5em; }
.prose h2 { font-size: 1.25em; }
.prose h3 { font-size: 1.1em; }
.prose p, .prose ul, .prose ol { margin: 0.8em 0; }
.prose li { margin: 0.3em 0; }
.prose a { color: var(--accent); text-decoration: underline; }
.prose blockquote {
    margin: 1em 0;
    padding: 0.2em 0 0.2em 1em;
    border-inline-start: 3px solid var(--border);
    color: var(--muted);
}
.prose hr { border: none; border-top: 1px solid var(--border); margin: 2em 0; }
.prose img { max-width: 100%; height: auto; border-radius: var(--radius-inner); }
/* A table in untrusted prose is the one thing that breaks the page width, because
   its own content sets its minimum size. */
.prose table { display: block; overflow-x: auto; max-width: 100%; border-collapse: collapse; }
.prose th, .prose td { padding: var(--space-3) var(--space-5); border: 1px solid var(--border); text-align: start; }
.prose th { background: var(--table-head-bg); color: var(--muted); font-size: 0.9em; }

/* ── Redacted ───────────────────────────────────────────────────────────────
   A value deliberately withheld — a secret, a masked identifier, a field the
   reader's role cannot see.

   It must not merely LOOK hidden. A blur or a low opacity leaves the real text in
   the DOM, where it is readable by view-source, by a screen reader, and by anyone
   who selects it. So the class marks a placeholder the server sent instead of the
   value; the value never reaches the browser. */
.redacted {
    display: inline-flex;
    align-items: center;
    gap: var(--space-2);
    padding: 1px var(--space-3);
    background: var(--secret-bg);
    border: 1px dashed var(--secret-border);
    border-radius: var(--radius-inner);
    color: var(--redacted);
    font-family: var(--font-mono);
    font-size: var(--text-3);
    user-select: none;
}
.redacted i { font-size: var(--text-4); }

/* ── Prose that arrived from somewhere else ─────────────────────────────────
   `.prose` sets the typography. This adds the CONTAINMENT, and the two are a
   different job: styling text is about how it reads, containing it is about
   what a 3000px-wide table, an unbroken 400-character URL or a 4000px image
   does to the card it landed in. A ticketing system's journal entry, a
   forwarded mail body, a CMS field — anything sanitized rather than authored
   — wants both.

   Getting it wrong is invisible until the one wide payload ships, which is why
   this is a modifier rather than advice.

   It contains; it does not sanitize. HTML from elsewhere still has to be
   cleaned server-side before it reaches the browser — and the same pass is where
   two attributes belong that CSS cannot add. A table that scrolls needs
   `tabindex="0"` so a keyboard can reach the part that is off-screen, and
   `display: block` drops a table's own role, so `role="table"` puts it back.
   Both are on the catalogue's example. */
.prose--foreign { overflow-wrap: anywhere; }
/* EXCEPT inside a table, which has to SCROLL rather than squash. `anywhere` makes a
   cell's min-content width one character, so the browser shrinks every column to fit
   the card instead of overflowing into the scroller `.prose table` already provides —
   and a five-column table arrives as a stack of broken words. The long token that
   needed breaking widens the table here, and the table scrolls, which is the whole
   point of the scroller. */
.prose--foreign th,
.prose--foreign td { overflow-wrap: normal; }
/* A <pre> arriving from elsewhere has lines nobody chose the length of, so it
   wraps rather than pushing the card wide — and still scrolls, because a single
   unbreakable token has nowhere to wrap. */
.prose--foreign pre { white-space: pre-wrap; overflow-x: auto; }
/* Everything that carries its own intrinsic size. `.prose` already caps <img>;
   an embed, a video and an inline <svg> are the ones that get missed. */
.prose--foreign img,
.prose--foreign svg,
.prose--foreign video,
.prose--foreign canvas,
.prose--foreign iframe,
.prose--foreign embed,
.prose--foreign object { max-width: 100%; }
.prose--foreign svg,
.prose--foreign video,
.prose--foreign canvas { height: auto; }
}

/* ── 63-drag.css → @layer sedna.paint ──────────────────────────────────────────────── */
@layer sedna.paint {
/* ── Drag and drop ──────────────────────────────────────────────────────────
   Moving the app's own items: a list reordered, a card carried to another lane, a
   grid of tiles rearranged. `data-drag-zone`, `data-drag-item` and `data-drag-handle`
   hand the input to 26-drag.js, which never moves a node — it writes the attributes
   below and dispatches `sedna-drop`, and the app moves the item in its own list.

   The states are keyed on those ATTRIBUTES, not on the classes, so an item that is a
   `.card` or a `.list-row` gets the lifted look, the insertion line and the zone states
   without `.drag-item`. The classes are the look of a plain draggable row, tile and lane.

     data-dragging="pointer"    the item under the pointer, raised into the top layer and
                                carried by --drag-x / --drag-y
     data-dragging="keyboard"   the item picked up with Space, still in place
     data-drop-state="valid"    a zone it can land in; "invalid" one that refuses it
     data-drop-over             the zone under the pointer, either kind
     data-drop-edge             "before" or "after": the item the line is drawn against
     data-drag-slot             "start", "end" or "column": the neighbour holding open the
                                slot a raised item left

   Not files. A file dropped from the desktop is `.dropzone`, in 47-form-file.css.
   ─────────────────────────────────────────────────────────────────────────── */

/* ── What makes a thing draggable ───────────────────────────────────────────
   Zero specificity, so any class that positions the item or sets its cursor wins. The
   position is what the insertion line and the lifted item's z-index resolve against. */
:where([data-drag-item], [data-drop-edge]) { position: relative; }

/* Where the pointer has carried the item. Declared on every item, so a nested item never
   inherits the offset of the one around it; the script sets both on the dragged item's
   own style and removes them on drop. */
:where([data-drag-item]) {
    --drag-x: 0px;
    --drag-y: 0px;
    --drag-left: 0px;
    --drag-top: 0px;
    --drag-width: auto;
    --drag-height: auto;
    --drag-slot-size: 0px;
    --drag-slot-column: auto;
}

/* A whole item that drags: no text selection under a mouse drag, and no callout or
   loupe under a finger held still to lift it. An item with a handle of its own keeps
   its text selectable, because only the handle drags. */
:where([data-drag-zone] > [data-drag-item]:not([aria-disabled="true"]):not(:has([data-drag-handle]))) {
    cursor: grab;
    user-select: none;
    -webkit-user-select: none;
    -webkit-touch-callout: none;
}

/* A handle is claimed outright: a finger on it drags at once rather than scrolling. */
:where([data-drag-handle]) {
    cursor: grab;
    touch-action: none;
    user-select: none;
    -webkit-user-select: none;
    -webkit-touch-callout: none;
}
:where([data-drag-item][aria-disabled="true"] [data-drag-handle]) { cursor: not-allowed; }

/* ── The zones ──────────────────────────────────────────────────────────────
   A column of rows, and a grid of tiles. Padding, so the zone's outline and the line
   over the first item have room inside whatever holds it. */
.drag-list,
.drag-grid {
    --drop-gap: var(--space-3);
    margin: 0;
    padding: var(--space-2);
    list-style: none;
    border-radius: var(--radius-surface);
}
.drag-list {
    display: flex;
    flex-direction: column;
    gap: var(--drop-gap);
}
.drag-grid {
    --drop-gap: var(--space-4);
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(min(100%, 7.5rem), 1fr));
    gap: var(--drop-gap);
}

/* ── A draggable row ────────────────────────────────────────────────────────── */
.drag-item {
    display: flex;
    align-items: center;
    gap: var(--space-4);
    min-height: var(--control-height-lg);
    padding: var(--space-2) var(--space-5);
    background: var(--card-bg);
    border: 1px solid var(--border);
    border-radius: var(--radius-control);
    color: var(--fg);
    font-size: var(--text-4);
}
.drag-item:has(> .drag-handle) { padding-inline-start: var(--space-2); }
.drag-item:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--brand-ring-soft); }
.drag-item[aria-disabled="true"] {
    background: var(--surface-soft);
    color: var(--muted);
}

/* In a grid the row becomes a tile: the content stacks and centres. */
.drag-grid > .drag-item {
    flex-direction: column;
    justify-content: center;
    min-height: 6rem;
    padding: var(--space-4);
    text-align: center;
}
.drag-grid > .drag-item > i { font-size: var(--text-8); color: var(--muted); }

/* The grip. As tall as the row, so a finger does not have to find a glyph. */
.drag-handle {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    flex-shrink: 0;
    align-self: stretch;
    width: var(--control-height-sm);
    min-height: var(--control-height-sm);
    margin: 0;
    padding: 0;
    background: none;
    border: 0;
    border-radius: var(--radius-small);
    color: var(--muted);
    font-size: var(--text-6);
    line-height: 1;
}
.drag-handle:hover { background: var(--bg-hover); color: var(--fg); }
.drag-handle:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--brand-ring-soft); }
[aria-disabled="true"] > .drag-handle { color: var(--border-strong); background: none; }

/* ── A board of lanes ───────────────────────────────────────────────────────
   Lanes side by side, scrolling sideways when they outgrow the page — which is also
   what a card carried to the edge scrolls. Each lane holds a heading and one zone; the
   zone stretches to the foot of the lane, so a card dropped under the last one still
   lands in it. */
.drag-board {
    display: grid;
    grid-auto-flow: column;
    grid-auto-columns: minmax(min(15rem, 80%), 1fr);
    gap: var(--space-5);
    overflow-x: auto;
    padding-block-end: var(--space-2);
}
.drag-lane {
    display: flex;
    flex-direction: column;
    gap: var(--space-3);
    min-width: 0;
    padding: var(--space-4);
    background: var(--surface-soft);
    border: 1px solid var(--border);
    border-radius: var(--radius-panel);
}
.drag-lane-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-3);
    margin: 0;
    padding-inline: var(--space-2);
    color: var(--fg);
    font-size: var(--text-4);
    font-weight: 600;
}
.drag-lane > .drag-list { flex: 1; }

/* ── An empty zone ──────────────────────────────────────────────────────────
   The app writes it once, inside the zone, and it shows only while the zone has no
   items — so an app renders it unconditionally and never has to track the count. It is
   not an item, so it is never counted as a position. */
.drag-empty {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: var(--space-3);
    min-height: var(--control-height-lg);
    padding: var(--space-4);
    border: 1px dashed var(--border);
    border-radius: var(--radius-control);
    color: var(--muted);
    font-size: var(--text-3);
    text-align: center;
}
[data-drag-zone]:has(> [data-drag-item]) > .drag-empty { display: none; }
[data-drop-state="valid"][data-drop-over] > .drag-empty {
    border-color: var(--brand);
    color: var(--brand-text);
}

/* The states come last, so they win over the look of a row, a tile and a lane at equal
   weight — a held row keeps its ring while it is also the focused one. */

/* ── The item being dragged ─────────────────────────────────────────────────
   The item itself is carried, by `translate` — the individual property, so an app's own
   `transform` on the item still applies. It keeps its slot in the layout while it
   travels, so nothing else on the page moves until the app re-renders the list.
   z-index 3 is the top of the local band: above its siblings and the next lane, and
   still inside the component. */
[data-dragging="pointer"] {
    translate: var(--drag-x) var(--drag-y);
    z-index: 3;
    box-shadow: var(--shadow-float);
    cursor: grabbing;
    opacity: 0.94;
}
[data-dragging="pointer"] [data-drag-handle] { cursor: grabbing; }

/* Raised into the top layer while it is carried — see `lift` in 26-drag.js — so nothing
   around it can clip it. Pinned at the box it had in the list. */
[data-dragging="pointer"]:popover-open {
    position: fixed;
    inset: auto;
    /* rtl-ok: a viewport coordinate measured by getBoundingClientRect, which is physical in either direction */
    left: var(--drag-left);
    top: var(--drag-top);
    width: var(--drag-width);
    height: var(--drag-height);
    margin: 0;
    box-sizing: border-box;
}
/* The slot the raised item left, held open on a neighbour so nothing the reader aimed at
   moves. The script measures the size; see `lift` in 26-drag.js. */
[data-drag-slot="start"] { margin-block-start: var(--drag-slot-size); }
[data-drag-slot="end"]   { margin-block-end: var(--drag-slot-size); }
[data-drag-axis="x"] > [data-drag-slot="start"] { margin-block-start: 0; margin-inline-start: var(--drag-slot-size); }
[data-drag-axis="x"] > [data-drag-slot="end"]   { margin-block-end: 0; margin-inline-end: var(--drag-slot-size); }
[data-drag-slot="column"] { grid-column-start: var(--drag-slot-column); }

/* What the platform's popover style would otherwise paint on an item whose own classes
   say nothing about it. Zero specificity, so every class that does say — a .card's
   border, a .drag-item's padding — keeps its own. */
:where([data-dragging="pointer"]:popover-open) {
    padding: 0;
    border: 0;
    color: inherit;
    background-color: transparent;
    overflow: visible;
}

/* Picked up from the keyboard: it stays where it is, and the ring says it is held. An
   outline, so it is painted in forced colours as it is. */
[data-dragging="keyboard"] {
    outline: 2px solid var(--brand);
    outline-offset: 2px;
    box-shadow: var(--shadow-dropdown);
}

/* ── Carrying a selection ───────────────────────────────────────────────────
   The other selected items stay in place, faded, until the app moves them on the drop;
   the item in hand says how many are travelling. A number read off the attribute the
   script writes, so there is no word in it to translate — the sentence a screen reader
   hears is the app's own, through {items}. The border is what forced colours paints. */
[data-drag-carried] { opacity: 0.45; }
[data-drag-count]::after {
    content: attr(data-drag-count);
    position: absolute;
    inset-block-start: calc(var(--space-4) * -1);
    inset-inline-end: calc(var(--space-4) * -1);
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-width: 1.5rem;
    height: 1.5rem;
    padding-inline: var(--space-2);
    border: 1px solid transparent;
    border-radius: var(--radius-pill);
    background: var(--brand);
    color: var(--on-solid);
    box-shadow: var(--shadow-dropdown);
    font-size: var(--text-2);
    font-weight: 600;
    font-variant-numeric: tabular-nums;
    line-height: 1;
    pointer-events: none;
}

/* ── Zones while something is dragged ───────────────────────────────────────
   Outlines, not borders, so a zone lighting up moves nothing. The style of the line is
   the second channel beside its colour: dashed where it can go, dotted where it cannot,
   solid where it is about to. */
[data-drop-state] {
    outline: 2px dashed var(--border-strong);
    outline-offset: 2px;
}
[data-drop-state="valid"][data-drop-over] {
    outline-style: solid;
    outline-color: var(--brand);
    background-color: var(--brand-tint);
}
[data-drop-state="invalid"] {
    outline-style: dotted;
    outline-color: var(--border);
}
[data-drop-state="invalid"] > [data-drag-item] { opacity: 0.55; }
[data-drop-state="invalid"][data-drop-over] {
    outline-color: var(--danger-border-strong);
    background-color: var(--danger-bg);
    cursor: not-allowed;
}

/* ── The insertion line ─────────────────────────────────────────────────────
   Drawn on the item the drop lands in front of, or after the last one, centred in the
   gap between items. A zone class sets --drop-gap to its own gap; an app zone with a
   different gap sets it too, or the line sits against the item's edge. A border rather
   than a fill, because forced colours paints borders and flattens backgrounds. */
[data-drop-edge]::before {
    content: "";
    position: absolute;
    inset-inline: 0;
    height: 0;
    border-top: 3px solid var(--brand);
    border-radius: var(--radius-pill);
    pointer-events: none;
}
[data-drop-edge="before"]::before { top: calc(var(--drop-gap, 0px) / -2 - 1.5px); }
[data-drop-edge="after"]::before  { bottom: calc(var(--drop-gap, 0px) / -2 - 1.5px); }

/* Side by side, the line stands up between the two items. */
:is([data-drag-axis="x"], [data-drag-axis="grid"]) > [data-drop-edge]::before {
    inset-block: 0;
    inset-inline: auto;
    width: 0;
    height: auto;
    border-top: 0;
    border-inline-start: 3px solid var(--brand);
}
:is([data-drag-axis="x"], [data-drag-axis="grid"]) > [data-drop-edge="before"]::before {
    top: 0;
    inset-inline-start: calc(var(--drop-gap, 0px) / -2 - 1.5px);
}
:is([data-drag-axis="x"], [data-drag-axis="grid"]) > [data-drop-edge="after"]::before {
    bottom: 0;
    inset-inline-end: calc(var(--drop-gap, 0px) / -2 - 1.5px);
}
}

/* ── 63-drawer.css → @layer sedna.paint ──────────────────────────────────────────────── */
@layer sedna.paint {
/* ── Drawer / sheet ─────────────────────────────────────────────────────────
   A panel sliding in from an edge, for a secondary flow that should not lose the
   page behind it — a filter panel, a record's detail, a form on a phone.

   Drawer vs modal: a modal demands an answer before anything else happens, a drawer
   is a place to work that can be left. If dismissing it loses nothing, it is a
   drawer.

   The z-order is the documented pair: scrim 480, panel 490. Both sit BELOW the modal
   backdrop at 500 on purpose — a modal opened from inside a drawer must cover it,
   which is exactly the case that a single "overlay" rung gets wrong.

   Open/closed is `.drawer--open` on the wrapper, not a `hidden` attribute, because
   the panel has to stay in the DOM to slide. It is `visibility: hidden` when closed,
   which does take it out of the tab order and the accessibility tree — a panel that
   is merely translated off-screen is still tabbable, and Tab then walks the reader
   into a panel they cannot see.
   ─────────────────────────────────────────────────────────────────────────── */
.drawer-scrim {
    position: fixed;
    inset: 0;
    z-index: 480;
    background: var(--backdrop);
    opacity: 0;
    visibility: hidden;
    transition: opacity var(--motion-mid) ease, visibility var(--motion-mid);
}

.drawer {
    position: fixed;
    inset-block: 0;
    inset-inline-end: 0;
    z-index: 490;
    display: flex;
    flex-direction: column;
    /* The knob for a drawer that needs more room than a form — a record with a
       picture in it, a wide table. Set it on the drawer; the viewport still caps it. */
    --drawer-width: 420px;
    width: min(var(--drawer-width), 100vw);
    /* The panel reaches the bottom edge in both forms — a side drawer spans the
       block axis, a sheet sits on it — so the home indicator would cross the footer's
       buttons on a phone. Padding rather than an offset, so the inset is filled with
       the panel's OWN elevated surface: a margin would leave the page showing through
       under a dark sheet, in a strip the eye reads as the panel not quite landing.
       0px on anything without an indicator, so a desktop drawer is unchanged. */
    padding-block-end: var(--safe-block-end);
    --surface-here: var(--bg-elevated);
    background: var(--bg-elevated);
    border-inline-start: 1px solid var(--border);
    box-shadow: var(--shadow-modal);
    visibility: hidden;
    transform: translateX(100%);
    transition: transform var(--motion-mid) ease, visibility var(--motion-mid);
}
.drawer--start {
    inset-inline-end: auto;
    inset-inline-start: 0;
    border-inline-start: none;
    border-inline-end: 1px solid var(--border);
    transform: translateX(-100%);
}

/* A sheet comes from the bottom edge instead — the right shape on a phone, where a
   side panel has nowhere to come from.

   CAPPED AND CENTRED, the same way `.drawer` is capped at 420px rather than filling
   the inline axis. A sheet that spans a 1440px window puts its rounded top corners
   at the far edges of the screen and its actions in a narrow column on the left,
   which reads as a bar across the bottom rather than a panel. `min()` is what makes
   one rule serve both: on a phone the viewport is the smaller value and the sheet is
   edge to edge with the corners where they belong, on a desktop it is 480px and the
   auto margins centre it.

   480 rather than the drawer's 420 because a sheet is the landscape shape of the
   same panel — the row lockup inside it wants the width the drawer's stacked content
   does not.

   Three borders rather than only a top one, now that the inline edges no longer sit
   off-screen. `.drawer` draws the edge that faces the page and leaves the ones lying
   against the viewport bare; for a capped sheet that is three edges facing the page
   and one below the viewport, so the block-end edge is the one taken away. */
.sheet {
    inset-block: auto 0;
    inset-inline: 0;
    --drawer-width: 480px;
    width: min(var(--drawer-width), 100vw);
    margin-inline: auto;
    max-height: 85vh;
    border: 1px solid var(--border);
    border-bottom: none;
    border-start-start-radius: var(--radius-panel);
    border-start-end-radius: var(--radius-panel);
    transform: translateY(100%);
}

.drawer--open { visibility: visible; transform: none; }
.drawer--open + .drawer-scrim,
.drawer-scrim--open { opacity: 1; visibility: visible; }

/* ── As a <dialog> — the preferred form ─────────────────────────────────────
   A drawer with a scrim is modal in behaviour: the page behind it should not be
   reachable while it is open. So say so, and let the platform do it —
   `<dialog class="drawer">` opened with showModal() gets the focus trap,
   Escape-to-close and inert content behind, and its ::backdrop replaces
   .drawer-scrim entirely. A hand-rolled trap is a great deal of code that is usually
   subtly wrong.

   A top-layer dialog's containing block is the viewport, so the same insets apply
   unchanged.

   `transition-behavior: allow-discrete` plus `@starting-style` is what keeps the
   slide: a dialog goes from `display: none` to displayed, and a discrete property
   cannot be interpolated without opting in. Without them the panel simply appears —
   which is correct, just abrupt, so this degrades rather than breaks. */
dialog.drawer {
    /* The UA stylesheet centres a dialog with `margin: auto` and sizes it to its
       content with `width`/`height: fit-content`. Both fight the edge anchoring above,
       and both have to be given back here, or the panel is sized to its own text and
       vertically centred while every rule that put it at the edge still applies.
       `height: auto` is what lets `inset-block: 0` mean full height. */
    margin: 0;
    height: auto;
    /* The UA also sets BOTH inline insets to 0 — that is how `margin: auto` centres a
       dialog. With a definite width and no auto margins that over-constrains the box,
       and the inset the spec then ignores is the END one, which puts the panel on the
       START edge. Only one inset may bind. */
    inset-inline-start: auto;
    inset-inline-end: 0;
    max-width: none;
    max-height: none;
    /* Not `padding: 0`: this rule is (0,1,1) against `.drawer`'s (0,1,0), so the
       shorthand would drop the safe-area inset the class above sets. The UA's own
       dialog padding is what has to go; the bottom inset has to stay. */
    padding: 0 0 var(--safe-block-end);
    /* And the UA gives a dialog `border: solid`, which is 3px of currentColor — a
       near-white frame against the viewport edges. All four edges are restated here
       rather than left to `.drawer`, whose class selector this rule outranks. */
    border: none;
    border-inline-start: 1px solid var(--border);
    color: var(--fg);
    --dialog-backdrop: var(--backdrop);
    transition: transform var(--motion-mid) ease, display var(--motion-mid) allow-discrete, overlay var(--motion-mid) allow-discrete;
}
/* A closed dialog goes back to `display: none`. `.drawer`'s own `display: flex` would
   otherwise keep it in the document as a hidden fixed box, and it is what the
   `display` transition above animates between. */
dialog.drawer:not([open]) { display: none; }

/* The two variants, restated at this specificity because the rule above outranks their
   own class selectors. */
dialog.drawer.drawer--start {
    inset-inline-start: 0;
    inset-inline-end: auto;
    border-inline-start: none;
    border-inline-end: 1px solid var(--border);
}
/* A sheet is content-sized in the block axis and capped. The width and the auto
   margins come from `.sheet`, but the border has to be restated: `dialog.drawer`
   above sets `border: none` at (0,2,1) to shed the UA's 3px of currentColor, and
   that outranks `.sheet`'s own (0,1,0) edges. */
dialog.sheet {
    inset-inline: 0;
    height: fit-content;
    max-height: 85vh;
    /* `dialog.drawer` above sets `margin: 0`, which it has to — auto margins would
       fight the edge anchoring. A capped sheet needs them back, and this is the rule
       that outranks it. Without them `inset-inline: 0` plus a definite width is
       over-constrained, and the inset the spec drops is the END one: the sheet ends
       up flush against the inline START edge, at its capped width, on the wrong side
       of the screen in RTL. */
    margin-inline: auto;
    border: 1px solid var(--border);
    border-bottom: none;
}
dialog.drawer::backdrop { background: var(--dialog-backdrop); }
/* The div form hides itself when closed; a dialog is already display:none, so `open`
   is what makes it visible and the visibility rule must not fight it. */
dialog.drawer[open] { visibility: visible; transform: none; }
@starting-style {
    dialog.drawer[open] { transform: translateX(100%); }
    dialog.drawer.drawer--start[open],
    [dir="rtl"] dialog.drawer[open] { transform: translateX(-100%); }
    dialog.sheet[open] { transform: translateY(100%); }
}

.drawer-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-4);
    flex-shrink: 0;
    padding: var(--space-7) var(--space-8);
    border-bottom: 1px solid var(--border);
}
.drawer-header h3 { margin: 0; font-size: var(--text-6); font-weight: 600; }
/* The only scrolling part, so the header and footer stay put. */
.drawer-body { flex: 1; min-height: 0; overflow-y: auto; padding: var(--space-7) var(--space-8); }
/* Drops the padding, for content drawn edge to edge inside the panel — a list of
   actions, a table that already has its own cell padding. `.card-body--flush`'s
   sibling, and the same reason: the gutter is a gap the content then paints over.

   A row inside one takes the header's own inline inset instead of its usual smaller
   one. Two things go right at once: the icon column lines up under the header's
   title, and a row's hover fill runs the full width of the panel rather than
   floating inside a gutter, which is what made the fill look clipped at its start
   edge — 12px of row padding is not enough of a gap when the fill's own edge is
   20px further out. */
.drawer-body--flush { padding: 0; }
.drawer-body--flush > .list > li > .list-row,
.drawer-body--flush > .list > .sticky-group-head { padding-inline: var(--space-8); }
.drawer-footer {
    display: flex;
    align-items: center;
    justify-content: flex-end;
    gap: var(--space-4);
    flex-shrink: 0;
    padding: var(--space-7) var(--space-8);
    border-top: 1px solid var(--border);
}

/* A grab handle at the top of a sheet.

   Absolutely positioned INSIDE the header's own top padding rather than laid out
   as a row above it. As a flex row it added its 4px plus an 8px margin to
   everything below, which read as a separate lip stacked on a header that was
   already 61px — two bands of chrome before the first word. Overlapping costs
   nothing: the padding is empty space that exists anyway.

   The sheet is `position: fixed`, in both its div and its dialog form, so it is
   the containing block and no extra `position` is needed anywhere.

   Decoration only — it is not focusable and carries no name, and the close button
   is what makes a sheet dismissible for everyone. Dragging is an addition on top
   of that, never the only way out. */
.sheet-handle {
    position: absolute;
    inset-block-start: var(--space-3);
    inset-inline: 0;
    width: 36px;
    height: 4px;
    margin-inline: auto;
    background: var(--border-strong);
    border-radius: var(--radius-pill);
}
/* A sheet without a header has nothing else to grab, and 36 × 4px is not a target.
   The area is grown by a pseudo-element rather than by the box, so the handle stays
   the same 4px band inside the header padding it has to fit in. */
.sheet-handle::before {
    content: '';
    position: absolute;
    inset: -10px -24px;
}

/* ── Drag to dismiss ────────────────────────────────────────────────────────
   `data-sheet` on a `<dialog class="sheet">` opts into dragging it down to close —
   the gesture the handle has always been drawn to promise. Opt-in for the same
   reason `data-tabs` is: an app that drives its own gesture would otherwise have
   two things closing the same dialog.

   `touch-action: none` is what makes it work under a finger at all. Without it the
   browser claims the vertical drag for scrolling before the first pointermove is
   dispatched, and the sheet does not move. It is set only on the grips, so the body
   below still scrolls.

   `user-select: none` beside it, because a drag that starts on the title otherwise
   sweeps a selection across the sheet as it goes. A grip is a grip; the text a reader
   would want to copy is in the body.
   ─────────────────────────────────────────────────────────────────────────── */
dialog.sheet[data-sheet] > .sheet-handle,
dialog.sheet[data-sheet] > .drawer-header { touch-action: none; user-select: none; }
dialog.sheet[data-sheet] > .drawer-header { cursor: grab; }
dialog.sheet[data-sheet].sheet--dragging > .drawer-header { cursor: grabbing; }
/* The drag writes an inline transform each frame, so the transition has to be off
   for the duration or every frame animates towards the last one and the sheet
   lags the finger. */
dialog.sheet.sheet--dragging { transition: none; }

/* ── A sheet of actions ─────────────────────────────────────────────────────
   The phone form of a menu: one action per row, with room for a second line saying
   what it will do, and a target big enough for a thumb.

   NO CLASS OF ITS OWN. It is a real <ul class="list"> so the count is announced, one
   `<button class="list-row">` filling each <li>, and the divider comes from
   `.list > li + li`. `.list-row` resets the platform's button chrome and carries the
   hover, pressed, focus and disabled states, so there is one row class in the library
   rather than a second one that differs only in where it is used. The button reset
   and the three states live in 60-lists.css.

   The body is `.drawer-body--flush`, so the rows meet the sheet's edges and the sheet
   is the surface they sit on rather than a card inside a padded one.

   No fill of its own either. The sheet is already an elevated surface and a tint per
   row would be a second surface inside it; separation is the divider `.list` draws
   between its items, and the states are what say the row is live. */

/* ── Tree ───────────────────────────────────────────────────────────────────
   Nested structure the reader expands — a group hierarchy, a folder of files, a
   configuration path.

   Built from nested <ul> inside <details>, so expansion, keyboard operation and the
   announcement come from the platform. A real ARIA `role="tree"` needs single-tab-stop
   navigation with arrow keys through a flattened view, and implementing half of it is
   worse than not claiming it: <details> is honest about being a set of disclosures.

   The indent is one padding step per level, applied by nesting rather than by a
   `--depth` variable the markup has to keep correct. */
.tree, .tree ul { list-style: none; margin: 0; padding: 0; }
.tree ul { padding-inline-start: var(--space-7); border-inline-start: 1px solid var(--divider); margin-inline-start: var(--space-4); }
.tree li { font-size: var(--text-4); }

.tree summary,
.tree-leaf {
    display: flex;
    align-items: center;
    gap: var(--space-3);
    padding: var(--space-2) var(--space-3);
    border-radius: var(--radius-inner);
    color: var(--fg-soft);
    text-decoration: none;
    cursor: pointer;
    list-style: none;
}
.tree summary::-webkit-details-marker { display: none; }
.tree summary:hover,
.tree-leaf:hover { background: var(--bg-hover); color: var(--fg); text-decoration: none; }
.tree summary:focus-visible,
.tree-leaf:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--brand-ring-soft); }
.tree summary::before {
    content: '';
    flex-shrink: 0;
    width: 0;
    height: 0;
    border-block: 4px solid transparent;
    border-inline-start: 5px solid currentColor;
    opacity: 0.55;
    transition: transform var(--motion-fast) ease;
}
.tree details[open] > summary::before { transform: rotate(90deg); }
/* A leaf has no caret, so it needs the same indent to line up with its siblings. */
/* Same derivation as the nav group: a leaf has no caret, so it takes the summary's
   padding plus the space the caret and its gap would have occupied. */
.tree-leaf { padding-inline-start: calc(var(--space-3) + var(--space-6)); }
.tree-leaf[aria-current="true"] { background: var(--brand-tint); color: var(--brand-text); }
.tree-count { margin-inline-start: auto; color: var(--muted); font-size: var(--text-2); font-variant-numeric: tabular-nums; }
}

/* ── 64-files.css → @layer sedna.paint ──────────────────────────────────────────────── */
@layer sedna.paint {
/* ── Files ────────────────────────────────────────────────────────────────────
   The pieces a page of files is made from: the type icon, a name that keeps its
   extension when it is cut, the size-date-owner line, and the tile a folder of them
   is a grid of. A row of files is the library's own `.table` or `.list` with these
   in its cells; an upload or an attachment is a `.file-item` (47-form-file.css).

     <span class="file-icon file-icon--pdf"><i class="ri-file-pdf-2-line"></i></span>
     <span class="file-name" title="delivery-note-week-38-signed.pdf">
       <span class="file-name-start">delivery-note-week-38-</span><span class="file-name-end">signed.pdf</span>
     </span>
     <span class="file-meta"><span>84 KB</span><time datetime="2026-09-12">12 Sep</time><span>Alex Fischer</span></span>

   Nothing here fetches, sorts or selects. Which files there are, which are selected
   and what a click does are the app's.
   ─────────────────────────────────────────────────────────────────────────── */

/* ── The type icon ──────────────────────────────────────────────────────────
   A Remix Icon glyph on a small tile. The kind tints the glyph from the categorical
   series, so a PDF, a spreadsheet and a picture tell apart at a glance, and the glyph
   itself still says which — colour is never the only signal. Anything else keeps the
   neutral ink. A thumbnail may stand in for the glyph: an `<img>` fills the tile. */
.file-icon {
    --file-icon-size: 36px;
    --file-icon-ink: var(--muted);
    display: inline-flex;
    align-items: center;
    justify-content: center;
    flex-shrink: 0;
    width: var(--file-icon-size);
    height: var(--file-icon-size);
    border-radius: var(--radius-inner);
    background: var(--surface-strong);
    color: var(--file-icon-ink);
    overflow: hidden;
    line-height: 1;
}
.file-icon > i { font-size: var(--text-8); }
.file-icon > img { width: 100%; height: 100%; object-fit: cover; }
.file-icon--sm { --file-icon-size: 28px; }
.file-icon--sm > i { font-size: var(--text-6); }
.file-icon--pdf    { --file-icon-ink: var(--viz-1); }
.file-icon--image  { --file-icon-ink: var(--viz-2); }
.file-icon--slides { --file-icon-ink: var(--viz-3); }
.file-icon--sheet  { --file-icon-ink: var(--viz-4); }
.file-icon--media  { --file-icon-ink: var(--viz-5); }
.file-icon--doc    { --file-icon-ink: var(--viz-6); }

/* ── A name cut in the middle ───────────────────────────────────────────────
   `text-overflow` only cuts at the end, which is where a file name keeps what tells
   two files apart — `-signed.pdf`, `-v2.xlsx`. So the app splits the name in two and
   only the start is cut: the end, a few characters and the extension, never shrinks.
   Read aloud, the two spans are the whole name. Put the name in `title` too, for the
   pointer. A name that fits shows whole and the split is invisible. */
.file-name {
    display: flex;
    min-width: 0;
    max-width: 100%;
    white-space: nowrap;
}
.file-name-start { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.file-name-end { flex-shrink: 0; }
/* The whole name over as many lines as it takes, for a reader who has to see every
   character of a name they did not choose — an upload straight off a phone. A token
   with no break in it breaks anywhere rather than running out of the tile, and does
   not widen the column it sits in. The split spans, if the markup has them, simply
   run on as text. */
.file-name--wrap {
    display: block;
    white-space: normal;
    overflow-wrap: anywhere;
}

/* The size, the date, the owner — separated by a dot the stylesheet draws, so it is
   never copied or read out. Wraps rather than widening what it sits in. */
.file-meta {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    column-gap: var(--space-3);
    min-width: 0;
    color: var(--muted);
    font-size: var(--text-3);
    font-variant-numeric: tabular-nums;
}
.file-meta > * + *::before {
    content: '·';
    margin-inline-end: var(--space-3);
}

/* ── In a table ─────────────────────────────────────────────────────────────
   The name column's cell: the icon and the name beside it. A table cell sizes to its content, so the column is told to take
   what the `.col-fit` columns leave and to let the name be cut inside it, rather
   than pushing the table wider than its card. */
.file-cell {
    display: flex;
    align-items: center;
    gap: var(--space-5);
    min-width: 0;
}
a.file-cell { color: inherit; text-decoration: none; }
a.file-cell:hover .file-name { text-decoration: underline; }
.table td:has(> .file-cell) { width: 100%; max-width: 0; min-width: 10rem; }

/* ── A picture waiting to go ────────────────────────────────────────────────
   A thumbnail of a picture that is attached and not yet sent or uploaded, with the
   button that takes it back over its corner. It sits beside the `.chip`s of the other
   files — in a message composer, or above an upload form's submit.

     <span class="file-thumb">
       <img src="…" width="48" height="48" alt="The crates on the floor">
       <button class="chip-dismiss" type="button" aria-label="Remove the photo">…</button>
     </span> */
.file-thumb {
    position: relative;
    display: inline-block;
    flex-shrink: 0;
    width: 48px;
    height: 48px;
    border-radius: var(--radius-inner);
    overflow: hidden;
    border: 1px solid var(--border);
}
.file-thumb > img { display: block; width: 100%; height: 100%; object-fit: cover; }
.file-thumb > .chip-dismiss {
    position: absolute;
    top: var(--space-1);
    inset-inline-end: var(--space-1);
    background: var(--bg-elevated);
}

/* ── A grid of tiles ────────────────────────────────────────────────────────
   A folder as pictures: a real `<ul>`, breakpoint-free like `.gallery`, each tile a
   thumbnail or a large type icon over the name and the meta line. `--wide` is for
   tiles whose picture is a frame of a video, which need the room.

   The tile is three controls, never one element with a click handler: the checkbox
   selects — a `.form-check` label around it, named for the file — the link or button opens (and its hit area is the whole tile, under the
   other two), and the menu button acts. Selected is read from the checkbox, so it
   needs no class and no script. */
.file-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(min(136px, 100%), 1fr));
    gap: var(--space-6);
    margin: 0;
    padding: 0;
    list-style: none;
}
.file-grid--wide { grid-template-columns: repeat(auto-fill, minmax(min(240px, 100%), 1fr)); }

.file-tile {
    position: relative;
    display: flex;
    flex-direction: column;
    gap: var(--space-2);
    min-width: 0;
    padding: var(--space-3);
    border: 1px solid var(--border);
    border-radius: var(--radius-surface);
    background: var(--surface-here, var(--bg));
    transition: border-color var(--motion-fast), background var(--motion-fast);
}
.file-tile:hover { border-color: var(--border-hover); }
.file-tile:has(.file-tile-check :checked) {
    border-color: var(--brand);
    box-shadow: inset 0 0 0 1px var(--brand);
    background-image: linear-gradient(var(--brand-tint), var(--brand-tint));
}
.file-tile:has(.file-tile-open:focus-visible),
.file-tile:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--brand-ring-soft); }

/* The picture: square, cropped to fill, with the type icon large in the middle when
   there is no picture to show. */
.file-tile-thumb {
    position: relative;
    display: flex;
    align-items: center;
    justify-content: center;
    aspect-ratio: 1;
    overflow: hidden;
    border-radius: var(--radius-inner);
    background: var(--surface-soft);
}
.file-tile-thumb > img { width: 100%; height: 100%; object-fit: cover; }
.file-tile-thumb > .file-icon { --file-icon-size: 56px; background: transparent; }
.file-tile-thumb > .file-icon > i { font-size: var(--text-11); }
.file-grid--wide .file-tile-thumb { aspect-ratio: 16 / 9; }
/* A picture that must not be cropped — a logo, a screenshot — in either of two shapes.
   `--contain` keeps the tile's frame and fits the whole picture inside it, so a row of
   tiles stays level. `--natural` drops the frame and takes the picture's own ratio,
   from its width and height attributes before it loads, so a 16:9 logo is a 16:9
   tile and the row goes ragged. Without a picture, both keep the frame, or a type
   icon would collapse the tile to its own height. */
.file-tile-thumb--contain > img { object-fit: contain; }
.file-tile-thumb.file-tile-thumb--natural:has(> img) { aspect-ratio: auto; }
.file-tile-thumb--natural > img { height: auto; object-fit: contain; }

/* Opens it. The link's own box is the name; its `::after` is the tile, so a click
   anywhere that is not the checkbox or the menu opens the file. */
.file-tile-open {
    display: flex;
    flex-direction: column;
    gap: var(--space-3);
    min-width: 0;
    padding: 0;
    border: none;
    background: none;
    color: var(--fg);
    font: inherit;
    font-size: var(--text-3);
    font-weight: 500;
    text-align: start;
    text-decoration: none;
    cursor: pointer;
}
.file-tile-open:focus-visible { outline: none; }
.file-tile-open::after { content: ''; position: absolute; inset: 0; border-radius: inherit; }
.file-tile-open:hover { text-decoration: none; }

/* The checkbox over the thumbnail's corner and the menu over the other, both above
   the stretched link. Shown on hover, on focus, and once anything is selected — a
   touch screen has no hover, so a selection in progress keeps every box in reach. */
.file-tile-check,
.file-tile-menu {
    position: absolute;
    top: var(--space-5);
    z-index: 1;
    opacity: 0;
    transition: opacity var(--motion-fast);
}
.file-tile-check { inset-inline-start: var(--space-5); }
.file-tile-menu { inset-inline-end: var(--space-5); }
.file-tile-menu.btn { background: var(--bg-elevated); }
.file-tile:hover :is(.file-tile-check, .file-tile-menu),
.file-tile:focus-within :is(.file-tile-check, .file-tile-menu),
.file-tile-check:has(:checked),
.file-grid:has(.file-tile-check :checked) .file-tile-check { opacity: 1; }
@media (hover: none) {
    .file-tile-check,
    .file-tile-menu { opacity: 1; }
}
/* An action a reader must find without knowing to hover for it — the one thing a
   tile is for, such as taking it back out of a selection. On the button, so the
   checkbox keeps its own rule. */
.file-tile-menu--always { opacity: 1; }
.file-tile > .file-meta { font-size: var(--text-2); }

/* A label on the picture's corner — a video's duration, a page count. */
.file-tile-badge {
    position: absolute;
    inset-block-end: var(--space-2);
    inset-inline-end: var(--space-2);
    padding: 0 var(--space-3);
    border-radius: var(--radius-inner);
    background: var(--media-scrim);
    color: var(--on-solid);
    font-size: var(--text-2);
    font-weight: 600;
    line-height: 1.6;
    font-variant-numeric: tabular-nums;
}
/* A video's thumbnail says it plays: a round play glyph in the middle. */
.file-tile-play {
    position: absolute;
    inset: 0;
    margin: auto;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 40px;
    height: 40px;
    border-radius: 50%;
    background: var(--media-scrim);
    color: var(--on-solid);
    font-size: var(--text-8);
}
}

/* ── 64-lightbox.css → @layer sedna.paint ──────────────────────────────────────────────── */
@layer sedna.paint {
/* ── Lightbox ───────────────────────────────────────────────────────────────
   One attachment, large: a picture or a video as big as the screen allows, a
   document in a frame, with its name, its caption, the way to the next one and
   the way out. A `<dialog>` opened with `showModal()`, so the top layer, the focus
   trap, Escape and inert content behind are the platform's — the same reasons as
   `.modal`, and the same `ShowModalAsync` from C#:

     <dialog class="lightbox" id="lb-1" aria-labelledby="lb-1-title">
       <div class="lightbox-head">
         <strong class="lightbox-title" id="lb-1-title">racking-bay-4209.jpg</strong>
         <span class="lightbox-meta">2 of 5 · 1.2 MB</span>
         <div class="lightbox-actions">
           <a class="btn btn-ghost btn-icon" href="…" download aria-label="Download"><i class="ri-download-2-line"></i></a>
           <form method="dialog"><button class="btn btn-ghost btn-icon" aria-label="Close"><i class="ri-close-line"></i></button></form>
         </div>
       </div>
       <div class="lightbox-stage"><img src="…" alt="…" /></div>
       <button class="lightbox-prev" type="button" aria-label="Previous"><i class="ri-arrow-left-s-line"></i></button>
       <button class="lightbox-next" type="button" aria-label="Next"><i class="ri-arrow-right-s-line"></i></button>
       <p class="lightbox-caption">Bay 4 after the racking was moved.</p>
     </dialog>

   Which picture is in it is the app's state — one dialog whose stage the app
   re-renders, or one dialog per attachment — and so are the arrow keys: the buttons
   are there for the pointer and the keyboard tabs to them. The chrome is drawn in
   `--on-solid` over the backdrop, which is dark in both variants.
   ─────────────────────────────────────────────────────────────────────────── */
dialog.lightbox {
    --lightbox-bar: 56px;   /* the head's and the caption's height, which the stage keeps clear */
    inset: 0;
    margin: 0;
    width: 100vw;
    height: 100dvh;
    max-width: none;
    max-height: none;
    padding: 0;
    border: none;
    background: transparent;
    color: var(--on-solid);
    overflow: hidden;
}
dialog.lightbox::backdrop {
    background: var(--backdrop);
    backdrop-filter: blur(4px);
}
/* The dialog's own ground, over the blurred backdrop: a picture on a translucent
   backdrop shows the page through its letterbox. */
dialog.lightbox::before {
    content: '';
    position: absolute;
    inset: 0;
    background: var(--backdrop);
}

/* The picture, as large as the viewport allows and no larger, clear of the head,
   the caption and the device's edges. */
.lightbox-stage {
    position: absolute;
    inset: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: calc(var(--lightbox-bar) + var(--safe-block-start)) calc(var(--space-11) + var(--safe-inline-end)) calc(var(--lightbox-bar) + var(--safe-block-end)) calc(var(--space-11) + var(--safe-inline-start));
}
.lightbox-stage > img,
.lightbox-stage > video {
    max-width: 100%;
    max-height: 100%;
    object-fit: contain;
    border-radius: var(--radius-surface);
    box-shadow: var(--shadow-float);
}
/* A document rather than a picture: a PDF, a text file. The frame is the whole
   stage, on the elevated surface so its own page reads. */
.lightbox-frame {
    width: 100%;
    height: 100%;
    border: none;
    border-radius: var(--radius-surface);
    background: var(--bg-elevated);
    box-shadow: var(--shadow-float);
}

.lightbox-head {
    position: absolute;
    top: var(--safe-block-start);
    inset-inline: var(--safe-inline-start) var(--safe-inline-end);
    display: flex;
    align-items: center;
    gap: var(--space-5);
    min-height: var(--lightbox-bar);
    padding: var(--space-4) var(--space-6);
    /* Above the stage, which is later in the source and covers the whole dialog. */
    z-index: 1;
}
.lightbox-title {
    flex: 1;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    font-size: var(--text-5);
}
.lightbox-meta {
    font-size: var(--text-3);
    opacity: 0.75;
    font-variant-numeric: tabular-nums;
    white-space: nowrap;
}
.lightbox-actions { display: flex; align-items: center; gap: var(--space-2); }
/* The library's ghost buttons, in the chrome's own ink: on the backdrop a ghost's
   text colour is the page's, which is the wrong one in the light variant. */
.lightbox-actions > .btn,
.lightbox-actions > form > .btn { color: var(--on-solid); }
.lightbox-actions > .btn:hover,
.lightbox-actions > form > .btn:hover { color: var(--on-solid); background: var(--backdrop); border-color: transparent; }

/* The way to the neighbours: round, on the sides, over the stage's padding. */
.lightbox-prev,
.lightbox-next {
    position: absolute;
    top: 50%;
    transform: translateY(-50%);
    z-index: 1;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 44px;
    height: 44px;
    border: none;
    border-radius: 50%;
    background: var(--backdrop);
    color: var(--on-solid);
    font-size: var(--text-8);
    cursor: pointer;
}
.lightbox-prev { inset-inline-start: calc(var(--space-4) + var(--safe-inline-start)); }
.lightbox-next { inset-inline-end: calc(var(--space-4) + var(--safe-inline-end)); }
.lightbox-prev:hover, .lightbox-next:hover { background: var(--bg-elevated); color: var(--fg); }
.lightbox-prev:focus-visible, .lightbox-next:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--brand-ring); }
.lightbox-prev:disabled, .lightbox-next:disabled { opacity: 0.35; cursor: not-allowed; }

.lightbox-caption {
    position: absolute;
    bottom: var(--safe-block-end);
    inset-inline: var(--safe-inline-start) var(--safe-inline-end);
    margin: 0;
    padding: var(--space-4) var(--space-8);
    min-height: var(--lightbox-bar);
    z-index: 1;
    display: flex;
    align-items: center;
    justify-content: center;
    text-align: center;
    font-size: var(--text-3);
    opacity: 0.85;
}

/* ── The thing that opens one ───────────────────────────────────────────────
   A picture that can be looked at large is a button around it. Cursor and focus
   ring, nothing else: the picture is the button's whole face. */
.media-open {
    display: block;
    padding: 0;
    border: none;
    border-radius: var(--radius-surface);
    background: none;
    cursor: zoom-in;
    overflow: hidden;
}
.media-open > img,
.media-open > .media-frame { display: block; }
.media-open:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--brand-ring-soft); }
}

/* ── 64-media-player.css → @layer sedna.paint ──────────────────────────────────────────────── */
@layer sedna.paint {
/* ── Video and audio ──────────────────────────────────────────────────────────
   The frame around the platform's own player. The `<video controls>` or
   `<audio controls>` is the app's and stays native — play, seek, volume, captions,
   picture-in-picture, full screen and the keyboard are the browser's, and none of it
   is redrawn here. What the library adds is what the element does not carry: the
   title, the length and the date, the actions, and the transcript.

     <figure class="media-player">
       <video controls preload="metadata" poster="…" width="1280" height="720">
         <source src="…" type="video/mp4" />
         <track kind="captions" src="…" srclang="en" label="English" />
       </video>
       <figcaption class="media-player-body">
         <span class="media-player-text">
           <strong class="media-player-title">Receiving a pallet at dock 4</strong>
           <span class="file-meta"><span>4:32</span><span>12 Sep 2026</span></span>
         </span>
         <span class="media-player-actions">…</span>
       </figcaption>
       <details class="media-transcript">…</details>
     </figure>

   A `<track kind="captions">` is what puts the captions button in the native
   controls; the transcript is the same words, readable and searchable without playing.
   ─────────────────────────────────────────────────────────────────────────── */
.media-player {
    display: flex;
    flex-direction: column;
    min-width: 0;
    margin: 0;
    border: 1px solid var(--border);
    border-radius: var(--radius-surface);
    background: var(--surface-here, var(--bg));
    overflow: hidden;
}
/* The picture on the letterbox, at its frame's ratio before a byte has loaded, so the
   page does not jump when the metadata arrives. A different ratio is the element's
   own `aspect-ratio`, set inline or by the app. */
.media-player > video {
    display: block;
    width: 100%;
    height: auto;
    aspect-ratio: 16 / 9;
    object-fit: contain;
    background: var(--media-bg);
}
/* The title, the meta line and the actions: under a video, or the whole of an audio
   player with the `<audio>` in the text column. */
.media-player-body {
    display: flex;
    align-items: center;
    gap: var(--space-6);
    min-width: 0;
    padding: var(--space-5) var(--space-6);
}
.media-player-text {
    flex: 1;
    min-width: 0;
    display: flex;
    flex-direction: column;
    gap: var(--space-1);
}
.media-player-title {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    color: var(--fg);
    font-size: var(--text-5);
    font-weight: 600;
}
.media-player-actions { display: flex; align-items: center; gap: var(--space-1); flex-shrink: 0; }
/* The native audio control takes the text column's width, under the title. */
.media-player-text > audio {
    display: block;
    width: 100%;
    height: var(--control-height);
    margin-top: var(--space-3);
}
/* On a phone the actions go under the text rather than squeezing the title to
   nothing. */
@media (max-width: 560px) {
    .media-player-body { flex-wrap: wrap; }
    .media-player-body > .media-player-actions { margin-inline-start: auto; }
}

/* ── Transcript ─────────────────────────────────────────────────────────────
   What was said, with the time it was said, under the player — a `<details>`, so it
   opens with no script. Each time is a button the app wires to `currentTime`; the
   line being spoken now carries `aria-current="true"`, set by the app from the
   element's `timeupdate`. Long transcripts scroll inside their own box. */
.media-transcript { border-top: 1px solid var(--divider); }
.media-transcript > summary {
    display: flex;
    align-items: center;
    gap: var(--space-4);
    padding: var(--space-4) var(--space-6);
    color: var(--fg-soft);
    font-size: var(--text-3);
    font-weight: 600;
    cursor: pointer;
    list-style: none;
}
.media-transcript > summary::-webkit-details-marker { display: none; }
.media-transcript > summary:hover { background: var(--bg-hover); }
.media-transcript > summary:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--brand-ring-soft); }
.media-transcript > summary::after {
    content: '';
    margin-inline-start: auto;
    width: 0;
    height: 0;
    border-inline: 4px solid transparent;
    border-top: 5px solid currentColor;
    opacity: 0.5;
    transition: transform var(--motion-fast) ease;
}
.media-transcript[open] > summary::after { transform: rotate(-180deg); }
.media-transcript > ol {
    margin: 0;
    padding: 0 0 var(--space-4);
    list-style: none;
    max-height: 16rem;
    overflow-y: auto;
}
.media-transcript li {
    display: grid;
    grid-template-columns: auto minmax(0, 1fr);
    align-items: baseline;
    gap: var(--space-5);
    padding: var(--space-2) var(--space-6);
    color: var(--fg-soft);
    font-size: var(--text-4);
    line-height: 1.5;
}
.media-transcript li[aria-current="true"] {
    background: var(--brand-tint);
    box-shadow: inset 3px 0 0 var(--brand);
    color: var(--fg);
}
.media-transcript-time {
    padding: 0;
    border: none;
    background: none;
    color: var(--brand-text);
    font-family: var(--font-mono);
    font-size: var(--text-3);
    font-variant-numeric: tabular-nums;
    cursor: pointer;
}
.media-transcript-time:hover { text-decoration: underline; }
.media-transcript-time:focus-visible { outline: none; border-radius: var(--radius-small); box-shadow: 0 0 0 2px var(--brand-ring-soft); }

/* ── A track in a list ──────────────────────────────────────────────────────
   A playlist is a `.list`: the row is a button that plays, the length is the
   `.list-meta`, and the one playing is `aria-current="true"`. The number column is a
   fixed width so one to twelve, and the glyph, line up, and the playing row's number is the app's
   equalizer glyph in the brand. */
.media-track-num {
    flex-shrink: 0;
    width: 20px;
    color: var(--muted);
    font-size: var(--text-3);
    font-variant-numeric: tabular-nums;
    text-align: center;
}
.media-track-num > i { font-size: var(--text-6); }
[aria-current="true"] > .media-track-num { color: var(--brand-text); }

/* ── A waveform ─────────────────────────────────────────────────────────────
   A voice message, a call recording, a dictated note: play, the bars, how long. The
   bars are `<i>` elements with their height in `--h`; the ones already heard carry
   `.audio-wave-heard`. The `<audio>` is the app's and hidden — the bars are the
   picture of it, and the play button's name says the length.

   Every bar is a fixed width with a fixed gap, so a waveform is as wide as its bars
   and looks the same in a list row, a card and a chat bubble. Stretched to its
   container instead, the same recording drew fat bars in a wide row and hairlines in
   a bubble that shrinks to its content. On a narrow screen the bars that do not fit
   are cut off at the end, and the time never wraps. */
.audio-wave {
    display: flex;
    align-items: center;
    gap: var(--space-4);
    max-width: 100%;
}
.audio-wave > .btn { flex-shrink: 0; border-radius: 50%; }
.audio-wave-bars {
    display: flex;
    align-items: center;
    gap: var(--space-1);
    flex: 0 1 auto;
    min-width: 0;
    height: 28px;
    overflow: hidden;
}
.audio-wave-bars > i {
    --h: 30%;
    flex: none;
    width: 3px;
    height: var(--h);
    min-height: 3px;
    border-radius: var(--radius-pill);
    background: var(--muted);
    opacity: 0.6;
}
.audio-wave-bars > .audio-wave-heard { background: var(--brand); opacity: 1; }
.audio-wave-time {
    flex-shrink: 0;
    white-space: nowrap;
    font-size: var(--text-3);
    color: var(--muted);
    font-variant-numeric: tabular-nums;
}
}

/* ── 64-palette-spotlight.css → @layer sedna.paint ──────────────────────────────────────────────── */
@layer sedna.paint {
/* ── Command palette ────────────────────────────────────────────────────────
   The ⌘K / Ctrl-K box: type, see matching commands, run one.

   A <dialog> opened with showModal(), which is the reason it can be this small a
   piece of CSS: the top layer, the focus trap, Escape and inert-behind all come from
   the platform. Positioned near the top rather than centred, because the list grows
   downwards and a centred box moves under the reader's eye as they type.

   The list is a real role="listbox" of role="option" items, with the input as the
   combobox that owns it through aria-activedescendant. That claim is made only
   because sednaUi.palette implements the keyboard contract behind it in full:
   arrows, Home/End, Enter, and the highlight moving while focus stays in the input.
   aria-selected on a plain button is invalid and axe rejects it.

   No fuzzy-match library. The scorer is `ui._.score` in js-parts/00-core.js, shared
   with the header search — this package loads nothing at runtime.
   ─────────────────────────────────────────────────────────────────────────── */
/* Any class that gives a <dialog> a display has to gate it on [open], or it overrides
   the UA's `display: none` for a closed dialog. The palette is built once and reused,
   so without the gate it stays in the page as a 560px panel below the content from the
   first Ctrl-K onwards. */
dialog.palette[open] {
    display: flex;
    flex-direction: column;
}
dialog.palette {
    width: min(560px, calc(100vw - 32px));
    max-height: 60vh;
    margin: 12vh auto auto;
    padding: 0;
    overflow: hidden;
    --surface-here: var(--bg-elevated);
    background: var(--bg-elevated);
    border: 1px solid var(--border);
    border-radius: var(--radius-panel);
    box-shadow: var(--shadow-pop);
    color: var(--fg);
    --dialog-backdrop: var(--backdrop);
}
dialog.palette::backdrop { background: var(--dialog-backdrop); backdrop-filter: blur(2px); }

.palette-input {
    flex-shrink: 0;
    width: 100%;
    padding: var(--space-7) var(--space-7);
    background: none;
    border: none;
    border-bottom: 1px solid var(--border);
    color: var(--fg);
    font-family: inherit;
    font-size: var(--text-6);
}
.palette-input:focus { outline: none; }
.palette-input::placeholder { color: var(--border-strong); }

.palette-list { flex: 1; min-height: 0; overflow-y: auto; padding: var(--space-3); margin: 0; list-style: none; }
.palette-item {
    display: flex;
    align-items: center;
    gap: var(--space-5);
    width: 100%;
    padding: var(--space-4) var(--space-5);
    background: none;
    border: none;
    border-radius: var(--radius-inner);
    color: var(--fg-soft);
    font-family: inherit;
    font-size: var(--text-4);
    text-align: start;
    text-decoration: none;
    cursor: pointer;
}
.palette-item i { font-size: var(--text-7); color: var(--muted); flex-shrink: 0; }
.palette-item-note { margin-inline-start: auto; color: var(--muted); font-size: var(--text-2); flex-shrink: 0; }
/* Highlighted by keyboard, which is a different thing from :hover — the pointer must
   not move the keyboard's position. The app sets aria-selected as it arrows. */
.palette-item[aria-selected="true"] { background: var(--brand-tint); color: var(--brand-text); }
.palette-item[aria-selected="true"] i { color: var(--brand-text); }
.palette-item:hover { background: var(--bg-hover); color: var(--fg); }
.palette-group {
    padding: var(--space-5) var(--space-5) var(--space-2);
    color: var(--muted);
    font-size: var(--text-1);
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.08em;
}
.palette-empty { padding: var(--space-9) var(--space-7); color: var(--muted); font-size: var(--text-4); text-align: center; }
.palette-footer {
    flex-shrink: 0;
    display: flex;
    align-items: center;
    gap: var(--space-6);
    padding: var(--space-4) var(--space-7);
    border-top: 1px solid var(--border);
    background: var(--surface-soft);
    color: var(--muted);
    font-size: var(--text-2);
}

/* A keyboard hint. Not a token-worthy surface of its own — it reuses the code tint,
   because that is what it is: a literal the reader types. */
.kbd {
    display: inline-flex;
    align-items: center;
    padding: 1px var(--space-2);
    background: var(--code-bg);
    border: 1px solid var(--border);
    border-radius: var(--radius-small);
    color: var(--code-fg);
    font-family: var(--font-mono);
    font-size: var(--text-2);
    line-height: 1.5;
}

/* ── Spotlight ──────────────────────────────────────────────────────────────
   Dims the page except one element, for a guided tour or a first-run hint.

   z-index 510 is the reserved rung between the modal backdrop (500) and the
   popover/dropdown band (550): a spotlight has to cover a modal it is explaining,
   and still be covered by a dropdown opened inside the thing it highlights.

   The hole is a box-shadow spread rather than a clip-path or four dimming strips: one
   element, no arithmetic, and the ring follows the highlighted element's own border
   radius. The app positions .spotlight-hole over the target.

   pointer-events: none on the dim, so the highlighted control is still operable —
   a tour that blocks the thing it is pointing at teaches nothing. */
.spotlight-hole {
    position: absolute;
    z-index: 510;
    border-radius: var(--radius-surface);
    pointer-events: none;
    /* 100vmax spread: large enough to cover any viewport, so the "hole" is simply
       the un-shadowed box. */
    box-shadow: 0 0 0 100vmax var(--spotlight-dim);
    /* The hole slides between steps rather than teleporting, so the reader's eye
       carries from one target to the next. `follow()` re-places it on scroll, resize
       and re-render, and this is what keeps that from reading as a stutter. */
    transition:
        top var(--motion-slow) ease,
        left var(--motion-slow) ease,
        width var(--motion-slow) ease,
        height var(--motion-slow) ease;
}
.spotlight-ring { box-shadow: 0 0 0 100vmax var(--spotlight-dim), 0 0 0 2px var(--brand); }

/* The explanatory bubble. Positioned by the app beside the hole. */
.spotlight-tip {
    position: absolute;
    z-index: 510;
    max-width: 300px;
    padding: var(--space-6) var(--space-7);
    --surface-here: var(--bg-elevated);
    background: var(--bg-elevated);
    border: 1px solid var(--border);
    border-radius: var(--radius-surface);
    box-shadow: var(--shadow-pop);
    color: var(--fg-soft);
    font-size: var(--text-4);
}
.spotlight-tip-title { display: block; color: var(--fg); font-weight: 600; margin-bottom: var(--space-2); }
.spotlight-tip-actions { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); margin-top: var(--space-5); }
.spotlight-step { color: var(--muted); font-size: var(--text-2); font-variant-numeric: tabular-nums; }

/* ── The sectioned bubble ────────────────────────────────────────────────────
   A hint is a title, a line of prose and two buttons, which the padded box above
   holds on its own. A step in a real tour needs more: a way out that is not "Next",
   a body that can run to a paragraph, and a footer that reads as a footer.

   So the same bubble takes sections. The container drops its own padding when it
   has a head, because each section then carries its own — one class to add, no
   modifier to remember, and the simple case is untouched. */
.spotlight-tip:has(.spotlight-tip-head) { padding: 0; }
.spotlight-tip-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-5);
    padding: var(--space-6) var(--space-7) 0;
}
.spotlight-tip-body { padding: var(--space-4) var(--space-7) var(--space-6); line-height: 1.55; }
.spotlight-tip-body strong { color: var(--fg); }
.spotlight-tip:has(.spotlight-tip-head) .spotlight-tip-actions {
    margin-top: 0;
    padding: var(--space-5) var(--space-7);
    border-top: 1px solid var(--border);
}

/* The way out of the tour. Quiet on purpose: it sits beside the title, where a
   close button is expected, and must not compete with the step's own action. */
.spotlight-tip-exit {
    flex-shrink: 0;
    padding: var(--space-1) var(--space-3);
    background: none;
    border: none;
    border-radius: var(--radius-small);
    color: var(--muted);
    font-family: inherit;
    font-size: var(--text-2);
    cursor: pointer;
}
.spotlight-tip-exit:hover { background: var(--bg-hover); color: var(--fg); }

/* "Now do this on the page" — the marker for a step the reader advances themselves
   rather than by pressing Next. It wears the control-change colours because that is
   what it announces: the next thing to happen is the reader's doing. */
.spotlight-tip-do {
    display: inline-flex;
    align-items: center;
    gap: var(--space-3);
    padding: var(--space-2) var(--space-5);
    background: var(--warn-bg);
    border: 1px solid var(--warn-border);
    border-radius: var(--radius-pill);
    color: var(--warn-fg);
    font-size: var(--text-2);
}
.spotlight-tip-do i { animation: sedna-pulse var(--pulse-duration) ease-in-out infinite; }

/* ── Over an open modal <dialog> ─────────────────────────────────────────────
   A dialog opened with showModal() is in the top layer, which no z-index reaches, so
   the 510 above cannot put a spotlight over one. `sednaUi.spotlight` raises the hole
   and the bubble into the top layer after the dialog instead — as manual popovers —
   and moves the bubble into the dialog, which is what keeps its buttons usable: the
   platform makes everything outside an open modal dialog inert, and inertness follows
   the DOM rather than the paint order.

   These rules undo the UA's popover box, which is centred by `inset: 0` with
   `margin: auto` and drawn as a bordered, padded panel in the system colours. Fixed,
   because a top-layer element's containing block is the viewport whatever its
   ancestors are — and that is the coordinate space the script writes while a step is
   raised. A raised spotlight is the same spotlight; nothing here is a second look. */
.spotlight-hole:popover-open,
.spotlight-tip:popover-open {
    position: fixed;
    inset: auto;
    margin: 0;
    overflow: visible;
}
/* The hole draws nothing of its own — the dim is a shadow around an empty box — so
   the UA's border, padding and background have to go, or the highlighted control is
   covered by a panel instead of revealed. */
.spotlight-hole:popover-open { padding: 0; border: 0; background: none; }

/* ── The interaction lock ────────────────────────────────────────────────────
   `sednaUi.spotlight.lock()` puts `.spotlight-lock` on <body> while a step is live,
   and the page stops accepting input except where the step needs it: the bubble, and
   whatever the step marked `.spotlight-allowed`.

   This layer kills the hover feedback; the capture-phase guards in the script are the
   behavioural half, and neither is a security boundary. Scroll containers are plain
   elements, so scrolling is untouched, and Blazor's reconnect UI stays usable — a
   dropped circuit has to be recoverable mid-step. */
body.spotlight-lock :is(button, a, input, select, textarea, label) { pointer-events: none; }
body.spotlight-lock :is(.spotlight-tip, .spotlight-allowed),
body.spotlight-lock :is(.spotlight-tip, .spotlight-allowed) :is(button, a, input, select, textarea, label) {
    pointer-events: auto;
}
/* Held apart from the rule above rather than folded into its `:is()`: an `:is()` takes
   the specificity of its most specific argument, so an id inside it would lift the
   whole selector over the opt-out below. */
body.spotlight-lock #components-reconnect-modal,
body.spotlight-lock #components-reconnect-modal :is(button, a) { pointer-events: auto; }
/* …but a control the step explicitly blocks stays dead even inside the live anchor —
   the Cancel beside the button the step is about. The extra class in the selector is
   what keeps this above the re-enable rule. */
body.spotlight-lock .spotlight-allowed [data-spotlight-block],
body.spotlight-lock .spotlight-allowed [data-spotlight-block] :is(button, a, input, select, textarea, label) {
    pointer-events: none;
}
}

/* ── 65-popover.css → @layer sedna.paint ──────────────────────────────────────────────── */
@layer sedna.paint {
/* ── Popover ────────────────────────────────────────────────────────────────
   A small panel of *content* anchored to the control that opened it: why a row
   is pending, what a setting does, the detail behind a number.

     <button class="btn" popovertarget="sla-detail">Why is this overdue?</button>

     <div class="popover" id="sla-detail" popover>
       <strong class="popover-title">Breached 2h ago</strong>
       <p>Priority 2 carries a four-hour response target…</p>
     </div>

   Not a .menu. A menu is a list of *actions* and its items are links and
   buttons; this is prose, a definition, a small table. Two panels that look
   alike but mean different things is how a design system starts to rot, so the
   difference is in what goes inside, and the surfaces are deliberately distinct:
   a menu is flush-padded for rows, this one has reading padding.

   THE `popover` ATTRIBUTE IS REQUIRED, and so is opening it from a
   `popovertarget` control. The platform then supplies everything this file would
   otherwise need JavaScript for — the top layer, light dismiss, Escape, focus
   handling, and `:popover-open`. There is no sednaUi.popover and there does
   not need to be one — with one exception the platform does not cover:
   22-anchored.js closes an open popover when a scroll moves its invoker, because
   an anchored panel's offset is computed at reveal and never recomputed while the
   anchor scrolls. It finds the invoker through `popovertarget`, which is why that
   attribute being required below is load-bearing twice over.

   `popovertarget` also makes the invoker the popover's IMPLICIT ANCHOR, which is
   what lets `position-area` place this with no `anchor-name` anywhere. That
   matters more than it looks: the alternative is a unique --dashed-ident per
   instance, which means an inline style on every popover in the app.

   The top layer ignores z-index entirely and paints by promotion order, so the
   550 below never applies to a real popover. It is there for the fallback path
   in the z-order table, and so that a panel carrying this class while something
   has gone wrong lands on the rung the documentation says it does.
   ─────────────────────────────────────────────────────────────────────────── */
.popover {
    /* inset: auto and margin: 0 undo the UA rule that centres a popover in the
       viewport. Without both, position-area has nothing to do. */
    inset: auto;
    margin: 0;
    position: fixed;
    position-area: block-end span-inline-end;
    /* Flips above the trigger when there is no room below. The flip takes the
       margin with it, so the gap stays on the side facing the invoker. */
    position-try-fallbacks: flip-block;
    margin-block-start: var(--space-3);
    z-index: 550;

    width: max-content;
    max-width: min(320px, calc(100vw - 32px));
    padding: var(--space-6) var(--space-7);
    --surface-here: var(--bg-elevated);
    background: var(--bg-elevated);
    border: 1px solid var(--border);
    border-radius: var(--radius-surface);
    box-shadow: var(--shadow-pop);
    color: var(--fg-soft);
    font-size: var(--text-4);
    line-height: 1.6;
    /* A popover is written next to the control that opens it, and that is often a
       table header cell — which is uppercase, spaced, bold and muted, and every one
       of those is inherited. A column's filter opened from its header came out as a
       panel of shouting labels. */
    font-weight: 400;
    letter-spacing: normal;
    text-align: start;
    text-transform: none;
    white-space: normal;
}
.popover > :first-child { margin-top: 0; }
.popover > :last-child { margin-bottom: 0; }

.popover-title {
    display: block;
    margin-bottom: var(--space-3);
    color: var(--fg);
    font-size: var(--text-4);
    font-weight: 600;
}

/* For content that brings its own edge — a table, a list. The panel's padding then
   draws a frame inside a frame, and a table in it cannot reach the corners it is
   rounded to. The title keeps its own inset, because a heading pressed against the
   edge is a different mistake, and the last row gives up its rule: the panel's own
   border is already there. */
.popover--flush { padding: 0; overflow: hidden; }
.popover--flush > .popover-title {
    margin-bottom: 0;
    padding: var(--space-6) var(--space-7) var(--space-3);
}
.popover--flush > .table tr:last-child > td { border-bottom: none; }
}

/* ── 66-calendar.css → @layer sedna.paint ──────────────────────────────────────────────── */
@layer sedna.paint {
/* ── Calendar ───────────────────────────────────────────────────────────────
   The surfaces a scheduling view is drawn on: a month grid with event pills, a week
   or day column with a time axis, a year of small months, and the date-picker panel
   that reuses the month grid. On the `.table` terms: the library paints the grid,
   and the app owns every date — which days are in the month, which week starts it,
   where an event starts and ends, how overlapping events are laid out, recurrence,
   dragging. None of that is here, and none of it should be.

   Positions are custom properties the app writes on the element — a grid column for
   a pill, an hour offset for a block — never a measurement the library takes, so a
   re-render cannot fight a script and a server render is already right.

   The semantics are the app's too, and the rules key off them where one exists:
   today is `aria-current="date"`; a chosen day is `aria-pressed="true"` on a day that
   is a <button>, or `aria-selected="true"` on a cell of the app's own grid pattern; an
   unavailable one is a disabled <button> or `aria-disabled="true"`. */

/* ── Month ─────────────────────────────────────────────────────────────────

     <div class="cal-month" style="--cal-lanes: 2">
       <div class="cal-weekdays"><span class="cal-weekday">Mon</span> … 7 </div>
       <div class="cal-row">
         <div class="cal-day cal-day--outside"><span class="cal-day-num">31</span></div>
         … 7 days …
         <div class="cal-row-events">
           <a class="cal-event" href="…" style="grid-column: 2 / span 3; grid-row: 1">Stocktake</a>
           <span class="cal-more" style="grid-column: 4">+2 more</span>
         </div>
       </div>
       … one .cal-row per week …
     </div>

   A week is a `.cal-row`: seven `.cal-day`s, and over them `.cal-row-events`, the same
   seven columns in lanes. A pill spans days with `grid-column` and takes a lane with
   `grid-row`; the app decides both. `--cal-lanes` is how many lanes a day shows before
   the rest is summarised by a `.cal-more`, which always sits in the row below them —
   a threshold the app sets, not a height the library measures. A span running into
   the next week is two pills: the first `--continues-end`, the second
   `--continues-start`, which square off the edge that continues.

   Week numbers are `.cal-month--weeknums` and one `.cal-weeknum` before each row's
   days, with an empty `.cal-weekday` heading it. The number is the app's — ISO 8601
   or the reader's locale's rule — because a stylesheet cannot count weeks:

     <div class="cal-row">
       <span class="cal-weeknum"><span class="visually-hidden">Week </span>38</span>
       … 7 days, then .cal-row-events …
     </div>

   `.cal-month--grow` swaps the threshold for rows that grow to fit every lane the
   app lays out: no `.cal-more`, and `--cal-lanes` becomes the height an empty week
   still keeps. */
.cal-month {
    --cal-lanes: 3;
    --cal-event-height: 20px;
    --cal-day-head: 28px;
    --cal-weeknum-width: 40px;
    display: flex;
    flex-direction: column;
    border: 1px solid var(--border);
    border-radius: var(--radius-surface);
    overflow: hidden;
    background: var(--card-bg);
    --surface-here: var(--card-bg);
}
/* Each day column starts at a line named `day`, so a layer can say "from the first
   day" and mean it with or without a week-number column in front. */
.cal-weekdays,
.cal-row {
    display: grid;
    grid-template-columns: repeat(7, [day] minmax(0, 1fr));
}
.cal-month--weeknums > :is(.cal-weekdays, .cal-row) {
    grid-template-columns: var(--cal-weeknum-width) repeat(7, [day] minmax(0, 1fr));
}
.cal-weekdays { border-bottom: 1px solid var(--border); }
.cal-weekday {
    padding: var(--space-3) var(--space-4);
    color: var(--muted);
    font-size: var(--text-2);
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.06em;
    text-align: start;
}
.cal-row { position: relative; }
.cal-row + .cal-row { border-top: 1px solid var(--divider); }

.cal-day {
    min-height: calc(var(--cal-day-head) + (var(--cal-lanes) + 1) * (var(--cal-event-height) + var(--space-1)) + var(--space-3));
    padding: var(--space-2) var(--space-3);
    color: var(--fg);
    text-align: start;
}
.cal-day + .cal-day { border-inline-start: 1px solid var(--divider); }
.cal-day--weekend { background: var(--surface-soft); }
/* Muted, and no further: a day number faded below --muted fails contrast, and it is
   still a date the reader may need to read. */
.cal-day--outside { color: var(--muted); }

.cal-day-num {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-inline-size: 24px;
    block-size: 24px;
    padding-inline: var(--space-2);
    border-radius: var(--radius-pill);
    font-size: var(--text-3);
    font-weight: 600;
    font-variant-numeric: tabular-nums;
}
.cal-day[aria-current="date"] .cal-day-num { background: var(--brand); color: var(--on-solid); }
.cal-day:is([aria-selected="true"], [aria-pressed="true"]) { background: var(--brand-tint); }

/* The week number: muted, and outside the days, so it never reads as a date. A
   <button> when choosing the week is something the app offers. */
.cal-weeknum {
    display: flex;
    align-items: flex-start;
    justify-content: center;
    padding: var(--space-2) 0;
    border-inline-end: 1px solid var(--divider);
    color: var(--muted);
    font-size: var(--text-2);
    font-variant-numeric: tabular-nums;
    line-height: 24px;
    text-align: center;
}
button.cal-weeknum { background: none; border-block: none; border-inline-start: none; font-family: inherit; cursor: pointer; }
button.cal-weeknum:hover { background: var(--bg-hover); color: var(--fg); }
button.cal-weeknum:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--brand-ring-soft); }
.cal-month--weeknums .cal-weekdays > .cal-weekday:first-child { padding-inline: 0; text-align: center; }

/* A positioned grid child takes its grid area as its containing block, so the layer
   covers the days and never the week number. */
.cal-row-events {
    position: absolute;
    grid-column: day 1 / -1;
    inset: calc(var(--cal-day-head) + var(--space-2)) 0 var(--space-2);
    display: grid;
    grid-template-columns: repeat(7, minmax(0, 1fr));
    grid-template-rows: repeat(var(--cal-lanes), var(--cal-event-height)) var(--cal-event-height);
    row-gap: var(--space-1);
    align-content: start;
    z-index: 1;
    /* The days underneath stay clickable; only the pills take the pointer. */
    pointer-events: none;
}
.cal-row-events > * { pointer-events: auto; min-width: 0; }

/* An event's colours are three custom properties, so a hue family sets them once and
   every fill variant below reads them: `--cal-fill` the tint, `--cal-accent` the edge,
   a dot, an icon and an outline, `--cal-solid` the filled background. */
.cal-event,
.cal-block {
    --cal-fill: var(--brand-tint);
    --cal-accent: var(--brand);
    --cal-solid: var(--brand-text);
}
.cal-event {
    display: flex;
    align-items: center;
    gap: var(--space-2);
    margin-inline: var(--space-2);
    padding-inline: var(--space-3);
    overflow: hidden;
    background: var(--cal-fill);
    border-inline-start: 3px solid var(--cal-accent);
    border-radius: var(--radius-inner);
    color: var(--fg);
    font-size: var(--text-2);
    font-weight: 500;
    line-height: var(--cal-event-height);
    white-space: nowrap;
    text-overflow: ellipsis;
    text-decoration: none;
}
a.cal-event:hover,
button.cal-event:hover { filter: brightness(1.15); text-decoration: none; }
button.cal-event { border-block: none; border-inline-end: none; font-family: inherit; text-align: start; cursor: pointer; }
.cal-event:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--brand-ring-soft); }
/* --fg-soft, not --muted: the pill is a tinted surface, and muted text on a tint falls
   under the contrast floor. The weight is what sets the time apart from the title. */
.cal-event-time { color: var(--fg-soft); font-weight: 400; font-variant-numeric: tabular-nums; flex-shrink: 0; }

/* A span that runs on into the week before or after. The continuing edge loses its
   margin, its radius and — at the start — its accent bar, so the two halves read as
   one bar broken only by the week's edge. */
.cal-event--continues-start {
    margin-inline-start: 0;
    border-start-start-radius: 0;
    border-end-start-radius: 0;
    border-inline-start-width: 0;
}
.cal-event--continues-end {
    margin-inline-end: 0;
    border-start-end-radius: 0;
    border-end-end-radius: 0;
}

/* The families badges use, by meaning and as categorical hues. Each sets the three
   colours and nothing else, so it works on a pill and a block, in every fill. */
.cal-event--go     { --cal-fill: var(--go-bg);     --cal-accent: var(--go-fg);     --cal-solid: var(--go-fg); }
.cal-event--warn   { --cal-fill: var(--warn-bg);   --cal-accent: var(--warn-fg);   --cal-solid: var(--warn-fg); }
.cal-event--danger { --cal-fill: var(--danger-bg); --cal-accent: var(--danger-fg); --cal-solid: var(--danger-fg); }
.cal-event--info   { --cal-fill: var(--info-bg);   --cal-accent: var(--info-fg);   --cal-solid: var(--info-fg); }
.cal-event--cyan   { --cal-fill: var(--badge-cyan-bg);   --cal-accent: var(--badge-cyan-fg);   --cal-solid: var(--badge-cyan-fg); }
.cal-event--orange { --cal-fill: var(--badge-orange-bg); --cal-accent: var(--badge-orange-fg); --cal-solid: var(--badge-orange-fg); }
.cal-event--teal   { --cal-fill: var(--badge-teal-bg);   --cal-accent: var(--badge-teal-fg);   --cal-solid: var(--badge-teal-fg); }

/* ── Fill ──────────────────────────────────────────────────────────────────
   Tinted is the default. `--filled` is for what must stand out — confirmed, or
   happening now — `--outline` for what should recede, and `--tentative` for an event
   not yet accepted: a dashed edge on a faint hatch. Say "tentative" in text as well; a
   dash is not a word. A filled event's ink is the calendar's own surface, which each
   family's accent is already measured against. The pair of classes on the selector
   outweighs `button.cal-event`, which removes a button's own borders. */
:is(.cal-event, .cal-block).cal-event--filled {
    background: var(--cal-solid);
    border-inline-start-color: var(--cal-solid);
    color: var(--card-bg);
}
.cal-event--filled .cal-event-time { color: inherit; }
:is(.cal-event, .cal-block):is(.cal-event--outline, .cal-event--tentative) {
    --cal-edge: 1px;
    background: var(--card-bg);
    border: var(--cal-edge) solid var(--cal-accent);
    border-inline-start-width: 3px;
}
:is(.cal-event, .cal-block).cal-event--tentative {
    /* Half the tint, as a hatch over the surface: faint, and still the family's hue. */
    background: repeating-linear-gradient(-45deg, var(--cal-fill) 0 4px, transparent 4px 8px), var(--card-bg);
    border-style: dashed;
}
.cal-event:is(.cal-event--outline, .cal-event--tentative) { line-height: calc(var(--cal-event-height) - 2 * var(--cal-edge)); }

/* ── Leading and trailing slots ────────────────────────────────────────────
   Before the title: an icon for the type of event, a dot, or a live dot for one in
   progress. After it: the people on it, as a small `.avatar-group`. A title that has
   to give way to the avatars is a `.cal-event-title`, which truncates.

     <a class="cal-event cal-event--go" href="…">
       <span class="cal-event-dot cal-event-dot--live"></span>
       <span class="cal-event-time">14:00</span>
       <span class="cal-event-title">Dispatch ORD-4209</span>
       <span class="avatar-group cal-event-avatars">
         <span class="avatar avatar--viz-2" title="Alex Fischer">AF</span> …
       </span>
     </a>

   The icon, the dot and the avatars are all decoration to a screen reader unless the
   app names them: `aria-hidden` on the icon, a visually hidden "Live" beside the dot,
   and the people's names in the link's own text or label. */
.cal-event-title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cal-block .cal-event-title { white-space: normal; }
.cal-event-icon {
    flex-shrink: 0;
    color: var(--cal-accent);
    font-size: var(--text-3);
    line-height: 1;
    vertical-align: -0.1em;
}
.cal-event-dot {
    display: inline-block;
    flex-shrink: 0;
    inline-size: 6px;
    block-size: 6px;
    border-radius: 50%;
    background: var(--cal-accent);
    vertical-align: 0.1em;
}
.cal-event-dot--live { animation: sedna-pulse var(--pulse-duration) ease-in-out infinite; }
.cal-event--filled :is(.cal-event-icon, .cal-event-dot) { color: inherit; }
.cal-event--filled .cal-event-dot { background: currentColor; }
.cal-block :is(.cal-event-icon, .cal-event-dot) { margin-inline-end: var(--space-2); }

.cal-event-avatars {
    --avatar-size: 18px;
    --avatar-overlap: 6px;
    --avatar-gap: 1px;
    flex-shrink: 0;
    margin-inline-start: auto;
}
.cal-event-avatars > .avatar { --avatar-size: 18px; --avatar-overlap: 6px; --avatar-gap: 1px; font-size: var(--text-1); letter-spacing: -0.04em; }
.cal-event-avatars > .avatar-more { padding-inline: var(--space-1) 0; font-size: var(--text-1); color: inherit; }
.cal-block .cal-event-avatars { float: inline-end; margin-block-start: var(--space-1); margin-inline-start: var(--space-2); line-height: 1; }

.cal-more {
    grid-row: -2 / -1;
    margin-inline: var(--space-2);
    padding-inline: var(--space-3);
    color: var(--muted);
    font-size: var(--text-2);
    font-weight: 600;
    line-height: var(--cal-event-height);
    white-space: nowrap;
}
button.cal-more { background: none; border: none; font-family: inherit; text-align: start; cursor: pointer; border-radius: var(--radius-inner); }
button.cal-more:hover { background: var(--bg-hover); color: var(--fg); }

/* ── Growing rows ──────────────────────────────────────────────────────────
   The lanes join the row's own grid instead of floating over it, so a row is as
   tall as its last lane. Items only overlap in a grid when every one of them is
   placed, which is why each day takes its column by position here. */
.cal-month--grow .cal-row > * { grid-row: 1; }
.cal-month--grow .cal-weeknum { grid-column: 1; }
.cal-month--grow .cal-day:nth-child(1 of .cal-day) { grid-column: day 1; }
.cal-month--grow .cal-day:nth-child(2 of .cal-day) { grid-column: day 2; }
.cal-month--grow .cal-day:nth-child(3 of .cal-day) { grid-column: day 3; }
.cal-month--grow .cal-day:nth-child(4 of .cal-day) { grid-column: day 4; }
.cal-month--grow .cal-day:nth-child(5 of .cal-day) { grid-column: day 5; }
.cal-month--grow .cal-day:nth-child(6 of .cal-day) { grid-column: day 6; }
.cal-month--grow .cal-day:nth-child(7 of .cal-day) { grid-column: day 7; }
.cal-month--grow .cal-day {
    min-height: calc(var(--cal-day-head) + var(--cal-lanes) * (var(--cal-event-height) + var(--space-1)) + var(--space-3));
}
.cal-month--grow .cal-row-events {
    position: relative;
    inset: auto;
    margin-block: calc(var(--cal-day-head) + var(--space-2)) var(--space-2);
    grid-template-rows: none;
    grid-auto-rows: var(--cal-event-height);
}

/* ── Week and day ──────────────────────────────────────────────────────────

     <div class="cal-week" style="--cal-hours: 12">
       <div class="cal-week-head">
         <span></span>
         <span class="cal-weekday" aria-current="date">Mon 14</span> …
       </div>
       <div class="cal-week-body sedna-scroll">
         <div class="cal-hours"><span class="cal-hour">07:00</span> … one per hour </div>
         <div class="cal-day-col" aria-current="date">
           <div class="cal-block" style="--cal-start: 2; --cal-span: 1.5">Stand-up</div>
           <div class="cal-now" style="--cal-at: 3.25"></div>
         </div>
         … one .cal-day-col per day …
       </div>
     </div>

   `--cal-days` is 7 by default; a day view sets 1, and nothing else changes. A block's
   `--cal-start` and `--cal-span` are hours from the top of the axis, so 09:30 on an
   axis starting at 07:00 is 2.5. Two blocks that overlap are laid side by side by
   `--cal-lane` and `--cal-lanes`, which the app works out; `--cal-hours` is how many
   hour rows the axis has, and the hour labels are the app's text. `.cal-now` is the line for
   the current time, at `--cal-at` hours.

   A block's text is clipped at the last whole line that fits, so a short block shows
   its time and the start of its title and never half a line.

   `--cal-day-min` is the narrowest a day may get; the week's own minimum width follows
   from it, so put the week in a `.sedna-scroll-x` where the width can run out.

   `.cal-week--by-lanes` lets each day's width follow how busy it is. CSS cannot tell
   which blocks overlap, so the app writes the day's lane count — the most blocks side
   by side at any moment, 1 for a quiet day — as `--cal-lanes` on the `.cal-day-col`
   and the same number on that day's `.cal-weekday`, which keeps the heading over its
   column. Days share the width in proportion to it and none gets narrower than
   `--cal-day-min`. Leave the modifier off and every day is the same width. */
.cal-week {
    --cal-days: 7;
    --cal-hours: 24;
    --cal-hour-height: 48px;
    --cal-gutter: 56px;
    --cal-day-min: 0px;
    --cal-week-max-height: 560px;
    display: flex;
    flex-direction: column;
    min-inline-size: calc(var(--cal-gutter) + var(--cal-days) * var(--cal-day-min));
    border: 1px solid var(--border);
    border-radius: var(--radius-surface);
    overflow: hidden;
    background: var(--card-bg);
    --surface-here: var(--card-bg);
}
.cal-week-head,
.cal-week-body {
    display: grid;
    grid-template-columns: var(--cal-gutter) repeat(var(--cal-days), minmax(var(--cal-day-min), 1fr));
}
/* Flex rather than grid, because a flex item's grow factor can come from a custom
   property on the item and a grid track's `fr` cannot. The heading and the body
   resolve the same factors against the same width, so their columns agree. */
.cal-week--by-lanes > :is(.cal-week-head, .cal-week-body) { display: flex; }
.cal-week--by-lanes > :is(.cal-week-head, .cal-week-body) > :first-child { flex: 0 0 var(--cal-gutter); }
.cal-week--by-lanes > .cal-week-head > .cal-weekday,
.cal-week--by-lanes .cal-day-col {
    --cal-lanes: 1;
    flex: var(--cal-lanes) 1 0;
    min-inline-size: var(--cal-day-min);
}
/* A flex item never gets narrower than its own padding and border, so a heading and
   its column only share the width identically when both carry the same. The column's
   padding moves nothing inside it: its blocks are positioned against the padding box. */
.cal-week--by-lanes > .cal-week-head > .cal-weekday { padding-inline: var(--space-4); border-inline-start: 1px solid transparent; }
.cal-week--by-lanes .cal-day-col { padding-inline: var(--space-4); }
.cal-week-head { border-bottom: 1px solid var(--border); }
.cal-week-head .cal-weekday { text-transform: none; letter-spacing: 0; font-size: var(--text-3); }
.cal-week-head .cal-weekday[aria-current="date"] { color: var(--brand-text); }
.cal-week-body { max-block-size: var(--cal-week-max-height); overflow-y: auto; }

.cal-hours {
    display: grid;
    grid-template-rows: repeat(var(--cal-hours), var(--cal-hour-height));
}
.cal-hour {
    padding-inline-end: var(--space-3);
    color: var(--muted);
    font-size: var(--text-2);
    font-variant-numeric: tabular-nums;
    text-align: end;
    /* The label names the line it sits on, not the hour below it. */
    translate: 0 -0.6em;
}
.cal-hour:first-child { translate: none; }

.cal-day-col {
    position: relative;
    block-size: calc(var(--cal-hours) * var(--cal-hour-height));
    border-inline-start: 1px solid var(--divider);
    /* One hairline per hour, drawn rather than laid out, so a block can sit across it. */
    background-image: linear-gradient(var(--divider) 1px, transparent 1px);
    background-size: 100% var(--cal-hour-height);
}
.cal-day-col[aria-current="date"] { background-color: var(--surface-soft); }

.cal-block {
    --cal-start: 0;
    --cal-span: 1;
    --cal-lane: 0;
    --cal-lanes: 1;
    position: absolute;
    inset-block-start: calc(var(--cal-start) * var(--cal-hour-height));
    inset-inline-start: calc(100% * var(--cal-lane) / var(--cal-lanes));
    inline-size: calc(100% / var(--cal-lanes) - var(--space-2));
    block-size: calc(var(--cal-span) * var(--cal-hour-height) - var(--space-1));
    z-index: 1;
    padding: var(--space-1) var(--space-3);
    background: var(--cal-fill);
    border-inline-start: 3px solid var(--cal-accent);
    border-radius: var(--radius-inner);
    color: var(--fg);
    font-size: var(--text-2);
    font-weight: 500;
    line-height: 1.35;
    text-decoration: none;
    /* Clipped at the content box, and the content box ends on a whole line: the bottom
       padding takes up whatever is left over after the lines that fit. Below one line
       the block shows what it can rather than nothing. */
    --cal-edge: 0px;
    --cal-block-inner: calc(var(--cal-span) * var(--cal-hour-height) - 2 * var(--space-1) - 2 * var(--cal-edge));
    padding-block-end: max(0px, var(--cal-block-inner) - max(1lh, round(down, var(--cal-block-inner) - var(--space-1), 1lh)));
    overflow: clip;
    overflow-clip-margin: content-box;
}
.cal-block:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--brand-ring-soft); }
.cal-block .cal-event-time { display: block; }

.cal-now {
    --cal-at: 0;
    position: absolute;
    inset-inline: 0;
    inset-block-start: calc(var(--cal-at) * var(--cal-hour-height));
    block-size: 2px;
    z-index: 2;
    background: var(--danger-fg);
    pointer-events: none;
}
.cal-now::before {
    content: '';
    position: absolute;
    inset-inline-start: -4px;
    inset-block-start: -3px;
    inline-size: 8px;
    block-size: 8px;
    border-radius: 50%;
    background: var(--danger-fg);
}

/* ── Timeline ──────────────────────────────────────────────────────────────
   Rows against a run of columns: one row per month with its days across — a year —
   or one row per room, vehicle or person with days or hours across — a resource
   timeline. It is the same layout with a different key down the side, so it is one
   class. The pills are the month's: `.cal-row-events`, lanes, `.cal-more`,
   `--continues-*`, the fills and the slots, all unchanged.

     <div class="sedna-scroll-x">
       <div class="cal-timeline" style="--cal-columns: 14; --cal-lanes: 2">
         <div class="cal-timeline-head">
           <span class="cal-timeline-corner">Dock</span>
           <span class="cal-timeline-col cal-day--weekend">Sa 19</span> … one per column …
         </div>
         <div class="cal-timeline-row">
           <span class="cal-timeline-label">Dock 1</span>
           <div class="cal-timeline-cells" aria-hidden="true">
             <span></span> … <span class="cal-day--weekend"></span> … one per column, or none
           </div>
           <div class="cal-row-events">
             <a class="cal-event" href="…" style="grid-column: 3 / span 4; grid-row: 1">Inbound, Northwind Retail</a>
           </div>
         </div>
         … one .cal-timeline-row per row …
         <div class="cal-now" style="--cal-at: 2.6"></div>
       </div>
     </div>

   `--cal-columns` is how many columns there are, and the app writes it: a stylesheet
   cannot count a month's days. A pill's `grid-column` counts from the first column,
   exactly as in a week of the month. The label column is `--cal-label-width` and stays
   pinned to the inline start while the columns scroll under it; a column is never
   narrower than `--cal-column-min`, so put the timeline in a `.sedna-scroll-x`.

   A column that is a day takes the day's own modifiers — `.cal-day--weekend`,
   `.cal-day--holiday`, `.cal-day--outside` — on its `.cal-timeline-col` and on the
   matching span in each row's `.cal-timeline-cells`, and today is `aria-current="date"`
   on the heading. A row without cells still draws its column lines. `.cal-now` is a
   vertical rule across every row, at `--cal-at` columns from the start: 2.5 is half
   way through the third. `.cal-timeline--grow` grows each row to fit its lanes instead
   of summarising the rest in a `.cal-more`, as `.cal-month--grow` does. */
.cal-timeline {
    --cal-columns: 7;
    --cal-lanes: 2;
    --cal-event-height: 20px;
    --cal-label-width: 136px;
    --cal-column-min: 32px;
    --cal-at: 0;
    --cal-label-edge: var(--shadow-edge-start);
    position: relative;
    min-inline-size: calc(var(--cal-label-width) + var(--cal-columns) * var(--cal-column-min));
    border: 1px solid var(--border);
    border-radius: var(--radius-surface);
    background: var(--card-bg);
    --surface-here: var(--card-bg);
}
.cal-timeline-head,
.cal-timeline-row {
    display: grid;
    grid-template-columns: var(--cal-label-width) repeat(var(--cal-columns), [col] minmax(var(--cal-column-min), 1fr));
}
.cal-timeline-head { border-bottom: 1px solid var(--border); }
.cal-timeline-row { position: relative; }
.cal-timeline-row + .cal-timeline-row { border-top: 1px solid var(--divider); }

/* The label and the corner above it stay put while the columns scroll. They sit above
   the pills (local stacking, 3 over the events' 1), on the surface, so a pill slides
   under the label rather than over it. */
.cal-timeline-corner,
.cal-timeline-label {
    position: sticky;
    inset-inline-start: 0;
    z-index: 3;
    display: flex;
    align-items: center;
    min-width: 0;
    padding: var(--space-2) var(--space-4);
    background: var(--card-bg);
    border-inline-end: 1px solid var(--divider);
}
.cal-timeline-corner {
    color: var(--muted);
    font-size: var(--text-2);
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.06em;
}
.cal-timeline-label {
    /* On the first lane's line, so a row's name reads level with its first pill. */
    align-items: flex-start;
    line-height: var(--cal-event-height);
    color: var(--fg);
    font-size: var(--text-3);
    font-weight: 500;
    min-block-size: calc((var(--cal-lanes) + 1) * (var(--cal-event-height) + var(--space-1)) + 2 * var(--space-2));
}
.cal-timeline-label > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* The edge, off until a column is scrolled under the label — the pinned table column's
   rule: on by default, and off while the scroller sits at its start, by the
   scroll-state query or, where there is none, by 22-scroll-edge.js's attribute. The
   token is a custom property so 70-rtl.css can turn the shadow round. */
.cal-timeline :is(.cal-timeline-corner, .cal-timeline-label) { box-shadow: var(--cal-label-edge); }
@container not scroll-state(scrollable: inline-start) {
    .cal-timeline :is(.cal-timeline-corner, .cal-timeline-label) { box-shadow: none; }
}
.sedna-scroll-x[data-scroll-start] .cal-timeline :is(.cal-timeline-corner, .cal-timeline-label) { box-shadow: none; }
/* The timeline cannot clip its corners, because a clipping box would stop the label
   pinning; the two pinned cells at the corners follow the radius themselves. */
.cal-timeline-corner { border-start-start-radius: calc(var(--radius-surface) - 1px); }
.cal-timeline-row:not(:has(~ .cal-timeline-row)) > .cal-timeline-label { border-end-start-radius: calc(var(--radius-surface) - 1px); }

.cal-timeline-col {
    padding: var(--space-3) var(--space-1);
    color: var(--muted);
    font-size: var(--text-2);
    font-variant-numeric: tabular-nums;
    text-align: center;
    white-space: nowrap;
}
.cal-timeline-col + .cal-timeline-col { border-inline-start: 1px solid var(--divider); }
.cal-timeline-col[aria-current="date"] { color: var(--brand-text); font-weight: 600; }

/* The columns behind a row: shading from the day modifiers, and one hairline per column
   drawn rather than laid out, so a row with no cells still has its lines. */
.cal-timeline-cells {
    grid-column: col 1 / -1;
    grid-row: 1;
    display: grid;
    grid-template-columns: repeat(var(--cal-columns), minmax(0, 1fr));
    background-image: linear-gradient(to right, var(--divider) 1px, transparent 1px);
    background-size: calc(100% / var(--cal-columns)) 100%;
}
.cal-timeline-cells > .cal-day--outside { background: var(--surface-soft); }
.cal-timeline-label { grid-column: 1; grid-row: 1; }

.cal-timeline-row > .cal-row-events {
    grid-column: col 1 / -1;
    inset: var(--space-2) 0;
    grid-template-columns: repeat(var(--cal-columns), minmax(0, 1fr));
}

.cal-timeline > .cal-now {
    inset-block: 0;
    inset-inline-start: calc(var(--cal-label-width) + (100% - var(--cal-label-width)) * var(--cal-at) / var(--cal-columns));
    inline-size: 2px;
    block-size: auto;
}
.cal-timeline > .cal-now::before { inset-inline-start: -3px; inset-block-start: -4px; }

.cal-timeline--grow .cal-timeline-row > .cal-row-events {
    position: relative;
    inset: auto;
    grid-row: 1;
    margin-block: var(--space-2);
    grid-template-rows: none;
    grid-auto-rows: var(--cal-event-height);
    min-block-size: calc(var(--cal-lanes) * (var(--cal-event-height) + var(--space-1)));
}
.cal-timeline--grow .cal-timeline-label { min-block-size: 0; }

/* ── Year ──────────────────────────────────────────────────────────────────
   Twelve small months. `.cal-month--mini` is the month grid with numbers only — no
   lanes, no pills — and square days; a day that has something on it is
   `.cal-day--marked`, a dot under the number. The same small grid is the body of the
   date picker below. */
.cal-year {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
    gap: var(--space-7);
}
.cal-year-month { display: flex; flex-direction: column; gap: var(--space-3); }
.cal-year-title { margin: 0; font-size: var(--text-4); font-weight: 600; color: var(--fg); }

.cal-month--mini { border: none; border-radius: 0; background: none; overflow: visible; }
.cal-month--mini .cal-weekdays { border-bottom: none; }
.cal-month--mini .cal-weekday { padding: var(--space-2) 0; text-align: center; font-size: var(--text-1); }
.cal-month--mini .cal-row + .cal-row { border-top: none; }
.cal-month--mini .cal-day {
    position: relative;
    display: flex;
    align-items: center;
    justify-content: center;
    min-height: 0;
    aspect-ratio: 1;
    padding: 0;
    border-radius: var(--radius-inner);
    font-size: var(--text-3);
    font-variant-numeric: tabular-nums;
}
.cal-month--mini .cal-day + .cal-day { border-inline-start: none; }
.cal-month--mini { --cal-weeknum-width: 28px; }
.cal-month--mini .cal-weeknum {
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 0;
    border-inline-end: none;
    font-size: var(--text-1);
    line-height: 1;
}
.cal-month--mini .cal-day--weekend { background: none; color: var(--fg-soft); }
.cal-month--mini .cal-day[aria-current="date"] { box-shadow: inset 0 0 0 1px var(--brand); color: var(--brand-text); font-weight: 600; }
.cal-month--mini .cal-day:is([aria-selected="true"], [aria-pressed="true"]) { background: var(--brand); color: var(--on-solid); }
.cal-day--marked::after {
    content: '';
    position: absolute;
    inset-block-end: 3px;
    inset-inline-start: calc(50% - 2px);
    inline-size: 4px;
    block-size: 4px;
    border-radius: 50%;
    background: currentColor;
    opacity: 0.7;
}

/* A day that is a control — a date picker's, or a clickable month cell. */
button.cal-day {
    background: none;
    border: none;
    font-family: inherit;
    cursor: pointer;
}
button.cal-day:hover { background: var(--bg-hover); }
button.cal-day:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--brand-ring-soft); }
.cal-month--mini button.cal-day[aria-pressed="true"]:hover { background: var(--brand); }
.cal-day:disabled,
.cal-day[aria-disabled="true"] {
    color: var(--muted);
    text-decoration: line-through;
    opacity: 0.55;
    cursor: not-allowed;
    pointer-events: none;
}

/* ── Holidays ──────────────────────────────────────────────────────────────
   A day nobody works is `.cal-day--holiday`, shaded like a weekend. Its name is a
   `.cal-holiday` beside the day number, or in a week's heading:

     <div class="cal-day cal-day--holiday">
       <span class="cal-day-num">3</span>
       <span class="cal-holiday" role="img" aria-label="Holiday: Autumn bank holiday" data-tip="Autumn bank holiday">
         <i class="ri-flag-2-line"></i><span class="cal-holiday-name">Autumn bank holiday</span>
       </span>
     </div>

   A name that does not fit is cut short, and in a very narrow cell it goes and the
   icon stays. Either way the `data-tip` takes over: the hint appears only while the
   whole name is not on screen. The label is what a screen reader hears. In a small month there is no room
   for a marker at all, so the day keeps the shade and carries the `data-tip` itself. */
.cal-day--holiday { background: var(--surface-soft); }
.cal-month--mini .cal-day--holiday { background: var(--surface-soft); }
:is(.cal-day, .cal-weekday):has(> .cal-holiday) {
    container: cal-cell / inline-size;
    display: flex;
    align-items: flex-start;
    gap: var(--space-2);
}
.cal-holiday {
    display: inline-flex;
    align-items: center;
    gap: var(--space-2);
    min-width: 0;
    block-size: 24px;
    color: var(--fg-soft);
    font-size: var(--text-2);
    font-weight: 500;
    letter-spacing: 0;
    text-transform: none;
}
.cal-holiday > i { flex-shrink: 0; color: var(--muted); font-size: var(--text-4); }
.cal-weekday > .cal-holiday { block-size: auto; }
.cal-holiday-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@container cal-cell (max-width: 72px) {
    .cal-holiday-name { display: none; }
}

/* ── Date picker ───────────────────────────────────────────────────────────
   A panel for a date — or a range — that looks the same in every engine, for the app
   whose native date input is not enough. It is a `.popover`, so the anchoring, the top
   layer, light dismiss and Escape are the platform's; the body is the small month grid.

     <button class="form-input" type="button" popovertarget="due">12 Sep 2026</button>
     <div class="popover datepicker" id="due" popover>
       <div class="datepicker-head">
         <button class="btn btn-ghost btn-icon btn-sm" type="button" aria-label="Previous month"><i class="ri-arrow-left-s-line"></i></button>
         <span class="datepicker-title">September 2026</span>
         <button class="btn btn-ghost btn-icon btn-sm" type="button" aria-label="Next month"><i class="ri-arrow-right-s-line"></i></button>
       </div>
       <div class="cal-month cal-month--mini">… <button class="cal-day" type="button" aria-label="Monday 21 September 2026" aria-pressed="true">21</button> …</div>
       <div class="datepicker-actions">…</div>
     </div>

   Each day is a <button> named with its full date — "21" alone is not a date to a
   screen reader — and `aria-pressed="true"` when it is chosen. A range is three classes
   on the days the app has worked out: `--range-start`, `--in-range` and `--range-end`.
   The band runs between them edge to edge; the ends are solid. Keys beyond Tab are the
   app's: the days are ordinary buttons. */
.datepicker { inline-size: 288px; max-inline-size: calc(100vw - 32px); padding: var(--space-5); }
.datepicker-head {
    display: flex;
    align-items: center;
    gap: var(--space-2);
    margin-block-end: var(--space-3);
}
.datepicker-title { flex: 1; text-align: center; font-size: var(--text-4); font-weight: 600; color: var(--fg); }
.datepicker-actions {
    display: flex;
    justify-content: flex-end;
    gap: var(--space-3);
    margin-block-start: var(--space-4);
    padding-block-start: var(--space-4);
    border-top: 1px solid var(--divider);
}
.datepicker-actions > :first-child { margin-inline-end: auto; }

/* ── Date picker: a time, and jumping further ───────────────────────────────
   A date with a time is a `.datepicker-time` row under the grid: a label and a
   `.form-input.form-input-sm` of `type="time"`, or two for a range. The input is the
   platform's, so the clock, the 12- or 24-hour form and the keyboard are the reader's.

     <div class="datepicker-time">
       <label for="due-time">Time</label>
       <input class="form-input form-input-sm" id="due-time" type="time" value="14:00">
     </div>

   Further than a month is the title as a <button> that switches the panel to a level
   above: `.cal-months`, then `.cal-years`, each a grid of `.cal-pick` buttons with the
   day grid's states — `aria-pressed="true"` chosen, `aria-current` now, disabled out of
   bounds, `.cal-pick--outside` for a year of the neighbouring decade. Which level shows,
   and what a pick does, are the app's; the panel swaps its body and keeps its head.

     <div class="datepicker-head">
       <button class="btn btn-ghost btn-icon btn-sm" type="button" aria-label="Previous year">…</button>
       <button class="datepicker-title" type="button" aria-label="2026, choose a year">2026 <i class="ri-arrow-down-s-line"></i></button>
       <button class="btn btn-ghost btn-icon btn-sm" type="button" aria-label="Next year">…</button>
     </div>
     <div class="cal-months">
       <button class="cal-pick" type="button" aria-pressed="false">Jan</button> … 12 …
     </div> */
button.datepicker-title {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: var(--space-2);
    padding: var(--space-2) var(--space-4);
    background: none;
    border: none;
    border-radius: var(--radius-control);
    font-family: inherit;
    cursor: pointer;
}
button.datepicker-title:hover { background: var(--bg-hover); }
button.datepicker-title:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--brand-ring-soft); }
button.datepicker-title > i { color: var(--muted); font-size: var(--text-5); }

.cal-months,
.cal-years {
    display: grid;
    grid-template-columns: repeat(3, minmax(0, 1fr));
    gap: var(--space-2);
}
.cal-years { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.cal-pick {
    display: flex;
    align-items: center;
    justify-content: center;
    min-block-size: var(--control-height);
    padding: var(--space-2);
    background: none;
    border: none;
    border-radius: var(--radius-inner);
    color: var(--fg);
    font-family: inherit;
    font-size: var(--text-3);
    font-variant-numeric: tabular-nums;
    cursor: pointer;
}
.cal-pick:hover { background: var(--bg-hover); }
.cal-pick:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--brand-ring-soft); }
.cal-pick--outside { color: var(--muted); }
/* `:where()` keeps "now" at one class of weight, so "chosen" below outranks it when a
   pick is both, and this month chosen is on-solid text on the fill, not brand on brand. */
.cal-pick:where([aria-current]:not([aria-current="false"])) { box-shadow: inset 0 0 0 1px var(--brand); color: var(--brand-text); font-weight: 600; }
.cal-pick[aria-pressed="true"],
.cal-pick[aria-pressed="true"]:hover { background: var(--brand); color: var(--on-solid); }
.cal-pick:disabled,
.cal-pick[aria-disabled="true"] {
    color: var(--muted);
    text-decoration: line-through;
    opacity: 0.55;
    cursor: not-allowed;
    pointer-events: none;
}

.datepicker-time {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--space-3);
    margin-block-start: var(--space-4);
    padding-block-start: var(--space-4);
    border-top: 1px solid var(--divider);
    color: var(--fg-soft);
    font-size: var(--text-3);
}
.datepicker-time > .form-input { flex: 1; min-inline-size: 0; }
/* The actions after a time row share its divider rather than drawing a second. */
.datepicker-time + .datepicker-actions { border-top: none; padding-block-start: 0; }

.cal-month--mini .cal-day--in-range,
.cal-month--mini .cal-day--in-range:is([aria-pressed="true"], [aria-selected="true"]) {
    background: var(--brand-tint);
    color: var(--fg);
    border-radius: 0;
}
.cal-month--mini .cal-day--range-start,
.cal-month--mini .cal-day--range-end {
    background: var(--brand);
    color: var(--on-solid);
}
.cal-month--mini .cal-day--range-start { border-start-end-radius: 0; border-end-end-radius: 0; }
.cal-month--mini .cal-day--range-end { border-start-start-radius: 0; border-end-start-radius: 0; }
.cal-month--mini .cal-day--range-start.cal-day--range-end { border-radius: var(--radius-inner); }
}

/* ── 66-chat.css → @layer sedna.paint ──────────────────────────────────────────────── */
@layer sedna.paint {
/* ── Chat ─────────────────────────────────────────────────────────────────────
   A conversation: messages in bubbles, mine on the end and theirs on the start,
   with the day, the author, the time, what happened to the message and how people
   reacted to it — and the composer under it, the launcher that keeps a chat one
   tap away, and the flyover that opens from it.

   Everything is markup and classes. The app owns every piece of state — which
   messages there are, who reacted, what is typed, whether the flyover is open — and
   the two things that need a hand from the platform use it rather than a script:
   the flyover is a `popover`, so the top layer and light dismiss are the browser's,
   and the message menu is the library's own delegated `.menu`.

     <div class="chat" role="log" aria-live="polite" aria-label="Chat with Alex Fischer">
       <div class="chat-day">Today</div>
       <div class="chat-message">
         <span class="avatar avatar-sm">AF</span>
         <div class="chat-body">
           <div class="chat-meta"><span class="chat-author">Alex Fischer</span><time class="chat-time">09:41</time></div>
           <div class="chat-bubble">Can you reroute ORD-4209 to EU-West?</div>
         </div>
       </div>
       <div class="chat-message chat-message--mine">
         <div class="chat-body">
           <div class="chat-bubble">Done — leaves Hamburg at 12:20.</div>
           <div class="chat-meta"><time class="chat-time">09:44</time><i class="ri-check-double-line chat-status" title="Read"></i></div>
         </div>
       </div>
     </div>

   `role="log"` with `aria-live="polite"`: a message that arrives is announced once,
   after whatever the reader is doing, and the history is one navigable region.
   ─────────────────────────────────────────────────────────────────────────── */

/* ── The thread ─────────────────────────────────────────────────────────────── */
.chat {
    display: flex;
    flex-direction: column;
    gap: var(--space-4);
    padding: var(--space-5) 0;
}

/* A line across the thread: the day, or "new messages". */
.chat-day,
.chat-unread {
    display: flex;
    align-items: center;
    gap: var(--space-5);
    margin-block: var(--space-4);
    font-size: var(--text-2);
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.06em;
    color: var(--muted);
}
.chat-day::before, .chat-day::after,
.chat-unread::before, .chat-unread::after {
    content: '';
    flex: 1;
    height: 1px;
    background: var(--divider);
}
.chat-unread { color: var(--brand-text); }
.chat-unread::before, .chat-unread::after { background: var(--brand); opacity: 0.5; }

/* Something the app did, told in the thread: joined, renamed, pinned. */
.chat-system {
    align-self: center;
    font-size: var(--text-3);
    color: var(--muted);
    text-align: center;
}

/* ── A message ──────────────────────────────────────────────────────────────
   Avatar on the start, then the body; mine has no avatar and sits on the end. A
   `--continued` message is the same author's next one and drops the meta above
   the bubble, so a run of them reads as one turn. */
.chat-message {
    display: flex;
    align-items: flex-end;
    gap: var(--space-4);
    max-width: min(72%, 40rem);
    position: relative;
}
.chat-message--mine { align-self: flex-end; flex-direction: row-reverse; }
.chat-message--continued { margin-top: calc(var(--space-2) * -1); }
.chat-message--continued > .avatar { visibility: hidden; }
.chat-message--continued .chat-meta:first-child { display: none; }
.chat-body { display: flex; flex-direction: column; gap: var(--space-2); min-width: 0; }
.chat-message--mine .chat-body { align-items: flex-end; }

.chat-meta {
    display: flex;
    align-items: center;
    gap: var(--space-4);
    font-size: var(--text-2);
    color: var(--muted);
    padding-inline: var(--space-2);
}
.chat-author { font-weight: 600; color: var(--fg-soft); }
.chat-time { font-variant-numeric: tabular-nums; }
.chat-edited { font-style: italic; }
/* What happened to my message: sent (one check), delivered (two), read (two, brand). */
.chat-status { font-size: var(--text-4); }
.chat-status--read { color: var(--brand-text); }
.chat-status--failed { color: var(--danger-fg); }

/* ── The bubble ─────────────────────────────────────────────────────────────
   Theirs on the neutral strong surface, mine on the brand tint. Both keep the
   page's text colour: the tint is translucent, so it reads on every surface and
   never fights a link or a mention inside it. Four corners rounded and the one
   nearest the avatar sharper, which is what points a bubble at its author. */
.chat-bubble {
    --surface-here: var(--surface-strong);
    background: var(--surface-here);
    padding: var(--space-4) var(--space-6);
    border-radius: var(--radius-surface);
    border-end-start-radius: var(--radius-inner);
    color: var(--fg);
    font-size: var(--text-4);
    line-height: 1.5;
    overflow-wrap: anywhere;
    max-width: 100%;
}
.chat-message--mine .chat-bubble {
    background: var(--brand-tint);
    border-end-start-radius: var(--radius-surface);
    border-end-end-radius: var(--radius-inner);
}
.chat-bubble p { margin: 0; }
.chat-bubble p + p { margin-top: var(--space-3); }
/* Not delivered. The border says it, the status icon says why, the retry is a
   real button in the meta line. */
.chat-bubble--failed { box-shadow: inset 0 0 0 1px var(--danger-border); }
/* Taken back: the bubble stays, so the thread keeps its shape, and says so. */
.chat-bubble--deleted { font-style: italic; color: var(--muted); }
/* Passed on from somewhere else: a line above the text that says so. */
.chat-forwarded {
    display: flex;
    align-items: center;
    gap: var(--space-2);
    margin-bottom: var(--space-2);
    font-size: var(--text-2);
    font-style: italic;
    color: var(--muted);
}
/* Rich text in a bubble: a code block, a list, a `.markdown-body` from a bot or an
   assistant. Each fits the bubble's width and takes its ink, so an answer rendered
   from Markdown reads like a message and not like a document. */
.chat-bubble pre {
    margin: var(--space-3) 0 0;
    padding: var(--space-3) var(--space-4);
    max-width: 100%;
    overflow-x: auto;
    border-radius: var(--radius-inner);
    background: var(--code-bg);
    color: var(--code-fg);
    font-family: var(--font-mono);
    font-size: var(--text-3);
    line-height: 1.5;
}
.chat-bubble code { font-family: var(--font-mono); font-size: 0.9em; }
.chat-bubble pre code { font-size: inherit; }
.chat-bubble ul,
.chat-bubble ol { margin: var(--space-3) 0 0; padding-inline-start: var(--space-7); }
.chat-bubble > .markdown-body { color: inherit; font-size: inherit; line-height: inherit; }
/* A picture or a file preview fills the bubble to its corners. */
.chat-bubble--media { padding: 0; overflow: hidden; }
.chat-bubble--media > img,
.chat-bubble--media > video { display: block; max-width: 100%; height: auto; }
/* Someone named. */
.chat-mention {
    color: var(--brand-text);
    font-weight: 600;
    text-decoration: none;
}
.chat-mention:hover { text-decoration: underline; }

/* The message this one answers, quoted at the top of the bubble. */
.chat-quote {
    display: block;
    margin-bottom: var(--space-3);
    padding: var(--space-2) var(--space-4);
    border-inline-start: 2px solid var(--border-strong);
    border-radius: var(--radius-inner);
    background: var(--surface-soft);
    font-size: var(--text-3);
    color: var(--muted);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}
.chat-quote > strong { color: var(--fg-soft); margin-inline-end: var(--space-3); }

/* A bubble builds nothing of its own for what it carries. A file is a `.file-item`
   (47-form-file.css) — the icon, the name, the size, a `.progress` while it uploads —
   on the bubble's own surface rather than the list's, so it reads as part of the
   message. Several pictures are a `.gallery--tight` and a link is a `.link-preview`
   (62-media-and-prose.css), a voice message is an `.audio-wave` (64-media-player.css),
   and a picture waiting in the composer is a `.file-thumb` (64-files.css). */
.chat-bubble .file-item { background: var(--surface-here); }
.chat-bubble .file-item + .file-item { margin-top: var(--space-2); }

/* A link the message carries is a `.link-preview` (62-media-and-prose.css), set off
   from the text above it. */
.chat-bubble > .link-preview { margin-top: var(--space-3); }

/* ── Reactions ──────────────────────────────────────────────────────────────
   Under the bubble: a pill per emoji with its count, `aria-pressed` on the ones
   that are mine, and the last pill adds one. */
.chat-reactions { display: flex; flex-wrap: wrap; gap: var(--space-2); }
.chat-reaction {
    display: inline-flex;
    align-items: center;
    gap: var(--space-2);
    min-height: 24px;
    padding: 0 var(--space-4);
    border: 1px solid var(--border);
    border-radius: var(--radius-pill);
    background: var(--surface-strong);
    color: var(--fg-soft);
    font-family: inherit;
    font-size: var(--text-3);
    font-variant-numeric: tabular-nums;
    cursor: pointer;
}
.chat-reaction:hover { border-color: var(--border-strong); }
.chat-reaction[aria-pressed="true"] {
    background: var(--brand-tint);
    border-color: var(--brand);
    color: var(--brand-text);
}
.chat-reaction:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--brand-ring-soft); }

/* The thread under a message: who answered, how many (the `<strong>`, which is the
   link's own colour and underline — the avatars carry neither) and when. */
.chat-replies {
    display: inline-flex;
    align-items: center;
    gap: var(--space-3);
    font-size: var(--text-3);
    color: var(--muted);
    text-decoration: none;
}
.chat-replies > strong { font-weight: 600; color: var(--brand-text); }
.chat-replies:hover { text-decoration: none; }
.chat-replies:hover > strong { text-decoration: underline; }

/* Answers offered under a message — a bot's, an assistant's — as chips that send
   themselves. The app removes the row once one is taken. */
.chat-quick-replies { display: flex; flex-wrap: wrap; gap: var(--space-2); }
.chat-quick-replies > .chip { cursor: pointer; font-family: inherit; }
.chat-quick-replies > .chip:hover { border-color: var(--border-strong); color: var(--fg); }

/* The picker: a grid of emoji buttons, inside a `.popover`. */
.chat-emoji-grid {
    display: grid;
    grid-template-columns: repeat(8, 1fr);
    gap: var(--space-1);
}
.chat-emoji {
    width: 32px;
    height: 32px;
    border: none;
    border-radius: var(--radius-inner);
    background: none;
    font-size: var(--text-7);
    line-height: 1;
    cursor: pointer;
}
.chat-emoji:hover { background: var(--bg-hover); }
.chat-emoji:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--brand-ring-soft); }

/* ── Actions on a message ───────────────────────────────────────────────────
   React, reply, more — a row of ghost icon buttons that appears beside the
   bubble on hover and whenever anything in the message has focus, so the keyboard
   reaches it. Positioned in the gap above the bubble's far corner so it covers
   nothing. */
.chat-actions {
    position: absolute;
    top: calc(var(--space-6) * -1);
    inset-inline-end: 0;
    display: flex;
    gap: var(--space-1);
    padding: var(--space-1);
    background: var(--bg-elevated);
    border: 1px solid var(--border);
    border-radius: var(--radius-control);
    box-shadow: var(--shadow-float);
    opacity: 0;
    visibility: hidden;
    transition: opacity var(--motion-fast), visibility var(--motion-fast);
}
.chat-message--mine .chat-actions { inset-inline-end: auto; inset-inline-start: 0; }
.chat-message:hover .chat-actions,
.chat-message:focus-within .chat-actions { opacity: 1; visibility: visible; }

/* Who has read up to here, after the last message. */
.chat-seen {
    display: flex;
    align-items: center;
    justify-content: flex-end;
    gap: var(--space-3);
    font-size: var(--text-2);
    color: var(--muted);
}

/* ── Typing ─────────────────────────────────────────────────────────────────
   Three dots in a bubble, breathing in turn. The words are in the markup for a
   screen reader ("Alex is typing"), visually hidden. */
.chat-typing {
    display: inline-flex;
    gap: var(--space-2);
    align-items: center;
    min-height: 1.5em;
}
.chat-typing > i {
    --chat-typing-duration: 1.2s;
    width: 6px;
    height: 6px;
    border-radius: 50%;
    background: var(--muted);
    animation: sedna-chat-typing var(--chat-typing-duration) ease-in-out infinite;
}
/* Each dot a sixth of a cycle behind the one before it. */
.chat-typing > i:nth-child(2) { animation-delay: calc(var(--chat-typing-duration) / 6); }
.chat-typing > i:nth-child(3) { animation-delay: calc(var(--chat-typing-duration) / 3); }
@keyframes sedna-chat-typing {
    0%, 60%, 100% { opacity: 0.3; transform: translateY(0); }
    30% { opacity: 1; transform: translateY(-3px); }
}

/* ── The composer ───────────────────────────────────────────────────────────
   Under the thread: what is being answered, what is attached, the field and the
   send. The field is a `.form-input` textarea that starts at one row; growing
   with the text is the app's (`rows` from the line count, or a script of its own). */
.chat-composer {
    display: flex;
    flex-direction: column;
    gap: var(--space-3);
    padding-top: var(--space-4);
    border-top: 1px solid var(--divider);
}
.chat-composer-reply {
    display: flex;
    align-items: center;
    gap: var(--space-4);
    padding: var(--space-2) var(--space-4);
    border-inline-start: 2px solid var(--brand);
    background: var(--surface-soft);
    border-radius: var(--radius-inner);
    font-size: var(--text-3);
    color: var(--muted);
}
.chat-composer-reply > span { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.chat-composer-reply > strong { color: var(--fg-soft); }
.chat-composer-files { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); }
.chat-composer-row { display: flex; align-items: flex-end; gap: var(--space-3); }
.chat-composer-row > .form-input {
    flex: 1;
    min-width: 0;
    resize: none;
    /* Grows with the text from its `rows` up to about six lines, then scrolls. */
    field-sizing: content;
    max-height: 9.5em;
}
/* Nothing can be sent: the composer's place, saying why. */
.chat-closed {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: var(--space-3);
    padding: var(--space-5);
    border-top: 1px solid var(--divider);
    font-size: var(--text-3);
    color: var(--muted);
    text-align: center;
}

/* ── A page of it ───────────────────────────────────────────────────────────
   The chat as the page's whole height: something pinned at the top, the thread
   scrolling in the middle with its days sticking as they pass, the composer
   staying at the bottom. Give the pane a height — `height: 100%` in a
   `.page--fill`, or one of its own — and the thread takes what is left.

     <div class="chat-pane">
       <div class="chat-pinned">…</div>
       <div class="chat" role="log">…</div>
       <button class="chat-jump" type="button">3 new messages <i class="ri-arrow-down-line"></i></button>
       <form class="chat-composer">…</form>
     </div>

   `.chat-jump` sits over the thread's bottom edge, after it in the source, for the
   moment the reader has scrolled up and something arrives. */
.chat-pane {
    position: relative;
    display: flex;
    flex-direction: column;
    min-height: 0;
    height: 100%;
    --surface-here: var(--bg);
}
.card .chat-pane { --surface-here: var(--card-bg); }
/* No top padding: a sticky day cannot leave its parent's content box, so padding
   there would be a strip the messages scroll through above it. The jump anchors
   to this box. */
.chat-pane > .chat {
    flex: 1;
    min-height: 0;
    overflow-y: auto;
    padding: 0 var(--space-5) var(--space-4);
    anchor-name: --sedna-chat-thread;
}
/* A scroller the keyboard can reach carries `tabindex="0"`, and so a focus ring. */
.chat-pane > .chat:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--brand-ring-soft); }
/* Sticks to the top of the thread on its own ground, so it is readable over what
   scrolls under it. */
.chat-pane .chat-day {
    position: sticky;
    top: 0;
    z-index: 1;
    margin-block: 0;
    padding-block: var(--space-3);
    background: var(--surface-here);
}
.chat-pane > .chat-composer,
.chat-pane > .chat-closed { padding-inline: var(--space-5); padding-bottom: var(--space-4); }
/* Over the thread's bottom edge, centred, taking no room in the column: anchored
   to the scroller rather than pulled up by a margin, which pulled the composer up
   over the last message with it. */
.chat-jump {
    position: absolute;
    position-anchor: --sedna-chat-thread;
    inset-block-end: anchor(end);
    justify-self: anchor-center;
    margin-block-end: var(--space-4);
    z-index: 1;
    display: inline-flex;
    align-items: center;
    gap: var(--space-2);
    padding: var(--space-2) var(--space-5);
    border: 1px solid var(--border);
    border-radius: var(--radius-pill);
    background: var(--bg-elevated);
    color: var(--fg-soft);
    font-family: inherit;
    font-size: var(--text-3);
    font-weight: 600;
    box-shadow: var(--shadow-float);
    cursor: pointer;
}
.chat-jump:hover { color: var(--fg); border-color: var(--border-strong); }
.chat-jump:focus-visible { outline: none; box-shadow: var(--shadow-float), 0 0 0 3px var(--brand-ring-soft); }
/* What is pinned, at the top of the pane. */
.chat-pinned {
    display: flex;
    align-items: center;
    gap: var(--space-4);
    padding: var(--space-3) var(--space-5);
    border-bottom: 1px solid var(--divider);
    background: var(--surface-soft);
    font-size: var(--text-3);
}
.chat-pinned > i { color: var(--muted); flex-shrink: 0; }
.chat-pinned > span { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.chat-pinned > span > strong { color: var(--fg-soft); margin-inline-end: var(--space-3); }

/* ── Flat ───────────────────────────────────────────────────────────────────
   A channel rather than a conversation: no bubbles, every message full width on
   the start with the author beside the time, the row lit on hover. Mine is not on
   the end here — in a channel of forty people, whose it is is the name. The same
   markup with `.chat--flat` on the thread. */
.chat--flat { gap: var(--space-1); }
.chat--flat .chat-message,
.chat--flat .chat-message--mine {
    max-width: none;
    align-self: stretch;
    flex-direction: row;
    align-items: flex-start;
    padding: var(--space-2) var(--space-4);
    border-radius: var(--radius-inner);
}
.chat--flat .chat-message:hover,
.chat--flat .chat-message:focus-within { background: var(--bg-hover); }
.chat--flat .chat-message--continued { margin-top: calc(var(--space-2) * -1); }
.chat--flat .chat-body,
.chat--flat .chat-message--mine .chat-body { align-items: flex-start; }
.chat--flat .chat-meta { padding-inline: 0; }
.chat--flat .chat-bubble,
.chat--flat .chat-message--mine .chat-bubble { padding: 0; background: transparent; border-radius: 0; }
.chat--flat .chat-bubble--failed { box-shadow: none; }
.chat--flat .chat-actions,
.chat--flat .chat-message--mine .chat-actions { top: calc(var(--space-4) * -1); inset-inline-end: var(--space-4); inset-inline-start: auto; }

/* ── The launcher and the flyover ───────────────────────────────────────────
   The launcher is the chat's one-tap way in: a round brand button in the corner,
   above the home indicator, with the unread count on it. The FAB's corner and the
   FAB's rung — it IS the page's one floating action where a chat is the page's job.

   The flyover is the panel it opens: a `popover`, so it is in the top layer, closes
   on Escape and light dismiss, and needs no script to show. Fixed in the same
   corner, a phone's full width, a desktop's card. Its parts are the thread and the
   composer above, under a head that names the conversation and closes it. */
.chat-launcher {
    position: fixed;
    inset-block-end: calc(var(--space-8) + var(--safe-block-end));
    inset-inline-end: calc(var(--space-8) + var(--safe-inline-end));
    z-index: 60;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 52px;
    height: 52px;
    border: none;
    border-radius: 50%;
    background: var(--brand);
    color: var(--on-solid);
    font-size: var(--text-8);
    box-shadow: var(--shadow-float);
    cursor: pointer;
}
.chat-launcher:hover { background: var(--brand-hover); }
.chat-launcher:focus-visible { outline: none; box-shadow: var(--shadow-float), 0 0 0 3px var(--brand-ring); }
.chat-launcher-count {
    position: absolute;
    top: -4px;
    inset-inline-end: -4px;
    min-width: 20px;
    height: 20px;
    padding: 0 var(--space-2);
    border-radius: var(--radius-pill);
    background: var(--danger-solid);
    color: var(--on-solid);
    font-size: var(--text-2);
    font-weight: 600;
    line-height: 20px;
    text-align: center;
    box-shadow: 0 0 0 2px var(--bg);
}

.chat-flyover {
    inset: auto;
    margin: 0;
    position: fixed;
    inset-block-end: calc(var(--space-8) + 52px + var(--space-4) + var(--safe-block-end));
    inset-inline-end: calc(var(--space-8) + var(--safe-inline-end));
    z-index: 550;
    display: none;
    flex-direction: column;
    width: min(380px, calc(100vw - 2 * var(--space-8)));
    height: min(560px, calc(100dvh - 120px - var(--safe-block-end)));
    padding: 0;
    border: 1px solid var(--border);
    border-radius: var(--radius-surface);
    --surface-here: var(--bg-elevated);
    background: var(--bg-elevated);
    color: var(--fg);
    box-shadow: var(--shadow-float);
    overflow: hidden;
}
.chat-flyover:popover-open { display: flex; }
.chat-flyover-head {
    display: flex;
    align-items: center;
    gap: var(--space-4);
    padding: var(--space-4) var(--space-5);
    border-bottom: 1px solid var(--divider);
}
.chat-flyover-head > strong { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.chat-flyover > .chat { flex: 1; min-height: 0; overflow-y: auto; padding-inline: var(--space-5); }
.chat-flyover > .chat-composer { padding: var(--space-4) var(--space-5); }

/* On a phone the flyover is the screen: a 380px card with a margin around it is
   most of a phone anyway, and the keyboard needs the rest. */
@media (max-width: 560px) {
    .chat-flyover {
        inset: 0;
        width: 100%;
        height: 100dvh;
        max-height: none;
        border: none;
        border-radius: 0;
        padding-top: var(--safe-block-start);
        padding-bottom: var(--safe-block-end);
    }
}
}

/* ── 66-status-indicator.css → @layer sedna.paint ──────────────────────────────────────────────── */
@layer sedna.paint {
/* ── Status indicator ───────────────────────────────────────────────────────
   A dot and a word for the state of something the app is connected to: a device, a
   stream, a job runner, an API. Small enough for a topbar, a table cell, a card
   header or above an output pane.

   Six states, in two groups. The three ACTIVE ones pulse, because something is
   happening and a static dot cannot say so; the three SETTLED ones do not.

     --live        connected, nothing in flight        pulses
     --streaming   receiving now                       pulses
     --connecting  establishing, or retrying           pulses
     --idle        connected, deliberately quiet       still
     --degraded    working, but not properly           still
     --down        not connected                      still

   The word is not optional. A colour alone fails for anybody who cannot see it, and
   "green" does not say whether green means connected or healthy or done. */
.health-badge {
    display: inline-flex;
    align-items: center;
    gap: var(--space-3);
    font-size: var(--text-3);
    font-weight: 500;
    white-space: nowrap;
}
.health-dot {
    width: 7px;
    height: 7px;
    border-radius: 50%;
    flex-shrink: 0;
    background: var(--border-hover);
}

.health-badge--live       { color: var(--go-fg); }
.health-badge--live       .health-dot { background: var(--go-solid); box-shadow: 0 0 6px var(--go-ring); }
.health-badge--streaming  { color: var(--info-fg); }
.health-badge--streaming  .health-dot { background: var(--info-solid); box-shadow: 0 0 6px var(--info-ring); }
.health-badge--connecting { color: var(--warn-fg); }
.health-badge--connecting .health-dot { background: var(--warn-solid); }
.health-badge--idle       { color: var(--muted); }
.health-badge--idle       .health-dot { background: var(--border-hover); }
.health-badge--degraded   { color: var(--warn-fg); }
.health-badge--degraded   .health-dot { background: var(--warn-solid); }
.health-badge--down       { color: var(--danger-fg); }
.health-badge--down       .health-dot { background: var(--danger-solid); }

/* The active states. One animation, three selectors, so the rhythm is identical
   across a page showing several connections at once. */
.health-badge--live .health-dot,
.health-badge--streaming .health-dot,
.health-badge--connecting .health-dot { animation: sedna-pulse var(--pulse-duration) ease-in-out infinite; }

/* `--healthy` is an alias for `--live`, kept for apps already using it. Prefer
   `--live` in new markup. */
.health-badge--healthy { color: var(--go-fg); }
.health-badge--healthy .health-dot {
    background: var(--go-solid);
    box-shadow: 0 0 6px var(--go-ring);
    animation: sedna-pulse var(--pulse-duration) ease-in-out infinite;
}

/* ── Freshness ──────────────────────────────────────────────────────────────
   How old the number beside it is. For anything polled or pushed, where the reader
   has to know whether they are looking at now or at four minutes ago.

   `--warn` when it is old enough to distrust. What counts as old is the app's
   judgement, not the library's. */
.stale {
    display: inline-flex;
    align-items: center;
    gap: var(--space-2);
    color: var(--muted);
    font-size: var(--text-2);
    font-variant-numeric: tabular-nums;
    white-space: nowrap;
}
.stale i { font-size: var(--text-3); }
.stale--warn { color: var(--warn-fg); }
}

/* ── 67-output.css → @layer sedna.paint ──────────────────────────────────────────────── */
@layer sedna.paint {
/* ── Output pane ────────────────────────────────────────────────────────────
   Lines of machine output the reader watches or scrolls back through: a build log, a
   command's stdout, an event stream, an audit trail.

   `.output` and not `.log`: a plain name is a claim on a shared namespace, and `.log`
   is commonly already taken in a consuming app.

   A real list, so the count is announced and a screen reader can walk it. The pane is
   the scroll container; add `data-follow` and Sedna.UI.js sticks it to the newest
   line, releasing when the reader scrolls up.

   Monospace with tabular figures, because the reader is comparing columns —
   timestamps, levels, ids — down the page rather than reading prose. */
.output {
    --output-level-width: 8ch;
    --output-source-width: 20ch;
    display: block;
    max-height: 320px;
    overflow-y: auto;
    margin: 0;
    padding: var(--space-4) 0;
    list-style: none;
    background: var(--code-bg);
    border: 1px solid var(--border);
    border-radius: var(--radius-surface);
    color: var(--fg-soft);
    font-family: var(--font-mono);
    font-size: var(--text-3);
    font-variant-numeric: tabular-nums;
    line-height: 1.65;
}

/* A header strip for the pane: what is being watched, and its state. Sits outside
   `.output` so it does not scroll away with the lines. */
.output-head {
    display: flex;
    align-items: center;
    gap: var(--space-5);
    padding: var(--space-4) var(--space-6);
    background: var(--surface-soft);
    border: 1px solid var(--border);
    border-bottom: none;
    border-start-start-radius: var(--radius-surface);
    border-start-end-radius: var(--radius-surface);
    font-size: var(--text-3);
    color: var(--muted);
}
.output-head + .output {
    border-start-start-radius: 0;
    border-start-end-radius: 0;
}

/* One line. `white-space: pre-wrap` keeps the output's own alignment while still
   wrapping a long line rather than forcing the pane to scroll sideways. */
.output-line {
    display: block;
    padding: 0 var(--space-6);
    white-space: pre-wrap;
    overflow-wrap: anywhere;
}
.output-line:hover { background: var(--bg-hover); }

/* A timestamp or a level, quieter than the message it introduces. */
.output-time { color: var(--muted); }

/* Severity. The whole line is tinted rather than a marker at the start, because the
   reader is scanning a wall of text and a coloured word is easy to miss in it. */
.output-line--warn   { background: var(--warn-bg);   color: var(--warn-fg); }
.output-line--error  { background: var(--danger-bg); color: var(--danger-fg); }
.output-line--go     { color: var(--go-fg); }
/* Something the app emitted about itself — "reconnected", "stream closed" — rather
   than a line from the thing being watched. */
.output-line--note   { color: var(--muted); font-style: italic; }

/* Debug chatter the reader usually skims past, and informational lines whose level
   word alone takes the info hue — a pane of info lines tinted whole is a wall of
   colour that hides the warnings it exists to surface. */
.output-line--debug  { color: var(--muted); }
.output-line--info .output-level { color: var(--info-fg); }

/* ── Log viewer ─────────────────────────────────────────────────────────────
   The same pane, read as an application's log: one entry per line, in columns.

     <ul class="output output--columns" aria-label="Application log">
       <li class="output-line output-line--warn">
         <span class="output-time">09:14:02.118</span>
         <span class="output-level"><i class="ri-alert-line" aria-hidden="true"></i> WARN</span>
         <span class="output-source">orders.sync</span>
         <span class="output-msg">retrying order <span class="output-id">ORD-4182</span></span>
       </li>
     </ul>

   The level word stays in the markup beside its icon, for the same reason a
   `.health-badge` keeps its word: an icon alone says nothing to a screen reader.

   `--output-level-width` and `--output-source-width` are the two column measures, knobs
   on the pane like `--toolbar-input-width`. A source longer than its column is cut
   with an ellipsis rather than pushing the message out of line. They are declared on
   `.output` itself, above. */
.output-time,
.output-level,
.output-source { flex: none; white-space: nowrap; }
.output-level {
    display: inline-flex;
    align-items: baseline;
    gap: var(--space-2);
    inline-size: var(--output-level-width);
    font-weight: 600;
}
.output-level > i { align-self: center; font-weight: normal; }
.output-source {
    display: inline-block;
    inline-size: var(--output-source-width);
    overflow: hidden;
    text-overflow: ellipsis;
    vertical-align: top;
    color: var(--muted);
}

/* The columns. The message takes what is left and, below its floor, drops onto a line
   of its own instead of being squeezed to a word per line — which is what a phone gets
   with no media query. The inline-start gutter holds an expanding row's caret, and is
   on every row so the columns line up whether a row expands or not. */
.output--columns .output-line,
.output--columns .output-details > summary {
    display: flex;
    flex-wrap: wrap;
    align-items: first baseline;
    column-gap: var(--space-5);
}
.output--columns .output-line { padding-inline-start: var(--space-9); }
.output-msg { flex: 1 1 32ch; min-width: 0; }

/* Highlighted tokens, marked up by the app — the library does not parse a message.
   The categorical hues carry no meaning of their own, which is what a highlight is.
   On a tinted warn or error line they take the line's colour and hold their weight,
   because a second hue on a tint fails contrast before it helps anyone. */
.output-id  { color: var(--badge-cyan-fg); }
.output-num { color: var(--badge-orange-fg); }
.output-str { color: var(--badge-teal-fg); }
.output-key { color: var(--muted); }
:is(.output-line--warn, .output-line--error) :is(.output-id, .output-num, .output-str) {
    color: inherit;
    font-weight: 600;
}

/* ── Expanding rows ─────────────────────────────────────────────────────────
   A row with more to show — a stack trace, the request that failed, the entry's
   fields — is a <details> inside the line, so open and closed, the keyboard and the
   announcement are the platform's. The caret sits in the row's gutter and turns the
   way `.accordion`'s does. */
/* `white-space: normal` on the <details>, because the line's `pre-wrap` would otherwise
   draw the markup's own line break between the summary and the body as a blank row. */
.output-details { flex: 1 1 100%; min-width: 0; white-space: normal; }
.output-details > summary {
    position: relative;
    white-space: pre-wrap;
    list-style: none;
    cursor: pointer;
}
.output-details > summary::-webkit-details-marker { display: none; }
.output-details > summary:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--brand-ring-soft); }
.output-details > summary::before {
    content: '';
    position: absolute;
    inset-inline-start: calc(var(--space-7) * -1);
    inset-block-start: calc(0.5lh - 2px);
    width: 0;
    height: 0;
    border-inline: 4px solid transparent;
    border-top: 5px solid currentColor;
    opacity: 0.6;
}
.output-details[open] > summary::before { transform: rotate(-180deg); }

/* What the row expands to. Any block: a <pre> for a trace, a `.kv` list for fields. */
.output-trace {
    margin: var(--space-2) 0 var(--space-4);
    padding: var(--space-4) var(--space-6);
    background: var(--surface-soft);
    border-inline-start: 2px solid var(--border-strong);
    border-radius: var(--radius-inner);
    color: var(--fg-soft);
    font: inherit;
    white-space: pre-wrap;
    overflow-wrap: anywhere;
}

/* ── Row actions ────────────────────────────────────────────────────────────
   Copy the line, open the trace, filter by this source: a small strip at the row's
   trailing edge that appears on hover and on focus, and stays while its menu is open.
   The buttons are `.btn-ghost .btn-icon .btn-sm` and the menu is `.menu-anchor` with
   `data-menu-toggle`, as anywhere else. Put the strip after a <details>, never inside
   its <summary>, where a click on the menu's padding would toggle the row.

   Transparent rather than hidden, so Tab still reaches the buttons and reveals them.
   Centred on the row's first line whatever the pane's line height, through `lh`. */
.output-line:has(> .output-actions) { position: relative; }
.output-actions {
    position: absolute;
    inset-block-start: calc((1lh - var(--control-height-sm)) / 2);
    inset-inline-end: var(--space-4);
    display: flex;
    gap: var(--space-1);
    background: var(--bg-elevated);
    border-radius: var(--radius-inner);
    box-shadow: var(--shadow-dropdown);
    font-family: var(--font-sans);
    white-space: normal;
    opacity: 0;
}
.output-line:hover > .output-actions,
.output-line:focus-within > .output-actions,
.output-actions:has([aria-expanded="true"]) { opacity: 1; }
/* Nothing hovers on a touch screen, so the strip is always there and the row keeps
   room for it rather than drawing it over the message. The room is two buttons wide:
   a copy button and a menu, with anything more in the menu. */
@media (hover: none) {
    .output-actions { opacity: 1; background: none; box-shadow: none; }
    .output-line:has(> .output-actions) { padding-inline-end: calc(var(--control-height-sm) * 2 + var(--space-6)); }
}

/* ── Wrap and density ───────────────────────────────────────────────────────
   `--nowrap` is one line per entry, cut with an ellipsis; the pane never scrolls
   sideways, so the row actions stay in view. Wrapping is the default because a cut
   line hides the part of a message that differs. An expanding row still shows the
   whole entry. `--dense` fits more rows for a reader scanning a long stretch. */
.output--nowrap .output-line,
.output--nowrap .output-msg {
    white-space: pre;
    overflow: hidden;
    text-overflow: ellipsis;
}
.output--nowrap.output--columns .output-line { overflow: visible; }
.output--nowrap.output--columns .output-line,
.output--nowrap.output--columns .output-details > summary { flex-wrap: nowrap; }
.output--nowrap .output-trace { white-space: pre-wrap; }
.output--dense { padding-block: var(--space-2); line-height: 1.35; }

/* ── Nothing to show, and still loading ─────────────────────────────────────
   An <li> in the pane, like `.list-empty` in a `.list`, so the pane keeps its frame
   and its height while it has no lines. Put an `.empty-state` inside `.output-empty`.
   `.output-busy` holds a `.spinner` and a few words: the first connect, or earlier
   lines being fetched above the ones already shown. Mark the pane `aria-busy="true"`
   while it is. The first load of a known shape is `.skeleton-text` lines instead. */
.output-empty,
.output-busy {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: var(--space-4);
    color: var(--muted);
    font-family: var(--font-sans);
    font-size: var(--text-4);
    text-align: center;
    white-space: normal;
}
.output-empty { padding-inline: var(--space-7); }
.output-busy { padding: var(--space-4) var(--space-7); }

/* ── Following, and the way back to it ──────────────────────────────────────
   `.output-panel` holds a `.output-head`, the pane and a `.output-jump` button. While
   a `data-follow` pane is on its newest line the button is gone; when the reader
   scrolls up, Sedna.UI.js marks the pane `data-follow-paused` and the button appears
   over the foot of the pane. `data-output-follow` on the button scrolls back down and
   follows again. Without the script there is no following, and no button either. */
.output-panel { position: relative; }
.output-jump {
    position: absolute;
    inset-block-end: var(--space-6);
    inset-inline: 0;
    width: max-content;
    margin-inline: auto;
    box-shadow: var(--shadow-dropdown);
}
.output-panel:not(:has(> .output[data-follow-paused])) > .output-jump { display: none; }
}

/* ── 68-guard.css → @layer sedna.paint ──────────────────────────────────────────────── */
@layer sedna.paint {
/* ── Guard ──────────────────────────────────────────────────────────────────
   A card the reader can SEE but must claim, accept or unlock before acting on.
   The content stays legible under a scrim; a prompt sits over it.

   It is none of the three things it looks like. `.empty-state` is for when
   there is nothing there, and here there is. `.modal` covers the page, and this
   covers one card. `disabled` greys content out, and the whole point of this is
   that the content reads — the reader is deciding whether to take it on.

   Scoped to a positioned ancestor, which is the card:

     <article class="card" style="position: relative">
       …the content, readable…
       <div class="guard">
         <div class="guard-card">
           <span class="guard-icon"><i class="ri-lock-line"></i></span>
           <p>Claim this request to see the customer's reply and decide it.</p>
           <button class="btn btn-brand">Claim</button>
         </div>
       </div>
     </article>

   Two things the markup has to carry, because CSS cannot:

     · put `inert` on the guarded content. Legible is not the same as
       reachable, and without it the whole card stays in the tab order behind a
       scrim the reader cannot type through.
     · the prompt says what claiming DOES. "Claim" alone is a button; "Claim to
       reply on behalf of the team" is a decision.

   z-index 1 is local stacking inside the card, not a rung on the documented
   scale — a guard belongs to one card and never rises over the page.
   ─────────────────────────────────────────────────────────────────────────── */
.guard {
    position: absolute;
    inset: 0;
    z-index: 1;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: var(--space-7);
    background: var(--overlay);
    /* Blur, not opacity: the reader should see there IS content and be unable to
       read it word for word, which is the shape of the offer. */
    backdrop-filter: blur(2px);
    /* Follows whatever it covers, so it does not square off a rounded card. */
    border-radius: inherit;
}
.guard-card {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: var(--space-6);
    max-width: 280px;
    padding: var(--space-8);
    --surface-here: var(--bg-elevated);
    background: var(--bg-elevated);
    border: 1px solid var(--border);
    border-radius: var(--radius-surface);
    box-shadow: var(--shadow-float);
    color: var(--fg-soft);
    font-size: var(--text-4);
    text-align: center;
}
.guard-card > p { margin: 0; }
.guard-icon {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 40px;
    height: 40px;
    background: var(--surface-strong);
    border-radius: 50%;
    color: var(--muted);
    font-size: var(--text-8);
}
}

/* ── 69-timeline.css → @layer sedna.paint ──────────────────────────────────────────────── */
@layer sedna.paint {
/* ── Timeline ───────────────────────────────────────────────────────────────
   Things that happened, newest first or oldest first, with when. An audit trail, a
   change history, a comment thread.

   Differs from .steps in what it is for: steps have a fixed sequence with an end, a
   timeline is an open-ended record. If the reader can be "on" one of them, it is
   steps.

   The rule runs down the markers as a border on the <li>, so it stops exactly at the
   last entry rather than needing a length.

     <ul class="timeline">
       <li data-kind="go">
         <span class="timeline-when">03:15</span>
         <span class="timeline-what">Approved and written to the warehouse</span>
       </li>
     </ul>

   ── The geometry is arithmetic, not constants ───────────────────────────────
   Five numbers describe the whole component, and no offset below is typed:

     --timeline-line       the connector's width
     --timeline-dot        the plain marker's diameter
     --timeline-marker     the icon tile's diameter
     --timeline-lead       the height of the row a marker is centred in
     --timeline-gap        the space under an entry

   A marker sits on the connector at `(line + size) / -2` and in its row at
   `(lead - size) / 2`, and the list is indented by `(size - line) / 2` so that the
   marker starts at the list's own edge rather than outside it.

   The inline offset is NOT `(line - size) / 2`, which is the shape it looks like it
   should be. An absolutely positioned box is placed against its ancestor's PADDING
   box, and the connector is that ancestor's border — outside it. The line's own width
   therefore comes out of the offset a second time.

   Both offsets were constants before, and both were wrong: the dot sat 2px right of
   the line it hangs off, the tile 1px. The 2px is worth knowing about, because the
   same trap is set for every pseudo-element in this stylesheet — `*` in
   `06-base-elements.css` matches elements and NOT pseudo-elements, so the dot was
   still `content-box`, its 2px ring was added to the 9px width instead of taken out
   of it, and an offset computed against 9 missed by 2. The `box-sizing: border-box`
   below is load-bearing.

   The diameters are odd against a 1px line on purpose. An even one centres on a half
   pixel, and a hairline circle straddling one antialiases into a smudge.

   ── The surface behind a marker ─────────────────────────────────────────────
   A marker is opaque, because the connector runs behind it — so it has to be the
   colour of whatever the list is sitting on, and a marker that picks one surface is a
   dark hole on every other. `--timeline-surface` reads `--surface-here` — the colour
   every library surface declares for its own contents — and falls back to the page,
   so a timeline in a card, a modal, a drawer or a popover is right with no help. Only
   on a surface the app itself paints does the app set it, and never restates the rule:

     <ul class="timeline" style="--timeline-surface: var(--surface-strong)">
   ─────────────────────────────────────────────────────────────────────────── */
.timeline {
    --timeline-line: 1px;
    --timeline-dot: 13px;
    --timeline-marker: 27px;
    --timeline-lead: 17px;
    --timeline-gap: var(--space-7);
    --timeline-surface: var(--surface-here, var(--bg));
    list-style: none;
    margin: 0;
    padding: 0;
}

/* The marker hangs half outside the <li>, so the list is indented by exactly that
   much — otherwise a tile pokes through the card it is in. */
.timeline > li {
    position: relative;
    margin-inline-start: calc((var(--timeline-dot) - var(--timeline-line)) / 2);
    padding-block: 0 var(--timeline-gap);
    padding-inline: var(--space-9) 0;
    border-inline-start: var(--timeline-line) solid var(--border);
}
.timeline > li:last-child { border-inline-start-color: transparent; padding-block-end: 0; }
.timeline > li::before {
    content: '';
    position: absolute;
    /* Not tidiness — see the header. Without it the ring is added to the width and
       every offset computed from `--timeline-dot` is short by 4px. */
    box-sizing: border-box;
    inset-inline-start: calc((var(--timeline-line) + var(--timeline-dot)) / -2);
    top: calc((var(--timeline-lead) - var(--timeline-dot)) / 2);
    width: var(--timeline-dot);
    height: var(--timeline-dot);
    background: var(--border-hover);
    border: 2px solid var(--timeline-surface);
    border-radius: 50%;
}

/* The timestamp's line box IS the row the marker centres in, so the two agree by
   construction rather than by a number typed in both places. */
.timeline-when {
    display: block;
    color: var(--muted);
    font-size: var(--text-2);
    line-height: var(--timeline-lead);
    font-variant-numeric: tabular-nums;
}
.timeline-what { color: var(--fg-soft); font-size: var(--text-4); }

.timeline > li[data-kind="go"]::before     { background: var(--go-solid); }
.timeline > li[data-kind="warn"]::before   { background: var(--warn-solid); }
.timeline > li[data-kind="danger"]::before { background: var(--danger-solid); }
.timeline > li[data-kind="info"]::before   { background: var(--info-solid); }

/* ── The entry that is still happening ──────────────────────────────────────
   The head of a live record — a job still running, a reply still being drafted.
   `data-state` rather than a modifier class for the reason `.steps` uses one: an
   entry has exactly one state and an attribute cannot accidentally carry two.

   A ring, not an animation. A record that pulses is a record nobody can read, and a
   ring survives greyscale, forced colours and the colour-blind palette, where a
   colour change on its own does not. */
.timeline > li[data-state="current"]::before {
    background: var(--brand);
    box-shadow: 0 0 0 3px var(--brand-glow);
}
.timeline > li[data-state="current"] > .timeline-mark {
    border-color: var(--brand);
    color: var(--brand-text);
    box-shadow: 0 0 0 3px var(--brand-glow);
}
.timeline > li[data-state="current"] > .timeline-what { color: var(--fg); font-weight: 500; }

/* ── An entry with more than a line in it ───────────────────────────────────
   The supporting block under the headline — the error a retry returned, the fields
   that changed, the chips for what it touched. A class rather than "whatever else is
   in the <li>", so an entry can hold a marker, a headline and a body without the
   three having to be told apart by position. */
.timeline-body {
    margin-top: var(--space-4);
    color: var(--fg-soft);
    font-size: var(--text-3);
}
.timeline-body > :first-child { margin-top: 0; }
.timeline-body > :last-child { margin-bottom: 0; }

/* ── A marker that is an icon ───────────────────────────────────────────────
   The dot says "something happened here". Sometimes the marker IS the information —
   a robot for "the agent worked it", a lock for "it needs a person", a tick for
   "decided" — and a dot throws that away, while a number would imply the reader can
   be "on" a step, which `.steps` reserves for wizards.

   So a `.timeline-mark` child REPLACES the dot. The connector, and its stopping at
   the last entry, are untouched: both live on the <li>, not on the marker.

     <li data-kind="go">
       <span class="timeline-mark"><i class="ri-check-line" aria-hidden="true"></i></span>
       <span class="timeline-when">09:42</span>
       <span class="timeline-what">Approved by the duty lead</span>
     </li>

   The gutter is set by the LIST, not by the entry that has the tile. One marked entry
   widens every row, which is the point: a record where only some entries carry an
   icon otherwise steps its connector in and out at each one.

   A `data-kind` FILLS the tile rather than tinting it, which is what `.steps` does
   with a done marker and for the same two reasons: the --*-bg tints are translucent
   and would let the connector show straight through, and a filled marker still reads
   as itself in greyscale and under forced colours. */
.timeline:has(.timeline-mark) {
    --timeline-lead: var(--timeline-marker);
}
.timeline:has(.timeline-mark) > li {
    margin-inline-start: calc((var(--timeline-marker) - var(--timeline-line)) / 2);
    padding-inline-start: var(--space-10);
}
.timeline > li:has(> .timeline-mark)::before { content: none; }
.timeline-mark {
    position: absolute;
    inset-inline-start: calc((var(--timeline-line) + var(--timeline-marker)) / -2);
    top: calc((var(--timeline-lead) - var(--timeline-marker)) / 2);
    display: flex;
    align-items: center;
    justify-content: center;
    width: var(--timeline-marker);
    height: var(--timeline-marker);
    background: var(--timeline-surface);
    border: 1px solid var(--border);
    border-radius: 50%;
    color: var(--muted);
    font-size: var(--text-4);
}
.timeline > li[data-kind="go"] > .timeline-mark {
    background: var(--go-solid);
    border-color: var(--go-solid);
    color: var(--on-solid);
}
.timeline > li[data-kind="warn"] > .timeline-mark {
    background: var(--warn-solid);
    border-color: var(--warn-solid);
    color: var(--on-solid);
}
.timeline > li[data-kind="danger"] > .timeline-mark {
    background: var(--danger-solid);
    border-color: var(--danger-solid);
    color: var(--on-solid);
}
.timeline > li[data-kind="info"] > .timeline-mark {
    background: var(--info-solid);
    border-color: var(--info-solid);
    color: var(--on-solid);
}

/* ── A marker that is a face ────────────────────────────────────────────────
   In a comment thread the marker is who wrote it, and an avatar inside a bordered
   tile reads as a face in a picture frame. `--bare` drops the tile's BORDER and hands
   the avatar the marker's diameter, so the two cannot disagree about size.

   It keeps the fill, and has to. `--brand-tint` and `--surface-strong` — what an
   `.avatar` is painted in — are translucent, so an avatar over nothing is a window:
   the connector runs straight through the face, and the tint is darkened by the line
   behind it into a colour no token names. The disc underneath is exactly the avatar's
   diameter and equally round, so it is invisible except for what it hides.

   Do not put a `data-kind` on a bare entry. The fill would be painted over by
   whatever is in the marker and only a coloured rim would survive. */
.timeline-mark--bare { background: var(--timeline-surface); border: none; }
.timeline-mark--bare .avatar { --avatar-size: var(--timeline-marker); }

/* ── A break in the record ──────────────────────────────────────────────────
   A day, a release, a shift — a caption the reader scans for while the connector runs
   straight through it, unbroken.

   It is an <li> in the same list rather than a heading between two lists, so the
   record stays one list: the count announced is the number of rows on screen, and the
   caption takes the entry's own margin and border without restating either. */
.timeline-group {
    padding-block: var(--space-7) var(--space-4);
    color: var(--muted);
    font-size: var(--text-2);
    font-weight: 600;
    letter-spacing: 0.04em;
    text-transform: uppercase;
}
.timeline > li.timeline-group::before { content: none; }
.timeline > li.timeline-group:first-child { padding-block-start: 0; }

/* ── The rest of the record ─────────────────────────────────────────────────
   A record with no end needs a foot that says so. The hollow marker is the point: a
   filled one reads as one more event, and this row is the absence of the ones not
   drawn.

   The outer shadow is the gap the ring would otherwise not have — the dot gets its
   separation from the connector out of a border painted in the surface colour, and
   this marker has spent that border on being hollow. */
.timeline-more { color: var(--muted); font-size: var(--text-3); }
.timeline > li.timeline-more::before {
    background: var(--timeline-surface);
    border-color: var(--border-hover);
    box-shadow: 0 0 0 2px var(--timeline-surface);
}

/* ── Timestamps in a gutter ─────────────────────────────────────────────────
   `--aside` moves the "when" outside the line, right-aligned in a column of its own.
   Reach for it when the reader scans by time — a shift log, a call record, a run
   history — because a column of times can be read down and a time that merely starts
   each entry cannot.

   The clearance between the gutter and the marker is one expression written twice by
   necessity: the list's padding makes the room, the timestamp's own margin sits in
   it. It is measured off `--timeline-marker` rather than the dot so that a list gains
   an icon without the gutter moving.

   Not for a narrow column. The gutter costs horizontal space the entries then do not
   have, and the entry text starts wrapping to three words a line. */
.timeline--aside {
    --timeline-aside: 68px;
    --timeline-lead: 21px;
    padding-inline-start: calc(var(--timeline-aside) + var(--timeline-marker) / 2 + var(--space-4));
}
.timeline--aside > li > .timeline-when {
    position: absolute;
    top: 0;
    inset-inline-end: 100%;
    margin-inline-end: calc(var(--timeline-marker) / 2 + var(--space-4));
    width: var(--timeline-aside);
    text-align: end;
}
/* The headline is the entry's first line once the timestamp has left the flow, so it
   is what the marker now has to line up with. */
.timeline--aside > li > .timeline-what { display: block; line-height: var(--timeline-lead); }
}

/* ── 70-rtl.css → @layer sedna.paint ──────────────────────────────────────────────── */
@layer sedna.paint {
/* ═══ Right-to-left ═════════════════════════════════════════════════════════
   Almost nothing is needed here, and that is the point: every directional property
   in this library is a logical one — `margin-inline-start`, `border-inline-end`,
   `inset-inline`, `text-align: start` — so the whole layout mirrors from `dir="rtl"`
   on <html> with no rules at all.
   `No_physical_direction_properties_without_a_justification` keeps it that way.

   What lands here is the residue that logical properties cannot express, of which
   there are exactly two kinds:

     1. `transform: translateX()` does not flip. There is no logical translate, so
        anything that moves a thing along the inline axis needs its sign inverted.
     2. `background-position` has no logical form. A percentage is always measured
        from the physical left edge.

   Both are real gaps in CSS rather than shortcuts taken here, which is why each one
   is named individually instead of being covered by a sweeping rule.
   ─────────────────────────────────────────────────────────────────────────── */

/* The switch knob travels from the inline start to the inline end. Its resting
   position is already logical; only the journey needs mirroring. */
[dir="rtl"] .switch input[type="checkbox"]:checked::after { transform: translateX(-15px); }

/* The select's FALLBACK caret is two gradients positioned from 100%, which is the
   physical right edge whatever the direction. Mirrored by measuring from 0%
   instead, and the reserved padding swaps sides with it.

   Behind the same `@supports not` gate as the caret itself in 45-form-controls.css,
   and for the same reason: where base appearance is available the caret is a real
   `::picker-icon` box held at the trailing edge by an auto margin, so it mirrors on
   its own. Without the gate these rules would paint a second, sideways caret over
   it and put 32px of reserved room back on the wrong side. */
@supports not (appearance: base-select) {
    [dir="rtl"] .form-select {
        padding-inline-end: var(--space-5);
        padding-inline-start: var(--space-10);
        background-position:
            calc(0% + 12px) calc(50% - 2px),
            calc(0% + 17px) calc(50% - 2px);
    }
    /* The two triangles also swap, or the caret points sideways: the left-hand box has
       to be the one filled towards the top-left. */
    [dir="rtl"] .form-select {
        background-image:
            linear-gradient(135deg, var(--muted) 50%, transparent 50%),
            linear-gradient(45deg,  transparent 50%, var(--muted) 50%);
    }
    [dir="rtl"] .form-select[multiple],
    [dir="rtl"] .form-select[size]:not([size="1"]) { padding-inline-start: var(--space-5); }
}

/* The drawer slides in from an inline edge; `inset-inline-end` already puts it on
   the correct side, so only the direction of travel is wrong. */
[dir="rtl"] .drawer { transform: translateX(-100%); }
/* 54 — a meter's mark label is centred on its mark by a translate. */
[dir="rtl"] .meter-mark-label { transform: translateX(50%); }
[dir="rtl"] .drawer--start { transform: translateX(100%); }

/* A sheet is one of those too — `<div class="drawer sheet">` — but it travels along
   the BLOCK axis, which does not flip. The rule above is (0,2,0) against `.sheet`'s
   own (0,1,0) translate in 63-drawer.css, so without this line a bottom sheet slid
   in from the inline start edge in RTL and nothing errored.

   Restated here rather than narrowed to `.drawer:not(.sheet)` up there: a negation
   silently stops covering the next class that composes with `.drawer`, and this way
   each rule says what it is for.

   THE THREE LINES ARE ORDER-DEPENDENT and all three are (0,2,0), so the cascade is
   settled by source position alone. `.sheet` has to sit after `.drawer` to beat it,
   and before `.drawer--open` to lose to it — an open sheet takes `transform: none`
   like every other panel. Move this line below the next one and an RTL sheet opens
   to a position 100% below the viewport, which is a panel that never appears. */
[dir="rtl"] .sheet { transform: translateY(100%); }
[dir="rtl"] .drawer--open { transform: none; }
/* The <dialog> form's closed state lives in a @starting-style block, where the same
   two selectors are (0,3,1) apiece and source order decides again — this file is
   later than 63-drawer.css, which is the whole reason its rules land. */
@starting-style {
    [dir="rtl"] dialog.sheet[open] { transform: translateY(100%); }
}

/* Kind 2 again. An avatar group's separating gap is a hole masked out of the avatar
   behind, centred on where the next one lands — and a radial-gradient position is
   measured from the physical left edge whatever the direction, while the overlap
   that put it there is a `margin-inline-start`. So the offset is restated from the
   other edge; the arithmetic is the same, mirrored. */
[dir="rtl"] .avatar { --avatar-cut-x: calc(var(--avatar-overlap) - var(--avatar-size) / 2); }
/* The same hole in an `.avatar-pair`, where the front avatar sits at the inline end:
   measured from the back avatar's left edge, which in RTL is the pair's inline end
   minus its own width. */
[dir="rtl"] .avatar-pair > .avatar:first-child:not(:last-child) { --avatar-cut-x: calc(var(--avatar-size) * 1.5 - var(--avatar-pair-size)); }

/* The tree's disclosure caret points along the inline axis, so it starts mirrored
   and rotates the other way when open. */
[dir="rtl"] .tree summary::before { transform: rotate(180deg); }
[dir="rtl"] .tree details[open] > summary::before { transform: rotate(90deg); }

/* A third kind of gap, and the only member of it: `box-shadow` has no logical form
   either, so the offset of a pinned column's edge shadow is physical. The two rules
   are one line each because the direction is held in a custom property rather than
   in the box-shadow declaration — the alternative was restating both long pinned-cell
   selector lists here.

   `--pin-edge-*` and not `--pin-shadow`: the shadow a side USES is the one a
   scroll-state query switches off while there is nothing hidden behind the column,
   and these rules are the more specific of the two. Which direction the edge faces
   and whether it is painted at all are different questions, so they are different
   properties. */
[dir="rtl"] .table--pin-start { --pin-edge-start: var(--shadow-edge-end); }
[dir="rtl"] .table--pin-end   { --pin-edge-end:   var(--shadow-edge-start); }
[dir="rtl"] .cal-timeline     { --cal-label-edge: var(--shadow-edge-end); }

/* The collapsed rail's flyout needs nothing here, and neither does anything else in
   the rail. `position-area: inline-end` and `align-self: anchor-center` are both
   logical and resolve against the writing mode, and the offset is a
   `margin-inline-start`. */
}

/* ── 71-forced-colors.css → @layer sedna.paint ──────────────────────────────────────────────── */
@layer sedna.paint {
/* ═══ Forced colours ════════════════════════════════════════════════════════
   Windows high-contrast mode. The browser replaces every colour with a small
   user-chosen palette, and it does so whatever this stylesheet says — so the job here
   is not to restyle anything, it is to make sure nothing the library draws
   *disappears* when it does.

   Two failure modes, and both are silent:

     1. Anything drawn with a background instead of a border vanishes. A tinted
        surface becomes Canvas, so a brand-tinted active nav item, a selected table
        row, a checked checkbox and a filled step marker all become indistinguishable
        from their neighbours.
     2. `box-shadow` is not painted at all in forced colours. Every focus ring in this
        library is a box-shadow, so without the rules below the whole library becomes
        unusable by keyboard — which is the exact population most likely to be in
        forced-colors mode.

   Everything below is a token remap on :root plus outline work, which is why it
   passes `Appearance_media_queries_only_remap_tokens`: the only selector rules are
   ones whose declarations are all `forced-color-adjust` or `outline`, the documented
   exception.
   ─────────────────────────────────────────────────────────────────────────── */
@media (forced-colors: active) {
    /* The system keywords are the palette the user chose; they are legal here and
       nowhere else. Mapping the tokens means every class picks the palette up without
       being touched. */
    :root {
        --bg:              Canvas;
        --bg-elevated:     Canvas;
        --bg-hover:        Highlight;
        --fg:              CanvasText;
        --fg-soft:         CanvasText;
        --muted:           GrayText;
        --border:          CanvasText;
        --border-strong:   CanvasText;
        --border-hover:    Highlight;
        --divider:         CanvasText;
        --card-bg:         Canvas;
        --surface-soft:    Canvas;
        --surface-strong:  Canvas;
        --on-solid:        HighlightText;
        --on-viz:          CanvasText;

        --brand:           Highlight;
        --brand-hover:     Highlight;
        --brand-active:    Highlight;
        --brand-soft:      Highlight;
        --brand-text:      LinkText;
        --brand-tint:      Canvas;
        --accent:          LinkText;

        --sidebar-bg:      Canvas;
        --sidebar-border:   CanvasText;
        --sidebar-fg:      CanvasText;
        --sidebar-active:  Highlight;

        /* The semantic families collapse: the user's palette has no green or amber,
           and inventing one defeats the mode. What must survive is the *shape* — the
           leading rules, the icons and the text, all of which are borders and glyphs. */
        --go-solid: CanvasText;   --go-bg: Canvas;     --go-border: CanvasText;   --go-fg: CanvasText;
        --warn-solid: CanvasText; --warn-bg: Canvas;   --warn-border: CanvasText; --warn-fg: CanvasText;
        --danger-solid: CanvasText; --danger-bg: Canvas; --danger-fg: CanvasText;
        --danger-border: CanvasText; --danger-border-strong: CanvasText;
        --info-bg: Canvas;   --info-border: CanvasText;   --info-fg: CanvasText;
        --presence-out-of-office: CanvasText;
        /* The nested card levels are palette steps, not aliases of --card-bg, and a
           presence mark knocks its glyph out in --surface-here — which on a raised card
           resolves to one of these. */
        --surface-raised-2: Canvas;
        --surface-raised-3: Canvas;
        --secret-bg: Canvas; --secret-border: CanvasText; --secret-fg: CanvasText;

        --badge-bg: Canvas;
        --table-head-bg: Canvas;
        --skeleton-bg: Canvas;
        --skeleton-sheen: Canvas;
        --progress-track: Canvas;
        --viz-previous: GrayText;
        --code-bg: Canvas;
        --code-fg: CanvasText;
        --backdrop: Canvas;
        --overlay: Canvas;
        --tip-bg: Canvas;
        --tip-border: CanvasText;
        --tip-fg: CanvasText;
    }

    /* Focus rings. Every one in this library is a box-shadow, and box-shadow is not
       painted in forced colours — so each of them is restated as an outline, which is.
       An `outline` on top of the existing `outline: none` is why these rules are
       permitted by the appearance-media-query guard. */
    .btn:focus-visible,
    /* The chromeless one especially: it has no border, no fill and no padding, so a
       ring that is not painted leaves nothing whatsoever to say where the keyboard is. */
    .btn-bare:focus-visible,
    .form-input:focus,
    /* The select declares its ring in the same rule .form-input does, so it needs
       the same restatement here — it was missing, and a focused dropdown in this
       mode had nothing on it at all. */
    .form-select:focus,
    .form-check input:focus-visible,
    .switch input[type="checkbox"]:focus-visible,
    .form-range:focus-visible,
    .input-group:focus-within,
    .tab:focus-visible,
    .menu-item:focus-visible,
    .page-link:focus-visible,
    .chip-dismiss:focus-visible,
    .th-sort:focus-visible,
    .th-filter:focus-visible,
    .input-clear:focus-visible,
    .accordion summary:focus-visible,
    .tree summary:focus-visible,
    .tree-leaf:focus-visible,
    .skip-link:focus,
    .fab:focus-visible,
    .form-color:focus-visible,
    .swatch:focus-visible,
    .editor:focus-within,
    a.file-item:focus-visible,
    .file-tile:has(.file-tile-open:focus-visible),
    .media-transcript > summary:focus-visible,
    .media-transcript-time:focus-visible,
    .drag-item:focus-visible,
    .drag-handle:focus-visible,
    :is(a, button).list-row:focus-visible { outline: 2px solid CanvasText; outline-offset: 1px; }

    /* Selected and current states are backgrounds, which are flattened away. An
       outline is what is left that the mode still paints. */
    .nav-link.active,
    .tab[aria-selected="true"],
    .page-link[aria-current="page"],
    .palette-item[aria-selected="true"],
    .tree-leaf[aria-current="true"],
    .list-row[aria-current="true"],
    .list-row[aria-selected="true"],
    .file-tile:has(.file-tile-check :checked),
    .media-transcript li[aria-current="true"],
    .segmented-option:has(input:checked),
    .th-filter--active,
    .chat-reaction[aria-pressed="true"],
    .swatch[aria-pressed="true"],
    .cal-day:is([aria-pressed="true"], [aria-selected="true"], [aria-current="date"]),
    .cal-pick:is([aria-pressed="true"], [aria-current]:not([aria-current="false"])),
    .editor-toolbar [aria-pressed="true"] { outline: 1px solid Highlight; }

    /* A chat bubble is nothing but a background; without a line around it a
       thread is a column of text with no turns in it. */
    .chat-bubble,
    .link-preview,
    .chat-reaction,
    .chat-jump,
    .cal-event,
    .cal-block { outline: 1px solid CanvasText; outline-offset: -1px; }

    /* A filtered column's heading is marked by a colour and an inset shadow, and
       neither survives; an outline inside the cell is what is left. */
    .table thead tr:not(.tr-filter) > th.col-filtered { outline: 2px solid Highlight; outline-offset: -2px; }

    /* A status dot is the one thing whose actual colour IS the content — it is not
       decoration on a label, it IS the label — so it opts out of the forced palette
       entirely. */
    .nav-status-dot,
    .health-dot,
    .chip-dot,
    .th-filter--active::after,
    .avatar-presence::after,
    .timeline > li::before { forced-color-adjust: none; }

    /* A bar is nothing but a background, and so is a meter's mark: forced colours
       paints every one of them Canvas, on a Canvas track, and the chart is blank. The
       previous value behind a bar is a hatch, which the mode removes outright. Each
       opts out, and each reads its colour from a token remapped above — the brand to
       Highlight, the semantic families to CanvasText, --viz-previous to GrayText — so
       what is painted is still the user's own palette. */
    .bar-list-fill,
    .stack-bar-segment,
    .stack-bar-swatch,
    .meter-fill,
    .meter-mark,
    .progress-bar,
    .bar-previous { forced-color-adjust: none; }
}
}

/* ── 72-print.css → @layer sedna.paint ──────────────────────────────────────────────── */
@layer sedna.paint {
/* ═══ Print ═════════════════════════════════════════════════════════════════
   An app page printed or saved as PDF. Chrome is the enemy here: a sidebar, a header
   and a toast stack are navigation, and navigation on paper is a waste of the page.

   Colours are left alone rather than forced to black on white. Browsers do not print
   backgrounds by default, so the dark theme's surfaces already come out white; forcing
   text to black as well would break the reader's own "print backgrounds" choice, which
   is the one case where the dark surfaces are wanted.

   This is a layout media query, so `Layout_media_queries_only_change_geometry`
   applies: geometry, or a value that comes from a token.
   ─────────────────────────────────────────────────────────────────────────── */
@media print {
    /* The shell stops being a viewport-height flex box: on paper there is no viewport
       and nothing scrolls, so a `height: 100dvh; overflow: hidden` shell would print
       exactly one screenful and silently drop the rest. */
    .layout,
    .bare-layout,
    .auth-layout { display: block; height: auto; min-height: 0; overflow: visible; }
    .content,
    .page,
    .full-layout { display: block; overflow: visible; min-height: 0; padding: 0; }

    /* Chrome and overlays. Anything that is navigation, transient, or floating over
       the content has no meaning on paper. */
    .sidebar,
    .topbar,
    .skip-link,
    .toast-stack,
    .sedna-tip,
    .fab,
    .drawer,
    .drawer-scrim,
    .spotlight-hole,
    .spotlight-tip,
    .status-bar,
    .modal-backdrop,
    .page-head-actions,
    .form-actions,
    .code-block-copy,
    .chat-launcher,
    .chat-flyover,
    .chat-composer,
    .chat-closed,
    .chat-actions,
    .chat-jump,
    .chat-quick-replies { display: none; }

    /* A thread prints whole: the scroller lets its messages out, and a message is
       not cut across a page. */
    .chat-pane > .chat { overflow: visible; }
    .chat-message { break-inside: avoid; }

    /* A card is a box on screen; on paper the border is what keeps it a box, and the
       shadow is nothing. Kept whole across a page break: a card split in half loses
       the connection between its head and its numbers. */
    .card,
    .code-block,
    .callout,
    .media-frame { box-shadow: none; break-inside: avoid; }

    /* A table's header repeats on every page — that is what <thead> is for, and it
       only happens if the header is not sticky. */
    .table--sticky thead { position: static; }
    .table thead { display: table-header-group; }
    .table tfoot { display: table-footer-group; }
    .table tr { break-inside: avoid; }

    /* Scroll containers have no scrollbar on paper, so their content must be allowed
       out rather than clipped to the box it had on screen. */
    .sedna-scroll,
    .drawer-body,
    .md-editor .md-preview,
    .palette-list { max-height: none; overflow: visible; }

    /* An accordion prints what it is showing. Forcing every <details> open would
       print things the reader deliberately collapsed; leaving them closed loses
       nothing they were reading. */

    /* A link's destination is invisible on paper. Printed after the text, and only
       for absolute URLs — a relative href resolves to nothing useful, and printing
       every in-app link's path makes the page unreadable. */
    .prose a[href^="http"]::after,
    .markdown-body a[href^="http"]::after {
        content: ' (' attr(href) ')';
        font-size: 0.85em;
        word-break: break-all;
    }

    /* Headings should not be the last line on a page. */
    h1, h2, h3, .card-head, .page-head { break-after: avoid; }
}
}

/* ── 80-utilities.css → @layer sedna.utilities ──────────────────────────────────────────────── */
@layer sedna.utilities {
/* ═══ Utilities ═════════════════════════════════════════════════════════════
   Single-purpose classes, in `@layer sedna.utilities`.

   The layer is what makes them work at all. A utility is (0,1,0) and a component rule
   like `.table th, .table td` is (0,1,1), so on specificity alone a utility loses
   every collision — `.text-end` on a table cell would do nothing. sedna.utilities sits
   after sedna.paint, so it wins regardless of specificity and no cap convention has to be
   remembered.

   Deliberately short, and staying short. This is not a utility framework: the point
   of tier 2 is semantic classes that carry meaning, and a page assembled out of
   fifteen utilities has moved its design decisions into the markup where nobody can
   review them. What is here is the handful of adjustments that are genuinely
   presentational and would otherwise be an inline style.
   ─────────────────────────────────────────────────────────────────────────── */

/* Taken out of the visual rendering, kept in the accessibility tree — a label for
   an icon-only control, a table caption, the target of an aria-describedby.

   Not `display: none` and not `width: 0`: both remove the element from the
   accessibility tree as well, which is the opposite of the point. The 1px box
   with the content clipped away is what every engine still announces. */
.visually-hidden {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: -1px;
    padding: 0;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
    border: 0;
}

/* For something that must come back when it is reached by keyboard — a skip link
   is the usual case. :focus-within covers a wrapper holding the focusable node. */
.visually-hidden--focusable:focus,
.visually-hidden--focusable:focus-within {
    position: static;
    width: auto;
    height: auto;
    margin: 0;
    overflow: visible;
    clip-path: none;
    white-space: normal;
}

/* ── Text ───────────────────────────────────────────────────────────────────
   Logical, so they mirror with the document direction. `.text-end` on a table cell is
   the common case. */
.text-start  { text-align: start; }
.text-center { text-align: center; }
.text-end    { text-align: end; }

.text-muted { color: var(--muted); }
.text-mono  { font-family: var(--font-mono); }
/* Digits that line up down a column, for anything a reader compares. */
.text-nums  { font-variant-numeric: tabular-nums; }
.text-nowrap { white-space: nowrap; }
/* Two type steps either side of the body size, for a line that has to be quieter or
   louder than its surroundings without becoming a heading. */
.text-sm { font-size: var(--text-3); }
.text-lg { font-size: var(--text-6); }
/* Breaks inside a word when there is nowhere else to break — an id, a URL, a token
   name in a narrow column. `anywhere` rather than `break-word` because it also shrinks
   the min-content width, so the element stops forcing its container wide. */
.text-break { overflow-wrap: anywhere; }
/* Cut after n lines with an ellipsis. `-webkit-` prefixed and `display: -webkit-box`
   are load-bearing: line-clamp has no unprefixed form that works. */
.text-clamp-2,
.text-clamp-3 {
    display: -webkit-box;
    -webkit-box-orient: vertical;
    overflow: hidden;
}
.text-clamp-2 { -webkit-line-clamp: 2; }
.text-clamp-3 { -webkit-line-clamp: 3; }
/* One line, cut with an ellipsis. `min-width: 0` is included because the usual
   parent is a flex item, which otherwise refuses to shrink below its content and
   the ellipsis never appears — the single most common reason truncation "does not
   work". */
.text-truncate {
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* ── Layout ─────────────────────────────────────────────────────────────────
   A row and a column with the library's own gap, for the arrangements that would
   otherwise be an inline `style="display:flex; gap:8px"` — which is most of them. */
.sedna-row { display: flex; align-items: center; gap: var(--space-4); }
.sedna-row-wrap { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-4); }
.sedna-col { display: flex; flex-direction: column; gap: var(--space-4); }
/* Pushes everything after it to the far end of a flex container. */
.sedna-push { margin-inline-start: auto; }

/* Three gap steps and none. `.sedna-row` and `.sedna-col` set one gap; these are for the
   rows that need a tighter or looser one, which was an inline `style="gap:12px"`. */
/* Each sets `--grid-gap` beside `gap`, because `.sedna-grid-24` computes its column
   widths from that property: a gap changed on its own would leave the widths sized for
   the old one and wrap the last panel of every row. */
.sedna-gap-0 { gap: 0;                --grid-gap: 0px; }
.sedna-gap-1 { gap: var(--space-4);   --grid-gap: var(--space-4); }
.sedna-gap-2 { gap: var(--space-6);   --grid-gap: var(--space-6); }
.sedna-gap-3 { gap: var(--space-8);   --grid-gap: var(--space-8); }

/* Cross-axis alignment. `.sedna-row` centres; a row of fields with labels wants
   `.sedna-start`, and a row of text of different sizes wants `.sedna-baseline`. */
.sedna-wrap     { flex-wrap: wrap; }
.sedna-start    { align-items: flex-start; }
.sedna-center   { align-items: center; }
.sedna-end      { align-items: flex-end; }
.sedna-baseline { align-items: baseline; }
.sedna-between  { justify-content: space-between; }

/* The main axis, for the row whose children sit together at one end or in the middle
   rather than filling it — a button pair centred under an empty state, a set of
   controls at the far end of a toolbar row. Also valid on a grid, where it places
   the tracks when they do not fill the width. */
.sedna-justify-center { justify-content: center; }
.sedna-justify-end    { justify-content: flex-end; }

/* One child against the rest: a button that must not stretch to the height of the
   panel beside it, a badge at the top of a tall row. They are align-SELF, so they
   work the same in a flex row, a flex column and a grid cell. */
.sedna-self-start   { align-self: flex-start; }
.sedna-self-center  { align-self: center; }
.sedna-self-end     { align-self: flex-end; }
.sedna-self-stretch { align-self: stretch; }

/* Its content in the middle of its box, both axes — a spinner in an empty panel, a
   figure in a tile, a sign-in card on a blank page. A grid with `place-items`, which
   is the one arrangement that centres a child of any display without it having to
   know. */
.sedna-place-center { display: grid; place-items: center; }

/* The flex child that takes the remaining room. `min-width: 0` comes with it, because
   without it the child refuses to shrink below its content and every ellipsis inside
   stops working — the commonest truncation bug there is. */
.sedna-fill { flex: 1; min-width: 0; }
.sedna-shrink-0 { flex-shrink: 0; }
.sedna-w-full { width: 100%; }

/* ── Control width ───────────────────────────────────────────────
   `.form-input`, `.form-select` and `.input-group` are `width: 100%`, because a
   field in a form row is the common case and filling the row is right there. There
   was no way out of it, so a control that should be as wide as its value — a status
   in a table cell, a page-number box, a short filter — either filled its column or
   got an inline `width` in the markup, and apps ended up styling library classes in
   their own stylesheets to escape it.

   These live in `@layer sedna.utilities`, which is the point: it comes after
   `sedna.paint`, so `.sedna-w-fit` at (0,1,0) beats
   `.table .col-control > .menu-anchor > .value-trigger` at (0,3,0) without
   `!important` and without a specificity race. A `--fit` modifier on each control
   could not — it would sit in the same layer as the rule it has to overrule, and
   it would be the same idea under five names, one per control class.

   There is no `--grow` here. `.sedna-fill` above already takes the remaining room
   in a flex row, and a second name for it is a second thing to keep in step.

   `max-width: 100%` rides the knob so a fixed width still gives way on a narrow
   window rather than pushing its container sideways. */
.sedna-w-fit { width: fit-content; }
.sedna-w {
    --sedna-w: 12rem;
    width: var(--sedna-w);
    max-width: 100%;
}
.sedna-w--sm { --sedna-w: 8rem; }
.sedna-w--md { --sedna-w: 12rem; }
.sedna-w--lg { --sedna-w: 18rem; }

/* Block margins only, and only for removing or adding a step. There is no inline
   pair: horizontal placement belongs to the layout, not to a utility on the child. */
.sedna-mt-0 { margin-top: 0; }
.sedna-mt-1 { margin-top: var(--space-4); }
.sedna-mt-2 { margin-top: var(--space-6); }
.sedna-mt-3 { margin-top: var(--space-8); }
.sedna-mb-0 { margin-bottom: 0; }
.sedna-mb-1 { margin-bottom: var(--space-4); }
.sedna-mb-2 { margin-bottom: var(--space-6); }
.sedna-mb-3 { margin-bottom: var(--space-8); }

/* A hairline between rows without an <hr> and without a wrapper. */
.sedna-divider-top    { border-top: 1px solid var(--divider); }
.sedna-divider-bottom { border-bottom: 1px solid var(--divider); }

/* A scroll container on the inline axis — a table too wide for its column, a row of
   cards.

   `container-type: scroll-state` applies no containment of any kind, so it changes
   nothing about layout or about what is a containing block for what. It is what
   lets a descendant ask whether this scroller can still be scrolled: a pinned table
   column asks, so that its edge shadow is painted only while a column is actually
   hidden behind it. */
.sedna-scroll-x { overflow-x: auto; container-type: scroll-state; }

/* And the block axis. `.sedna-scroll` supplied the scrollbar and the edge fade (in
   16-frame-page-scroll.css) but neither of the two declarations that make it a
   scroller at all, so every use of it was the class plus
   `style="max-height:22rem; overflow-y:auto"` — a number in the markup because a
   consuming app may not declare a rule for a library class.

   `--scroll-max` defaults to `none`, so adding these changes nothing for markup that
   already states its own max-height inline: an inline style beats every rule here.
   The three steps are the sizes a scroller is actually asked for — a short list, a
   panel, most of a page — and `--scroll-max` covers the rest, including `70vh` and
   friends, which are viewport-relative and cannot be a step. */
.sedna-scroll {
    --scroll-max: none;
    max-height: var(--scroll-max);
    overflow-y: auto;
}
.sedna-scroll--sm { --scroll-max: 12rem; }
.sedna-scroll--md { --scroll-max: 20rem; }
.sedna-scroll--lg { --scroll-max: 32rem; }

/* ── State ──────────────────────────────────────────────────────────────────
   Not a `hidden` replacement: use the `hidden` ATTRIBUTE for that, because it also
   removes the element from the accessibility tree and the tab order. This is for
   hiding something that must stay in the layout's flow calculations. */
.sedna-invisible { visibility: hidden; }

/* Marks a block as busy without removing it. Pair it with aria-busy="true", which is
   what is announced; this only stops the reader interacting with stale content. */
.sedna-busy { opacity: 0.55; pointer-events: none; cursor: progress; }

/* For something unavailable that is NOT a <button>, <input> or <a> — those have the
   `disabled` attribute, which is announced and removes them from the tab order, and
   this is no substitute for it. Add aria-disabled="true" alongside. */
.sedna-disabled {
    color: var(--muted);
    pointer-events: none;
    cursor: not-allowed;
}

/* A copy button's outcome, which the script reports as `data-copied="ok"` or
   `data-copied="failed"` on the button for a moment and never by rewriting it. The
   words and icons are the app's: `data-copied-hide` marks what shows at rest, and
   `data-copied-show="ok"` / `"failed"` what replaces it. Nothing in the button is
   inserted or removed, so a framework that owns the button has nothing to revert. */
[data-copied] [data-copied-hide] { display: none; }
[data-copied-show]:not([data-copied="ok"] [data-copied-show="ok"], [data-copied="failed"] [data-copied-show="failed"]) {
    display: none;
}

/* ── Safe area ──────────────────────────────────────────────────────────────
   For an app's OWN element on a viewport edge, once the host page carries
   `viewport-fit=cover` — a sticky action bar under a form, a footer, a bottom
   navigation, a banner. The library's frame already holds itself clear (the shell
   takes the top and the sides, and each bottom-pinned overlay takes its own inset);
   these are the escape for the ones the library cannot see.

   PADDING, not margin, and that is the whole point of them. The element's own
   background then paints into the strip the device has taken, instead of leaving a
   transparent band that shows the page through — under a dark bar on a light page
   that band is the artefact people file bugs about. Put the class on the element
   that carries the background, not on a wrapper around it.

   Each resolves to 0px wherever there is no inset, so one class is correct on a
   phone, a tablet and a desktop and nothing has to detect a device.

   No `--safe-inline-start`-only utility. An element wide enough to reach one inline
   edge reaches both, and a single-edge class is a landscape bug waiting for the
   phone to be turned round. */
.sedna-safe-top    { padding-block-start: var(--safe-block-start); }
.sedna-safe-bottom { padding-block-end: var(--safe-block-end); }
.sedna-safe-inline { padding-inline: var(--safe-inline-start) var(--safe-inline-end); }

/* ── Print ──────────────────────────────────────────────────────────────────
   The two every printed page needs: keep the chrome off the paper, and let a sheet
   carry something the screen does not. 72-print.css handles the frame; these are for
   an app's own content. */
@media print {
    .sedna-no-print { display: none; }
}
.sedna-print-only { display: none; }
@media print {
    .sedna-print-only { display: revert; }
}
}

/* ── 90-density.css → @layer sedna.overrides ──────────────────────────────────────────────── */
@layer sedna.overrides {
/* ═══ Compact density ═══════════════════════════════════════════════════════
   [data-density="compact"] on <html>. A pure token remap, like every other theme
   block, so it composes with the themes and CSS load order stays irrelevant.

   Three families tighten: table cells (--cell-pad-*), card parts (--card-pad-*) and
   list rows (--list-pad-*, plus --list-gap between the cards of `.list--cards`).
   Anything else that should tighten with them reads the same tokens and gets it for
   free — an app's own card part written with `.card-body`, or its own component
   padded with var(--card-pad-inline).

   There is deliberately no selector override alongside this. An app's own stylesheet is
   unlayered and wins whatever this file declares, so an app holding its own
   `.table th, .table td { padding: … }` or `.card-body { padding: … }` does not go
   compact until that rule is deleted. No specificity here can substitute for deleting it.
   ─────────────────────────────────────────────────────────────────────────── */
:root[data-density="compact"] {
    --cell-pad-y: var(--space-2);
    --cell-pad-x: var(--space-5);
    --card-pad-block: var(--space-4);
    --card-pad-inline: var(--space-6);
    --list-pad-block: var(--space-3);
    --list-pad-inline: var(--space-5);
    --list-gap: var(--space-2);
}
}

/* ── 95-reduced-motion.css → @layer sedna.overrides ──────────────────────────────────────────────── */
@layer sedna.overrides {
/* ═══ Reduced motion ════════════════════════════════════════════════════════
   Every animation and transition in the library, switched off in one place. It is
   one block on purpose: scattered per-part media queries are how a new transition
   ends up with no reduced-motion counterpart, and nobody notices, because nobody
   testing the feature has the setting on.

   Not a blanket `*, *::before, *::after { transition: none }`. Two reasons:
   without !important — which this library does not use — a sweeping selector at
   (0,0,0) loses to every real rule anyway; and it would also silence the
   consuming app's own motion, which is not this package's call to make.

   So each line below names the selector it is undoing, at the same specificity.
   Numbered 95 rather than 85 so it lands in @layer sedna.overrides, which is the last
   layer: motion has to be switched off whatever declared it, including a utility.
   When you add a transition or an animation anywhere in css-parts/, add its off
   switch here in the same edit.

   What is NOT switched off: nothing here removes information. The spinner still
   reads as busy while static, the skeleton still reads as a placeholder, and the
   indeterminate progress bar is filled rather than blanked so it still says "in
   progress, no estimate".
   ─────────────────────────────────────────────────────────────────────────── */
@media (prefers-reduced-motion: reduce) {

    /* ── Tier 1 — the frame ─────────────────────────────────────────────── */
    .sidebar          { transition: none; }   /* 11 — collapse to the icon rail */
    /* 12 — the rail's layout arrives with the width, not after it. */
    .sidebar.collapsed > .brand,
    .sidebar.collapsed > .nav,
    .sidebar.collapsed > .nav-tools { transition: none; }
    .sidebar.collapsed .brand { transition: none; }
    .sidebar.collapsed .brand-text { transition: none; }
    .sidebar.collapsed .nav-section-label { transition: none; }
    .sidebar.collapsed .nav-section { transition: none; }
    .sidebar.collapsed .nav-link { transition: none; }
    .sidebar.collapsed .nav-link > span:not(.nav-count) { transition: none; }
    .sidebar.collapsed .nav-link-ext { transition: none; }
    .sidebar.collapsed .nav-count { transition: none; }
    .sidebar.collapsed .nav-status-card { transition: none; }
    .sidebar.collapsed .nav-status-label,
    .sidebar.collapsed .nav-status-state { transition: none; }
    .sidebar.collapsed .nav-status-dot { transition: none; }
    .sidebar.collapsed .nav-tools-sep { transition: none; }
    .sidebar.collapsed .nav-filter { transition: none; }
    .sidebar.collapsed.sidebar--areas > .nav { transition: none; }
    .sidebar.collapsed.sidebar--areas .nav-areas { transition: none; }
    .nav-status-card  { transition: none; }   /* 11 */
    .nav-filter-input { transition: none; }   /* 14 */
    .bottombar-items  { transition: none; }   /* 24 — steps away on scroll */
    .user-signout     { transition: none; }   /* 15 */
    /* 17 — the hint appears at its final position instead of sliding in. Opacity
       is kept as a step, not a fade, by removing the transition entirely. */
    .sedna-tip           { transition: none; transform: none; }
    .status-bar--reconnecting > i { animation: none; }   /* 18 */
    .nav-group > summary::after { transition: none; }   /* 20 — the group caret */
    /* 20 — the skip link jumps into view rather than sliding down. It has to still
       become visible, so only the transition goes. */
    .skip-link        { transition: none; }

    /* ── Tier 2 — the paint ─────────────────────────────────────────────── */
    .btn              { transition: none; }   /* 33 */
    .form-input,
    .form-select      { transition: none; }   /* 34 */
    .form-check input[type="checkbox"] { transition: none; }   /* 34 */
    .editor           { transition: none; }   /* 44 */
    .form-color       { transition: none; }   /* 45 */
    /* 49 — the caret is drawn in its open position instead of turning over, and the
       drop-down is simply there. Neither carries information: `:open` already
       changes the border, and a panel that appears is a panel. */
    .form-select::picker-icon      { transition: none; }
    .form-select::picker(select)   { transition: none; }
    .form-combo-box,
    .form-combo-toggle i { transition: none; }   /* 49 */
    .form-check input[type="radio"]    { transition: none; }   /* 45 */
    .switch input[type="checkbox"]     { transition: none; }   /* 45 */
    .switch input[type="checkbox"]::after { transition: none; }
    .input-group      { transition: none; }   /* 46 */
    .form-trigger::after { transition: none; }   /* 46 */
    .dropzone         { transition: none; }   /* 47 */
    dialog.modal      { animation: none; }    /* 36 */
    .modal-close      { transition: none; }   /* 36 */
    /* 36 — the slide arrives instead of rising into place, and the dot marking
       the current one changes width in one step. The dots still say where the
       reader is; only the travel goes. */
    .deck-step        { animation: none; }    /* 36 */
    .deck-dot         { transition: none; }   /* 36 */
    .spinner          { animation: none; }    /* 39 */
    .btn[aria-busy="true"]::after { animation: none; }   /* 33 — the open ring still reads as busy */
    .skeleton         { animation: none; background-image: none; }   /* 40 */
    .progress-bar     { transition: none; }   /* 41 */
    .progress--indeterminate .progress-bar { animation: none; width: 100%; }
    .tab              { transition: none; }   /* 50 */
    a.stat            { transition: none; }   /* 54 */
    /* 66 — the dot keeps its colour and the word keeps saying the state; only the
       pulse stops. Nothing is lost, because the pulse was never the only signal. */
    .health-badge--live .health-dot,
    .health-badge--streaming .health-dot,
    .health-badge--connecting .health-dot { animation: none; }
    .health-badge--healthy .health-dot { animation: none; }
    .cal-event-dot--live { animation: none; }   /* 66 — the event still says "Live" in text */
    .segmented-option { transition: none; }   /* 51 */
    /* 64 — the hole appears at the new target instead of sliding to it, and the
       "do this now" marker stops pulsing. Neither carries information: the ring is
       the highlight, and the marker's words already say what to do. */
    .spotlight-hole   { transition: none; }
    .spotlight-tip-do i { animation: none; }
    .code-block-copy  { transition: none; }   /* 56 */
    .code-block-expand { transition: none; }  /* 56 */
    .accordion summary::after { transition: none; }   /* 60 — the disclosure caret */
    .tree summary::before     { transition: none; }   /* 63 — the same caret in a tree */

    /* 63 — the drawer appears and disappears instead of sliding. `visibility` and
       `display` are in those transition lists only to keep the panel present while it
       moves, so with no movement the whole transition goes and the panel is simply
       there or not. */
    .drawer-scrim     { transition: none; }
    .drawer           { transition: none; }
    dialog.drawer     { transition: none; }

    /* 42 — every drawing's moving part rests. Each animation's first frame is the
       resting pose, so the part with no animation is the still drawing, not a broken
       one. The rules reach the parts inside a `<use>` shadow tree because class rules
       apply to the clones as they apply to the originals in the sprite. */
    .sedna-art-bob,
    .sedna-art-sweep,
    .sedna-art-nudge,
    .sedna-art-blink,
    .sedna-art-tick,
    .sedna-art-twinkle { animation: none; }

    /* 64 — a tile's border and its corner controls change at once, and the
       transcript's caret flips rather than turning. */
    .file-tile,
    .file-tile-check,
    .file-tile-menu,
    .media-transcript > summary::after { transition: none; }

    /* 66 — the typing dots hold still and the action bar is simply there or not. */
    .chat-typing > i { animation: none; }
    .chat-typing > i:nth-child(2) { animation: none; }
    .chat-typing > i:nth-child(3) { animation: none; }
    .chat-actions    { transition: none; }

    /* 54 — a bar, a segment or a meter fill lands at its value instead of growing
       to it. The value was never the motion. */
    .bar-list-fill,
    .stack-bar-segment,
    .meter-fill,
    .bar-previous { transition: none; }
}
}

