/* ==========================================================================
   Moto Edit — Roadbook design system (docs/SPEC28.md).

   Every colour, type size, radius, shadow, duration and stacking level in this
   file comes from a token declared in one of the four blocks below. Nothing
   outside those blocks may carry a colour literal, a px font size, a literal
   box-shadow, a z-index above 9 or raw px spacing — `node test/css-audit.mjs`
   fails the build if it does (SPEC28 §1.3).

   The token blocks are machine-read, so their marker comments matter:

     tokens:base   theme-invariant. Type, space, controls, layout, radii,
                   motion, z-index, the utility elevations, and the STAGE:
                   video, thumbnails, the timeline (until P4), the highlights
                   strip and the calibration frame stay dark in both themes
                   (SPEC28 §2.2), because surrounding brightness changes how
                   footage reads.
     tokens:dark   Night Chart. The same names as light. Bare :root, so a
                   browser with no media query resolves to it (SPEC28 D12).
     tokens:light  Survey Film, the design reference. Declared twice — once
                   behind prefers-color-scheme, once behind [data-theme="light"]
                   for the manual override. The audit asserts the two copies are
                   identical and that every dark token has a light twin.

   Colour roles (SPEC28 §0.1): ink is the action colour (primary buttons,
   checked boxes, selected segmented cells, meter fills); magenta --accent is
   spatial only (routes, selection, focus, links) and never fills a control;
   state is ok / warn / danger, always with a word; water blue is measured data.

   Theme resolution:
     no attribute        → follows prefers-color-scheme
     [data-theme=dark]   → dark wins over the light media query
     [data-theme=light]  → light wins everywhere
   ========================================================================== */

/* tokens:base */
:root {
  /* -- type (SPEC28 §3.1, §4) ---------------------------------------------- */
  /* The three self-hosted faces. Each names its @font-face family first; the
     rest of the stack only shows while a file loads or for a glyph the Latin
     subset lacks. "Public Sans Fallback" is the metric-matched local stand-in
     declared with the @font-face rules below. */
  --font-display: "Anybody", "Bahnschrift", "DIN Alternate", "Arial Narrow", sans-serif;
  --font-ui: "Public Sans", "Public Sans Fallback", "Segoe UI", "Helvetica Neue", Arial, sans-serif;
  --font-data: "Overpass Mono", "SF Mono", "Cascadia Mono", Menlo, "DejaVu Sans Mono", monospace;
  --fs-tick: 11px;
  --fs-meta: 12.5px;
  --fs-data: 13.5px;
  --fs-body: 14px;
  --fs-title: 16px;
  --fs-head: 20px;
  --fs-display-wide: 28px;
  --fs-display-phone: 24px;
  --fs-numeral-wide: 44px;
  --fs-numeral-phone: 36px;
  /* Re-pointed below 600px / on touch by the block after the light copies. */
  --fs-display: var(--fs-display-wide);
  --fs-numeral: var(--fs-numeral-wide);
  --fs-input-touch: 16px;
  --fs-input: var(--fs-body);
  /* The overlay-layout letters (gps.js) are SVG text in the user units of a
     160-wide viewBox that the frame draws at twice that, so this is a frame
     size, not a screen size, and sits below the scale on purpose. */
  --fs-ov-letter: 9px;
  --lh-tick: 1.2;
  --lh-meta: 1.35;
  --lh-body: 1.45;
  --lh-title: 1.35;
  --lh-head: 1.25;
  --lh-display: 1.05;
  --lh-numeral: 0.9;
  /* The only UI weights; Public Sans ships 400–600. 700/800 are Anybody's. */
  --fw-regular: 400;
  --fw-medium: 500;
  --fw-semibold: 600;
  --fw-display: 700;
  --fw-numeral: 800;
  --wdth-caps: 125%;
  --wdth-title: 80%;
  --wdth-numeral: 62%;
  --track-caps: 0.06em;
  --track-brand: 0.08em;

  /* -- space (SPEC28 §3.2). No 20: renamed from --sp-* so no stale step
        silently changes meaning. ------------------------------------------ */
  --s-1: 4px;
  --s-2: 8px;
  --s-3: 12px;
  --s-4: 16px;
  --s-5: 24px;
  --s-6: 32px;
  --s-7: 48px;

  /* -- controls, icons, layout (SPEC28 §3.3) ------------------------------- */
  --ctl-s: 32px;
  --ctl-m: 40px;
  --ctl-touch: 44px;
  --icon-xs: 10px;
  --icon-s: 16px;
  --icon-m: 20px;
  --icon-l: 32px;
  /* library.js reads --topbar-h (getComputedStyle) to offset scroll-into-view,
     and .toolbar sticks directly beneath it, so it must always equal the
     rendered head height. */
  --head-h: 56px;
  --head-h-phone: 52px;
  --topbar-h: var(--head-h);
  --bottombar-h: 50px;
  --rail-w: 72px;
  /* The library's status rail — the column beside the footage, not the season
     rail — and the strip it collapses to. */
  --lib-rail-w: 340px;
  --lib-rail-w-collapsed: 44px;
  --margin-w: 432px;
  --tray-w: 400px;
  --dialog-w: 440px;
  --login-w: 400px;
  --toast-w: 420px;
  --sheet-half: 55vh;
  --measure: 64ch;

  /* -- radii (SPEC28 §3.5) ------------------------------------------------- */
  --r-0: 0;
  --r-1: 3px;
  --r-2: 6px;
  --r-dot: 50%;
  --r-pill: 999px;

  /* -- elevation and utility effects (SPEC28 §3.7). --e-float is themed. --- */
  --e-press: inset 0 2px 0 rgba(0, 0, 0, 0.25);
  --e-select: inset 0 0 0 2px var(--accent);
  --e-select-stage: inset 0 0 0 2px var(--stage-route);
  --e-sticky-line: inset 0 -1px 0 var(--grid);
  --e-sticky-edge: inset 1px 0 0 var(--grid);
  --e-box: inset 0 0 0 1px var(--stage-box-ring);
  --e-box-on: inset 0 0 0 1px var(--stage-box-ring), 0 0 0 2px var(--stage-route);
  --e-box-focus: 0 0 0 4px var(--stage);
  --e-box-aux-on: inset 0 0 0 1px var(--stage-box-ring), 0 0 0 2px var(--stage-aux);
  --e-box-anchor-on: inset 0 0 0 1px var(--stage-box-ring), 0 0 0 2px var(--stage-anchor);
  /* A sideways-scrolling strip fades out at its trailing edge, so a control
     cut by the edge reads as "more this way" rather than as clipping. Masks
     use only alpha, so the colour here carries no theme. */
  --mask-fade-end: linear-gradient(to right, #000 calc(100% - var(--s-5)), transparent);

  /* -- motion (SPEC28 §3.8). Exits never outlast modal.js's 140ms or
        toast.js's 250ms removal. ------------------------------------------- */
  --dur-1: 100ms;
  --dur-2: 160ms;
  --dur-3: 220ms;
  --dur-exit: 140ms;
  --dur-meter: 180ms;
  --dur-spin: 800ms;
  --dur-hatch: 800ms;
  --dur-route: 480ms;
  --ease-out: cubic-bezier(0.2, 0, 0, 1);
  --ease-in: cubic-bezier(0.4, 0, 1, 1);

  /* -- stacking (SPEC28 §3.9). Menus sit above dialogs because menus open
        from inside them and are appended to <body> (D1). ------------------- */
  --z-sticky: 10;
  --z-rail: 15;
  --z-head: 20;
  --z-margin: 30;
  --z-pop: 40;
  --z-drop: 45;
  --z-modal: 50;
  --z-menu: 55;
  --z-toast: 60;

  /* -- the stage: identical in both themes (SPEC28 §2.2) ------------------- */
  --stage: #0B0E0D;
  --stage-2: #151A18;
  --stage-ink: #E3EAE6;
  --stage-ink-2: #9DAAA4;
  --stage-rule: #6B7872;
  --stage-grid: #2A322F;
  /* Focus and selection on stage in both themes — never the themed --accent,
     which darkens in light mode and would vanish against the footage. */
  --stage-route: #EE7DBE;
  --stage-route-soft: rgba(238, 125, 190, 0.14);
  --stage-aux: #6DBEDC;
  --stage-aux-soft: rgba(109, 190, 220, 0.12);
  --stage-anchor: #5CC990;
  --stage-anchor-soft: rgba(92, 201, 144, 0.1);
  --stage-badge: rgba(11, 14, 13, 0.78);
  --stage-scrim: rgba(11, 14, 13, 0.55);
  --stage-hatch: rgba(227, 234, 230, 0.06);
  --stage-box-ring: rgba(11, 14, 13, 0.65);
  --stage-text-shadow: 0 1px 3px rgba(0, 0, 0, 0.85);
  --stage-drop-shadow: drop-shadow(0 0 3px rgba(0, 0, 0, 0.8));
  /* Interim timeline segments (P1–P3), retired when the lanes move onto
     paper in P4. */
  --stage-seg: rgba(238, 125, 190, 0.28);
  --stage-seg-sel: rgba(238, 125, 190, 0.45);
  --stage-seg-fast: rgba(238, 125, 190, 0.14);
  --stage-seg-fast-sel: rgba(238, 125, 190, 0.24);
  --stage-seg-edge: rgba(238, 125, 190, 0.6);
  --stage-seg-edge-fast: rgba(238, 125, 190, 0.35);
}
/* endtokens */

/* tokens:dark */
:root {
  color-scheme: dark;

  /* Night Chart */
  --theme-color: #0E1413;
  --surface-0: #0E1413;
  --surface-1: #141C1A;
  --surface-2: #1C2623;
  --surface-hover: #1A2421;

  --text-1: #E3EAE6;
  --text-2: #A7B4AE;
  --text-3: #86938D;

  --grid: #24302C;
  --rule: #66756E;

  --accent: #EE7DBE;
  --accent-hi: #F5A3D0;
  --accent-soft: #2E1E28;
  --accent-fill: #3A2432;
  --accent-fill-kept: #2B2128;

  --ink-fill: #E3EAE6;
  --ink-fill-hover: #F4F8F6;
  --ink-fill-text: #141C1A;
  --ink-fill-divider: rgba(20, 28, 26, 0.18);

  --mark: #E0C23A;
  --mark-text: #141C1A;

  --ok: #5CC990;
  --ok-soft: #16291F;
  --warn: #E8B94B;
  --warn-soft: #2D2616;
  --danger: #FF6F6A;
  --danger-soft: #331B1B;
  --danger-hover: #FF8C87;
  --danger-fill-text: #141C1A;

  --water: #6DBEDC;
  --water-area: rgba(109, 190, 220, 0.35);
  --trace: #E3EAE6;
  --hatch: rgba(134, 147, 141, 0.35);
  --route-w: 2.75px;

  --scrim: rgba(3, 6, 5, 0.62);
  --e-float: 0 0 0 1px #2C3834, inset 0 1px 0 rgba(255, 255, 255, 0.04);

  /* The toast is an inverted tape: the other theme's tone glyphs (D5). */
  --toast-bg: var(--ink-fill);
  --toast-text: var(--ink-fill-text);
  --toast-ok: #1D6B42;
  --toast-warn: #855400;
  --toast-danger: #B3301A;
  --toast-action: #141C1A;
  --toast-focus: #A3166E;
}
/* endtokens */

@media (prefers-color-scheme: light) {
  /* tokens:light */
  :root:not([data-theme="dark"]) {
    color-scheme: light;

    /* Survey Film */
    --theme-color: #EEF1EE;
    --surface-0: #EEF1EE;
    --surface-1: #FAFBF9;
    --surface-2: #E2E7E3;
    --surface-hover: #E8EDE9;

    --text-1: #141D19;
    --text-2: #434F49;
    --text-3: #5B6862;

    --grid: #D2D9D4;
    --rule: #74817B;

    --accent: #A3166E;
    --accent-hi: #C7258A;
    --accent-soft: #F5E3ED;
    --accent-fill: #EBD0DF;
    --accent-fill-kept: #F1E1E9;

    --ink-fill: #141D19;
    --ink-fill-hover: #2B3631;
    --ink-fill-text: #FAFBF9;
    --ink-fill-divider: rgba(250, 251, 249, 0.18);

    --mark: #FFE24D;
    --mark-text: #141D19;

    --ok: #1D6B42;
    --ok-soft: #DFEEE4;
    --warn: #855400;
    --warn-soft: #F5EAD3;
    --danger: #B3301A;
    --danger-soft: #F7E3DE;
    --danger-hover: #962714;
    --danger-fill-text: #FFFFFF;

    --water: #1B6784;
    --water-area: rgba(27, 103, 132, 0.35);
    --trace: #141D19;
    --hatch: rgba(91, 104, 98, 0.35);
    --route-w: 2.5px;

    --scrim: rgba(20, 29, 25, 0.38);
    --e-float: 0 0 0 1px var(--grid), 0 12px 32px -8px rgba(20, 29, 25, 0.18);

    /* The toast is an inverted tape: the other theme's tone glyphs (D5). */
    --toast-bg: var(--ink-fill);
    --toast-text: var(--ink-fill-text);
    --toast-ok: #5CC990;
    --toast-warn: #E8B94B;
    --toast-danger: #FF6F6A;
    --toast-action: #FFE24D;
    --toast-focus: #EE7DBE;
  }
  /* endtokens */
}

/* tokens:light */
:root[data-theme="light"] {
  color-scheme: light;

  /* Survey Film */
  --theme-color: #EEF1EE;
  --surface-0: #EEF1EE;
  --surface-1: #FAFBF9;
  --surface-2: #E2E7E3;
  --surface-hover: #E8EDE9;

  --text-1: #141D19;
  --text-2: #434F49;
  --text-3: #5B6862;

  --grid: #D2D9D4;
  --rule: #74817B;

  --accent: #A3166E;
  --accent-hi: #C7258A;
  --accent-soft: #F5E3ED;
  --accent-fill: #EBD0DF;
  --accent-fill-kept: #F1E1E9;

  --ink-fill: #141D19;
  --ink-fill-hover: #2B3631;
  --ink-fill-text: #FAFBF9;
  --ink-fill-divider: rgba(250, 251, 249, 0.18);

  --mark: #FFE24D;
  --mark-text: #141D19;

  --ok: #1D6B42;
  --ok-soft: #DFEEE4;
  --warn: #855400;
  --warn-soft: #F5EAD3;
  --danger: #B3301A;
  --danger-soft: #F7E3DE;
  --danger-hover: #962714;
  --danger-fill-text: #FFFFFF;

  --water: #1B6784;
  --water-area: rgba(27, 103, 132, 0.35);
  --trace: #141D19;
  --hatch: rgba(91, 104, 98, 0.35);
  --route-w: 2.5px;

  --scrim: rgba(20, 29, 25, 0.38);
  --e-float: 0 0 0 1px var(--grid), 0 12px 32px -8px rgba(20, 29, 25, 0.18);

  /* The toast is an inverted tape: the other theme's tone glyphs (D5). */
  --toast-bg: var(--ink-fill);
  --toast-text: var(--ink-fill-text);
  --toast-ok: #5CC990;
  --toast-warn: #E8B94B;
  --toast-danger: #FF6F6A;
  --toast-action: #FFE24D;
  --toast-focus: #EE7DBE;
}
/* endtokens */

/* ==========================================================================
   Responsive re-pointing (SPEC28 §1.4)

   Media queries cannot read custom properties, so breakpoints are literals,
   and a token that changes with width or pointer is re-pointed to another
   base token here — never redeclared with a literal. var() is substituted at
   computed-value time, so library.js still reads --topbar-h as 56px / 52px.
   ========================================================================== */

@media (max-width: 599.98px), (pointer: coarse) {
  :root {
    --ctl-s: var(--ctl-touch);
    --ctl-m: var(--ctl-touch);
    --fs-input: var(--fs-input-touch);
  }
}

@media (max-width: 599.98px) {
  :root {
    --topbar-h: var(--head-h-phone);
    --fs-display: var(--fs-display-phone);
    --fs-numeral: var(--fs-numeral-phone);
  }
}

/* ==========================================================================
   Fonts (SPEC28 §4.3)

   Latin subsets of the Google Fonts variable files, cut by the recipe in
   SPEC28 §4.4; licences in /fonts/OFL.txt. File names carry the first 8 hex
   digits of the file's SHA-256: Cloudflare caches for 4h and the service
   worker keys its cache by pathname, so a changed font must get a new name,
   never overwrite the old one. A rename touches four places, all checked by
   cmd/moto-edit/shell_test.go: the src below, the Public Sans preload in
   index.html, sw.js SHELL_ASSETS, and the file itself.
   ========================================================================== */

@font-face {
  font-family: "Public Sans";
  src: url("/fonts/public-sans-var.latin.bb421d23.woff2") format("woff2");
  font-weight: 400 600;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: "Anybody";
  src: url("/fonts/anybody-var.latin.4224b531.woff2") format("woff2");
  font-weight: 600 800;
  font-stretch: 60% 130%;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: "Overpass Mono";
  src: url("/fonts/overpass-mono-var.latin.ef564f7c.woff2") format("woff2");
  font-weight: 400 600;
  font-style: normal;
  font-display: swap;
}

/* Metric-matched stand-in while Public Sans loads, so the swap does not
   reflow text. Arial and its metric clones scaled to Public Sans Regular's
   average advance and hhea metrics (values from SPEC28 §4.4 step 4). Where
   none of the three is installed this face simply fails to load and the
   stack moves on. */
@font-face {
  font-family: "Public Sans Fallback";
  src: local("Arial"), local("Liberation Sans"), local("Arimo");
  size-adjust: 102.7064%;
  ascent-override: 92.4966%;
  descent-override: 21.9071%;
  line-gap-override: 0%;
}

/* ==========================================================================
   Base
   ========================================================================== */

/* The hidden attribute must win over any display rule below (flex/grid on
   .drop-overlay, .uploads, .empty, … would otherwise defeat it). */
[hidden] {
  display: none !important;
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  min-height: 100dvh;
  color: var(--text-1);
  background-color: var(--surface-0);
  font-family: var(--font-ui);
  font-size: var(--fs-body);
  line-height: var(--lh-body);
  -webkit-font-smoothing: antialiased;
  accent-color: var(--ink-fill);
}

h1, h2, h3, p {
  margin: 0;
}

h1, h2, h3 {
  line-height: var(--lh-head);
}

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

img {
  max-width: 100%;
}

::selection {
  background: var(--accent-soft);
}

/* One focus ring for the whole app (SPEC28 §3.6): a 2px accent outline, offset
   2px, on :focus-visible only. No halo, no box-shadow, no border-colour change,
   so programmatic autofocus after pointer input never looks like an error. */
:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

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

/* A text field always matches :focus-visible, even when a dialog focuses it
   by script right after a click — which read as an error on open. showModal
   marks such an autofocus target quiet when its opener showed no ring, until
   the target first loses focus; Tab back in and the ring is there. */
[data-focus-quiet]:focus-visible {
  outline: none;
}

.mono {
  font-family: var(--font-data);
  font-variant-numeric: tabular-nums;
}

.noscript {
  padding: var(--s-7);
  text-align: center;
  color: var(--text-2);
}

.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* --------------------------------------------------------------------------
   Icons, spinners, kbd
   -------------------------------------------------------------------------- */

/* The monoline set (SPEC28 §5.1): every glyph is stroked in currentColor on a
   20px grid and sized here, never by its own markup — --icon-m by default,
   the other steps by context. The stroke does not scale with the box, so a
   glyph reads 1.5px at 10px and at 32px. The selector list names every place
   an ICONS string lands without an .icon wrapper (thumbnail placeholders, the
   state block's glyph, the editor's transport buttons, the helmet mark); none
   of them may set `fill`, which would flood the outlines. */
.icon {
  display: inline-flex;
  flex: none;
  width: var(--icon-m);
  height: var(--icon-m);
}

.icon svg,
.thumb-ph svg,
.dt-thumb-ph svg,
.state-icon svg,
.btn-icon > svg,
.brand-logo svg,
.login-logo svg {
  display: block;
  width: 100%;
  height: 100%;
}

.icon svg *,
.thumb-ph svg *,
.dt-thumb-ph svg *,
.state-icon svg *,
.btn-icon > svg * {
  vector-effect: non-scaling-stroke;
}

/* The one ring (SPEC28 §6.5): 12px, a 1.5px plate ring with an ink arc; 8px
   inside tags. A spinner in a button keeps the full 12px beside its label. */
.spin {
  width: 12px;
  height: 12px;
  flex: none;
  border: 1.5px solid var(--surface-2);
  border-top-color: var(--text-1);
  border-radius: var(--r-dot);
  animation: rot var(--dur-spin) linear infinite;
}

.spin-sm {
  width: 8px;
  height: 8px;
}

.btn .spin-sm {
  width: 12px;
  height: 12px;
}

@keyframes rot {
  to { transform: rotate(360deg); }
}

/* A key name set as a hint (SPEC28 §6.1): data face, a hairline box. Inside a
   button it trails the label on desktop only — a touch screen has no keys. */
.kbd {
  display: inline-grid;
  place-items: center;
  height: 18px;
  padding: 0 var(--s-1);
  margin: 0 2px;
  border: 1px solid var(--grid);
  border-radius: var(--r-1);
  color: var(--text-2);
  font-family: var(--font-data);
  font-size: var(--fs-tick);
  font-weight: var(--fw-regular);
  line-height: var(--lh-tick);
  vertical-align: middle;
}

.btn-primary .kbd,
.btn-danger .kbd {
  color: inherit;
  border-color: var(--ink-fill-divider);
}

@media (max-width: 599.98px), (pointer: coarse) {
  .btn .kbd {
    display: none;
  }
}

/* --------------------------------------------------------------------------
   Buttons (SPEC28 §6.1)

   One height scale — --ctl-m, --ctl-s for .btn-sm, both 44px on touch through
   the re-pointing block — and one disabled treatment. Every variant carries a
   1px border (transparent where the fill or nothing supplies the edge), and
   hover, focus and press never change border width, padding or weight, so a
   row never reflows under the pointer. No press translate: ink and danger
   fills sink with --e-press, the others darken to --surface-2.
   -------------------------------------------------------------------------- */

.btn,
.btn-icon {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--s-2);
  min-height: var(--ctl-m);
  padding: 0 var(--s-4);
  border: 1px solid var(--rule);
  border-radius: var(--r-1);
  background: var(--surface-1);
  color: var(--text-1);
  font-family: inherit;
  font-size: var(--fs-body);
  font-weight: var(--fw-medium);
  line-height: var(--lh-body);
  text-decoration: none;
  white-space: nowrap;
  cursor: pointer;
  user-select: none;
  transition:
    background-color var(--dur-1) var(--ease-out),
    border-color var(--dur-1) var(--ease-out),
    color var(--dur-1) var(--ease-out);
}

.btn:hover,
.btn-icon:hover {
  background: var(--surface-hover);
}

.btn:active,
.btn-icon:active {
  background: var(--surface-2);
}

.btn .icon {
  width: var(--icon-m);
  height: var(--icon-m);
}

.btn-sm {
  min-height: var(--ctl-s);
  padding: 0 var(--s-3);
}

.btn-sm .icon {
  width: var(--icon-s);
  height: var(--icon-s);
}

/* Ink: the action colour (SPEC28 §0.1). At most one per region. */
.btn-primary {
  background: var(--ink-fill);
  border-color: var(--ink-fill);
  color: var(--ink-fill-text);
  font-weight: var(--fw-semibold);
}

.btn-primary:hover {
  background: var(--ink-fill-hover);
  border-color: var(--ink-fill-hover);
}

.btn-primary:active {
  background: var(--ink-fill);
  box-shadow: var(--e-press);
}

/* Quiet: Dismiss, Not now, All / None. Hover answers with the ink colour and an
   underline, never with a fill — the button stays text. */
.btn-ghost {
  background: transparent;
  border-color: transparent;
  color: var(--text-2);
}

.btn-ghost:hover,
.btn-ghost:active {
  background: transparent;
  color: var(--text-1);
  text-decoration: underline;
  text-underline-offset: 3px;
}

/* Filled danger: only the destructive confirm (confirmDialog, the delete
   prompts). Everywhere else a destructive action is the outlined variant. */
.btn-danger {
  background: var(--danger);
  border-color: var(--danger);
  color: var(--danger-fill-text);
  font-weight: var(--fw-semibold);
}

.btn-danger:hover {
  background: var(--danger-hover);
  border-color: var(--danger-hover);
}

.btn-danger:active {
  background: var(--danger);
  box-shadow: var(--e-press);
}

.btn-danger-ghost {
  border-color: var(--danger);
  color: var(--danger);
}

.btn-danger-ghost:hover,
.btn-danger-ghost:active {
  background: var(--danger-soft);
  color: var(--danger);
}

.btn-block {
  width: 100%;
}

/* Icon-only: a square. Each one carries an aria-label (and the same words in
   title) — there is no text to read otherwise. */
.btn-icon {
  flex: none;
  width: var(--ctl-m);
  height: var(--ctl-m);
  padding: 0;
}

.btn-icon.btn-sm {
  width: var(--ctl-s);
  height: var(--ctl-s);
  min-height: var(--ctl-s);
}

/* The editor's transport buttons take an ICONS string straight into
   innerHTML, so the square sizes a bare svg as well as an .icon. */
.btn-icon > svg {
  width: var(--icon-m);
  height: var(--icon-m);
}

.btn-icon.btn-sm > svg,
.btn-icon.btn-sm .icon {
  width: var(--icon-s);
  height: var(--icon-s);
}

/* The one disabled look, for every variant: faint text on a hairline, no
   fill, no hover, no press, no opacity. The reason stays in `title` and inline
   where the caller puts it. `.disabled` marks anchors that must be inert (the
   card's Edit link) — pointer-events is what makes that true, so it stays. */
.btn:disabled,
.btn:disabled:hover,
.btn:disabled:active,
.btn.disabled,
.btn[aria-disabled="true"],
.btn[aria-disabled="true"]:hover,
.btn[aria-disabled="true"]:active,
.btn-icon:disabled,
.btn-icon:disabled:hover,
.btn-icon:disabled:active {
  background: transparent;
  border-color: var(--grid);
  color: var(--text-3);
  box-shadow: none;
  text-decoration: none;
  cursor: not-allowed;
}

.btn.disabled {
  pointer-events: none;
}

/* A quiet button is text at rest, so it stays text when disabled: a hairline
   box appearing on disable would read as a different control. */
.btn-ghost:disabled,
.btn-ghost:disabled:hover,
.btn-ghost:disabled:active,
.btn-ghost.disabled,
.btn-ghost[aria-disabled="true"],
.btn-ghost[aria-disabled="true"]:hover {
  border-color: transparent;
}

/* --------------------------------------------------------------------------
   Segmented control (SPEC28 §6.2)

   One component for the library's view and resolution toggles and the
   editor's speed picker: `.seg` (role="group" + aria-label) holding
   <button aria-pressed> cells. The group has no `overflow: hidden` — that
   would clip the focus ring drawn outside a cell — so the end cells round
   their own outer corners, and a focused cell is lifted over its neighbours.
   -------------------------------------------------------------------------- */

.seg {
  display: inline-flex;
  flex: none;
  height: var(--ctl-m);
  border: 1px solid var(--rule);
  border-radius: var(--r-1);
  background: var(--surface-1);
}

.seg-s {
  height: var(--ctl-s);
}

.seg > button {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--s-2);
  min-width: 0;
  padding: 0 var(--s-3);
  border: 0;
  border-radius: 0;
  background: transparent;
  color: var(--text-1);
  font-family: inherit;
  font-size: var(--fs-body);
  font-weight: var(--fw-medium);
  line-height: var(--lh-body);
  white-space: nowrap;
  cursor: pointer;
  transition:
    background-color var(--dur-1) var(--ease-out),
    color var(--dur-1) var(--ease-out);
}

.seg > button + button {
  border-left: 1px solid var(--grid);
}

.seg > button:first-child {
  border-radius: calc(var(--r-1) - 1px) 0 0 calc(var(--r-1) - 1px);
}

.seg > button:last-child {
  border-radius: 0 calc(var(--r-1) - 1px) calc(var(--r-1) - 1px) 0;
}

.seg > button:only-child {
  border-radius: calc(var(--r-1) - 1px);
}

.seg > button:hover {
  background: var(--surface-hover);
}

/* Selected: ink, the same weight as rest so the label never shifts. `.on` is
   the editor's own state class (SPEC28 §6.0), kept beside aria-pressed. */
.seg > button[aria-pressed="true"],
.seg > button.on {
  background: var(--ink-fill);
  color: var(--ink-fill-text);
}

.seg > button:focus-visible {
  position: relative;
  z-index: 1;
}

/* An unselected cell rings inside itself: an outer ring would cross onto a
   selected (ink) neighbour, where magenta is under 3:1. The selected cell
   keeps the outer ring, which only its unselected neighbours sit under
   (SPEC28 §3.6 never insets on ink). */
.seg > button:focus-visible:not([aria-pressed="true"], .on) {
  outline-offset: -2px;
}

.seg > button .icon {
  width: var(--icon-s);
  height: var(--icon-s);
}

.seg > button:disabled,
.seg > button[aria-disabled="true"] {
  background: transparent;
  color: var(--text-3);
  cursor: not-allowed;
}

/* A group with nothing to press (the speed picker with no segment selected)
   takes the disabled hairline too, so it does not read as a live control. */
.seg:not(:has(> button:not(:disabled, [aria-disabled="true"]))) {
  border-color: var(--grid);
}

/* Numeric cells (1×, 16×): the data face, tabular. */
.seg-num > button {
  font-family: var(--font-data);
  font-size: var(--fs-data);
  font-variant-numeric: tabular-nums slashed-zero;
}

/* --------------------------------------------------------------------------
   Fields (SPEC28 §6.3) — inputs, selects, textareas, checkboxes, radios,
   switches and sliders. Focus is the global outline only: no border-colour
   change, no halo. Inputs are 16px on touch (--fs-input), so iOS never zooms.
   -------------------------------------------------------------------------- */

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

/* Sentence case, ink, above the field — no uppercase micro-label. */
.field-label {
  font-size: var(--fs-meta);
  font-weight: var(--fw-semibold);
  line-height: var(--lh-meta);
  color: var(--text-1);
}

.input {
  height: var(--ctl-m);
  min-width: 0;
  padding: 0 var(--s-3);
  border: 1px solid var(--rule);
  border-radius: var(--r-1);
  background-color: var(--surface-1);
  color: var(--text-1);
  font-family: var(--font-ui);
  font-size: var(--fs-input);
  line-height: var(--lh-body);
}

textarea.input {
  height: auto;
  min-height: calc(var(--ctl-m) * 2);
  padding-block: var(--s-2);
}

.input::placeholder {
  color: var(--text-3);
  opacity: 1;
}

.input[aria-invalid="true"] {
  border-color: var(--danger);
}

.input:disabled {
  background-color: transparent;
  border-color: var(--grid);
  color: var(--text-3);
  cursor: not-allowed;
}

/* A placeholder too long for a phone ends in an ellipsis, not mid-word. */
input.input {
  text-overflow: ellipsis;
}

.input.num {
  width: 108px;
  font-family: var(--font-data);
  font-variant-numeric: tabular-nums slashed-zero;
  text-align: right;
  appearance: textfield;
}

/* Chromium keeps room for its hidden spin buttons, so a right-aligned value
   sat 30px in from the edge. Arrow keys still step the value. */
.input.num::-webkit-inner-spin-button,
.input.num::-webkit-outer-spin-button {
  -webkit-appearance: none;
  margin: 0;
}

/* A filename is data (SPEC28 §3.1): the export's output name. */
.input.filename {
  font-family: var(--font-data);
  font-size: var(--fs-data);
  font-variant-numeric: tabular-nums slashed-zero;
}

/* The native arrow goes. The chevron is the monoline chevronDown drawn as two
   1.5px currentColor strokes, one diagonal per 5px cell, so it follows the
   text colour in both themes (and dims with a disabled select) and matches
   every other disclosure chevron instead of being the one filled glyph. */
select.input {
  appearance: none;
  padding-right: calc(var(--s-3) + var(--icon-s));
  background-image:
    linear-gradient(to top right, transparent calc(50% - 0.75px), currentColor calc(50% - 0.75px) calc(50% + 0.75px), transparent calc(50% + 0.75px)),
    linear-gradient(to top left, transparent calc(50% - 0.75px), currentColor calc(50% - 0.75px) calc(50% + 0.75px), transparent calc(50% + 0.75px));
  background-position:
    right calc(var(--s-3) + 5px) center,
    right var(--s-3) center;
  background-size: 5px 5px;
  background-repeat: no-repeat;
  cursor: pointer;
}

select.input:disabled {
  cursor: not-allowed;
}

/* A label row around a checkbox, radio or switch: the whole row is the hit
   area, --ctl-s tall (44px on touch). */
.chk {
  display: inline-flex;
  align-items: center;
  gap: var(--s-2);
  min-height: var(--ctl-s);
  color: var(--text-1);
  font-size: var(--fs-body);
  cursor: pointer;
  user-select: none;
}

.chk:has(input:disabled),
.chk.chk-dim {
  color: var(--text-3);
}

.chk:has(input:disabled) {
  cursor: not-allowed;
}

/* Checkbox and radio: an 18px square (radio round) on the sheet, ink when
   checked. The tick is the ink-fill-text colour cut by an alpha-only mask. */
input[type="checkbox"],
input[type="radio"] {
  appearance: none;
  display: inline-grid;
  place-content: center;
  flex: none;
  width: 18px;
  height: 18px;
  margin: 0;
  border: 1px solid var(--rule);
  border-radius: var(--r-1);
  background: var(--surface-1);
  vertical-align: middle;
  cursor: pointer;
}

input[type="radio"] {
  border-radius: var(--r-dot);
}

input[type="checkbox"]::before,
input[type="radio"]::before {
  content: "";
  visibility: hidden;
  background: var(--ink-fill-text);
}

input[type="checkbox"]::before {
  width: 12px;
  height: 12px;
  -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cpath d='M2.2 6.3 4.8 8.8 9.8 3.4' fill='none' stroke='black' stroke-width='1.9'/%3E%3C/svg%3E") center / contain no-repeat;
  mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cpath d='M2.2 6.3 4.8 8.8 9.8 3.4' fill='none' stroke='black' stroke-width='1.9'/%3E%3C/svg%3E") center / contain no-repeat;
}

input[type="checkbox"]:indeterminate::before {
  width: 8px;
  height: 2px;
  -webkit-mask: none;
  mask: none;
}

input[type="radio"]::before {
  width: 8px;
  height: 8px;
  border-radius: var(--r-dot);
}

input[type="checkbox"]:checked,
input[type="checkbox"]:indeterminate,
input[type="radio"]:checked {
  background: var(--ink-fill);
  border-color: var(--ink-fill);
}

input[type="checkbox"]:checked::before,
input[type="checkbox"]:indeterminate::before,
input[type="radio"]:checked::before {
  visibility: visible;
}

input[type="checkbox"]:disabled,
input[type="radio"]:disabled {
  background: transparent;
  border-color: var(--grid);
  cursor: not-allowed;
}

input[type="checkbox"]:disabled::before,
input[type="radio"]:disabled::before {
  background: var(--text-3);
}

/* Switch: a checkbox drawn as a 36×20 track, for immediate toggles only
   (Preview cuts, Thumbnails). Still a checkbox to assistive tech. */
input[type="checkbox"].switch {
  place-content: center start;
  width: 36px;
  height: 20px;
  border-radius: var(--r-pill);
}

input[type="checkbox"].switch::before {
  width: 14px;
  height: 14px;
  visibility: visible;
  border-radius: var(--r-dot);
  background: var(--text-2);
  -webkit-mask: none;
  mask: none;
  transform: translateX(2px);
  transition: transform var(--dur-1) var(--ease-out);
}

input[type="checkbox"].switch:checked::before {
  background: var(--ink-fill-text);
  transform: translateX(18px);
}

input[type="checkbox"].switch:disabled::before {
  background: var(--text-3);
}

/* Slider: a 2px track, ink up to the thumb where a handler sets --pct (and
   natively in Firefox), a 16px ring thumb. The track pseudo is the thumb's
   height with the line drawn through its middle, so the thumb needs no
   offset. */
input[type="range"] {
  appearance: none;
  height: var(--icon-m);
  margin: 0;
  background: transparent;
  cursor: pointer;
}

input[type="range"]::-webkit-slider-runnable-track {
  height: 16px;
  background: linear-gradient(to right, var(--text-1) var(--pct, 0%), var(--text-3) var(--pct, 0%)) center / 100% 2px no-repeat;
}

input[type="range"]::-webkit-slider-thumb {
  appearance: none;
  box-sizing: border-box;
  width: 16px;
  height: 16px;
  border: 2px solid var(--text-1);
  border-radius: var(--r-dot);
  background: var(--surface-1);
}

input[type="range"]::-moz-range-track {
  height: 2px;
  background: var(--text-3);
}

input[type="range"]::-moz-range-progress {
  height: 2px;
  background: var(--text-1);
}

input[type="range"]::-moz-range-thumb {
  box-sizing: border-box;
  width: 16px;
  height: 16px;
  border: 2px solid var(--text-1);
  border-radius: var(--r-dot);
  background: var(--surface-1);
}

input[type="range"]:disabled {
  cursor: not-allowed;
}

input[type="range"]:disabled::-webkit-slider-thumb {
  border-color: var(--text-3);
}

input[type="range"]:disabled::-moz-range-thumb {
  border-color: var(--text-3);
}

input[type="range"]:disabled::-moz-range-progress {
  background: var(--text-3);
}

/* A slider is a 44px target on a touch screen. */
@media (pointer: coarse) {
  input[type="range"] {
    height: var(--ctl-touch);
  }
}

/* --------------------------------------------------------------------------
   Layout & top bar
   -------------------------------------------------------------------------- */

.page {
  max-width: 1200px;
  margin: 0 auto;
  padding: 0 var(--s-5) var(--s-7);
}

.page-main {
  display: flex;
  flex-direction: column;
  gap: var(--s-4);
}

/* min-height pins the bar to exactly --topbar-h, which library.js reads to
   offset scroll-into-view and .toolbar uses as its sticky offset. Without it
   the height drifts whenever the button metrics change. No vertical padding:
   the min-height IS the height, and the --ctl-m controls centre inside it.

   The bar is solid --surface-1 with no blur (SPEC28 §2.3). It paints out to
   both viewport edges through border-image's outset, which is ink overflow —
   it never makes the page scroll sideways — and the gradient is a solid fill
   with the 1px --grid rule as its bottom pixel. */
.topbar {
  position: sticky;
  top: 0;
  z-index: var(--z-head);
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s-4);
  min-height: var(--topbar-h);
  padding: 0 2px;
  margin-bottom: var(--s-5);
  border-image: linear-gradient(to top, var(--grid) 1px, var(--surface-1) 1px) fill 0 / 0 / 0 100vmax;
}

.brand {
  display: inline-flex;
  align-items: center;
  gap: var(--s-3);
  min-width: 0;
  color: var(--text-1);
}

/* The wordmark (SPEC28 §6.12): the 22px helmet and MOTO EDIT in Anybody caps,
   the same voice as the login page's. The span's text stays "Moto Edit". */
.brand-logo {
  width: 22px;
  height: 22px;
  flex: none;
}

.brand-name {
  font-family: var(--font-display);
  font-size: var(--fs-meta);
  font-weight: var(--fw-semibold);
  font-stretch: var(--wdth-caps);
  line-height: var(--lh-meta);
  letter-spacing: var(--track-brand);
  text-transform: uppercase;
  white-space: nowrap;
}

/* Brand, the two primary actions, then one ⋯ menu holding folder upload,
   settings, theme, install and log out (SPEC14 §3). The overflow menu is
   present at EVERY width — it is the only route to those commands, so hiding
   it on a wide screen would strand them. What collapses instead is the button
   text: `.btn-text` drops at the narrow breakpoint, leaving the logo, the name
   on one line, and three equal icon squares that hold one row at 320px. */
.topbar-actions {
  display: flex;
  align-items: center;
  gap: var(--s-3);
  min-width: 0;
}

/* One height for every topbar control on every screen, whatever its variant or
   content: the ⋯ is an icon button (--ctl-m square) and the rest would
   otherwise size to their text. The library's controls live in
   .topbar-actions, the editor's back link and Export in .topbar-side; before
   the second selector, the editor's Export was 37px beside the library's 42px
   Upload — the primary action changing size between the two most-used screens. */
.topbar-actions .btn,
.topbar-side .btn {
  height: var(--ctl-m);
}

/* "Reconnecting…" over a painted library that has lost the server
   (library.js syncReconnect, SPEC28 §6.14). A warn tag before Upload where
   the head has room; below 600px the head is already full, so a line at the
   top of the page says it instead. Neither is announced: an sr-only status in
   the head is. */
.conn-line {
  display: none;
  align-items: center;
  gap: var(--s-2);
  min-width: 0;
  color: var(--text-2);
  font-size: var(--fs-meta);
  line-height: var(--lh-meta);
}

/* The tag keeps its word; the note beside it is what wraps. */
.conn-line > .tag {
  flex: none;
}

@media (max-width: 599.98px) {
  .topbar .tb-conn {
    display: none;
  }

  .conn-line {
    display: flex;
  }
}

/* --------------------------------------------------------------------------
   Login and launch states (PLAN §2.7, SPEC28 §6.14, §6.16)

   One frame for Login, the offline launch and "Can't reach the server"
   (router.js entryFrame): a --login-w column on the film, inset 96px at
   1440, and a blank survey grid on a sheet filling the rest. The grid carries
   a north tick and the tulip glyph and never a route: a real route shape
   before sign-in would show the owner's roads to anyone who opens the page.
   On a phone the grid is a band across the top and the column sits under
   it, so the password field and Sign in land at thumb height.
   -------------------------------------------------------------------------- */

.login-wrap {
  display: grid;
  grid-template-columns:
    calc(var(--login-w) + 2 * clamp(var(--s-5), 6.667vw, 2 * var(--s-7)))
    minmax(0, 1fr);
  min-height: 100dvh;
}

.login-side {
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: var(--s-5);
  min-width: 0;
  padding-block: var(--s-7);
  padding-inline: clamp(var(--s-5), 6.667vw, 2 * var(--s-7));
}

/* .login-card is the form (and a harness hook): brand, the line, the field,
   the error, Sign in — one left-aligned column. */
.login-card {
  display: flex;
  flex-direction: column;
  gap: var(--s-4);
}

/* The wordmark (SPEC28 §4.2 rule 3): the helmet and MOTO EDIT in Anybody
   caps, the one uppercase in the frame. The h1's text stays "Moto Edit". */
.login-brand {
  display: flex;
  align-items: center;
  gap: var(--s-3);
  color: var(--text-1);
}

.login-logo {
  flex: none;
  width: var(--icon-l);
  height: var(--icon-l);
}

.login-title {
  font-family: var(--font-display);
  font-size: var(--fs-meta);
  font-weight: var(--fw-semibold);
  font-stretch: var(--wdth-caps);
  line-height: var(--lh-meta);
  letter-spacing: var(--track-brand);
  text-transform: uppercase;
}

/* The product's line is the page title: display face, one sentence a line. */
.login-sub {
  margin-block: var(--s-3) var(--s-2);
  color: var(--text-1);
  font-family: var(--font-display);
  font-size: var(--fs-display);
  font-weight: var(--fw-display);
  font-stretch: var(--wdth-title);
  line-height: var(--lh-display);
}

.login-sub > span {
  display: block;
}

/* Below the field, as every field error is (SPEC28 §6.3). */
.login-error {
  display: flex;
  align-items: flex-start;
  gap: var(--s-2);
  color: var(--danger);
  font-size: var(--fs-meta);
  line-height: var(--lh-meta);
}

.login-error .icon {
  width: var(--icon-s);
  height: var(--icon-s);
}

/* The offline title and message are a sentence each: neither leaves one word
   alone on its last line ("Moto Edit" is also joined by a no-break space). */
.login-side .state-title,
.login-side .state-msg {
  text-wrap: pretty;
}

.login-survey {
  position: relative;
  overflow: hidden;
  border-inline-start: 1px solid var(--grid);
  background-color: var(--surface-1);
  background-image:
    linear-gradient(to right, var(--grid) 1px, transparent 1px),
    linear-gradient(to bottom, var(--grid) 1px, transparent 1px);
  /* Major lines every five cells, one of them through the middle, where the
     tulip's dot sits. */
  background-size: calc(5 * var(--s-6)) calc(5 * var(--s-6));
  background-position: calc(50% + 2.5 * var(--s-6)) calc(50% + 2.5 * var(--s-6));
}

/* The minor lines, at 60% (the tulip-box grid's weight, ROADBOOK §5.5). */
.login-survey::before {
  content: "";
  position: absolute;
  inset: 0;
  background-image:
    linear-gradient(to right, var(--grid) 1px, transparent 1px),
    linear-gradient(to bottom, var(--grid) 1px, transparent 1px);
  background-size: var(--s-6) var(--s-6);
  background-position: calc(50% + var(--s-6) / 2) calc(50% + var(--s-6) / 2);
  opacity: 0.6;
}

.login-north {
  position: absolute;
  top: var(--s-5);
  right: var(--s-5);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 2px;
  padding: 2px;
  background: var(--surface-1);
  color: var(--text-3);
  font-family: var(--font-data);
  font-size: var(--fs-tick);
  line-height: var(--lh-tick);
}

.login-north::after {
  content: "";
  width: 1.5px;
  height: 10px;
  background: currentColor;
}

/* The tulip mark in route style (ROADBOOK §5.5), about 96px of drawn line:
   its start ring (48, 120 of 160) on the middle intersection. Strokes keep
   their width at any size; the arrowhead scales with the box. */
.login-mark {
  position: absolute;
  left: 50%;
  top: 50%;
  width: calc(5 * var(--s-6));
  height: calc(5 * var(--s-6));
  transform: translate(-30%, -75%);
}

.login-mark svg {
  display: block;
  width: 100%;
  height: 100%;
  overflow: visible;
}

.login-mark-line {
  fill: none;
  stroke: var(--accent);
  stroke-width: var(--route-w);
  stroke-linecap: round;
  stroke-linejoin: round;
  vector-effect: non-scaling-stroke;
}

.login-mark-end {
  fill: var(--accent);
  stroke: none;
}

.login-mark-start {
  fill: var(--surface-1);
  stroke: var(--text-1);
  stroke-width: 1.5px;
  vector-effect: non-scaling-stroke;
}

@media (max-width: 599.98px) {
  .login-wrap {
    display: flex;
    flex-direction: column;
  }

  .login-survey {
    order: -1;
    flex: 1 0 auto;
    min-height: calc(4 * var(--s-6));
    border-inline-start: 0;
    border-bottom: 1px solid var(--grid);
  }

  /* Three cells tall in the band, so the whole mark stays on it. */
  .login-mark {
    width: calc(3 * var(--s-6));
    height: calc(3 * var(--s-6));
  }

  .login-side {
    justify-content: flex-end;
    padding: var(--s-5) var(--s-4) calc(var(--s-6) + var(--safe-bottom));
  }
}

/* --------------------------------------------------------------------------
   Toasts (SPEC28 §6.6) — an inverted tape, bottom-left

   Ink in light and lamp in dark, so the tone glyphs are the other theme's
   (--toast-ok/-warn/-danger) and the actions are underlined text in
   --toast-action. toast.js appends, so in a column anchored to the bottom the
   newest toast is the one nearest the edge. Every toast keeps its words and
   its glyph; the colour is never the only cue.
   -------------------------------------------------------------------------- */

#toast-root {
  position: fixed;
  left: calc(var(--s-5) + var(--safe-left));
  bottom: calc(var(--s-5) + var(--safe-bottom));
  z-index: var(--z-toast);
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: var(--s-2);
  max-width: min(var(--toast-w), 100vw - 2 * var(--s-5));
}

/* Glyph | message | ×, with any actions on a row of their own under the
   message, so a long message never squeezes the button. */
.toast {
  display: grid;
  grid-template-columns: var(--icon-s) minmax(0, 1fr) var(--ctl-s);
  align-items: start;
  column-gap: var(--s-3);
  max-width: 100%;
  padding: var(--s-3) var(--s-4);
  border-radius: var(--r-2);
  background: var(--toast-bg);
  color: var(--toast-text);
  box-shadow: var(--e-float);
  font-size: var(--fs-body);
  line-height: var(--lh-body);
  animation: toast-in var(--dur-2) var(--ease-out);
}

.toast :focus-visible {
  outline-color: var(--toast-focus);
}

/* Inset on the ×: at phone width it is as tall as the tape, so an outer ring
   would land on the page, not the tape it was chosen for. */
.toast-x:focus-visible {
  outline-offset: -2px;
}

/* Centred on the first line of the message. */
.toast-icon {
  margin-top: calc((var(--lh-body) * var(--fs-body) - var(--icon-s)) / 2);
  color: var(--toast-text);
}

.toast .toast-icon {
  width: var(--icon-s);
  height: var(--icon-s);
}

.toast-success .toast-icon { color: var(--toast-ok); }
.toast-warn .toast-icon { color: var(--toast-warn); }
.toast-error .toast-icon { color: var(--toast-danger); }

.toast.out {
  opacity: 0;
  transform: translateY(8px);
  transition: opacity var(--dur-exit) var(--ease-in), transform var(--dur-exit) var(--ease-in);
}

.toast-msg {
  overflow-wrap: anywhere;
}

/* A --ctl-s square (44px on touch) whose extra height hangs into the toast's
   padding instead of making every toast taller. */
.toast-x {
  display: grid;
  place-items: center;
  width: var(--ctl-s);
  height: var(--ctl-s);
  margin-block: calc((var(--lh-body) * var(--fs-body) - var(--ctl-s)) / 2);
  margin-right: calc(var(--s-2) * -1);
  padding: 0;
  border: 0;
  border-radius: var(--r-1);
  background: transparent;
  color: var(--toast-text);
  font-family: inherit;
  font-size: var(--fs-title);
  line-height: 1;
  cursor: pointer;
}

.toast-x:hover {
  color: var(--toast-action);
}

.toast-x .icon,
.tb-qclear .icon {
  width: var(--icon-s);
  height: var(--icon-s);
}

.toast-actions {
  grid-column: 2 / -1;
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-3);
  margin-top: var(--s-1);
}

.toast-actions .btn,
.toast-actions .btn:hover,
.toast-actions .btn:active {
  min-height: var(--ctl-s);
  padding: 0;
  border-color: transparent;
  background: transparent;
  color: var(--toast-action);
  font-weight: var(--fw-semibold);
  text-decoration: underline;
  text-underline-offset: 3px;
}

@keyframes toast-in {
  from { opacity: 0; transform: translateY(8px); }
  to { opacity: 1; transform: none; }
}

@media (max-width: 599.98px) {
  #toast-root {
    left: calc(var(--s-4) + var(--safe-left));
    right: calc(var(--s-4) + var(--safe-right));
    bottom: calc(var(--s-4) + var(--safe-bottom));
    align-items: stretch;
    max-width: none;
  }

  /* A phone dialog docks to the bottom edge with its actions there, so while
     one is open the tape comes down from the top instead of covering them
     (newest still nearest the edge). */
  body:has(#modal-root > .modal-overlay) #toast-root {
    top: calc(var(--s-4) + var(--safe-top));
    bottom: auto;
    flex-direction: column-reverse;
  }
}

/* In the editor the bottom of the screen is where the hands are — the
   timeline, the segment tools, Start and End — so the tape comes down under
   the head instead, at any width, until P4 gives the editor its own layout
   (SPEC28 D30). */
body:has(.editor-page) #toast-root {
  top: calc(var(--topbar-h) + var(--s-4) + var(--safe-top));
  bottom: auto;
  flex-direction: column-reverse;
}

/* --------------------------------------------------------------------------
   Modals
   -------------------------------------------------------------------------- */

.modal-overlay {
  position: fixed;
  inset: 0;
  z-index: var(--z-modal);
  background: var(--scrim);
  display: grid;
  place-items: center;
  padding: var(--s-5);
  animation: fade-in var(--dur-2) var(--ease-out);
}

/* A dialog opened from inside another one paints no scrim of its own (SPEC28
   D9): the first has already set the scene, and stacked scrims reached 86%
   black in dark (Settings → Profiles → wizard). */
#modal-root > .modal-overlay ~ .modal-overlay {
  background: transparent;
}

.modal-overlay.out {
  opacity: 0;
  transition: opacity var(--dur-exit) var(--ease-in);
}

@keyframes fade-in {
  from { opacity: 0; }
  to { opacity: 1; }
}

/* One dialog shell for every modal (SPEC14 §3, SPEC28 §6.8). dialogShell()
   wraps whatever a dialog builds into three parts — header.dialog-head, the
   scrollable div.dialog-body, footer.dialog-foot — so the gutters live on the
   parts, NOT on the box. That is what lets the head and foot bleed to the box's
   edges and pin themselves with plain `position: sticky`.

   Every dialog takes the confirm look: a 20px title, a hairline under the head
   and over the foot, the actions right-aligned with Cancel first. A plain
   .modal is --dialog-w wide; a dialog with its own width class keeps today's
   width until PLAN re-hosts it (SPEC28 D8). */
.modal {
  width: min(var(--dialog-w), 100%);
  background: var(--surface-1);
  border-radius: var(--r-2);
  padding: 0;
  box-shadow: var(--e-float);
  display: flex;
  flex-direction: column;
}

.dialog-head {
  display: flex;
  flex-direction: column;
  gap: var(--s-1);
  padding: var(--s-5) var(--s-5) var(--s-3);
  border-bottom: 1px solid var(--grid);
  background: var(--surface-1);
  border-radius: var(--r-2) var(--r-2) 0 0;
}

.dialog-body {
  display: flex;
  flex-direction: column;
  gap: var(--s-3);
  padding: var(--s-5);
  font-size: var(--fs-body);
  line-height: var(--lh-body);
}

/* The error line holds the left, the actions the right; a long error wraps
   onto its own row above them rather than squeezing the buttons. */
.dialog-foot {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--s-2) var(--s-4);
  padding: var(--s-3) var(--s-5);
  border-top: 1px solid var(--grid);
  background: var(--surface-1);
  border-radius: 0 0 var(--r-2) var(--r-2);
}

.dialog-foot > .modal-error {
  flex: 1 1 16em;
}

.dialog-foot > .modal-actions {
  margin-left: auto;
}

.modal-title {
  font-size: var(--fs-head);
  font-weight: var(--fw-semibold);
  line-height: var(--lh-head);
  color: var(--text-1);
}

.modal-sub {
  color: var(--text-2);
  font-size: var(--fs-body);
}

.modal-msg {
  color: var(--text-1);
}

/* A footnote (SPEC28 §6.11): quiet, but never faint, and never wider than a
   comfortable line. */
.modal-note {
  max-width: var(--measure);
  color: var(--text-2);
  font-size: var(--fs-meta);
  line-height: var(--lh-meta);
}

/* Danger text with its octagon, so an error is never colour alone. The glyph
   is currentColor cut by an alpha-only mask. */
.modal-error {
  color: var(--danger);
  font-size: var(--fs-meta);
  line-height: var(--lh-meta);
}

.modal-error:not(:empty)::before {
  content: "";
  display: inline-block;
  width: var(--icon-s);
  height: var(--icon-s);
  margin-right: var(--s-2);
  vertical-align: text-bottom;
  background: currentColor;
  -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M4.9 1.8h6.2l3.1 3.1v6.2l-3.1 3.1H4.9l-3.1-3.1V4.9z' fill='none' stroke='black' stroke-width='1.5'/%3E%3Cpath d='M8 4.6v4.2M8 10.2v1.4' stroke='black' stroke-width='1.5'/%3E%3C/svg%3E") center / contain no-repeat;
  mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M4.9 1.8h6.2l3.1 3.1v6.2l-3.1 3.1H4.9l-3.1-3.1V4.9z' fill='none' stroke='black' stroke-width='1.5'/%3E%3Cpath d='M8 4.6v4.2M8 10.2v1.4' stroke='black' stroke-width='1.5'/%3E%3C/svg%3E") center / contain no-repeat;
}

.modal-actions {
  display: flex;
  flex-wrap: wrap;
  justify-content: flex-end;
  gap: var(--s-2);
}

.mode-opt {
  display: flex;
  align-items: flex-start;
  gap: var(--s-3);
  border: 1px solid var(--grid);
  border-radius: var(--r-1);
  padding: var(--s-3);
  cursor: pointer;
  font-size: var(--fs-body);
  color: var(--text-2);
  transition:
    border-color var(--dur-1) var(--ease-out),
    background var(--dur-1) var(--ease-out);
}

.mode-opt strong {
  color: var(--text-1);
}

.mode-opt input {
  margin-top: 1px;
  flex: none;
}

.mode-opt:has(input:checked) {
  border-color: var(--accent);
  background: var(--accent-soft);
}

.mode-opt.mode-off {
  color: var(--text-3);
  cursor: not-allowed;
}

.mode-opt.mode-off strong {
  color: var(--text-3);
}

.mode-note {
  font-style: normal;
  color: var(--text-3);
}

/* Dialogs — one shell -------------------------------------------------------- */

/* EVERY dialog scrolls inside itself rather than growing past the viewport
   (dialogShell() marks each box `.dialog`). The max-height tracks the overlay's
   padding — both shrink together on phones, see the responsive block at the
   bottom of this file. */
.modal.dialog {
  max-height: calc(100dvh - var(--s-7));
  overflow: hidden;
}

/* The title bar and the action footer never scroll away, so a dialog that
   scrolls never pushes its buttons off-screen (SPEC14 §3). The body is the ONLY
   scrollport, between them — not the box with a sticky head and foot over it.
   A sticky foot sat on top of the box's own scroll edge, so a control reached
   by Tab was scrolled to just under the foot and its focus ring hidden
   (SPEC28 §7.2, WCAG 2.4.11). Each direct child of the body keeps its height:
   a child that clips its own overflow (the day list, the strip, the frame
   scroller) would otherwise have an automatic minimum of 0 and be squeezed
   flat before the body ever scrolled. */
.modal.dialog > .dialog-head,
.modal.dialog > .dialog-foot {
  flex: none;
}

.modal.dialog > .dialog-body {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  overscroll-behavior: contain;
  /* Room for the 2px ring and its 2px offset past a control scrolled to the
     body's edge, which the scrollport would otherwise clip. */
  scroll-padding-block: var(--s-3);
}

.modal.dialog > .dialog-body > * {
  flex-shrink: 0;
}

/* The foot's own top rule closes the body: a group that ends it (past any
   hidden fields) drops its bottom hairline rather than doubling it. */
.dialog-body > .dgroup:not(:has(~ :not([hidden]))),
.dialog-body > div:not(:has(~ :not([hidden]))) > .dgroup:last-child {
  border-bottom: 0;
}

.export-modal {
  width: min(520px, 100%);
}

/* Settings was a plain 460px dialog before --dialog-w; it keeps that width
   until P2 moves it to the Setup page (SPEC28 D8). */
.settings-modal {
  width: min(460px, 100%);
}

/* Below 600px a plain (confirm-class) dialog docks to the bottom edge as a
   sheet: full width, top corners rounded, the foot clear of the home
   indicator. The workspace dialogs — each carries its own width class — keep
   today's centred phone geometry until they are re-hosted (SPEC28 §6.8, D8).
   Only the geometry changes; the overlay, its backdrop click and the focus
   trap are the same elements. */
@media (max-width: 599.98px) {
  .modal-overlay:has(> .modal:not(.export-modal, .settings-modal, .share-modal)) {
    padding: 0;
    place-items: end stretch;
  }

  .modal.dialog:not(.export-modal, .settings-modal, .share-modal) {
    width: 100%;
    max-height: 92dvh;
    border-radius: var(--r-2) var(--r-2) 0 0;
  }

  .modal.dialog:not(.export-modal, .settings-modal, .share-modal) > .dialog-foot {
    padding-bottom: calc(var(--s-3) + var(--safe-bottom));
    border-radius: 0;
  }
}

.mode-opt.mode-fixed {
  cursor: default;
  border-color: var(--accent);
  background: var(--accent-soft);
}

.opt-row {
  display: flex;
  gap: var(--s-5);
  flex-wrap: wrap;
}

.vol-row {
  display: flex;
  align-items: center;
  gap: var(--s-3);
}

.vol-range {
  flex: 1;
  min-width: 0;
  cursor: pointer;
}

.vol-val {
  color: var(--text-2);
  font-size: var(--fs-meta);
  font-variant-numeric: tabular-nums;
  width: 40px;
  text-align: right;
  flex: none;
}

/* Collapsible option group — the <details> primitive every dialog is built
   from, and the one disclosure idiom (SPEC28 §6.10). No box: groups are ruled
   rows, a hairline above each and one closing the run. The summary is the
   title in sentence case, the live hint pushed right, and a monoline chevron
   that turns 90° to point down when the group is open. The advanced groups
   ship closed; .dgroup-hint is the live summary of what a closed group
   holds, so collapsing never hides state. */
.dgroup {
  border-block: 1px solid var(--grid);
}

/* A run of groups is one ruled list: when the next thing on screen is another
   group — directly, past a hidden field (the encoder select in defaults mode),
   or as the first child of a wrapper (the ride summary block) — that group's
   top rule closes this one, and it moves up a column gap (--s-3 in every
   host) to meet it. */
.dgroup:is(:has(+ .dgroup), :has(+ [hidden] + .dgroup), :has(+ div:not([hidden]) > .dgroup:first-child)) {
  border-bottom: 0;
}

.dgroup + .dgroup,
.dgroup + [hidden] + .dgroup,
.dgroup + div:has(> .dgroup:first-child) {
  margin-top: calc(var(--s-3) * -1);
}

.dgroup-sum {
  display: flex;
  align-items: center;
  gap: var(--s-2);
  min-height: var(--ctl-m);
  padding-block: var(--s-2);
  cursor: pointer;
  user-select: none;
  /* Both spellings: the marker is drawn by ::after so it can rotate. */
  list-style: none;
}

.dgroup-sum::-webkit-details-marker {
  display: none;
}

/* The chevron is the icon set's own (ICONS.chevron), cut from currentColor
   by an alpha-only mask. */
.dgroup-sum::after {
  content: "";
  flex: none;
  width: var(--icon-s);
  height: var(--icon-s);
  background: currentColor;
  color: var(--text-2);
  -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M7.5 4l6 6-6 6' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='square'/%3E%3C/svg%3E") center / contain no-repeat;
  mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M7.5 4l6 6-6 6' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='square'/%3E%3C/svg%3E") center / contain no-repeat;
  transition: transform var(--dur-1) var(--ease-out);
}

.dgroup[open] > .dgroup-sum::after {
  margin-left: auto;
  transform: rotate(90deg);
}

.dgroup-sum:hover::after {
  color: var(--text-1);
}

.dgroup-title {
  flex: none;
  max-width: 100%;
  font-size: var(--fs-title);
  font-weight: var(--fw-semibold);
  line-height: var(--lh-title);
  color: var(--text-1);
}

/* A group inside a group steps down one size, so the nesting still reads
   without a box around it. */
.dgroup-body .dgroup-title {
  font-size: var(--fs-body);
  line-height: var(--lh-body);
}

/* The closed-state summary, right-aligned before the chevron. It has to be
   allowed to shrink and ellipsize — "Music: track.mp3 · 40%" must never push
   the marker off the row. */
.dgroup-hint {
  min-width: 0;
  margin-left: auto;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: var(--fs-meta);
  line-height: var(--lh-meta);
  color: var(--text-2);
  text-align: right;
}

/* An empty hint is not a flex item worth its gap. */
.dgroup-hint:empty {
  display: none;
}

.dgroup:not([open]) > .dgroup-sum:has(> .dgroup-hint:empty)::after {
  margin-left: auto;
}

/* Open, the group's own controls say all of this — twice would be noise. */
.dgroup[open] > .dgroup-sum .dgroup-hint {
  display: none;
}

.dgroup-body {
  display: flex;
  flex-direction: column;
  gap: var(--s-3);
  padding-block: var(--s-1) var(--s-4);
}

.dgroup-row {
  display: flex;
  align-items: center;
  gap: var(--s-4);
  flex-wrap: wrap;
}

.adv-note {
  flex-basis: 100%;
}

/* Highlights dialog v3 ------------------------------------------------------ */

.hl-row {
  display: flex;
  gap: var(--s-4);
  flex-wrap: wrap;
}

.hl-row .field {
  flex: 1 1 150px;
}

.sens-row {
  display: flex;
  align-items: center;
  gap: var(--s-3);
}

.sens-range {
  flex: 1;
  min-width: 0;
  cursor: pointer;
}

.sens-end {
  flex: none;
  color: var(--text-3);
  font-size: var(--fs-meta);
  white-space: nowrap;
}

/* Highlights dialog v4 — prompt, analysis gate, live preview ---------------- */

.hl-chips {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-2);
}

/* Prompt examples and detected themes are small secondary buttons that
   toggle (SPEC28 §6.4). Pressed is the ink fill, carried by aria-pressed. */
.hl-chips > .btn[aria-pressed="true"] {
  border-color: var(--ink-fill);
  background: var(--ink-fill);
  color: var(--ink-fill-text);
}

.hl-chips > .btn[aria-pressed="true"]:hover {
  border-color: var(--ink-fill-hover);
  background: var(--ink-fill-hover);
}

/* Prompt influence while there is no prompt. The Highlights dialog now hides
   the field outright until a prompt exists (syncPromptUI); this stays as the
   inert treatment should it ever be shown without one. */
.hl-muted {
  opacity: 0.5;
}

.hl-muted .sens-range {
  cursor: not-allowed;
}

/* The analysis gate and live preview: a section of the dialog's ruled list,
   not a well. Its top rule opens it; the group under it (Filters) closes it. */
.hl-panel {
  display: flex;
  flex-direction: column;
  gap: var(--s-3);
  border-top: 1px solid var(--grid);
  padding-top: var(--s-3);
}

.hl-gate,
.hl-preview {
  display: flex;
  flex-direction: column;
  gap: var(--s-3);
  align-items: stretch;
}

.hl-gate .btn {
  align-self: flex-start;
}

/* The gate stacks its status line over the shared bar primitive. */
.hl-gate-prog {
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
  min-width: 0;
  font-size: var(--fs-meta);
  color: var(--text-2);
}

.hl-gate-line {
  display: flex;
  align-items: center;
  gap: var(--s-2);
  min-width: 0;
}

.hl-gate-msg {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.hl-gate-pct {
  flex: none;
  color: var(--text-1);
  font-variant-numeric: tabular-nums;
}

.hl-sum {
  display: flex;
  align-items: center;
  gap: var(--s-2);
  min-width: 0;
  font-size: var(--fs-meta);
  color: var(--text-1);
}

.hl-sum-text {
  min-width: 0;
}

.hl-sum.is-dim .hl-sum-text {
  color: var(--text-3);
}

/* The whole selection at a glance — one ride day, or several with day dividers
   drawn over the segments. Background = cut standstill, so a gap in the
   segments below reads as time the reel drops entirely.

   Stage surface (SPEC28 §2.2): the strip and its legend keys stay dark in
   both themes, because the strip is a picture of the footage. */
.hl-strip {
  position: relative;
  /* flex: none is load-bearing. The strip is a flex item several levels down
     from .export-modal, which caps its height and scrolls; because the strip
     clips its overflow its automatic minimum height is 0, so a dialog taller
     than the viewport would squeeze it flat (leaving two hairline borders)
     before it ever scrolled. */
  flex: none;
  height: 26px;
  border-radius: var(--r-1);
  border: 1px solid var(--stage-rule);
  background: var(--stage);
  overflow: hidden;
}

.hl-seg {
  position: absolute;
  top: 0;
  bottom: 0;
}

.hl-seg-fast {
  background: var(--stage-rule);
  min-width: 1px;
}

/* A 4 s window inside a 40-minute day is a fraction of a pixel wide — floor
   it so every kept moment stays visible. app.js overrides this per render with
   a floor that shrinks as the windows multiply (see LIVE_SEG_FLOOR_BUDGET):
   across a month, 3px x every window would paint most of the strip route-pink for
   footage that is almost entirely timelapse. This value is the one-day case and
   the fallback. */
.hl-seg-live {
  background: var(--stage-route);
  border-radius: 2px;
  min-width: 3px;
}

.hl-legend {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-3);
  font-size: var(--fs-meta);
  color: var(--text-3);
}

.hl-legend span {
  display: inline-flex;
  align-items: center;
  gap: var(--s-1);
}

.hl-key {
  width: 10px;
  height: 10px;
  border-radius: 2px;
  flex: none;
}

.hl-key-live { background: var(--stage-route); }
.hl-key-fast { background: var(--stage-rule); }

.hl-key-cut {
  background: var(--stage);
  border: 1px solid var(--stage-rule);
}

.hl-matches {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: var(--s-1);
}

/* Inline, not flex: the separators are real spaces in the text so the row
   reads identically on screen, to a screen reader, and on the clipboard. */
.hl-match {
  font-size: var(--fs-meta);
  color: var(--text-2);
}

.hl-match-when {
  color: var(--text-1);
  font-variant-numeric: tabular-nums;
}

.hl-match-sep {
  color: var(--text-3);
}

.hl-match-len {
  color: var(--text-2);
  font-variant-numeric: tabular-nums;
}

/* "— strong match" is the text that keeps the strength from being colour-only
   (SPEC14 §4); it must not wrap away from the row it belongs to. */
.hl-match-str {
  white-space: nowrap;
}

.hl-str-strong { color: var(--text-1); }
.hl-str-good { color: var(--text-2); }
.hl-str-weak { color: var(--text-3); }

/* Highlights dialog v5 — multi-day selection ------------------------------- */

.hl-dates-bar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--s-1) var(--s-2);
}

/* "Ride days" never breaks over two lines; on a narrow bar the toggle wraps
   instead. */
.hl-dates-bar .field-label {
  white-space: nowrap;
}

/* Compact enough to sit inline with the All / None buttons. */
.hl-thumbs-toggle {
  color: var(--text-2);
  font-size: var(--fs-meta);
  white-space: nowrap;
}

.hl-dates-hint {
  margin-left: auto;
  color: var(--text-3);
  font-size: var(--fs-meta);
  white-space: nowrap;
}

/* The list scrolls on its own. Without this the dialog grows past the phone
   viewport and pushes Create reel off the bottom — .export-modal would scroll,
   but then the actions are a long way from the controls that matter. */
.hl-datelist {
  max-height: 168px;
  overflow-y: auto;
  overscroll-behavior: contain;
  border: 1px solid var(--grid);
  border-radius: var(--r-0);
  background: var(--surface-1);
}

.hl-datelist-empty {
  padding: var(--s-3);
  color: var(--text-3);
  font-size: var(--fs-meta);
}

/* SPEC27: a day is a wrapper around its head (the expander + the date label) and
   its collapsible clip list. The row border sits on the WRAPPER so it falls
   between whole days, never between a day and its own clips. */
.hl-day {
  border-top: 1px solid var(--grid);
}

.hl-day:first-child {
  border-top: 0;
}

.hl-date-head {
  display: flex;
  align-items: center;
}

/* The tint belongs to the whole head, so the chevron column is part of the
   selected row rather than an untinted square beside it. */
.hl-date-head:hover {
  background: var(--surface-hover);
}

/* Selected: the tint and the 2px magenta edge (SPEC28 §7.4, --e-select), so a
   chosen day is never a tint alone. */
.hl-date-head:has(.hl-date input:checked) {
  background: var(--accent-soft);
  box-shadow: var(--e-select);
}

/* The clip expander: a chevron pointing right, rotating to point down when open.
   An empty one (a one-clip day) keeps its space so every date name lines up. */
.hl-expand {
  flex: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 22px;
  height: 22px;
  margin-left: var(--s-2);
  padding: 0;
  border: 0;
  background: none;
  color: var(--text-3);
  cursor: pointer;
  border-radius: var(--r-1);
  transition: transform var(--dur-1) var(--ease-out), color var(--dur-1) var(--ease-out), background var(--dur-1) var(--ease-out);
}

.hl-expand .icon {
  width: var(--icon-s);
  height: var(--icon-s);
}

.hl-expand:hover {
  color: var(--text-1);
  background: var(--surface-hover);
}

.hl-expand.is-open {
  transform: rotate(90deg);
}

.hl-expand-empty {
  visibility: hidden;
}

/* ...unless no day in the list has clips to expand: then the column is a blank
   indent on every row, and the names start at the checkbox instead. */
.hl-datelist:not(:has(.hl-expand:not(.hl-expand-empty))) .hl-expand-empty {
  display: none;
}

.hl-date {
  flex: 1 1 auto;
  min-width: 0;
  display: flex;
  align-items: center;
  gap: var(--s-2);
  padding: var(--s-2) var(--s-3) var(--s-2) var(--s-1);
  font-size: var(--fs-meta);
  color: var(--text-2);
  cursor: pointer;
  /* Shift-clicking a range would otherwise select the rows' text as it goes. */
  user-select: none;
}

.hl-date input {
  flex: none;
}

/* SPEC27 clip picker: the day's clips, indented under it in a sunken well. */
.hl-clips {
  background: var(--surface-2);
  padding: 2px 0 var(--s-1);
}

.hl-clip {
  display: flex;
  align-items: center;
  gap: var(--s-2);
  padding: var(--s-1) var(--s-3) var(--s-1) calc(var(--s-6) + 2px);
  font-size: var(--fs-meta);
  color: var(--text-2);
  cursor: pointer;
  user-select: none;
}

.hl-clip:hover {
  background: var(--surface-hover);
}

.hl-clip input {
  flex: none;
}

.hl-clip-thumb {
  width: 40px;
  height: 23px;
  flex: none;
  object-fit: cover;
  border-radius: var(--r-1);
  background: var(--stage);
  border: 1px solid var(--grid);
}

.hl-clip-name {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  color: var(--text-1);
}

.hl-clip-time {
  margin-left: auto;
  flex: none;
  color: var(--text-3);
}

/* Opt-in per-day cover (the Thumbnails toggle). 16:9, small and rounded; hidden
   until the toggle is on, and flex:none so it never squeezes the date text. */
.hl-date-thumb {
  width: 46px;
  height: 26px;
  flex: none;
  object-fit: cover;
  border-radius: var(--r-1);
  background: var(--stage);
  border: 1px solid var(--grid);
}

/* A day whose videos went away while the dialog was open. `.hl-date-gone` now
   rides on the .hl-day wrapper (SPEC27), so it dims the whole day and kills the
   label's hover highlight. */
.hl-date-gone {
  opacity: 0.45;
  cursor: default;
}

.hl-date-gone .hl-date-head:hover {
  background: none;
}

.hl-date-name {
  flex: none;
  color: var(--text-1);
}

.hl-date:has(input:checked) .hl-date-name {
  font-weight: var(--fw-semibold);
}

.hl-date-meta {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  color: var(--text-3);
  font-size: var(--fs-meta);
}

.hl-date-tick {
  margin-left: auto;
  flex: none;
  color: var(--ok);
  font-size: var(--fs-meta);
}

/* Read aloud, never drawn: the row would otherwise announce a bare "✓". */
.hl-date-tick-word {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.hl-dates-sum {
  color: var(--text-2);
  font-size: var(--fs-meta);
}

/* Live counters don't jitter as they change (SPEC28 §4.2 rule 5). */
.hl-dates-sum,
.dgroup-hint,
.modal-sub,
.state-msg,
.trk-head-hint,
.pager-info {
  font-variant-numeric: tabular-nums;
}

.hl-dates-sum.is-warn {
  color: var(--warn);
}

.hl-multi {
  display: flex;
  flex-direction: column;
  gap: var(--s-3);
}

/* Day labels share the strip's coordinate space, so they must sit directly
   above it in the same positioned wrapper. */
.hl-stripwrap {
  display: flex;
  flex-direction: column;
  gap: var(--s-1);
  flex: none;
}

.hl-days {
  position: relative;
  height: 15px;
  flex: none;
}

.hl-day-label {
  position: absolute;
  top: 0;
  line-height: 15px;
  font-size: var(--fs-meta);
  color: var(--text-3);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  padding-left: var(--s-1);
  border-left: 1px solid var(--rule);
}

/* Drawn after the segments and inside the strip, so a boundary is never buried
   under one — and so it keeps the strip's dark palette in both themes. */
.hl-day-div {
  position: absolute;
  top: 0;
  bottom: 0;
  width: 1px;
  background: var(--stage-ink-2);
}

.hl-match-day {
  color: var(--text-2);
}

/* --------------------------------------------------------------------------
   Upload cards & resume hint
   -------------------------------------------------------------------------- */

.resume-hint {
  display: flex;
  align-items: center;
  gap: var(--s-3);
  padding: var(--s-3) var(--s-4);
  border: 1px dashed var(--rule);
  background: var(--surface-1);
  border-radius: var(--r-0);
  color: var(--text-2);
  font-size: var(--fs-body);
}

.resume-hint .icon {
  color: var(--text-2);
}

/* The notice's text takes the room, so the dismiss button stays at the edge. */
.resume-hint .resume-text {
  flex: 1;
  min-width: 0;
}

.resume-hint .btn {
  flex: none;
}

.uploads {
  display: flex;
  flex-direction: column;
  gap: var(--s-3);
}

.ucard {
  background: var(--surface-1);
  border: 1px solid var(--grid);
  border-radius: var(--r-0);
  padding: var(--s-3) var(--s-4);
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
}

.ucard[data-state="error"] {
  border-color: var(--danger);
}

.ucard[data-state="done"] {
  border-color: var(--ok);
}

.ucard-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--s-3);
}

.ucard-name {
  font-weight: var(--fw-semibold);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.ucard-size {
  color: var(--text-3);
  font-size: var(--fs-meta);
  font-variant-numeric: tabular-nums;
  flex: none;
}

/* The stripe meter (SPEC28 §6.5) — uploads, the editor's waiting screen and
   the highlights analysis gate all draw progress here. 4px, square, an ink
   fill on a plate track; the fill is never accent. Its label sits beside it,
   never inside. */
.pbar {
  position: relative;
  height: 4px;
  border-radius: var(--r-0);
  background: var(--surface-2);
  overflow: hidden;
}

.pbar-fill {
  height: 100%;
  width: 0;
  border-radius: var(--r-0);
  background: var(--text-1);
  transition: width var(--dur-meter) linear;
}

/* Work whose extent is unknown: the track itself carries a 45° hatch that
   steps along, and the fill steps aside. The hatch is on the track rather
   than on .pbar-fill because progressBar().set() writes an inline width onto
   the fill — a determinate bar that later goes indeterminate would otherwise
   keep whatever width it stopped at. The stripes repeat every 8px across and
   the tile is 16px, so the pattern has no seam; each step moves it half a
   stripe. Static under reduced motion. */
.pbar-indeterminate > .pbar-fill {
  visibility: hidden;
}

.pbar-indeterminate {
  background:
    repeating-linear-gradient(45deg, var(--text-3) 0 1px, transparent 1px 5.657px) 0 0 / 16px 100%,
    var(--surface-2);
  animation: meter-hatch calc(var(--dur-hatch) * 2) steps(4) infinite;
}

@keyframes meter-hatch {
  to { background-position-x: 16px, 0; }
}

.ucard[data-state="done"] .pbar-fill {
  background: var(--ok);
}

.ucard[data-state="error"] .pbar-fill {
  background: var(--danger);
}

.ucard-foot {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s-3);
  flex-wrap: wrap;
}

.ucard-status {
  color: var(--text-2);
  font-size: var(--fs-meta);
  font-variant-numeric: tabular-nums;
  overflow-wrap: anywhere;
}

.ucard-actions {
  display: flex;
  gap: var(--s-2);
  margin-left: auto;
}

/* --------------------------------------------------------------------------
   Video grid & cards — thumbnail, title, metadata row, actions
   -------------------------------------------------------------------------- */

.grid {
  display: grid;
  gap: var(--s-4);
  grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
}

/* Flat at rest and on hover (SPEC28 §0.1, §6.13): the hairline carries the
   edge, and hover changes the fill — no lift, no shadow. */
.card {
  background: var(--surface-1);
  border: 1px solid var(--grid);
  border-radius: var(--r-0);
  overflow: hidden;
  display: flex;
  flex-direction: column;
  transition: background var(--dur-1) var(--ease-out);
}

.card:hover {
  background: var(--surface-hover);
}

.thumb {
  position: relative;
  display: block;
  aspect-ratio: 16 / 9;
  background: var(--stage);
}

.thumb.disabled {
  pointer-events: none;
}

.thumb-img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.thumb-ph {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
}

.thumb-ph {
  color: var(--stage-rule);
}

.thumb-ph svg {
  width: var(--icon-l);
  height: var(--icon-l);
}

.thumb-dur {
  position: absolute;
  right: var(--s-2);
  bottom: var(--s-2);
  background: var(--stage-badge);
  padding: 2px var(--s-2);
  border-radius: var(--r-1);
  font-size: var(--fs-meta);
  font-variant-numeric: tabular-nums;
  color: var(--stage-ink);
}

.thumb-busy {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  background: var(--stage-scrim);
}

.thumb-busy .spin {
  border-color: var(--stage-rule);
  border-top-color: var(--stage-ink);
}

.card-body {
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
  padding: var(--s-3) var(--s-4) var(--s-4);
  flex: 1;
}

.card-name {
  display: block;
  width: 100%;
  text-align: left;
  background: none;
  border: none;
  padding: 0;
  color: var(--text-1);
  font-family: inherit;
  font-weight: var(--fw-semibold);
  font-size: var(--fs-body);
  line-height: var(--lh-body);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  cursor: text;
  border-radius: var(--r-1);
}

.card-name:hover {
  color: var(--accent);
}

.rename-inp {
  width: 100%;
  font-weight: var(--fw-semibold);
  font-size: var(--fs-input);
  padding: var(--s-1) var(--s-2);
}

/* Recorded date+time from the video's own metadata ("Aug 29 · 19:36"), and the
   click target that filters the library down to that one ride day. */
.card-rec {
  align-self: flex-start;
  background: none;
  border: none;
  padding: 1px var(--s-1);
  margin: calc(var(--s-1) * -1) 0 0 calc(var(--s-1) * -1);
  border-radius: var(--r-1);
  /* NOT `font: inherit` — the shorthand would clobber .mono's family, which
     these two elements are also carrying. */
  font-size: var(--fs-meta);
  font-weight: var(--fw-regular);
  color: var(--text-2);
  cursor: pointer;
}

.card-rec:hover,
.card-rec.is-on {
  background: var(--accent-soft);
  color: var(--accent);
}

.meta-row {
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: var(--s-1);
  font-size: var(--fs-meta);
  color: var(--text-2);
}

/* The fact line (SPEC28 §6.4): size, codec, Track, Audio, Export, Sidecar,
   B-roll, GPU draft, CPU fallback and lineage. Things simply true of the file
   are plain sentence-case text joined by '·', never a box and never a tone —
   boxes are reserved for .tag, the state that needs attention or can be acted
   on, and a tone without a glyph would be colour-only (D10). */
.badge {
  font-size: var(--fs-meta);
  font-weight: var(--fw-regular);
  letter-spacing: 0;
  text-transform: none;
  padding: 0;
  border-radius: 0;
  background: none;
  border: 0;
  color: var(--text-2);
  white-space: nowrap;
}

/* One '·' between facts. The empty alt text keeps the dot out of the
   accessibility tree, so a screen reader hears the facts, not the punctuation.
   A --s-1 either side (the row's gap, then this margin) is about a word space,
   so these dots keep the same rhythm as the one inside "Jun 15 · 12:00". */
.meta-row > * + *::before,
.dt-c-fmt > * + *::before {
  content: "·" / "";
  margin-right: var(--s-1);
  color: var(--text-3);
}

/* The format cell's facts are inline, so there is no gap before the dot. */
.dt-c-fmt > * + *::before {
  margin-left: var(--s-1);
}

/* The recorded date leads the meta line. It stays a button (click filters the
   library to that ride day), set in the line's own face and figures. */
.meta-row > .card-rec {
  margin: 0 calc(var(--s-1) * -1);
  align-self: auto;
  font-family: inherit;
  line-height: inherit;
  font-variant-numeric: tabular-nums;
}

.status-row {
  display: flex;
  align-items: center;
  gap: var(--s-2);
  min-width: 0;
}

/* A ready card has nothing to say, so it has no status row at all. */
.status-row:empty {
  display: none;
}

/* The details table's Status cell is never blank: a ready row with nothing
   else to report says so quietly, in text. */
.status-quiet {
  font-size: var(--fs-meta);
  font-weight: var(--fw-regular);
  color: var(--text-3);
}

/* --------------------------------------------------------------------------
   Tags (SPEC28 §6.4) — state that needs attention or can be acted on
   -------------------------------------------------------------------------- */

/* One component, six tones. The tone sets border, fill, text and glyph; the
   word always says it again, so no state is colour-only. Pseudo-elements are
   reserved: ::before is an actionable tag's hit area, ::after the progress
   meter — which is why every glyph is a child element. */
.tag {
  position: relative;
  display: inline-flex;
  align-items: center;
  gap: var(--s-1);
  flex: 0 1 auto;
  min-width: 0;
  max-width: 100%;
  height: 22px;
  margin: 0;
  padding: 0 calc(var(--s-1) + 2px);
  border: 1px solid var(--rule);
  border-radius: var(--r-1);
  background: transparent;
  color: var(--text-2);
  font-family: var(--font-ui);
  font-size: var(--fs-meta);
  font-weight: var(--fw-medium);
  line-height: var(--lh-meta);
  text-decoration: none;
  white-space: nowrap;
  vertical-align: middle;
}

.tag .icon {
  width: var(--icon-xs);
  height: var(--icon-xs);
}

/* At 8px a plate ring vanishes on a borderless ground and leaves only its arc,
   which reads as a stray quote mark; in a tag the ring is drawn in --rule. */
.tag .spin {
  border-color: var(--rule);
  border-top-color: var(--text-1);
}

.tag-label {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* A readout (42%, 3) is data: its own face, and never the part that is cut.
   `.tag-sep` puts a silent '·' between it and the label, in the label's face. */
.tag-pct {
  flex: none;
  font-family: var(--font-data);
  font-variant-numeric: tabular-nums;
}

.tag-sep::before {
  content: "·" / "";
  margin-inline-end: var(--s-1);
  font-family: var(--font-ui);
}

.tag-word {
  flex: none;
}

/* The route tone's glyph: a short length of the magenta line. */
.tag-glyph {
  flex: none;
  width: 8px;
  height: 2px;
  background: var(--accent);
}

.tag[data-tone="ok"] {
  border-color: var(--ok);
  background: var(--ok-soft);
  color: var(--ok);
}

.tag[data-tone="warn"] {
  border-color: var(--warn);
  background: var(--warn-soft);
  color: var(--warn);
}

.tag[data-tone="danger"] {
  border-color: var(--danger);
  background: var(--danger-soft);
  color: var(--danger);
}

.tag[data-tone="route"] {
  border-color: var(--accent);
  background: var(--accent-soft);
  color: var(--accent);
}

/* A running job: ink text, the small ring, and a 2px ink meter along the
   bottom edge at the job's fraction (--pct, set inline; absent while queued). */
.tag[data-tone="progress"] {
  color: var(--text-1);
}

.tag[data-tone="progress"]::after {
  content: "";
  position: absolute;
  left: 0;
  bottom: 0;
  width: var(--pct, 0%);
  height: 2px;
  background: var(--text-1);
  transition: width var(--dur-meter) linear;
}

/* Actionable tags: the fill never changes; hover underlines the words, and an
   invisible ::before takes the target to --ctl-s (44px on touch). */
button.tag,
a.tag {
  cursor: pointer;
}

button.tag::before,
a.tag::before {
  content: "";
  position: absolute;
  top: 50%;
  left: 50%;
  width: max(100%, var(--ctl-s));
  height: var(--ctl-s);
  transform: translate(-50%, -50%);
}

button.tag:hover .tag-label,
a.tag:hover .tag-label {
  text-decoration: underline;
  text-underline-offset: 3px;
}

/* A toggle (the loop flag): pressed is an ink edge and ink words. */
.tag[aria-pressed="true"] {
  border-color: var(--text-1);
  color: var(--text-1);
}

button.tag:disabled {
  border-color: var(--grid);
  background: transparent;
  color: var(--text-3);
  cursor: not-allowed;
}

button.tag:disabled .tag-label {
  text-decoration: none;
}

/* The untagged message that follows a tag: a job's own words, which can be
   long, so they ellipsise and keep the whole of it in the tooltip. */
.tag-msg {
  min-width: 0;
  overflow: hidden;
  color: var(--text-2);
  font-size: var(--fs-meta);
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* The ↗ after a link that leaves the app, in a tag or a button. */
.ext {
  color: inherit;
  font-size: var(--fs-meta);
}

/* A wrapping row of statements (the Re-process list's improvements). */
.tag-row {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-1);
}

/* The day filter's ×: a glyph inside the tag with a --ctl-s target. */
.tag-x {
  position: relative;
  display: grid;
  place-items: center;
  flex: none;
  width: var(--icon-s);
  height: var(--icon-s);
  margin-inline-end: -2px;
  padding: 0;
  border: 0;
  border-radius: var(--r-1);
  background: transparent;
  color: inherit;
  cursor: pointer;
}

.tag-x::before {
  content: "";
  position: absolute;
  top: 50%;
  left: 50%;
  width: var(--ctl-s);
  height: var(--ctl-s);
  transform: translate(-50%, -50%);
}

.tag-x:hover {
  background: var(--accent-fill);
}

.tag-x .icon {
  width: var(--icon-xs);
  height: var(--icon-xs);
}

/* The highlighter (SPEC28 §6.4): a decision waiting for the owner — never a
   warning, never an error, at most two on screen, always followed by real
   buttons. A swipe behind the words, not a box. */
.mark {
  padding-inline: var(--s-1);
  background: linear-gradient(transparent 14%, var(--mark) 14% 90%, transparent 90%);
  color: var(--mark-text);
  -webkit-box-decoration-break: clone;
  box-decoration-break: clone;
}

@media (max-width: 599.98px) {
  .tag {
    height: 28px;
  }
}

/* The footer is one line of --ctl-s controls: the primary action (Edit, on
   footage) and the ⋯ square pinned bottom-right, so video, audio and track
   cards all end on the same corner. */
.card-actions {
  display: flex;
  align-items: center;
  gap: var(--s-2);
  margin-top: auto;
  padding-top: var(--s-1);
  flex-wrap: wrap;
}

.card-actions .btn:not(.card-more) {
  height: var(--ctl-s);
}

/* --------------------------------------------------------------------------
   Library toolbar (SPEC7) — search, filters, sort, view, page size
   -------------------------------------------------------------------------- */

/* The toolbar sits on the film with a hairline under it. A second sheet band
   here read as a stripe of film trapped between two bars at rest; stuck under
   the head it still covers what scrolls beneath it. */
.toolbar {
  position: sticky;
  top: var(--topbar-h);
  z-index: var(--z-sticky);
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--s-2);
  padding: var(--s-3) 0;
  margin-bottom: calc(var(--s-1) * -1);
  border-image: linear-gradient(to top, var(--grid) 1px, var(--surface-0) 1px) fill 0 / 0 / 0 100vmax;
}

.tb-gap {
  flex: 1 1 auto;
  min-width: 0;
}

/* The filters, sort, order, view toggle and page size. On a wide screen the
   wrapper is not a box at all, so the row lays out exactly as it did without
   it: search … sort ↓ view page-size, in DOM order. Below 720px it becomes one
   scrollable line (see the responsive block), and library.js moves the view
   toggle out beside the search so the Tab order still matches the screen. */
.tb-strip {
  display: contents;
}

.tb-search {
  position: relative;
  display: flex;
  align-items: center;
  flex: 1 1 190px;
  max-width: 320px;
  min-width: 140px;
}

.tb-search > .icon {
  position: absolute;
  left: var(--s-2);
  width: var(--icon-s);
  height: var(--icon-s);
  color: var(--text-3);
  pointer-events: none;
}

.tb-q {
  width: 100%;
  padding-left: calc(var(--s-5) + var(--s-1));
  padding-right: calc(var(--s-5) + 2px);
  height: var(--ctl-s);
}

/* The UA's own search clear button would sit next to ours. */
.tb-q::-webkit-search-cancel-button {
  display: none;
}

.tb-qclear {
  position: absolute;
  right: var(--s-1);
  width: 20px;
  height: 20px;
  display: grid;
  place-items: center;
  border: none;
  border-radius: var(--r-1);
  background: transparent;
  color: var(--text-2);
  font-size: var(--fs-title);
  line-height: 1;
  cursor: pointer;
}

.tb-qclear:hover {
  background: var(--surface-hover);
  color: var(--text-1);
}

.tb-sel {
  height: var(--ctl-s);
  padding: 0 var(--s-2);
  font-size: var(--fs-input);
  max-width: 190px;
}

/* A select is as wide as its longest option, and the facet options only gain
   their counts ("Camera sidecars (12)") once the first page answers — so the
   search and every filter after it slid sideways under the skeleton. Each
   facet holds the width of its longest label with a two-digit count from the
   first paint (SPEC28 §6.14: no layout shift out of loading). */
.tb-type {
  min-width: 7.5em;
}

.tb-kind {
  min-width: 12.5em;
}

.tb-status {
  min-width: 10em;
}

.tb-order {
  height: var(--ctl-s);
  width: var(--ctl-s);
  padding: 0;
  flex: none;
}

.tb-order .icon {
  width: var(--icon-s);
  height: var(--icon-s);
}

/* The "show only this day" filter, set by clicking a card's recorded date: a
   route tag with its own × (library.js). It sits on the toolbar's centre line. */
.tb-pill {
  align-self: center;
  flex: none;
}

/* --------------------------------------------------------------------------
   Details view (SPEC7) — hairline rows, no zebra, a header that really sticks
   -------------------------------------------------------------------------- */

/* The wrap must scroll sideways inside its own box (never the page body), and
   per CSS Overflow 3 that makes the other axis `auto` too — so the wrap is the
   nearest scrollport for any sticky descendant. Capping its height is therefore
   what makes the sticky header work at all: without a cap it never scrolls
   vertically and the header pins itself to an edge that leaves with the page.
   The floor keeps short tables from being squeezed on a laptop. */
.details-wrap {
  overflow: auto;
  max-height: max(320px, calc(100dvh - var(--topbar-h) - 124px));
  overscroll-behavior-y: contain;
  border: 1px solid var(--grid);
  border-radius: var(--r-0);
  background: var(--surface-1);
}

.details {
  width: 100%;
  min-width: 720px;
  border-collapse: separate;
  border-spacing: 0;
  font-size: var(--fs-meta);
}

.details th,
.details td {
  text-align: left;
  padding: var(--s-2) var(--s-3);
  vertical-align: middle;
  border-bottom: 1px solid var(--grid);
}

.details thead th {
  position: sticky;
  top: 0;
  z-index: 1;
  background: var(--surface-2);
  font-size: var(--fs-meta);
  font-weight: var(--fw-semibold);
  color: var(--text-2);
  white-space: nowrap;
  /* border-collapse: separate keeps the cells' own borders from being merged
     away under the sticky offset, so the header rule is drawn as a shadow. */
  border-bottom: none;
  box-shadow: var(--e-sticky-line);
}

.details tbody tr:last-child td {
  border-bottom: none;
}

.details tbody tr:hover {
  background: var(--surface-hover);
}

.dt-sort {
  display: inline-flex;
  align-items: center;
  gap: var(--s-1);
  background: none;
  border: none;
  padding: 0;
  font: inherit;
  color: inherit;
  cursor: pointer;
}

.dt-sort:hover {
  color: var(--text-1);
}

/* The active sort column (SPEC28 §6.13): ink words over a 2px magenta rule.
   Every sort button carries the 2px edge, transparent until it is the active
   one, so choosing a column never moves the header by a pixel; the negative
   margin keeps the row at its own height. */
.dt-sort {
  border-bottom: 2px solid transparent;
  margin-bottom: -2px;
}

.dt-sortable.is-sorted .dt-sort {
  color: var(--text-1);
  border-bottom-color: var(--accent);
}

.dt-arrow {
  font-size: var(--fs-meta);
  width: 10px;
}

.dt-c-thumb { width: 76px; }
/* Auto, like the other columns: a fixed 40% made the name the widest column by
   far and squeezed Status, the one column whose content can grow. */
.dt-c-name { width: auto; min-width: 180px; }
.dt-c-recorded { white-space: nowrap; }
.dt-c-duration,
.dt-c-size { white-space: nowrap; }
/* Numbers line up on their units. Qualified by .details so it outranks the
   `.details th, .details td { text-align: left }` reset above. Their headers
   put the sort arrow's slot on the left, so the label ends where the values do. */
.details .dt-c-duration,
.details .dt-c-size { text-align: right; }
/* Baseline alignment keeps the reversed button's baseline on its label, not on
   the empty arrow slot that now comes first, so the header row does not grow. */
.details .dt-c-duration .dt-sort,
.details .dt-c-size .dt-sort { flex-direction: row-reverse; align-items: baseline; }
.dt-c-act { width: 48px; }

.dt-thumb {
  position: relative;
  display: grid;
  place-items: center;
  width: 60px;
  height: var(--ctl-s);
  border-radius: var(--r-1);
  overflow: hidden;
  background: var(--stage);
}

.dt-thumb-img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.dt-thumb-ph {
  color: var(--stage-rule);
}

.dt-thumb-ph svg {
  width: var(--icon-s);
  height: var(--icon-s);
}

/* Audio rows share the thumbnail's dark well so the column reads as one strip
   in both themes. */
.dt-thumb-audio {
  background-color: var(--stage-2);
}

.dt-thumb-audio .dt-thumb-ph {
  color: var(--stage-ink-2);
}

.dt-namewrap {
  display: flex;
  align-items: center;
  gap: var(--s-2);
  min-width: 0;
}

.card-name.dt-name {
  font-size: var(--fs-body);
  font-weight: var(--fw-semibold);
  max-width: 320px;
}

.dt-day {
  background: none;
  border: none;
  padding: 2px var(--s-1);
  margin: 0 calc(var(--s-1) * -1);
  border-radius: var(--r-1);
  /* The same face and figures as the date on a tile's meta line. */
  font-family: inherit;
  font-size: var(--fs-meta);
  font-weight: var(--fw-regular);
  font-variant-numeric: tabular-nums;
  color: var(--text-2);
  cursor: pointer;
  white-space: nowrap;
}

.dt-day:hover:not(:disabled) {
  background: var(--accent-soft);
  color: var(--accent);
}

.dt-day:disabled {
  cursor: default;
  color: var(--text-3);
}

.dt-day.is-on {
  background: var(--accent-soft);
  color: var(--accent);
}

.dt-c-duration,
.dt-c-size {
  color: var(--text-2);
  font-variant-numeric: tabular-nums;
}

/* Measurements in the data face (SPEC28 §4.2 rule 6, §6.13); the header cells
   above them stay sentence-case UI type. */
td.dt-c-duration,
td.dt-c-size,
.meta-size {
  font-family: var(--font-data);
}

/* A monospace space is a whole cell: "97  KB" read as two facts. */
td.dt-c-size,
.meta-size {
  word-spacing: -0.3em;
}

.dt-c-recorded {
  font-variant-numeric: tabular-nums;
}

.dt-c-fmt {
  white-space: nowrap;
}

.dt-c-status .tag {
  max-width: 240px;
}

/* The status cell's own flex row: a failure and the dash chip can appear
   together, and they wrap as a unit instead of indenting each other. */
.dt-status {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-1);
  align-items: center;
}

/* A second copy of the status chips, under the name. Only the phone layout
   shows it (see the 500px block), in place of the Status column; everywhere
   else it is display:none, so assistive tech only ever meets one copy. */
.dt-status-inline {
  display: none;
}

/* --------------------------------------------------------------------------
   Paging controls
   -------------------------------------------------------------------------- */

.pager {
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: var(--s-3);
  padding-top: var(--s-1);
}

.pager-info {
  color: var(--text-2);
  font-size: var(--fs-meta);
  font-variant-numeric: tabular-nums;
}

.pager-btns,
.pager-nums {
  display: flex;
  align-items: center;
  gap: var(--s-2);
  flex-wrap: wrap;
}

.pager-num {
  min-width: 32px;
  padding: var(--s-1) var(--s-2);
  font-variant-numeric: tabular-nums;
}

.pager-num.is-current {
  background: var(--accent-soft);
  border-color: var(--accent);
  color: var(--accent);
}

.pager-at {
  color: var(--text-2);
  font-size: var(--fs-meta);
  padding: 0 var(--s-1);
  white-space: nowrap;
}

/* --------------------------------------------------------------------------
   Menus (SPEC28 §6.7) — library.js showMenu builds them and owns the keys
   (↓ ↑ Home End, Esc back to the opener, Tab out); this is only the look.
   -------------------------------------------------------------------------- */

/* Appended to <body>, so it sits at --z-menu, above #modal-root: a menu opened
   from inside a dialog would otherwise render behind it (SPEC28 D1). showMenu
   positions it from offsetWidth/offsetHeight, which a transform does not
   change, so the enter translate never moves where it lands. */
.menu {
  position: fixed;
  z-index: var(--z-menu);
  display: flex;
  flex-direction: column;
  min-width: 200px;
  max-width: min(320px, 100vw - 2 * var(--s-4));
  padding: var(--s-1);
  background: var(--surface-1);
  border-radius: var(--r-2);
  box-shadow: var(--e-float);
  animation: menu-in var(--dur-2) var(--ease-out);
}

@keyframes menu-in {
  from { opacity: 0; transform: translateY(-4px); }
  to { opacity: 1; transform: none; }
}

.menu-group {
  display: flex;
  flex-direction: column;
}

/* A group's heading ("Theme"): a sentence-case label, not a control. */
.menu-head {
  padding: var(--s-2) var(--s-3) var(--s-1);
  color: var(--text-2);
  font-size: var(--fs-meta);
  font-weight: var(--fw-semibold);
  line-height: var(--lh-meta);
}

.menu-sep {
  height: 1px;
  margin: var(--s-1) 0;
  background: var(--grid);
}

.menu-item {
  display: flex;
  align-items: center;
  gap: var(--s-3);
  width: 100%;
  min-height: var(--ctl-m);
  padding: 0 var(--s-3);
  border: 0;
  border-radius: var(--r-1);
  background: transparent;
  color: var(--text-1);
  font-family: inherit;
  font-size: var(--fs-body);
  font-weight: var(--fw-regular);
  line-height: var(--lh-body);
  text-align: left;
  text-decoration: none;
  cursor: pointer;
}

.menu-item:hover:not(:disabled),
.menu-item:focus-visible {
  background: var(--surface-hover);
}

/* Inset: an outer ring would touch the menu's own edge. */
.menu-item:focus-visible {
  outline-offset: -2px;
}

.menu-item .icon {
  flex: none;
  color: var(--text-2);
}

.menu-item .icon {
  width: var(--icon-s);
  height: var(--icon-s);
}

.menu-label {
  flex: 1;
  min-width: 0;
}

/* The checked Theme row's trailing tick. */
.menu-check {
  flex: none;
  display: inline-flex;
  width: var(--icon-s);
  color: var(--text-1);
}

.menu-item .menu-check .icon {
  color: inherit;
}

.menu-item-danger,
.menu-item-danger .icon {
  color: var(--danger);
}

.menu-item:disabled,
.menu-item:disabled .icon {
  color: var(--text-3);
  cursor: not-allowed;
}

/* --------------------------------------------------------------------------
   YouTube surface (SPEC8)
   -------------------------------------------------------------------------- */

.yt-slot {
  display: inline-flex;
  align-items: center;
}

.card-more {
  margin-left: auto;
}

.yt-desc {
  resize: vertical;
}

.yt-kids {
  justify-content: flex-end;
}

.yt-kids .chk {
  height: 37px;
  align-items: center;
}

/* Honesty copy (SPEC28 §6.11): footnotes on the sheet, never a well. */
.yt-caveats {
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
}

.yt-caveats .modal-note {
  margin: 0;
}

/* Settings dialog */
.set-row {
  display: flex;
  align-items: flex-start;
  gap: var(--s-3);
  padding: var(--s-4);
  border: 1px solid var(--grid);
  border-radius: var(--r-0);
  background: var(--surface-2);
  flex-wrap: wrap;
}

.set-icon {
  flex: none;
  display: grid;
  place-items: center;
  width: 36px;
  height: 36px;
  border-radius: var(--r-1);
  background: var(--surface-1);
  color: var(--text-1);
}

.set-icon .icon {
  width: var(--icon-m);
  height: var(--icon-m);
}

.set-main {
  flex: 1 1 190px;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 2px;
}

.set-title {
  margin: 0;
  font-size: var(--fs-body);
  font-weight: var(--fw-semibold);
}

.set-status {
  font-size: var(--fs-meta);
  font-weight: var(--fw-semibold);
  color: var(--text-1);
}

.set-sub {
  margin: 0;
}

.set-actions {
  display: flex;
  align-items: center;
  gap: var(--s-2);
  margin-left: auto;
}

/* The Settings dialog's rows: icon | text | actions on a grid, so the icon
   never wraps onto a line of its own and every row's actions share one
   right-hand column. Scoped to the dialog body: the library's Tracks rows and
   ride-day banners are .set-row too and keep their flex layout. */
.dialog-body > .set-row {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto;
  align-items: start;
}

/* In a dialog the rows are a ruled list, like the disclosure groups
   (SPEC28 §6.10): no well and no tile behind the icon, a hairline between
   one visible row and the next. The library's own .set-rows (Tracks, the
   proposal banners) stay wells on the film. */
.dialog-body > .set-row {
  padding: var(--s-4) 0;
  border: 0;
  border-top: 1px solid var(--grid);
  background: none;
}

.dialog-body > .set-row:not(.set-row:not([hidden]) ~ .set-row) {
  padding-top: 0;
  border-top: 0;
}

.dialog-body > :is(.set-row + .set-row, .set-row + [hidden] + .set-row, .set-row + [hidden] + [hidden] + .set-row) {
  margin-top: calc(var(--s-3) * -1);
}

.dialog-body > .set-row .set-icon {
  background: none;
  color: var(--text-2);
}

.dialog-body > .set-row .set-actions {
  min-width: 96px;
  margin-left: 0;
  justify-content: flex-end;
}

/* A row with nothing to press (Re-process with nothing to review, YouTube not
   configured) keeps no empty action column: its text runs to the card's edge
   instead of wrapping short beside a blank 96px. */
.dialog-body > .set-row .set-actions:not(:has(> :not([hidden]))) {
  display: none;
}

.dialog-body > .set-row:not(:has(> .set-actions > :not([hidden]))) > .set-main {
  grid-column: 2 / -1;
}

/* Two or more live controls (Link YouTube + Link with playlists, Re-link +
   Unlink) cannot share the row with the text: the text column would be
   squeezed to a few words. They take their own line under the text instead. */
.dialog-body > .set-row .set-actions:has(> :not([hidden]) ~ :not([hidden])) {
  grid-column: 2 / -1;
  justify-content: flex-start;
  flex-wrap: wrap;
}

/* One tier below the title: the status is a fact about the row, not a second
   heading. */
.dialog-body > .set-row .set-status {
  font-weight: var(--fw-regular);
  color: var(--text-2);
}

/* --------------------------------------------------------------------------
   Audio entries (the SPEC2 Music section is folded into the one list)
   -------------------------------------------------------------------------- */

.thumb-audio {
  background-color: var(--stage-2);
}

.thumb-ph-audio {
  color: var(--stage-ink-2);
}

.badge-audio {
  color: var(--text-2);
}

/* --------------------------------------------------------------------------
   State blocks (SPEC28 §6.14) — empty, filtered, loading, error
   -------------------------------------------------------------------------- */

/* primitives.js stateBlock(): a ruled row, not a dashed box. The glyph sits
   in the left margin and everything after it — title, message, the one
   action, and whatever a caller appends (the editor's meter and its way back)
   — reads down one left-aligned column beside it. */
.state {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: var(--s-2);
  padding: var(--s-6) var(--s-5) var(--s-6) calc(var(--s-5) + var(--icon-l) + var(--s-4));
  border-block: 1px solid var(--grid);
  background: var(--surface-1);
  color: var(--text-2);
}

.state-icon {
  position: absolute;
  top: var(--s-6);
  left: var(--s-5);
  width: var(--icon-l);
  height: var(--icon-l);
  color: var(--text-3);
}

.state-title {
  color: var(--text-1);
  font-size: var(--fs-title);
  font-weight: var(--fw-semibold);
  line-height: var(--lh-title);
}

.state-msg {
  max-width: var(--measure);
  font-size: var(--fs-body);
  overflow-wrap: anywhere;
}

.state > .btn {
  margin-top: var(--s-2);
}

/* A quiet action (Clear filters) is words at rest: they line up with the
   message above rather than with the button box around them. */
.state > .btn-ghost {
  margin-inline-start: calc(var(--s-4) * -1);
}

/* Server unreachable: the octagon in danger, and the last error as data. */
.state-error .state-icon {
  color: var(--danger);
}

.state-error .state-msg {
  font-family: var(--font-data);
  font-size: var(--fs-meta);
}

/* Loading (skeleton): one status line over the real geometry, with plate bars
   where the words will be and plate wells where the pictures will be. No
   shimmer — it stands still, and aria-busy tells assistive tech why. */
.state-skeleton {
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: var(--s-4);
  padding: 0;
  border: 0;
  background: none;
}

.state-status {
  color: var(--text-2);
  font-size: var(--fs-body);
}

.skel-bar,
.skel-well {
  display: block;
  border-radius: var(--r-1);
  background: var(--surface-2);
}

.skel-bar {
  height: calc(var(--fs-body) * var(--lh-body) - var(--s-1));
}

.skel-card {
  display: flex;
  flex-direction: column;
  border: 1px solid var(--grid);
  background: var(--surface-1);
}

.skel-card > .skel-well {
  aspect-ratio: 16 / 9;
  border-radius: var(--r-0);
}

.skel-body {
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
  padding: var(--s-3) var(--s-4) var(--s-4);
}

.skel-w-name { width: 72%; }
.skel-w-meta { width: 48%; }
.skel-w-date { width: 12ch; }
.skel-w-num { width: 7ch; }

.skel-w-btn {
  width: calc(var(--ctl-m) * 2);
  height: var(--ctl-s);
  margin-top: var(--s-2);
}

.skel-rows {
  border-block: 1px solid var(--grid);
  background: var(--surface-1);
}

.skel-row {
  display: grid;
  grid-template-columns: 60px minmax(0, 1fr) auto auto auto;
  align-items: center;
  gap: var(--s-4);
  min-height: 44px;
  padding: var(--s-2) var(--s-3);
}

.skel-row + .skel-row {
  border-top: 1px solid var(--grid);
}

.skel-thumb {
  height: var(--ctl-s);
}

/* A name is as long as a clip name, not as wide as the column. */
.skel-row > .skel-w-name {
  max-width: 320px;
}

@media (max-width: 599.98px) {
  .skel-row {
    grid-template-columns: minmax(0, 1fr) auto;
    min-height: 56px;
  }

  .skel-row > .skel-thumb,
  .skel-row > .skel-w-num {
    display: none;
  }
}

/* Inside a dialog the same block has to be a paragraph's worth of room, not a
   page's — the b-roll picker's "nothing searched yet" sits above a grid. */
.dialog-body > .state {
  padding: var(--s-4) var(--s-4) var(--s-4) calc(var(--s-4) + var(--icon-l) + var(--s-3));
}

.dialog-body > .state > .state-icon {
  top: var(--s-4);
  left: var(--s-4);
}

.drop-overlay {
  position: fixed;
  inset: 0;
  z-index: var(--z-drop);
  background: var(--scrim);
  display: grid;
  place-items: center;
  animation: fade-in var(--dur-2) var(--ease-out);
}

.drop-box {
  display: flex;
  align-items: center;
  gap: var(--s-4);
  border: 2px dashed var(--accent);
  border-radius: var(--r-2);
  padding: var(--s-6) var(--s-7);
  color: var(--accent);
  font-size: var(--fs-head);
  font-weight: var(--fw-semibold);
  background: var(--accent-soft);
}

.drop-box .icon {
  width: var(--icon-l);
  height: var(--icon-l);
}

/* --------------------------------------------------------------------------
   Editor
   -------------------------------------------------------------------------- */

.editor-topbar {
  gap: var(--s-4);
}

.topbar-side {
  flex: 1;
  display: flex;
}

.topbar-side-r {
  justify-content: flex-end;
}

.edit-title {
  flex: 2;
  min-width: 0;
  text-align: center;
}

.edit-name {
  font-size: var(--fs-title);
  font-weight: var(--fw-semibold);
  line-height: var(--lh-title);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.edit-sub {
  font-size: var(--fs-meta);
  color: var(--text-2);
  margin-top: 1px;
}

/* The numbers carry the weight; the words between them stay ink-2. */
.edit-sub .mono {
  font-weight: var(--fw-semibold);
  color: var(--text-1);
}

.edit-main {
  display: flex;
  flex-direction: column;
  gap: var(--s-4);
}

/* The player is a stage surface: --stage behind the picture in both themes,
   so surrounding brightness never changes how footage reads. */
.video-wrap {
  background: var(--stage);
  border: 1px solid var(--grid);
  border-radius: var(--r-0);
  overflow: hidden;
}

.edit-video {
  display: block;
  width: 100%;
  max-height: 62vh;
  cursor: pointer;
  background: var(--stage);
}

.transport {
  display: flex;
  align-items: center;
  gap: var(--s-4);
  flex-wrap: wrap;
  background: var(--surface-1);
  border: 1px solid var(--grid);
  border-radius: var(--r-0);
  padding: var(--s-3) var(--s-4);
}

/* The big timecode (SPEC28 §3.1): data face 600 at --fs-head, the playhead
   in ink and the length after the slash quiet. */
.time-readout {
  font-size: var(--fs-head);
  font-weight: var(--fw-semibold);
  line-height: var(--lh-head);
  color: var(--text-1);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

.t-sep,
.t-sep ~ .mono {
  font-weight: var(--fw-regular);
  color: var(--text-3);
}

.transport-spacer {
  flex: 1;
}

.rate-sel {
  width: 84px;
  padding: var(--s-2);
}

/* Timeline ---------------------------------------------------------------- */

/* Stage surface (SPEC28 §2.2): until the P4 lanes, the whole timeline card is
   the track's dark bed in both themes, so the playhead knob above the track
   stays visible in light theme too. */
.timeline {
  background: var(--stage);
  border: 1px solid var(--stage-rule);
  border-radius: var(--r-0);
  padding: var(--s-2) var(--s-4) var(--s-4);
}

.tl-ruler {
  position: relative;
  height: 18px;
  margin-bottom: var(--s-2);
  overflow: hidden;
}

.tl-tick {
  position: absolute;
  top: 1px;
  transform: translateX(-50%);
  font-family: var(--font-data);
  font-size: var(--fs-tick);
  font-variant-numeric: tabular-nums;
  color: var(--stage-ink-2);
  white-space: nowrap;
}

.tl-tick:first-child {
  transform: none;
}

/* The last label ends AT its mark instead of centring on it, or the ruler's
   overflow clip cut "0:06" down to "0:". */
.tl-tick:last-child:not(:first-child) {
  transform: translateX(-100%);
}

/* Video-first surface: the track keeps its dark bed in both themes. */
.tl-track {
  position: relative;
  height: 76px;
  border-radius: var(--r-0);
  border: 1px solid var(--stage-rule);
  background:
    repeating-linear-gradient(-45deg, var(--stage-hatch) 0 7px, transparent 7px 14px),
    var(--stage);
  cursor: pointer;
  touch-action: none;
}

.tl-segs {
  position: absolute;
  inset: 0;
}

/* A kept segment is an opaque tint laid over the stage black, so the track
   bed's hatch — which means "removed" (ROADBOOK §5.9) — shows only in the gaps
   between segments, never through what is kept. The first layer is a flat
   colour written as a gradient only so it can sit over a second background;
   it draws no visual gradient (SPEC28 §0.1 rule 7, like D14's bars). */
.tl-seg {
  position: absolute;
  top: var(--s-2);
  bottom: var(--s-2);
  background: linear-gradient(var(--stage-seg), var(--stage-seg)), var(--stage);
  border: 1px solid var(--stage-seg-edge);
  border-radius: var(--r-1);
  cursor: pointer;
}

.tl-seg.sel {
  border-color: var(--stage-route);
  box-shadow: var(--e-select-stage);
  background: linear-gradient(var(--stage-seg-sel), var(--stage-seg-sel)), var(--stage);
  z-index: 2;
}

/* Sped-up segments render dimmer, with a centered speed label. */
.tl-seg.fast {
  background: linear-gradient(var(--stage-seg-fast), var(--stage-seg-fast)), var(--stage);
  border-color: var(--stage-seg-edge-fast);
}

.tl-seg.fast.sel {
  border-color: var(--stage-route);
  background: linear-gradient(var(--stage-seg-fast-sel), var(--stage-seg-fast-sel)), var(--stage);
}

.tl-speed {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  font-size: var(--fs-meta);
  font-weight: var(--fw-semibold);
  letter-spacing: 0.03em;
  color: var(--stage-ink);
  text-shadow: var(--stage-text-shadow);
  pointer-events: none;
  overflow: hidden;
  z-index: 1;
}

.tl-handle {
  position: absolute;
  top: 0;
  bottom: 0;
  width: 12px;
  cursor: ew-resize;
  z-index: 3;
}

.tl-handle.l { left: calc(var(--s-1) * -1 + 1px); }
.tl-handle.r { right: calc(var(--s-1) * -1 + 1px); }

.tl-handle::after {
  content: "";
  position: absolute;
  top: 22%;
  bottom: 22%;
  left: 50%;
  width: 3px;
  transform: translateX(-50%);
  border-radius: 2px;
  background: var(--stage-ink);
  opacity: 0;
  transition: opacity var(--dur-1) var(--ease-out);
}

.tl-seg:hover .tl-handle::after,
.tl-seg.sel .tl-handle::after {
  opacity: 1;
}

.tl-playhead {
  position: absolute;
  top: calc(var(--s-2) * -1 + 1px);
  bottom: 0;
  width: 0;
  margin-left: -1px;
  border-left: 2px solid var(--stage-ink);
  z-index: 6;
  pointer-events: none;
  filter: var(--stage-drop-shadow);
}

.tl-ph-knob {
  position: absolute;
  top: 0;
  left: calc(var(--s-2) * -1 + 1px);
  width: 12px;
  height: 9px;
  background: var(--stage-ink);
  clip-path: polygon(0 0, 100% 0, 50% 100%);
}

/* Segment tools ----------------------------------------------------------- */

.seg-tools {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s-3);
  flex-wrap: wrap;
  background: var(--surface-1);
  border: 1px solid var(--grid);
  border-radius: var(--r-0);
  padding: var(--s-3) var(--s-4);
}

.seg-actions {
  display: flex;
  gap: var(--s-3);
  flex-wrap: wrap;
}

.seg-range {
  display: flex;
  gap: var(--s-4);
  flex-wrap: wrap;
}

.seg-speed {
  display: inline-flex;
  align-items: center;
  gap: var(--s-2);
}

.num-label {
  display: inline-flex;
  align-items: center;
  gap: var(--s-2);
  font-size: var(--fs-meta);
  font-weight: var(--fw-semibold);
  color: var(--text-1);
}

.kbd-hints {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-2) var(--s-4);
  color: var(--text-3);
  font-size: var(--fs-meta);
  margin: 2px var(--s-1) var(--s-6);
}

.kbd-hints span {
  white-space: nowrap;
}

/* Notices (still processing / errors) ------------------------------------- */

.notice-wrap {
  padding: 12vh var(--s-4) 0;
}

.notice-wrap > .state {
  max-width: 560px;
  margin-inline: auto;
}

/* The waiting screen's meter spans the text column, its readout beside it. */
.notice-progress {
  display: flex;
  align-items: center;
  justify-self: stretch;
  gap: var(--s-3);
  color: var(--text-2);
  font-family: var(--font-data);
  font-size: var(--fs-meta);
  font-variant-numeric: tabular-nums;
}

.notice-progress .pbar {
  flex: 1;
}

/* --------------------------------------------------------------------------
   Telemetry — the per-video preview strip, the gauge/route option block, and
   the coverage chip. The calibration wizard and profile manager that used to
   head this section went with the dash OCR reader.
   -------------------------------------------------------------------------- */

/* ---- per-video preview strip --------------------------------------------- */

.tele-stripbox {
  padding: var(--s-3);
  border: 1px solid var(--grid);
  border-radius: var(--r-0);
  background: var(--surface-1);
}

.tele-strip {
  display: flex;
  flex-direction: column;
  gap: var(--s-1);
}

.tele-axis {
  display: flex;
  justify-content: space-between;
  font-size: var(--fs-meta);
  font-variant-numeric: tabular-nums;
  color: var(--text-3);
}

/* preserveAspectRatio="none" stretches the plot to the box; the stroke is kept
   honest with vector-effect on the line itself. */
.tele-plot {
  display: block;
  width: 100%;
  height: 72px;
}

.tele-line {
  fill: none;
  stroke: var(--water);
  stroke-width: 1.6px;
  stroke-linejoin: round;
  stroke-linecap: round;
}

.tele-dot {
  fill: var(--water);
}

/* Unread frames are shaded, never bridged — the gap IS the information. The
   speed line is measured data, so it is water blue (SPEC28 §0.1). */
.tele-gap {
  fill: var(--grid);
}

.tele-mid {
  stroke: var(--rule);
  stroke-width: 1px;
  stroke-dasharray: 3 4;
}

.tele-gears {
  position: relative;
  height: var(--s-5);
  border-radius: var(--r-1);
  background: var(--surface-2);
  overflow: hidden;
}

.tele-gear {
  position: absolute;
  top: 0;
  bottom: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--ink-fill);
  color: var(--ink-fill-text);
  font-size: var(--fs-meta);
  font-weight: var(--fw-semibold);
  font-variant-numeric: tabular-nums;
}

/* ---- gauge option block + coverage chip ---------------------------------- */

.tele-controls {
  display: flex;
  flex-direction: column;
  gap: var(--s-3);
}

/* The gates' escape hatch: "Exclude those 18 clips from the reel". A ghost
   button, shrink-to-fit under the explanation rather than a full-width bar —
   it is the smaller of the two ways out (the other is going and fixing the
   sync), and it must not read as the block's primary action. */
.tele-drop {
  align-self: flex-start;
}

/* SPEC20: the route-map shape preview inside the telemetry section. */
.route-preview {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--s-1);
  padding: var(--s-2);
  background: var(--surface-1);
  border: 1px solid var(--grid);
  border-radius: var(--r-0);
}

.route-cap {
  align-self: flex-start;
  font-size: var(--fs-meta);
  color: var(--text-2);
}

.route-svg {
  width: 148px;
  height: 92px;
  max-width: 100%;
}

.route-line {
  fill: none;
  stroke: var(--accent);
  stroke-width: 2;
  stroke-linejoin: round;
  stroke-linecap: round;
}

.route-start {
  fill: var(--text-2);
}

/* A warning note keeps reading type: the tone is carried by a leading warn
   triangle, not by setting the whole paragraph in amber, which made the
   longest copy in a dialog its loudest (SPEC28 §0.1 rule 4, §6.11). */
.modal-note.is-warn {
  color: var(--text-2);
}

.modal-note.is-warn::before {
  content: "";
  display: inline-block;
  width: var(--icon-s);
  height: var(--icon-s);
  margin-right: var(--s-2);
  vertical-align: text-bottom;
  background: var(--warn);
  -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M8 2.4L2 13.2h12z' fill='none' stroke='black' stroke-width='1.5'/%3E%3Cpath d='M8 6.4v3.2M8 10.8v1.2' stroke='black' stroke-width='1.5'/%3E%3C/svg%3E") center / contain no-repeat;
  mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M8 2.4L2 13.2h12z' fill='none' stroke='black' stroke-width='1.5'/%3E%3Cpath d='M8 6.4v3.2M8 10.8v1.2' stroke='black' stroke-width='1.5'/%3E%3C/svg%3E") center / contain no-repeat;
}

/* --------------------------------------------------------------------------
   Public share links (SPEC10)

   Nothing here is a new primitive: the dialog is the .export-modal shell, the
   rows are plain list rows, the state marks are .tag, and the row's extra
   actions are the same overflow .menu every other list uses. Only the widths,
   the "this is public" note and the copy confirmation are new.
   -------------------------------------------------------------------------- */

/* Wider than a plain dialog because it holds a LIST as well as a form — the
   link rows are unreadable when the URL, the expiry and three buttons have to
   share 460px. */
.share-modal {
  width: min(600px, 100%);
}

/* Several tags can coincide on one card — the dash coverage, the share count,
   an improvements offer — which is more than the status row was sized for. Let them
   wrap onto a second line rather than run off the edge of a narrow card; the
   row-gap keeps the wrapped line off the one above it. */
.status-row {
  flex-wrap: wrap;
  row-gap: var(--s-1);
}

/* The honest-notes block: what a link is, and the two bandwidth lines. Same
   shell as the publish dialog's caveats, because it does the same job. */
/* The decision sentence and its footnotes (SPEC28 §6.11): plain on the
   sheet, never boxed. */
.share-notes {
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
}

.share-notes .modal-note {
  margin: 0;
}

/* "Anyone with the link can watch this video." Full text colour and weight —
   it is the sentence the operator has to have read, so it is not a footnote. */
.share-plain {
  font-size: var(--fs-body);
  font-weight: var(--fw-semibold);
  line-height: var(--lh-body);
  color: var(--text-1);
}

/* ---- the link just created ---------------------------------------------- */

.share-made {
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
  padding: var(--s-3);
  border-radius: var(--r-0);
  background: var(--accent-soft);
  border: 1px solid var(--accent);
}

.share-made-title {
  display: flex;
  align-items: center;
  gap: var(--s-2);
  margin: 0;
  font-size: var(--fs-body);
  font-weight: var(--fw-semibold);
  color: var(--accent);
}

.share-made-title .icon {
  width: var(--icon-s);
  height: var(--icon-s);
}

.share-made-row {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--s-2);
}

/* The URL is readable and selectable, never truncated with an ellipsis: a
   half-copied link is worse than a wrapped one. */
.share-url {
  flex: 1 1 220px;
  min-width: 0;
  font-size: var(--fs-input);
}

/* Copy confirms in its own label. The tick is the ok colour so it reads
   without depending on the word alone. */
.share-copy.is-copied {
  color: var(--ok);
  border-color: var(--ok);
}

/* ---- existing links ------------------------------------------------------ */

.share-list {
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
}

.share-list-title {
  margin: 0;
  font-size: var(--fs-meta);
  font-weight: var(--fw-semibold);
  color: var(--text-1);
}

/* Rows of one ruled list (SPEC28 D26): hairlines, no wells. */
.share-item {
  display: flex;
  align-items: center;
  gap: var(--s-3);
  padding: var(--s-3) 0;
  border-block: 1px solid var(--grid);
  flex-wrap: wrap;
}

.share-item + .share-item {
  margin-top: calc(var(--s-2) * -1);
  border-top: 0;
}

/* Expired links stay listed (they can be extended) but recede — the tag
   already says "Expired", so this only has to stop them competing. */
.share-item.is-expired {
  opacity: 0.75;
}

.share-item-main {
  flex: 1 1 220px;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: var(--s-1);
}

.share-item-top {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--s-2);
  min-width: 0;
}

.share-video {
  font-size: var(--fs-body);
  font-weight: var(--fw-semibold);
  color: var(--text-1);
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.share-item-meta {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--s-1);
  font-size: var(--fs-meta);
  color: var(--text-3);
}

.share-id {
  color: var(--text-2);
}

/* The separators between the meta facts recede so the facts themselves lead. */
.share-dot {
  opacity: 0.55;
}

.share-item-act {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--s-2);
  margin-left: auto;
}

/* The label reads as text until you touch it, then as a field — click-to-edit
   with no pencil to hunt for, the same bargain the card title makes. */
.share-label {
  border: 1px solid transparent;
  background: none;
  font-family: inherit;
  font-size: var(--fs-meta);
  font-weight: var(--fw-semibold);
  line-height: var(--lh-meta);
  color: var(--text-1);
  padding: 2px var(--s-1);
  border-radius: var(--r-1);
  cursor: text;
  text-align: left;
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  transition:
    background var(--dur-1) var(--ease-out),
    border-color var(--dur-1) var(--ease-out);
}

.share-label:hover,
.share-label:focus-visible {
  background: var(--surface-hover);
  border-color: var(--rule);
}

.share-label.is-empty {
  color: var(--text-3);
  font-weight: var(--fw-regular);
  font-style: italic;
}

.share-label-inp {
  flex: 1 1 160px;
  min-width: 0;
  font-size: var(--fs-input);
  padding: 2px var(--s-2);
}

/* --------------------------------------------------------------------------
   Provenance marks and the attribution block (was SPEC12)

   The `bro-` prefix is the thematic b-roll panel's, and that panel is gone.
   What is left is everything the MUSIC side borrowed from it and still
   paints: the source line on a card, the two honesty lines, and the credit
   block in the publish dialog.
   -------------------------------------------------------------------------- */

/* ---- the library's own provenance mark ----------------------------------- */

/* The stored licence string and the author, on the card, linking to the
   source's page (shown, not merely recorded). Not a .badge — it carries real
   words rather than a label, so it keeps its sentence case. */
.bro-src {
  font-size: var(--fs-meta);
  color: var(--text-3);
  max-width: 100%;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* ---- the two honesty lines ----------------------------------------------- */

/* Same box as .share-notes, because it does the same job — but the type inside
   is reading type, not footnote type. These are the sentences the publish
   dialog will not bury, so they are not styled like something to skip. */
.bro-notes {
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
}

.bro-plain {
  margin: 0;
  font-size: var(--fs-body);
  font-weight: var(--fw-medium);
  line-height: var(--lh-body);
  color: var(--text-1);
}

/* ---- results ------------------------------------------------------------- */

.bro-author {
  color: var(--text-2);
}

a.bro-author:hover,
a.bro-lic:hover,
a.bro-src:hover {
  color: var(--accent);
}

/* The terms, on the card, at the moment of the decision — not a tooltip and
   not a README (SPEC12). The name opens the license itself; Source opens the
   clip's own page on the provider. */
.bro-lic {
  display: flex;
  align-items: baseline;
  gap: var(--s-1);
  font-size: var(--fs-meta);
  font-weight: var(--fw-semibold);
  color: var(--text-2);
  min-width: 0;
  overflow: hidden;
  white-space: nowrap;
}

/* ---- the attribution preview in the publish dialog ----------------------- */

/* What is about to be pasted into the YouTube description, shown verbatim so
   the credit the operator is sending is the credit they can read. */
.bro-attrib {
  margin: 0;
  max-height: 150px;
  overflow: auto;
  overscroll-behavior: contain;
  padding: var(--s-3);
  border-radius: var(--r-0);
  background: var(--surface-2);
  border: 1px solid var(--grid);
  font-family: var(--font-data);
  font-size: var(--fs-meta);
  line-height: var(--lh-body);
  color: var(--text-2);
  white-space: pre-wrap;
  word-break: break-word;
}

.bro-attrib-field .chk {
  margin-top: var(--s-1);
}

/* The per-clip terms behind that block: who, under what license, from where.
   SPEC12 asks that the license string and the source URL be SHOWN wherever
   b-roll is, and the publish dialog is the last place that matters. */
.bro-credits {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
}

.bro-credit {
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 0;
  padding: var(--s-2) var(--s-3);
  border-radius: var(--r-1);
  background: var(--surface-2);
  border: 1px solid var(--grid);
}

.bro-credit-name {
  font-size: var(--fs-body);
  font-weight: var(--fw-semibold);
  color: var(--text-1);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.bro-credit-meta {
  font-size: var(--fs-meta);
  color: var(--text-3);
}

/* --------------------------------------------------------------------------
   Audio modes (SPEC13)

   Almost nothing here is a new primitive. The Audio section is a .dgroup
   holding the existing .vol-row slider twice; the honesty box is the same
   rule as .bro-notes. The CLAP match panel that used to sit under this banner
   went with its model; what is left is the picker, the one preview player and
   the loop flag.
   -------------------------------------------------------------------------- */

/* ---- the Audio section in the export / highlights dialogs ---------------- */

/* The picker and the controls that sound it, on one line — this is the moment
   the question "which track?" is actually being asked. */
.aud-pick {
  display: flex;
  align-items: center;
  gap: var(--s-2);
  flex-wrap: wrap;
}

.aud-track {
  flex: 1 1 200px;
  min-width: 0;
}

/* ---- hearing it, wherever it is chosen (SPEC13 addendum) ----------------- */

/* ONE look for the one player, on all four of its surfaces — the match panel's
   row, the picker in the render dialogs, an audio card and an audio row — so
   "this is the thing that is playing" reads the same everywhere. Each surface's
   own class below carries nothing but its layout.

   Filled while playing, because the button is a toggle and `aria-pressed` on
   its own is invisible. The fill swap rides the global --dur-1 transition,
   which prefers-reduced-motion already neutralises; nothing here animates,
   pulses or scrolls a row into view. */
.play-btn.on {
  color: var(--ink-fill-text);
  border-color: var(--ink-fill);
  background: var(--ink-fill);
}

/* Between the select and the loop chip: choose → hear → read the flag. */
.aud-play {
  flex: none;
}

/* The decision the note has just described, one press away — a full-width
   sentence-sized button under the note rather than a second meaning bolted
   onto the chip, which stays a statement (SPEC28 §6.4). */
.aud-loopfix {
  margin-top: var(--s-2);
}

/* Beside ⋯ in the details table's action cell. The cell is a fixed --ctl-s
   square for every other row, so only a row that HAS a play button asks the
   column to grow: a library with no audio in it is a library whose action
   column has not moved a pixel. */
.dt-play {
  flex: none;
  margin-right: var(--s-2);
}

.dt-c-act:has(.dt-play) {
  width: auto;
  white-space: nowrap;
}

/* ...and once it HAS grown, every row's ⋯ has to stay under every other row's.
   The cell is a real <td>, so its content is left-packed: a widened column left
   an audio row reading [▷ ⋯] and the video row above it reading [⋯] followed by
   fifty empty pixels, with the same menu button landing at two different x
   positions on adjacent rows. Right-packing pins ⋯ to the column edge on every
   row and puts ▷ beside it.

   Scoped to a table that actually contains a play button: a library with no
   audio in it has not moved a pixel, which is the same promise the width rule
   above makes. */
.details:has(.dt-play) .dt-c-act {
  text-align: right;
}

/* The mode's own sentence. It says what the render will DO, because "Music
   only" and "Ride + music" differ by whether the ride is audible at all, and
   that is not something to discover after a twenty-minute encode. */
.aud-note {
  margin-top: var(--s-1);
}

/* ---- the library's own mark on a matched track --------------------------- */

/* Accent, not warn: this IS something the operator went and chose. The licence
   line beside it is the caution, and it carries its own words. */
.badge-music {
  color: var(--text-2);
}

/* Which ride it was found for — SPEC13's library hint. Same quiet type as the
   source line, because it is the same kind of fact: provenance. */
.mus-for {
  font-style: italic;
}

/* --------------------------------------------------------------------------
   Encoder + re-processing (SPEC17, SPEC15)
   -------------------------------------------------------------------------- */

/* The two sentences, above both faces of the dialog. In a well rather than
   inline, because they are the terms of the whole screen and not a note about
   the control above them. */
.rep-warn {
  display: flex;
  flex-direction: column;
  gap: var(--s-1);
}

.rep-listbox,
.rep-detail {
  display: flex;
  flex-direction: column;
  gap: var(--s-3);
}

.rep-list {
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
}

.rep-row {
  display: flex;
  align-items: flex-start;
  gap: var(--s-3);
  padding: var(--s-3);
  background: var(--surface-1);
  border: 1px solid var(--grid);
  border-radius: var(--r-0);
  transition: border-color var(--dur-1) var(--ease-out);
}

.rep-row:hover {
  border-color: var(--rule);
}

/* A candidate with no stored recipe. Present and readable — it is still true
   that it would improve — but visibly inert, with the reason on its button. */
.rep-row-off {
  opacity: 0.72;
}

/* The "these cannot be re-processed" heading inside the same list. Separated
   from the rows above it because it introduces a different kind of row: ones
   the operator can read but not act on (SPEC15). */
.rep-skiphead {
  margin-top: var(--s-2);
  padding-top: var(--s-3);
  border-top: 1px solid var(--grid);
}

.rep-body {
  flex: 1;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
}

.rep-title {
  font-size: var(--fs-body);
  font-weight: var(--fw-semibold);
  color: var(--text-1);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.rep-meta {
  margin: 0;
}

.rep-link {
  font-size: var(--fs-meta);
  color: var(--text-2);
}

.rep-link:hover {
  color: var(--accent);
}

.rep-side {
  flex: none;
  display: flex;
  align-items: flex-start;
}

.rep-apply {
  display: flex;
  flex-direction: column;
  gap: var(--s-1);
}

/* One improvement in the checklist: its tickbox, and — for a rule that carries
   SPEC15's `needs` — the input it cannot be applied without, indented under it
   so it reads as part of that improvement rather than a stray export option. */
.rep-improve {
  display: flex;
  flex-direction: column;
  gap: var(--s-1);
}

.rep-needs {
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
  margin-left: var(--s-5);
  padding-bottom: var(--s-1);
}

.rep-pubfields {
  display: flex;
  flex-direction: column;
  gap: var(--s-3);
}

/* The delete confirmation. Set apart and tinted so that choosing the one
   irreversible action looks different from choosing any other option, and so
   the details it shows (title, link, view count) sit inside a box that reads
   as a stop, not as another form row. */
.rep-delbox {
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
  padding: var(--s-3);
  border: 1px solid var(--danger);
  border-radius: var(--r-2);
  background: var(--danger-soft);
}

.rep-delbox .modal-note {
  margin: 0;
}

.rep-delwhat {
  font-weight: 600;
  overflow-wrap: anywhere;
}

/* What the merge WOULD render, before anything is rendered. */
.rep-prev {
  margin: 0;
  font-variant-numeric: tabular-nums;
}

/* --------------------------------------------------------------------------
   GPS telemetry (SPEC19) + the music loop flag (SPEC13 addendum)

   Almost nothing here is a new primitive, deliberately: a Tracks row is a
   .set-row, the source badge and the loop toggle are .tag, the nudge is the
   .vol-row slider the audio section already uses, and the overlay is the same
   SVG treatment as .tele-plot.

   The one thing that IS particular is how the two curves are drawn. They are
   separated by WEIGHT and by legend text, not by two competing hues: the GPS
   speed is the water-blue measured line, and the
   motion trace is quiet text colour, because it is the thing being compared
   against rather than a second measurement. Two saturated curves would read as
   two claims of equal standing, which is exactly what they are not.
   -------------------------------------------------------------------------- */

.tracks {
  display: flex;
  flex-direction: column;
  gap: var(--s-3);
}

/* While the first load failed, the page says it can't reach the server; a
   Tracks row still listed above that would say the opposite. The toolbar is
   hidden the same way (syncEmptyStates). */
.lib-shell:has(.state-error:not([hidden]), .state-offline:not([hidden])) .tracks {
  display: none;
}

/* The section title (SPEC28 §4.2 rule 3): Anybody caps, the one uppercase in
   the library body. The count beside it is a figure, the hint a sentence. */
.trk-head {
  display: flex;
  align-items: center;
  gap: var(--s-2);
  margin: 0;
  font-family: var(--font-display);
  font-size: var(--fs-meta);
  font-weight: var(--fw-semibold);
  font-stretch: var(--wdth-caps);
  letter-spacing: var(--track-caps);
  text-transform: uppercase;
  color: var(--text-1);
}

.trk-head .icon {
  width: var(--icon-s);
  height: var(--icon-s);
}

/* The Tracks section's header is a disclosure button (gps.js syncTracksRow)
   that reads as the section title above, not as a chunky button. It sets no
   font or colour of its own, so .trk-head's Anybody caps survive. */
.trk-toggle {
  display: flex;
  align-items: center;
  gap: var(--s-2);
  width: 100%;
  background: none;
  border: 0;
  padding: var(--s-2) 0;
  margin: 0;
  cursor: pointer;
  text-align: left;
  border-radius: var(--r-1);
}

.trk-toggle:hover .trk-caret {
  color: var(--text-1);
}

/* The one disclosure idiom (SPEC28 §6.10): a monoline chevron that turns 90°
   to point down when the list is open. */
.trk-caret {
  width: var(--icon-s);
  height: var(--icon-s);
  color: var(--text-2);
  transition: transform var(--dur-1) var(--ease-out);
}

.trk-toggle[aria-expanded="true"] .trk-caret {
  transform: rotate(90deg);
}

/* The count of tracks with footage: a sentence in the UI face, not a second
   label. */
.trk-head-hint {
  margin-left: var(--s-2);
  color: var(--text-2);
  font-family: var(--font-ui);
  font-weight: var(--fw-regular);
  font-stretch: normal;
  text-transform: none;
  letter-spacing: 0;
}

.trk-more-toggle {
  margin-top: var(--s-2);
}

/* The Tracks rows are a ruled list on the film, like the Settings rows
   (SPEC28 D26 amended): no well and no tile behind the route glyph. */
.tracks > .trk-row {
  padding: var(--s-3) 0;
  border: 0;
  border-block: 1px solid var(--grid);
  background: none;
}

.tracks > .trk-row + .trk-row {
  margin-top: calc(var(--s-3) * -1);
  border-top: 0;
}

.tracks > .trk-row .set-icon {
  background: none;
  color: var(--text-2);
}

/* The track row's ⋯ holds what the grid tile used to: Download, Delete, and
   the sync against footage. A fixed square, like the card's, and Sync beside
   it the same --ctl-s height, as Edit is on a card. */
.trk-row .set-actions .btn {
  height: var(--ctl-s);
}

/* The track's name renames it on click, exactly as a card's name does; at rest
   it is the row title and nothing more. */
.trk-name {
  background: none;
  border: 0;
  padding: 0;
  margin: 0;
  font: inherit;
  color: inherit;
  text-align: left;
  cursor: text;
  border-radius: var(--r-1);
}

.trk-name:hover {
  color: var(--accent);
}

.trk-menu {
  flex: none;
}

.trk-row .set-main {
  gap: var(--s-1);
}

/* The span, length, top speed and fixes are a fact line (SPEC28 §6.4), one
   tier below the track's name, as a Settings row's status is. */
.trk-row .set-status {
  font-weight: var(--fw-regular);
  color: var(--text-2);
}

/* Which clips this track covers, and where each one's sync stands. Wraps: a
   long ride can overlap more clips than fit on one line, and the row is worth
   more than its own tidiness. */
.trk-clips {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-2);
  margin-top: var(--s-1);
}

/* A covered clip: its name is what ellipsises, and its sync word never does. */
.trk-clip .tag-label {
  max-width: 16ch;
}

.trk-more {
  align-self: center;
  font-size: var(--fs-meta);
  color: var(--text-3);
}

/* ---- the per-video Telemetry panel --------------------------------------- */

/* A section of the dialog, closed by a hairline — not a well holding a chart
   box holding a route box three boxes deep. */
.sync-panel {
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
  padding-bottom: var(--s-4);
  border-bottom: 1px solid var(--grid);
}

.sync-top {
  display: flex;
  align-items: center;
  gap: var(--s-2);
  flex-wrap: wrap;
}

/* The source tag. With a track on the clip it reads "GPS · <sync state>": a
   route tag when the track is drawing the gauge, warn until its sync is
   confirmed, neutral otherwise. Without one it is plain "Dash" or "None". */
.sync-src {
  flex: none;
}

.sync-head {
  margin: 0;
  font-size: var(--fs-meta);
  color: var(--text-1);
}

.sync-body {
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
}

/* Scrubbable: the preview below is time-dependent, so the plot is where you
   pick the second to check. touch-action keeps a drag from scrolling the page
   out from under the pointer mid-scrub. */
.sync-plotbox {
  padding: var(--s-2);
  border: 1px solid var(--grid);
  border-radius: var(--r-0);
  background: var(--surface-1);
  cursor: crosshair;
  touch-action: none;
}

.sync-plot {
  display: block;
  width: 100%;
  height: 88px;
}

.sync-gps {
  fill: none;
  stroke: var(--water);
  stroke-width: 1.7px;
  stroke-linejoin: round;
  stroke-linecap: round;
}

.sync-motion {
  fill: none;
  stroke: var(--text-3);
  stroke-width: 1.1px;
  stroke-linejoin: round;
  stroke-linecap: round;
  opacity: 0.8;
}

.sync-cursor {
  stroke: var(--rule);
  stroke-width: 1px;
}

.sync-legend {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-3);
  font-size: var(--fs-meta);
  color: var(--text-3);
}

.sync-key {
  display: inline-flex;
  align-items: center;
  gap: var(--s-1);
}

.sync-key::before {
  content: "";
  width: 16px;
  border-top: 2px solid var(--text-3);
}

.sync-key-gps::before {
  border-top-color: var(--water);
}

/* No analysis for this clip: the motion half of the legend keeps its words
   (they explain why the sync could only use timestamps) and marks the missing
   curve with a dashed swatch. Dimming the whole key took its text below
   readable contrast. */
.sync-legend.is-gpsonly .sync-key-motion::before {
  border-top-style: dashed;
}

.sync-row {
  flex-wrap: wrap;
}

.sync-range {
  flex: 1 1 180px;
}

/* The live gauge preview. Big number, its unit, and the second it is read at —
   and it dims to "--" exactly where the exported gauge would, because a preview
   that behaved better than the render would be advertising the wrong thing. */
.sync-preview {
  display: flex;
  align-items: baseline;
  gap: var(--s-2);
  flex-wrap: wrap;
}

.sync-plabel {
  font-size: var(--fs-meta);
  color: var(--text-2);
}

.sync-num {
  font-size: var(--fs-head);
  font-variant-numeric: tabular-nums;
  color: var(--text-1);
}

.sync-unit {
  font-size: var(--fs-meta);
  color: var(--text-2);
}

/* "123 mph" wraps as one unit. */
.sync-numwrap {
  display: inline-flex;
  align-items: baseline;
  gap: var(--s-2);
  white-space: nowrap;
}

.sync-at {
  margin-left: auto;
  font-size: var(--fs-meta);
  color: var(--text-3);
}

.sync-preview.is-dim {
  opacity: 0.6;
}

.sync-actions {
  display: flex;
  justify-content: flex-end;
}

.dash-part {
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
}

/* A GPS/OBD upload in the library. Marked like the other "not ride footage"
   marks, because that is what it has in common with them. */
.badge-track {
  color: var(--text-2);
}

/* ---- the loop flag ------------------------------------------------------- */

/* In a music picker the tag only REPORTS the flag — it is set on the library
   row, where the track is — so it is a statement (a span), not a target. */
.aud-loop {
  flex: none;
}

.aud-loopnote {
  margin-top: var(--s-1);
}

/* --------------------------------------------------------------------------
   Responsive
   -------------------------------------------------------------------------- */

@media (max-width: 720px) {
  .page {
    padding: 0 var(--s-3) var(--s-7);
  }

  .topbar {
    padding: 0;
    margin-bottom: var(--s-4);
  }

  /* The actions keep their own width and sit at the right edge; the brand
     takes what is left, so the two can never overlap. */
  .topbar-actions {
    flex: none;
    margin-left: auto;
  }

  /* The editor's bar is the one that wraps (the title takes a second row), so
     it keeps a little vertical padding and a tight gap between the rows. */
  .editor-topbar {
    flex-wrap: wrap;
    padding-block: var(--s-2);
    row-gap: var(--s-1);
  }

  .edit-title {
    order: 3;
    flex-basis: 100%;
    text-align: left;
  }

  .topbar-side {
    flex: none;
  }

  .grid {
    grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
    gap: var(--s-3);
  }

  /* Two rows, never more: the search beside the view toggle, then every
     filter, the sort and the page size on one line that scrolls sideways.
     Sticking even that under the topbar would eat the screen, so below this
     width the toolbar scrolls away with the content. */
  .toolbar {
    position: static;
    padding: var(--s-2) 0 var(--s-3);
  }

  .tb-search {
    flex: 1 1 0;
    max-width: none;
    min-width: 0;
  }

  /* The padding (cancelled by the negative margin) is room for the focus ring,
     which the strip's own overflow would otherwise clip. Sideways the strip
     runs out to the screen edges, so a control is cut by the edge of the
     screen and not by the page gutter, and its trailing edge fades: a select
     sliced mid-word read as clipping, not as a row that scrolls. The wider end
     padding lets the last control scroll clear of the fade. */
  .tb-strip {
    flex: 1 1 100%;
    display: flex;
    flex-wrap: nowrap;
    gap: var(--s-2);
    overflow-x: auto;
    scrollbar-width: none;
    padding: var(--s-1) var(--s-5) var(--s-1) var(--s-3);
    margin: calc(var(--s-1) * -1) calc(var(--s-3) * -1);
    scroll-padding-inline: var(--s-3) var(--s-5);
    -webkit-mask-image: var(--mask-fade-end);
    mask-image: var(--mask-fade-end);
  }

  .tb-strip::-webkit-scrollbar {
    display: none;
  }

  .tb-strip .tb-sel {
    flex: none;
    max-width: none;
  }

  .tb-gap {
    display: none;
  }

  /* The table is the scrollport for its own sticky header; on a phone it can
     have most of the screen. */
  .details-wrap {
    max-height: max(320px, calc(100dvh - 190px));
  }

  /* A phone table keeps what identifies a clip and what can be done with it:
     name, recorded, status and ⋯. Length, size and format are on the tile. */
  .details {
    min-width: 0;
  }

  .details .dt-c-duration,
  .details .dt-c-size,
  .details .dt-c-fmt {
    display: none;
  }

  /* The name takes what the other columns leave and ellipsizes; it is the
     column that gives way, never Status. `max-width: 0` is what lets an
     auto-layout cell shrink below its own text; `width: 100%` hands it the
     rest of the row. */
  .details .dt-c-name {
    width: 100%;
    max-width: 0;
    min-width: 6em;
  }

  /* ⋯ stays pinned to the table's right edge while the rest scrolls under it;
     the hairline says the row carries on beneath. */
  .details .dt-c-act {
    position: sticky;
    right: 0;
    background: var(--surface-1);
    box-shadow: var(--e-sticky-edge);
  }

  .details tbody tr:hover .dt-c-act {
    background: var(--surface-hover);
  }

  .details thead th.dt-c-act {
    right: 0;
    z-index: 2;
    background: var(--surface-2);
    box-shadow: var(--e-sticky-edge), var(--e-sticky-line);
  }

  .pager {
    justify-content: center;
  }

  .pager-info {
    flex-basis: 100%;
    text-align: center;
  }

  .edit-video {
    max-height: 42vh;
  }

  .transport {
    gap: var(--s-3);
  }

  .time-readout {
    font-size: var(--fs-title);
    line-height: var(--lh-title);
  }

  .tl-track {
    height: 64px;
  }

  .tl-handle {
    width: 18px;
  }

  .tl-handle.l { left: calc(var(--s-1) * -1 - 2px); }
  .tl-handle.r { right: calc(var(--s-1) * -1 - 2px); }

  /* Phone segment tools: Split / Delete / Undo share one even row (and may
     still wrap at 320px), the speed picker spans the card under its label,
     and Start / End take two equal columns. nowrap matters: a wrapping column
     sizes its one line to the widest child's max-content, so `stretch` pushed
     the rows past the card's right edge. */
  .seg-tools {
    flex-direction: column;
    flex-wrap: nowrap;
    align-items: stretch;
  }

  .seg-actions {
    gap: var(--s-2);
  }

  /* A zero basis makes the three equal; each still keeps its content width
     as a floor, so at 320px the row wraps rather than clipping a label. */
  .seg-actions .btn {
    flex: 1 1 0;
    padding-inline: var(--s-3);
  }

  .seg-speed {
    display: grid;
    gap: var(--s-1);
  }

  .seg-speed .seg {
    display: flex;
  }

  .seg-speed .seg > button {
    flex: 1;
  }

  .seg-range {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: var(--s-3);
  }

  /* Label above the field, as "Segment speed" sits above its picker: beside
     it, "Start" and "End" are different widths and the two inputs came out
     unequal and out of line. */
  .seg-range .num-label {
    display: grid;
    gap: var(--s-1);
  }

  .seg-range .input.num {
    width: 100%;
    min-width: 0;
  }

  .kbd-hints {
    display: none;
  }

  .drop-box {
    padding: var(--s-6);
    font-size: var(--fs-title);
  }

  /* Give the (now much taller) highlights dialog the whole phone screen and
     let it scroll internally — the overlay padding, .export-modal's max-height
     and the sticky header/footer offsets must stay in step or the actions row
     gets clipped. */
  /* The phone gutter (--s-4), the same as the page's. */
  .modal-overlay {
    padding: var(--s-4);
  }

  /* The gutters live on the shell's parts, never on the box — box padding
     would inset the sticky head and foot from the dialog's edges. */
  .dialog-head,
  .dialog-body,
  .dialog-foot {
    padding-inline: var(--s-4);
  }

  .modal.dialog {
    max-height: calc(100dvh - var(--s-6));
  }

  /* Roughly four rows. The list has to give up height before the dialog does,
     or the day picker alone fills a phone screen. */
  .hl-datelist {
    max-height: 140px;
  }

  .hl-date {
    padding: var(--s-2) var(--s-3);
  }

  .dialog-body > .set-row {
    padding-block: var(--s-3);
  }

  /* One swipeable line of prompt examples instead of four rows of them. The
     padding (taken back by the margin) leaves room for a chip's focus ring,
     which the scroller would otherwise clip. */
  .hl-prompt-chips {
    flex-wrap: nowrap;
    overflow-x: auto;
    scrollbar-width: none;
    /* Out to the dialog's edges (the body's --s-4 gutter), so the last chip is
       cut by the dialog border rather than floating in the gutter, with the
       same trailing fade as the library's filter strip. */
    padding: var(--s-1) var(--s-5) var(--s-1) var(--s-4);
    margin: calc(var(--s-1) * -1) calc(var(--s-4) * -1);
    scroll-padding-inline: var(--s-4) var(--s-5);
    -webkit-mask-image: var(--mask-fade-end);
    mask-image: var(--mask-fade-end);
  }

  .hl-prompt-chips::-webkit-scrollbar {
    display: none;
  }

  .hl-prompt-chips > .btn {
    flex: none;
  }
}

/* Icon-only Upload and Highlights keep the brand, both actions and the overflow
   menu on one line on a phone: three equal squares. Both buttons carry
   aria-labels, so nothing is lost with the text. The cut-off is where the
   labelled buttons stop fitting beside the one-line name (about 490px), not the
   420px phone block — a 428-430px phone would otherwise overlap them again. */
@media (max-width: 500px) {
  .topbar-actions .btn-text {
    display: none;
  }

  .topbar-actions .btn {
    width: var(--ctl-m);
    padding: 0;
  }

  /* The Telemetry panel's offset slider takes its own row, so "Offset · 0 s ·
     Clear offset" read together above it. Without this, Clear wrapped alone
     below the slider on every phone up to ~500px (428/430px included). */
  .sync-range {
    order: 9;
    flex-basis: 100%;
  }

  /* The value reads straight after its label; Clear offset holds the right
     edge, so a wider "+12.3 s" never shifts it. */
  .sync-row .vol-val {
    width: auto;
  }

  .sync-row .btn {
    margin-left: auto;
  }

  /* On a phone the details row is name | recorded | ⋯, with any status chip
     under the name. The Status COLUMN used to win this width over the date, on
     the grounds that a status pill may need acting on — but "Ready" is silent
     now, so for every normal clip that column was blank while the date, which
     is how rides are found, was hidden. Moving the chips under the name gives
     the attention-needing rows a fully readable pill (no tooltip, which a phone
     cannot reach) and costs the normal rows nothing. (At 428-430px the columns
     overflowed too, which is why this is not the 420px block.) */
  .details .dt-c-thumb,
  .details .dt-c-status {
    display: none;
  }

  .details .dt-status-inline {
    display: flex;
    margin-top: var(--s-1);
  }

  .details .dt-status-inline:empty {
    display: none;
  }

  .details th,
  .details td {
    padding-inline: var(--s-2);
  }

  /* Two lines of name before the ellipsis. The tail of a clip name ("-0001",
     the camera's counter) is usually what tells rows apart, and two lines
     still fit inside the row the ⋯ button already sets. */
  .details .dt-name {
    display: -webkit-box;
    -webkit-box-orient: vertical;
    -webkit-line-clamp: 2;
    line-clamp: 2;
    white-space: normal;
    overflow-wrap: anywhere;
  }

  /* The pinned ⋯ is its --ctl-s square and a hair either side. */
  .details .dt-c-act {
    width: calc(var(--ctl-s) + var(--s-2));
    padding-inline: var(--s-1);
  }

  /* ...unless the row also carries Play, which is a --ctl-touch target here and
     must not be clipped by the sticky column it shares. */
  .details .dt-c-act:has(.dt-play) {
    width: auto;
  }
}

@media (max-width: 420px) {
  .grid {
    grid-template-columns: 1fr;
  }

  /* Seven ruler labels do not fit a phone: keep every other one, and always
     the last. */
  .tl-tick:nth-child(even):not(:last-child) {
    display: none;
  }

  /* With an even count the kept last label is one step from the odd label
     before it, and its right-aligned text runs into that one. */
  .tl-tick:nth-child(odd):nth-last-child(2):nth-child(n+5) {
    display: none;
  }

  /* Edit fills the row; the ⋯ beside it stays a square. */
  .card-actions .btn:not(.card-more) {
    flex: 1;
  }

  .set-actions {
    margin-left: 0;
    width: 100%;
  }

  .set-actions .btn {
    flex: 1;
  }

  /* A track row's ⋯ stays square beside its full-width Sync. */
  .set-actions .trk-menu {
    flex: none;
  }

  /* In Settings the actions drop under the text, keep their own width and
     start where the text starts: a full-width bar under every card was most of
     what made the phone dialog busy. */
  .dialog-body > .set-row .set-actions {
    grid-column: 2 / -1;
    width: auto;
    min-width: 0;
    justify-content: flex-start;
  }

  /* Content-width, but never wider than the text column: "Re-link with
     playlist access" wraps its label rather than running out of the card. */
  .dialog-body > .set-row .set-actions .btn {
    flex: 0 1 auto;
    max-width: 100%;
    white-space: normal;
  }

  /* Same bargain for a share row: at this width Copy / Open / Revoke / ⋯
     cannot share a line with the label, so they take their own. */
  .share-item-act {
    margin-left: 0;
    width: 100%;
  }

  .yt-kids {
    justify-content: flex-start;
  }

  /* At this width "more timelapse ⟷ more real-time" squeezes the slider to
     nothing — stack the end labels under it instead. */
  .sens-row {
    flex-wrap: wrap;
    row-gap: 2px;
  }

  .sens-range {
    flex-basis: 100%;
    order: -1;
  }

  .sens-end {
    flex: 1 1 auto;
  }

  .sens-end:last-child {
    text-align: right;
  }

  /* No shift-click on a touch screen, and at this width the hint pushes the
     All / None buttons into the label. */
  .hl-dates-hint {
    display: none;
  }

}

/* Touch targets (SPEC28 §7.6): below 600px and on a coarse pointer every
   control's hit area is at least 44×44. Buttons, fields and tags get there
   through --ctl-touch; the text-sized buttons below keep their look and take
   an invisible ::after centred on them instead (::before is already the fact
   line's separator dot). Nothing here moves a pixel of layout except where two
   of them are stacked too closely to both reach 44 (the tile's name over its
   recorded date). */
@media (max-width: 599.98px), (pointer: coarse) {
  .brand,
  .trk-toggle,
  .trk-name,
  .card-rec,
  .dt-sort,
  .dt-day,
  .seg > button {
    position: relative;
  }

  .brand::after,
  .trk-toggle::after,
  .trk-name::after,
  .card-rec::after,
  .dt-sort::after,
  .dt-day::after,
  .tb-qclear::after {
    content: "";
    position: absolute;
    top: 50%;
    left: 50%;
    width: max(100%, var(--ctl-touch));
    height: max(100%, var(--ctl-touch));
    transform: translate(-50%, -50%);
  }

  /* A segmented group is --ctl-touch tall including its 1px border, so each
     cell reaches out over the border, and an icon cell is 44 wide. */
  .seg > button {
    min-width: var(--ctl-touch);
  }

  .seg > button::after {
    content: "";
    position: absolute;
    inset: -1px 0;
  }

  /* The header's sort buttons reach 44 inside their own cell: the table is a
     scrollport, and it would clip an extender that stood above its top edge. */
  .details thead th {
    height: var(--ctl-touch);
  }

  /* The tile and row names clip their text for the ellipsis, which would clip
     a pseudo-element too; their own box grows instead, the negative margin
     handing the height back to the layout. */
  .card-name {
    padding-block: calc((var(--ctl-touch) - 1lh) / 2);
    margin-block: calc((1lh - var(--ctl-touch)) / 2);
  }

  .card-body > .card-name + .meta-row:has(> .card-rec) {
    margin-top: var(--s-4);
  }

  /* An actionable tag (Shared, On YouTube) carries its own 44px target, so
     the row under the date, and a details row's inline status under the
     name, stand far enough off that the two targets don't overlap. */
  .card-body > .meta-row:has(> .card-rec) + .status-row:has(button.tag, a.tag) {
    margin-top: var(--s-4);
  }

  .details td.dt-c-name {
    padding-block: var(--s-3);
  }

  .details .dt-status-inline {
    margin-top: var(--s-3);
  }

  .details .dt-status-inline:has(button.tag, a.tag) {
    margin-top: var(--s-5);
  }

  /* The share label is a field at rest on touch: there is no hover to reveal
     that it can be edited, and its own box is the target. */
  .share-label {
    min-height: var(--ctl-touch);
  }

  .hl-date {
    min-height: var(--ctl-touch);
  }

  /* The clip expander sits flush against the day's checkbox, so an extender
     would take taps meant for the box; the square itself is 44 instead. */
  .hl-expand {
    width: var(--ctl-touch);
    height: var(--ctl-touch);
    margin-left: 0;
  }

  /* A day's clip rows are targets too, and their checkboxes stay under the
     day's own (the 44px expander plus the date's --s-1 inset). */
  .hl-clip {
    min-height: var(--ctl-touch);
    padding-left: calc(var(--ctl-touch) + var(--s-1));
  }
}

/* Wide screens: the tall option dialogs get more room before they scroll. */
@media (min-width: 1000px) {
  .export-modal {
    width: min(640px, 100%);
  }

}

/* --------------------------------------------------------------------------
   Reduced motion — no transitions, and the spinner becomes a static ring
   rather than a frozen frame of an animation (SPEC14 §4)
   -------------------------------------------------------------------------- */

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }

  /* The ring keeps its arc and the hatch its stripes; neither moves. */
  .spin,
  .pbar-indeterminate {
    animation: none;
  }
}

/* Clip length badge in the highlight picker (automation.js): right-aligned
   beside the recorded clock, muted like the time. */
.hl-clip-dur { margin-left: auto; flex: none; color: var(--text-2); font-size: var(--fs-meta); }
.hl-clip-dur + .hl-clip-time { margin-left: var(--s-2); }


/* ---------------------------------------------------------------------------
   The status rail (library)
   Upload progress, the interrupted-uploads hint and the GPS tracks sit in a
   column BESIDE the footage instead of stacked above it, and collapse to a slim
   strip. Below 1100px there is no room beside the grid, so the same rail becomes
   a disclosure above it, closed by default. A rail with nothing to show is
   removed and the column given back.
   ------------------------------------------------------------------------- */
.lib-shell {
  display: grid;
  grid-template-columns: minmax(0, 1fr) var(--lib-rail-w);
  column-gap: var(--s-5);
  align-items: start;
}

.lib-shell:has(> .lib-rail.is-collapsed) {
  grid-template-columns: minmax(0, 1fr) var(--lib-rail-w-collapsed);
}

/* Nothing to show: no rail, no empty column. Declared after the collapsed rule
   so it wins when both apply. */
.lib-shell:not(:has(.rail-body > :not([hidden]))) {
  grid-template-columns: minmax(0, 1fr);
}

.lib-shell:not(:has(.rail-body > :not([hidden]))) > .lib-rail {
  display: none;
}

.lib-col {
  display: flex;
  flex-direction: column;
  gap: var(--s-4);
  min-width: 0;
}

/* Not sticky: the toolbar above it is, and a second stuck element under it
   collides. The rail scrolls with the page, which is fine, it is collapsible. */
.lib-rail {
  display: flex;
  flex-direction: column;
  gap: var(--s-3);
  min-width: 0;
  padding-left: var(--s-4);
  border-left: 1px solid var(--grid);
}

.rail-body {
  display: flex;
  flex-direction: column;
  gap: var(--s-4);
  min-width: 0;
}

/* The build this page is running, at the foot of the rail. Quiet: it is a fact
   you go looking for, not news. Outside .rail-body so it cannot keep an
   otherwise-empty rail on screen (see the :has rule above), and gone when the
   rail is collapsed to its 44px strip, where there is no room for it. */
.rail-foot {
  margin: 0;
  font-size: var(--fs-meta);
  color: var(--text-2);
  font-variant-numeric: tabular-nums;
}

.lib-rail.is-collapsed .rail-foot {
  display: none;
}

.rail-toggle {
  display: flex;
  align-items: center;
  gap: var(--s-2);
  align-self: flex-start;
  padding: var(--s-1) 0;
  background: none;
  border: 0;
  cursor: pointer;
  font-family: var(--font-display);
  font-size: var(--fs-meta);
  font-weight: var(--fw-semibold);
  font-stretch: var(--wdth-caps);
  letter-spacing: var(--track-caps);
  text-transform: uppercase;
  color: var(--text-1);
}

.rail-toggle:hover {
  color: var(--accent);
}

.rail-toggle .icon {
  width: var(--icon-s);
  height: var(--icon-s);
  flex: none;
  transition: transform 120ms;
}

/* The icon points right by default. Open: right, toward the edge the rail
   collapses to. Collapsed: left, back toward the footage it will open over. On a
   narrow screen the rail opens DOWNWARD, so there it points down when open and
   right when closed, like the Tracks disclosure beneath it. */
.lib-rail.is-collapsed .rail-toggle .icon {
  transform: rotate(180deg);
}

@media (max-width: 1099px) {
  .lib-rail:not(.is-collapsed) .rail-toggle .icon {
    transform: rotate(90deg);
  }

  .lib-rail.is-collapsed .rail-toggle .icon {
    transform: none;
  }
}

/* A dot on the collapsed strip when something inside is asking to be seen. */
.lib-rail.is-collapsed:has(.rail-body > :not([hidden])) .rail-toggle::after {
  content: '';
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--accent);
  flex: none;
}

/* Rows inside a 340px rail: the title and chips take the full width and the
   actions drop beneath, instead of squeezing the name into a sliver. */
.lib-rail .tracks > .trk-row {
  flex-wrap: wrap;
}

.lib-rail .tracks > .trk-row .set-actions {
  flex: 1 0 100%;
  justify-content: flex-start;
}

/* The header's row: the collapse toggle and whatever sits beside it. */
.trk-headrow {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s-2);
  flex-wrap: wrap;
}

@media (min-width: 1100px) {
  .lib-rail.is-collapsed {
    padding-left: 0;
    align-items: center;
  }

  .lib-rail.is-collapsed .rail-toggle {
    flex-direction: column;
    align-self: center;
  }

  .lib-rail.is-collapsed .rail-title {
    writing-mode: vertical-rl;
  }
}

@media (max-width: 1099px) {
  .lib-shell {
    display: flex;
    flex-direction: column;
    /* The desktop grid aligns to start; in a COLUMN that shrinks every child to
       its own content width instead of the page's. */
    align-items: stretch;
    row-gap: var(--s-4);
  }

  .lib-rail {
    order: -1;
    padding: 0 0 var(--s-3);
    border-left: 0;
    border-bottom: 1px solid var(--grid);
  }

  .lib-rail.is-collapsed {
    padding-bottom: var(--s-2);
  }
}


/* ---------------------------------------------------------------------------
   Sync panel: also apply this offset to the other clips on the ride
   ------------------------------------------------------------------------- */
.sync-apply {
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
  padding: var(--s-3);
  border: 1px solid var(--grid);
  border-radius: var(--r-1);
}

.sync-apply-master {
  font-weight: var(--fw-semibold);
}

.sync-apply-details > summary {
  cursor: pointer;
  color: var(--text-2);
  font-size: var(--fs-meta);
  padding: var(--s-1) 0;
}

.sync-apply-details > summary:hover {
  color: var(--accent);
}

.sync-apply-list {
  display: flex;
  flex-direction: column;
  gap: var(--s-1);
  max-height: 180px;
  overflow: auto;
  padding-top: var(--s-2);
}

.sync-apply-row {
  display: flex;
  align-items: baseline;
  gap: var(--s-2);
}

.sync-apply-name {
  flex: 1 1 auto;
  min-width: 0;
  overflow-wrap: anywhere;
}

.sync-apply-state {
  flex: none;
  color: var(--text-2);
  font-size: var(--fs-meta);
}


/* The gauge block's wrapper holds two groups (gauge, route map). The ruled-list
   rules above pull a group up by one column gap to meet the rule above it, which
   assumes the gap exists. Between the two groups INSIDE this wrapper there is no
   flex gap to cancel, so they must not be pulled up (they were drawn on top of
   each other), and the seam AFTER the wrapper needs the same treatment the seam
   before it already gets. */
.tele-stack > .dgroup + .dgroup {
  margin-top: 0;
}

.tele-stack:has(+ div:not([hidden]) > .dgroup:first-child) > .dgroup:last-child {
  border-bottom: 0;
}

.tele-stack + div:has(> .dgroup:first-child) {
  margin-top: calc(var(--s-3) * -1);
}


/* ---------------------------------------------------------------------------
   Overlay layout helper (Export and Highlights dialogs)
   A 16:9 frame drawn to the renderer's real proportions, showing where the gauge
   (G), the route map (M) and the summary card (S) will land. Boxes that overlap
   turn red. Colours are theme tokens so it reads in both themes.
   ------------------------------------------------------------------------- */
.ov-layout {
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
  padding: var(--s-3);
  border: 1px solid var(--grid);
  border-radius: var(--r-1);
}

.ov-head {
  font-size: var(--fs-meta);
  color: var(--text-2);
}

.ov-frame {
  display: block;
  width: 100%;
  max-width: 320px;
  aspect-ratio: 16 / 9;
}

.ov-bg {
  fill: var(--surface-2);
  stroke: var(--grid);
  stroke-width: 1;
}

.ov-box {
  fill-opacity: 0.35;
  stroke-width: 1.2;
}

.ov-gauge   { fill: var(--accent); stroke: var(--accent); }
.ov-map     { fill: var(--ok);     stroke: var(--ok); }
.ov-summary { fill: var(--warn);   stroke: var(--warn); }

.ov-box.is-clash {
  fill: var(--danger);
  fill-opacity: 0.55;
  stroke: var(--danger);
  stroke-width: 1.8;
}

.ov-letter {
  fill: var(--text-1);
  font-size: var(--fs-ov-letter);
  font-weight: var(--fw-semibold);
  pointer-events: none;
}

.ov-note {
  margin: 0;
}

.ov-note.is-warn {
  color: var(--danger);
}
