/* ══════════════════════════════════════════════════════════════════════════
   Routesmith design system.
   Warm off-whites and racing green, set in Bricolage Grotesque: soft cards on
   a quiet ground, pill chips, 10px controls, 14px cards, 1px hairline rules.
   Ships light and dark themes off one set of role/ramp tokens; the app
   resolves light / dark / system and stamps `data-theme` on <html>, so every
   component below is theme-agnostic and reads from the tokens.
   App-specific chrome (tab bar, chat, route cards, admin console) is
   layered underneath.
   ════════════════════════════════════════════════════════════════════════ */

/* ── the typeface ──────────────────────────────────────────────────────────
   Bricolage Grotesque, served from this origin. It used to come from
   fonts.googleapis.com via a <link> in every template, which cost a DNS
   lookup, a connection and a TLS handshake before the returned stylesheet
   could even name the font files — and those live on a SECOND origin
   (fonts.gstatic.com) for another round trip. `preconnect` softened that
   and could not remove it. static/fonts/README.md carries the rest of the
   reasoning, including what it does for the privacy notice and for the
   browser tests.

   ONE face per subset with a weight RANGE, because these are variable
   fonts (wght 200-800, opsz 12-96). The app uses 400 body / 600 UI / 800
   display and all three are instances of the one file — which is exactly
   how Google served them too, returning a single URL for all three
   weights. Optical sizing then needs no declaration: `font-optical-sizing`
   is `auto` by default and the axis is there.

   `unicode-range` is what keeps latin-ext free until a character needs it
   (place names, mostly — `Łódź` rather than anything in the app's own
   copy). The vietnamese subset is deliberately not shipped.

   The version sits IN THE FILENAME. /static is served immutable for a year
   by extension, and a font named inside a stylesheet gets no `?v=` from
   asset_v() the way every template-referenced asset does — so a same-named
   replacement would be stuck in caches for a year. Bump the name and the
   src together.
   ──────────────────────────────────────────────────────────────────────── */
@font-face {
  font-family: "Bricolage Grotesque";
  font-style: normal;
  font-weight: 200 800;
  font-display: swap;
  src: url("/static/fonts/bricolage-grotesque-v9-latin.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6,
    U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122,
    U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: "Bricolage Grotesque";
  font-style: normal;
  font-weight: 200 800;
  font-display: swap;
  src: url("/static/fonts/bricolage-grotesque-v9-latin-ext.woff2") format("woff2");
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7,
    U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F,
    U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F,
    U+A720-A7FF;
}

:root {
  color-scheme: light;

  --color-bg: #faf8f5;
  --color-surface: #f1ede7;
  --color-card: #ffffff;
  --color-text: #191c1a;
  --color-accent: #1e6b4e;
  --color-accent-hover: #164f3a;
  --color-accent-active: #0f3527;
  /* Accent-tinted panel + the ink that sits on it (wind notes, user bubbles). */
  --color-tint: #e3efe8;
  --color-on-tint: #124534;
  --color-divider: color-mix(in srgb, #191c1a 25%, transparent);
  --color-hairline: color-mix(in srgb, #191c1a 12%, transparent);
  /* Map-background inks (basemap.js). Plain rgb triples, not color-mix:
     a canvas cannot read a CSS custom property, so the painter resolves
     these and hands the numbers over — which keeps the map's palette
     defined here with every other colour rather than hardcoded in JS. */
  --basemap-road: 92 96 94; /* @kind color */
  --basemap-wood: 124 152 116; /* @kind color */
  --basemap-water: 118 152 178; /* @kind color */
  --basemap-admin: 158 146 166; /* @kind color */
  /* The one warm counter-role: destructive buttons, at-rest sync errors, the
     pre-production badge. Was repeated as a literal at each of those sites —
     named here so the ramp has a single definition to edit. */
  --color-danger: #a63d2a;
  --color-danger-hover: #8c3323;
  --color-on-danger: #faf8f5;
  /* Danger as ink on theme ground (small text, dots) — lighter than the fill red in dark. */
  --color-danger-text: #a63d2a;

  /* Tonal ramps — generated in OKLCH on one shared lightness scale, so the
     same step of any role matches the others in visual value. */
  --color-neutral-100: #f3f1ec;
  --color-neutral-200: #e8e5de;
  --color-neutral-300: #d8d4cd;
  --color-neutral-400: #b9b5af;
  --color-neutral-500: #9c9f96;
  /* Muted ink. Carries 12–14px running copy (status lines, consent body,
     empty states), so it must clear AA on the page ground: #84887e measured
     3.4:1 on --color-bg and was the light theme's one contrast failure. */
  --color-neutral-600: #666b62;
  --color-neutral-700: #54584f;
  /* Warning ink (the perf tiles) and the sheet scrim, per theme: the amber
     literal failed contrast for small text in dark mode and the near-black
     38% scrim barely read as a layer over a near-black page. */
  --color-warn-text: #8f6316;
  --color-scrim: color-mix(in srgb, #191c1a 38%, transparent);
  --color-neutral-800: #3f443e;
  --color-neutral-900: #262a26;

  /* Racing-green accent ramp (OKLCH, hue 162) — 100 = lightest tint,
     900 = deepest. The accent role pins step 700 (6.2:1 on bg). */
  --color-accent-100: #eef6f1;
  --color-accent-200: #dcece3;
  --color-accent-300: #b9d9c9;
  --color-accent-400: #8abfa5;
  --color-accent-500: #57a181;
  --color-accent-600: #2e8262;
  --color-accent-700: #1e6b4e;
  --color-accent-800: #164f3a;
  --color-accent-900: #0f3527;

  --font-heading: "Bricolage Grotesque", system-ui, -apple-system, sans-serif;
  --font-heading-weight: 800;
  --font-body: "Bricolage Grotesque", system-ui, -apple-system, sans-serif;
  /* UI weight — buttons, chips, controls sit at 600, between body and display. */
  --font-ui-weight: 600;

  --space-1: 4px;
  --space-2: 8px;
  --space-3: 12px;
  --space-4: 16px;
  --space-6: 24px;
  --space-8: 32px;

  /* Radius scale: 999 pills (chips, tags) · 10px controls · 14px cards. */
  --radius-sm: 6px;
  --radius-md: 10px;
  --radius-lg: 14px;
  --radius-pill: 999px;

  --shadow-sm: 0 1px 3px color-mix(in srgb, #191c1a 8%, transparent);
  --shadow-md: 0 3px 10px color-mix(in srgb, #191c1a 12%, transparent);
  --shadow-lg: 0 12px 32px color-mix(in srgb, #191c1a 18%, transparent);
}

/* ── Dark theme ────────────────────────────────────────────────────────────
   Same token contract, dark values. The app stamps `data-theme="dark"` (or
   "light") on <html> from the resolved preference, so `[data-theme="dark"]`
   is the primary source of truth. The `prefers-color-scheme` block is a
   no-JS / pre-hydration fallback that themes the same tokens — it never wins
   over an explicit `data-theme="light"`.

   The neutral ramp is inverted for dark (100 = darkest surface → 900 =
   lightest text) so components that read "100 = subtle raised surface" and
   "600–700 = muted text" keep working. The accent role picks a brighter green
   (#3ba57c) so small text passes on #151816, and the ramp flips its tint steps
   (100 = deep green wash → 700–900 = light green) so tinted panels and links
   stay legible on dark. */
:root[data-theme="dark"] {
  color-scheme: dark;

  --color-bg: #151816;
  --color-surface: #1e2320;
  --color-card: #242a26;
  --color-text: #e9ece8;
  --color-accent: #3ba57c;
  --color-accent-hover: #57bb92;
  --color-accent-active: #2f8a66;
  --color-tint: #1c3529;
  --color-on-tint: #8fd4b6;
  --color-divider: color-mix(in srgb, #e9ece8 18%, transparent);
  --color-hairline: color-mix(in srgb, #e9ece8 12%, transparent);
  /* Lifted off the dark ground — the light inks disappear against it. */
  --basemap-road: 150 158 152; /* @kind color */
  --basemap-wood: 96 132 104; /* @kind color */
  --basemap-water: 92 124 156; /* @kind color */
  --basemap-admin: 132 120 142; /* @kind color */
  /* Re-anchored for a dark base — the light values assume light ground and
     would sit too heavy here (the .btn-danger override rationale, now a token). */
  --color-danger: #b8482f;
  --color-danger-hover: #9c3c26;
  --color-on-danger: #fdeee9;
  --color-danger-text: #e08a72;

  --color-neutral-100: #1e2320;
  --color-neutral-200: #262c28;
  --color-neutral-300: #333833;
  --color-neutral-400: #454b45;
  --color-neutral-500: #626861;
  --color-neutral-600: #9aa39b;
  --color-neutral-700: #b9bfb8;
  --color-warn-text: #e2b25f;
  --color-scrim: color-mix(in srgb, #000 55%, transparent);
  --color-neutral-800: #ced3cd;
  --color-neutral-900: #e9ece8;

  --color-accent-100: #12241b;
  --color-accent-200: #1c3529;
  --color-accent-300: #234d39;
  --color-accent-400: #2c6b4d;
  --color-accent-500: #2f8a66;
  --color-accent-600: #3ba57c;
  --color-accent-700: #57bb92;
  --color-accent-800: #8fd4b6;
  --color-accent-900: #c6ead9;

  --shadow-sm: 0 1px 2px color-mix(in srgb, #000 40%, transparent);
  --shadow-md: 0 3px 12px color-mix(in srgb, #000 48%, transparent);
  --shadow-lg: 0 14px 36px color-mix(in srgb, #000 58%, transparent);
}

@media (prefers-color-scheme: dark) {
  /* No-JS / pre-hydration fallback. Declared on plain :root (lower specificity
     than the [data-theme] scopes) with an explicit light re-assert below, so an
     explicit data-theme="light" still wins — behaviour identical to the previous
     :root:not([data-theme="light"]) selector. */
  :root {
    color-scheme: dark;
    --color-danger: #b8482f;
    --color-danger-hover: #9c3c26;
    --color-on-danger: #fdeee9;
    --color-danger-text: #e08a72;

    --color-bg: #151816;
    --color-surface: #1e2320;
    --color-card: #242a26;
    --color-text: #e9ece8;
    --color-accent: #3ba57c;
    --color-accent-hover: #57bb92;
    --color-accent-active: #2f8a66;
    --color-tint: #1c3529;
    --color-on-tint: #8fd4b6;
    --color-divider: color-mix(in srgb, #e9ece8 18%, transparent);
    --color-hairline: color-mix(in srgb, #e9ece8 12%, transparent);
    --basemap-road: 150 158 152; /* @kind color */
    --basemap-wood: 96 132 104; /* @kind color */
    --basemap-water: 92 124 156; /* @kind color */
    --basemap-admin: 132 120 142; /* @kind color */

    --color-neutral-100: #1e2320;
    --color-neutral-200: #262c28;
    --color-neutral-300: #333833;
    --color-neutral-400: #454b45;
    --color-neutral-500: #626861;
    --color-neutral-600: #9aa39b;
    --color-neutral-700: #b9bfb8;
    --color-warn-text: #e2b25f;
    --color-scrim: color-mix(in srgb, #000 55%, transparent);
    --color-neutral-800: #ced3cd;
    --color-neutral-900: #e9ece8;

    --color-accent-100: #12241b;
    --color-accent-200: #1c3529;
    --color-accent-300: #234d39;
    --color-accent-400: #2c6b4d;
    --color-accent-500: #2f8a66;
    --color-accent-600: #3ba57c;
    --color-accent-700: #57bb92;
    --color-accent-800: #8fd4b6;
    --color-accent-900: #c6ead9;

    --shadow-sm: 0 1px 2px color-mix(in srgb, #000 40%, transparent);
    --shadow-md: 0 3px 12px color-mix(in srgb, #000 48%, transparent);
    --shadow-lg: 0 14px 36px color-mix(in srgb, #000 58%, transparent);
  }

  /* Explicit light choice re-asserted under OS-dark (higher specificity). */
  :root[data-theme="light"] {
    color-scheme: light;
    --color-danger: #a63d2a;
    --color-danger-hover: #8c3323;
    --color-on-danger: #faf8f5;
    --color-danger-text: #a63d2a;
    --color-bg: #faf8f5;
    --color-surface: #f1ede7;
    --color-card: #ffffff;
    --color-text: #191c1a;
    --color-accent: #1e6b4e;
    --color-accent-hover: #164f3a;
    --color-accent-active: #0f3527;
    --color-tint: #e3efe8;
    --color-on-tint: #124534;
    --color-divider: color-mix(in srgb, #191c1a 25%, transparent);
    --color-hairline: color-mix(in srgb, #191c1a 12%, transparent);
    --basemap-road: 92 96 94; /* @kind color */
    --basemap-wood: 124 152 116; /* @kind color */
    --basemap-water: 118 152 178; /* @kind color */
    --basemap-admin: 158 146 166; /* @kind color */
    --color-neutral-100: #f3f1ec;
    --color-neutral-200: #e8e5de;
    --color-neutral-300: #d8d4cd;
    --color-neutral-400: #b9b5af;
    --color-neutral-500: #9c9f96;
    --color-neutral-600: #666b62;
    --color-neutral-700: #54584f;
    --color-warn-text: #8f6316;
    --color-scrim: color-mix(in srgb, #191c1a 38%, transparent);
    --color-neutral-800: #3f443e;
    --color-neutral-900: #262a26;
    --color-accent-100: #eef6f1;
    --color-accent-200: #dcece3;
    --color-accent-300: #b9d9c9;
    --color-accent-400: #8abfa5;
    --color-accent-500: #57a181;
    --color-accent-600: #2e8262;
    --color-accent-700: #1e6b4e;
    --color-accent-800: #164f3a;
    --color-accent-900: #0f3527;
    --shadow-sm: 0 1px 3px color-mix(in srgb, #191c1a 8%, transparent);
    --shadow-md: 0 3px 10px color-mix(in srgb, #191c1a 12%, transparent);
    --shadow-lg: 0 12px 32px color-mix(in srgb, #191c1a 18%, transparent);
  }
}

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

body {
  background: var(--color-bg);
  color: var(--color-text);
  font-family: var(--font-body);
  font-size: 0.9375rem;
  line-height: 1.55;
  font-weight: 400;
  -webkit-font-smoothing: antialiased;
}

h1, h2, h3, h4, h5, h6 {
  font-family: var(--font-heading); font-weight: var(--font-heading-weight);
  line-height: 1.12; letter-spacing: -0.015em;
}
h6 { letter-spacing: 0.08em; text-transform: uppercase; }
a { color: var(--color-accent); text-decoration: none; text-underline-offset: 3px; }
img { display: block; max-width: 100%; }

.text-muted { color: var(--color-neutral-600); }
.grayscale { filter: grayscale(1) contrast(1.08); }

:focus { outline: none; }
:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
::selection { background: color-mix(in srgb, var(--color-accent) 30%, transparent); }

/* ── EVERY :hover rule in this file is wrapped in `@media (hover: hover)`,
   and a new one must be too (tests/test_hover_affordances.py pins it).

   A hover state is a POINTING-DEVICE affordance — "the thing under your
   cursor is the thing you would hit". A touch screen has no cursor and
   therefore nothing to preview, so what it does with a `:hover` rule is
   apply it on tap and then LEAVE IT THERE: there is no pointer to move
   away, so nothing ever takes the state off again. The row you last
   touched stays tinted until you happen to touch something else.

   That is not a cosmetic nit, because the tint means "selected" to a
   reader. Tapping a route row opens the route sheet over it, and the row
   underneath then sits highlighted through the whole life of the sheet and
   after it is dismissed — so swiping the sheet away appears to leave the
   list holding on to a row behind it.

   Every rule gated here is a background, colour, border or underline
   change; none of them reveals content, and each interactive one keeps its
   own :active pressed state, which is what #app's transparent tap
   highlight leaves the components to carry. So a touch device loses a
   preview it could never use and keeps every acknowledgement it can.

   A rule pairing :hover with :focus-visible must be SPLIT, never gated
   whole — a keyboard works perfectly well on a touch device, and its focus
   ring is not a hover affordance (see .br-thumb). ─────────────────────── */

/* — rules — */
.hr { height: 1px; border: 0; margin: var(--space-4) 0; background: var(--color-divider); }

/* — buttons — */
.btn {
  display: inline-flex; align-items: center; justify-content: center; gap: 6px;
  cursor: pointer; text-decoration: none;
  font-family: var(--font-heading); font-weight: var(--font-ui-weight);
  font-size: 0.875rem; line-height: 1.2; color: var(--color-text);
  background: transparent; border: 1px solid transparent;
  padding: var(--space-2) calc(var(--space-3) * 1.2);
  border-radius: var(--radius-md);
}
.btn[hidden] { display: none; }   /* inline-flex would beat the hidden attribute */
/* Icon glyphs never capture the pointer themselves: a tap anywhere in the
   button box lands on the button, not on an SVG <path>. Without this, taps on
   the fill:none interior of a stroked icon are hit-tested against the path
   geometry, which iOS Safari intermittently misses — the button then reads as
   "sometimes doesn't respond". */
.btn svg, .btn-icon span { pointer-events: none; }
.btn svg { display: block; }
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
.btn-primary { background: var(--color-accent); color: var(--color-bg); }
@media (hover: hover) { .btn-primary:hover:not(:disabled) { background: var(--color-accent-hover); } }
.btn-primary:active:not(:disabled) { background: var(--color-accent-active); }
.btn-secondary { border-color: var(--color-divider); }
@media (hover: hover) { .btn-secondary:hover:not(:disabled) { background: color-mix(in srgb, var(--color-text) 7%, transparent); } }
.btn-secondary:active:not(:disabled) { background: color-mix(in srgb, var(--color-text) 14%, transparent); }
.btn-ghost { color: var(--color-accent); padding-inline: var(--space-1); }
@media (hover: hover) { .btn-ghost:hover:not(:disabled) { background: color-mix(in srgb, var(--color-accent) 10%, transparent); } }
/* Danger keeps a genuine brick red — the green accent can't read destructive. */
.btn-danger { background: var(--color-danger); color: var(--color-on-danger); }
@media (hover: hover) { .btn-danger:hover:not(:disabled) { background: var(--color-danger-hover); } }
.btn-icon { width: 36px; height: 36px; padding: 0; flex: none; }
.btn-block { width: 100%; justify-content: flex-start; text-align: left; }

/* The two small button sizes the app actually uses, named rather than
   pasted inline. They were `style="font-size:13px;padding:7px 14px"` and
   `style="font-size:12px"` on ~70 buttons across four templates, which is
   how two sizes silently became several (a missed digit reads as a third
   size nobody chose). Declared AFTER .btn-ghost deliberately: the padding
   shorthand has to beat that rule's padding-inline, which is exactly what
   the inline style was doing. */
.btn-sm { font-size: 0.8125rem; padding: 7px 14px; }
.btn-xs { font-size: 0.75rem; }

/* — forms — */
.input {
  width: 100%; min-height: 36px; padding: 8px 12px; font: inherit;
  font-size: 0.875rem; color: var(--color-text); caret-color: var(--color-accent);
  background: var(--color-card);
  border: 1px solid var(--color-divider); border-radius: var(--radius-md);
}
.input::placeholder { color: var(--color-neutral-600); }
@media (hover: hover) { .input:hover { border-color: color-mix(in srgb, var(--color-text) 45%, transparent); } }
.input:focus-visible { border-color: var(--color-accent); }
textarea.input { resize: none; }
/* iOS Safari auto-zooms when focusing a control whose font-size is <16px and
   never zooms back out on blur. Bump form controls to 16px on touch devices so
   focus doesn't trigger the zoom; desktop keeps the 14px design. */
@media (pointer: coarse) {
  .input { font-size: 16px; }
}
/* The same guard for every OTHER focusable control. Declared LATE in the
   file (see the end) rather than here, because the rules that shrink these
   controls — `.input.dp-bound`, `.pe-num`, `.pe-clause select`,
   `.admin-select` — come later in the source and would beat a rule placed
   here. Grep `IOS_ZOOM_GUARD` to find it. */

/* A segmented control. `flex: none` is not cosmetic and must not be dropped:
   combined with `overflow: hidden` below, a .seg that is ALLOWED to shrink
   does not get narrower — it clips its own options away, and keeps clipping
   until there is nothing left to press. That failure is silent by
   construction. It doesn't overflow its row (so no scrollbar, no
   `scrollWidth` change, nothing a page-level overflow assertion can see) and
   it leaves no gap where the control used to be; the options simply stop
   existing, at whatever width the row ran out of room. It has shipped that
   way at least once — the Routes view toggle at 2px on a 375px phone.

   So the guard belongs HERE, at the component, not on each row that happens
   to contain one. Every .seg in the app is a flex child of something
   (.near-row, the sort sheet's direction seg, the settings rows), and every
   one of those rows can run short on a phone. Making each remember its own
   `flex: none` is how this gets forgotten; making it the default means a new
   row inherits the guard, and shrinking one becomes a deliberate override
   that has to be written down.

   If a .seg ever genuinely needs to yield, give that instance its own
   `flex-shrink` AND a `min-width` floor wide enough to keep every option
   pressable — never a bare `flex: 1`. See `tests/e2e/app.spec.mjs`'
   "controls stay usable at phone width", which fails on anything under the
   WCAG 2.5.8 target size. */
.seg {
  display: inline-flex; overflow: hidden; flex: none;
  border: 1px solid var(--color-divider); border-radius: var(--radius-md);
}
.seg-opt {
  display: inline-flex; align-items: center; justify-content: center; gap: 6px;
  padding: 8px 12px; font-size: 0.8125rem; cursor: pointer; border: 0; /* a <button>: the UA's outset border drew a bevel */
  font-family: var(--font-heading); font-weight: var(--font-ui-weight);
  color: var(--color-text); background: transparent; user-select: none;
}
.seg-opt + .seg-opt { border-left: 1px solid var(--color-divider); }
.seg-opt.active { background: var(--color-accent); color: var(--color-bg); }
@media (hover: hover) { .seg-opt:not(.active):hover { background: color-mix(in srgb, var(--color-text) 7%, transparent); } }

/* `width: 100%` is the default, but several callers override it inline with
   `width: auto` so a short picker doesn't stretch the whole row. A select
   sized that way takes the width of its WIDEST OPTION and, as a flex item
   with the automatic minimum size, refuses to shrink below it — which is how
   the "Routes starting in" picker ("Wherever most of my routes are", 274px) came
   to run 50px past the right edge of a 320px phone, taking its label with it.
   The pair below is what bounds that: `min-width: 0` lets the flex item
   shrink at all, `max-width: 100%` stops it exceeding the row whatever the
   option text says. Both are needed — either alone leaves the overflow. */
.admin-select {
  width: 100%; max-width: 100%; min-width: 0;
  min-height: 40px; padding: 8px 12px; font: inherit; font-size: 0.875rem;
  color: var(--color-text); background: var(--color-card);
  border: 1px solid var(--color-divider); border-radius: var(--radius-md); cursor: pointer;
}
.admin-select:disabled { opacity: 0.5; cursor: default; }

/* — cards — */
.card {
  display: flex; flex-direction: column; gap: var(--space-2);
  padding: var(--space-3); border-radius: var(--radius-lg); background: var(--color-card);
  box-shadow: var(--shadow-sm);
}
.card-title { font-family: var(--font-heading); font-weight: var(--font-heading-weight); font-size: 1.0625rem; line-height: 1.2; }
.elev-sm { box-shadow: var(--shadow-sm); }
.elev-md { box-shadow: var(--shadow-md); }
.elev-lg { box-shadow: var(--shadow-lg); }

/* — tags — */
.tag {
  display: inline-flex; align-items: center; font-size: 0.6875rem; font-weight: var(--font-ui-weight);
  letter-spacing: 0.02em; padding: 3px 10px; border-radius: var(--radius-pill); white-space: nowrap;
}
.tag-accent { background: var(--color-tint); color: var(--color-on-tint); }
.tag-neutral { background: var(--color-neutral-100); color: var(--color-neutral-800); }
.tag-solid { background: var(--color-accent); color: var(--color-bg); }
.tag-outline { border: 1px solid var(--color-accent); color: var(--color-accent); }

/* — navigation header — */
.nav {
  display: flex; align-items: center; gap: var(--space-3);
  padding: 12px 18px; border-bottom: 1px solid var(--color-divider);
}
.nav-brand { display: flex; align-items: center; gap: 10px; margin-right: auto; }
.brand-mark { flex: none; display: block; }
.brand-mark svg { display: block; width: 100%; height: 100%; }
.nav-brand .brand-text { font-family: var(--font-heading); font-weight: var(--font-heading-weight); font-size: 1.125rem; line-height: 1; }
.nav-brand .tagline {
  display: block; font-family: var(--font-body); font-weight: 400; font-size: 0.625rem;
  letter-spacing: 0.06em; text-transform: uppercase; color: var(--color-accent); margin-top: 3px;
}

/* Pre-production marker — rendered only when routesmith/environment.py's
   `env_label()` is non-None, i.e. never on production. It reads as a warm
   counter-note against the racing green precisely so it can't be mistaken
   for ordinary chrome: the whole job of this badge is answering "am I
   looking at the live app or dev?" at a glance, on a phone, from the
   home screen. Deliberately the ONLY visual difference between the two
   deployments — see the manifest handler in routesmith/web/pages.py for
   why everything else stays byte-identical. */
.env-badge {
  display: inline-block; flex: none;
  font-family: var(--font-body); font-size: 0.6875rem; font-weight: var(--font-ui-weight);
  letter-spacing: 0.09em; text-transform: uppercase; white-space: nowrap;
  background: var(--color-danger); color: var(--color-on-danger);
  border-radius: var(--radius-pill); padding: 2px 8px; vertical-align: middle;
}

/* — tables — */
.table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
.table th {
  text-align: left; font-size: 0.6875rem; letter-spacing: 0.08em; text-transform: uppercase;
  color: var(--color-neutral-600); padding: var(--space-2); border-bottom: 1px solid var(--color-divider);
}
.table td { padding: var(--space-2); border-bottom: 1px solid var(--color-hairline); }

/* ══════════════════════════════════════════════════════════════════════════
   App chrome
   ════════════════════════════════════════════════════════════════════════ */

html, body { height: 100%; }
/* On both, not body alone: the root scroller reads the property off the
   root element, and body's value reaching the viewport is a propagation
   Chrome has been withdrawing. */
html, body { overscroll-behavior-y: none; }

/* THE DOCUMENT IS A VERTICAL SCROLLER TOO, and it was the one nothing
   pinned. The app shell never scrolls the document (#app is a 100dvh
   overflow: hidden column), but /settings, /admin, /admin/flags, /help,
   the club admin page, the login card and the public doc pages all
   scroll the document itself — and the root's overflow-x fell under the
   same spec rule as every list (tests/test_scroll_containers.py): an
   auto y computes an unspecified x to auto, so the first box wider than
   the screen made the whole page pan sideways under a thumb and
   rubber-band on the way back. That is a phone failure by construction:
   the width that overflows only exists there (a select sized by its
   widest option, a nowrap chip row under iOS Larger Text, one long
   unbroken word), so a desktop never shows it and the e2e sweeps only
   see it at the widths and text sizes they run.

   Declared on html ONLY. The root's overflow propagates to the
   viewport, whose vertical scrolling is unchanged (visible already
   computed to auto there, so scroll restoration, the status-bar tap and
   scroll-top.js's one pixel all behave as before). On body it would do
   the wrong thing: with html no longer visible, body's value stops
   propagating and body — 100% tall from the rule above — becomes its
   own scroll container, taking the document scroll away from the
   platform. `documentElement.scrollWidth` still reports content that is
   too wide, so the narrow-phone sweep in tests/e2e/app.spec.mjs keeps
   catching the content itself: this stops the page moving, it does not
   excuse a box from fitting. */
html { overflow: hidden auto; }

/* Full-viewport app shell — header, swappable panels, bottom tab bar.

   THE CHROME IS PINNED. Nothing in the shell that is not itself a scroll
   container may pan the document: touch-action: pinch-zoom on #app
   disallows every touch pan whose nearest scroller is the document — a
   finger landing on the header, the tab bar, a banner, a chip row's
   surround, the composer's frame. The lists are untouched by it, since a
   touch-action chain stops at the nearest scrolling ancestor and every
   list is its own (#routes-list, #stops-list, #messages, .lib-scroll, the
   filter panels, the wheels, the chip rails), and their
   overscroll-behavior: contain is what keeps an edge bounce from chaining
   up to the document. Why it matters: scroll-top.js gives the document
   ONE pixel of scroll so iOS's status-bar tap has something to move, and
   with pans allowed a drag on the header moved that pixel and then
   rubber-banded the whole page — the platform's own bounce on the root
   scroller, which overscroll-behavior does not reliably suppress in every
   iOS surface. Disallowing the pan at the touch is what a native app's
   chrome does; the pixel stays reachable by the system gesture, which is
   not a touch the page ever sees. pinch-zoom is kept on purpose so the
   page stays zoomable for anyone who needs it, and — like the
   manipulation value it replaces — it excludes double-tap-zoom, so no
   control regains the 300ms tap delay. The transparent tap highlight
   replaces the platform's grey flash with the components' own :active
   pressed states below. Same rule on every fixed full-screen surface
   outside #app (.screen, .pwa-wall, the .sheet scrim). */
#app {
  height: 100%; /* older WebKit drops the dvh line below */
  height: 100dvh; display: flex; flex-direction: column; overflow: hidden;
  background: var(--color-bg);
  touch-action: pinch-zoom; -webkit-tap-highlight-color: transparent;
}

/* Admin broadcast banner (routesmith/platform/banner.py) — one sentence
   across the top of the shell; first child of #app so the flex column
   just makes room for it. */
.broadcast-banner {
  flex-shrink: 0; text-align: center; font-size: 0.8125rem; line-height: 1.4;
  padding: 8px calc(18px + env(safe-area-inset-right))
           8px calc(18px + env(safe-area-inset-left));
  padding-top: max(8px, env(safe-area-inset-top));
  background: var(--color-accent); color: var(--color-bg);
}
body > .broadcast-banner { border-radius: 0; }

/* The update bar — "a new version is ready, restart when you like".
   Deliberately NOT styled as a broadcast banner: that one is solid accent
   and carries an admin's words, this one is the app talking about itself
   and carries an action. A tinted card surface with a hairline reads as
   chrome rather than as an announcement, which is the right volume for
   something the rider is free to ignore all day. */
.update-bar {
  flex-shrink: 0; display: flex; align-items: center; gap: 10px;
  /* Wrapping, not squeezing. The two controls are unshrinkable, so with a
     nowrap row the sentence gets whatever is left — about 20 characters on
     a narrow phone, which is four cramped lines beside a button. Given a
     flex-basis it keeps a readable measure instead and pushes the controls
     onto a second line when the room genuinely isn't there. */
  flex-wrap: wrap;
  font-size: 0.8125rem; line-height: 1.35;
  padding: 8px calc(14px + env(safe-area-inset-right))
           8px calc(14px + env(safe-area-inset-left));
  background: color-mix(in srgb, var(--color-accent) 12%, var(--color-card));
  border-bottom: 1px solid var(--color-hairline);
}
/* display:flex would otherwise beat the hidden attribute. */
.update-bar[hidden] { display: none; }
/* Only when nothing is above it: a broadcast banner already spends the
   inset, and both claiming it stacks two notches' worth of padding. */
#app > .update-bar:first-child { padding-top: max(8px, env(safe-area-inset-top)); }
.update-bar-icon { flex: none; color: var(--color-accent); }
/* min-width:0 lets the sentence wrap rather than force the row wider than
   the phone; the 16ch basis is what decides that a second line for the
   controls beats a four-line sentence in a 20-character column. */
.update-bar-text { flex: 1 1 16ch; min-width: 0; }
/* The two controls wrap as ONE unit and stay right-aligned when they do.
   Wrapping them individually put Reload at the end of the sentence's line
   and left the dismiss × stranded and left-aligned on a line of its own.
   The auto margin is inert while everything fits: the text's flex-grow
   leaves no free space to take, so the one-line layout is unchanged. */
.update-bar-actions {
  flex: none; margin-left: auto; display: flex; align-items: center; gap: 6px;
}
.update-bar-btn {
  flex: none; min-height: 32px; padding-block: 4px;
  background: var(--color-accent); color: var(--color-bg);
}
@media (hover: hover) { .update-bar-btn:hover:not(:disabled) { background: var(--color-accent-hover); } }
.update-bar-btn:active:not(:disabled) { background: var(--color-accent-active); }
/* The worded acknowledgement, on the one bar that states a fact rather than
   offering something. The update bar and the install nudge close with an ×,
   which reads "not now" — right for an offer you can take later, wrong for a
   disclosure, where the answer is that you have read it. So that bar answers
   with two labelled buttons and the hierarchy stays put: the filled one is
   the button that DOES something (the opt-out), this one just closes. Matches
   .update-bar-btn's own vertical rhythm; the coarse-pointer block below takes
   both of them to 44px, being .btn. */
.update-bar-ok { flex: none; min-height: 32px; padding-block: 4px; }
.update-bar-dismiss {
  flex: none; display: inline-flex; align-items: center; justify-content: center;
  width: 30px; height: 30px; padding: 0; cursor: pointer;
  background: transparent; border: 0; border-radius: var(--radius-md);
  color: var(--color-neutral-600);
}
.update-bar-dismiss svg { display: block; pointer-events: none; }
@media (hover: hover) { .update-bar-dismiss:hover { background: color-mix(in srgb, var(--color-text) 7%, transparent); } }

/* The offline bar shares the update bar's chassis (same slot above the
   header, same wrap behaviour) but drops the accent: nothing here is an
   offer — it is the app stating a fact about the network, with no button
   to press — so it wears a neutral tint at the same volume as ordinary
   chrome and leaves on its own when the network returns. */
.offline-bar { background: color-mix(in srgb, var(--color-text) 6%, var(--color-card)); }
.offline-bar .update-bar-icon { color: var(--color-neutral-600); }

.app-header {
  padding-top: calc(12px + env(safe-area-inset-top));
  padding-right: calc(18px + env(safe-area-inset-right));
  padding-left: calc(18px + env(safe-area-inset-left));
  flex-shrink: 0;
}
.app-header .btn-icon { color: var(--color-text); }

.header-user { display: flex; align-items: center; gap: 8px; font-size: 0.75rem; color: var(--color-neutral-600); }
.header-user img { width: 26px; height: 26px; border-radius: 50%; object-fit: cover; }

/* Header overflow menu — theme + sign-out, so they stop being top-level icons. */
.header-menu-wrap { position: relative; display: flex; }
.header-menu {
  position: absolute; top: calc(100% + 6px); right: 0; z-index: 45;
  min-width: 180px; background: var(--color-card); border: 1px solid var(--color-divider);
  border-radius: var(--radius-md);
  box-shadow: var(--shadow-lg); display: flex; flex-direction: column; padding: 4px;
}
.header-menu[hidden] { display: none; }
.hm-item {
  display: flex; align-items: center; gap: 10px; width: 100%; box-sizing: border-box;
  padding: 10px 12px; background: none; border: none; cursor: pointer;
  font: inherit; font-size: 0.8125rem; color: var(--color-text); text-align: left; text-decoration: none;
}
.hm-item { border-radius: var(--radius-sm); }
.hm-item[hidden] { display: none; }   /* flex would beat the hidden attribute */
@media (hover: hover) { .hm-item:hover { background: var(--color-surface); } }
.hm-item svg { flex: none; color: var(--color-text); }
/* Reply badge — an unread COUNT, where .attn-dot is a state: replies to
   the rider's feedback they have not read yet. The pill itself is
   .pill-count, the system's one numeric badge (the Sort and Filters
   pills' — same size, weight and accent), so nothing here restyles it;
   these rules only PLACE it. Two-level, and deliberately nowhere else:
   on the ⋯ trigger (so it is seen with the menu closed) and beside the
   menu's Help entry (the one way to the page that shows them). Only
   reading clears it — /help marks replies seen — never opening the
   menu. Accent, not danger: a reply is news, not trouble. */
.header-menu-wrap > .btn { position: relative; }
.header-menu-wrap > .btn .pill-count {
  position: absolute; top: -4px; right: -4px;
  border: 2px solid var(--color-bg);   /* the .tab .attn-dot ring, same reason */
}
.hm-item .pill-count { margin-left: auto; }
/* The version line at the menu's foot — identity, not an action: no hover
   state, and selectable so a rider can copy it into a bug report. */
.hm-version {
  margin-top: 4px; padding: 8px 12px 6px;
  border-top: 1px solid var(--color-divider);
  font-size: 0.6875rem; color: var(--color-neutral-600);
  user-select: text; cursor: text;
}

/* Header background-activity indicator — a spinner shown only while the app
   is doing something (a provider import, the map-data download, a rescore);
   tapping it opens a popover naming each job. Hidden entirely at rest. */
.activity-wrap { position: relative; display: flex; }
.activity-wrap[hidden] { display: none; }
.activity-spinner { display: block; animation: activity-spin 1s linear infinite; }
@keyframes activity-spin { to { transform: rotate(360deg); } }
/* Reduced motion: the arc stands still — still reads as a "working" glyph. */
@media (prefers-reduced-motion: reduce) { .activity-spinner { animation: none; } }
.activity-pop { min-width: 240px; max-width: min(320px, calc(100vw - 24px)); }
.ap-title {
  padding: 8px 12px 4px; font-size: 0.6875rem; font-weight: 600;
  letter-spacing: 0.06em; text-transform: uppercase; color: var(--color-neutral-600);
}
.ap-row {
  display: flex; align-items: flex-start; gap: 10px; padding: 8px 12px;
  font-size: 0.8125rem; line-height: 1.45; color: var(--color-text);
}
.ap-row svg { flex: none; margin-top: 2px; }
.ap-row.queued { color: var(--color-neutral-600); }
.ap-empty { padding: 10px 12px; font-size: 0.8125rem; color: var(--color-neutral-600); }

/* Panels: only the active one is shown. */
.panel { flex: 1; min-height: 0; display: none; flex-direction: column; overflow: hidden; }
.panel.active { display: flex; }

/* — bottom tab bar — */
.tabbar {
  display: flex; flex-shrink: 0;
  border-top: 1px solid var(--color-divider); background: var(--color-bg);
  padding-bottom: env(safe-area-inset-bottom);
}
.tab {
  flex: 1; display: flex; flex-direction: column; align-items: center; gap: 4px;
  padding: 11px 0 12px; color: var(--color-neutral-600);
  background: none; border: none; cursor: pointer; font: inherit;
}
.tab svg { display: block; }
.tab span { font-size: 0.75rem; letter-spacing: 0.06em; text-transform: uppercase; font-weight: 600; }
.tab.active { color: var(--color-accent); }
.tab:active { background: color-mix(in srgb, var(--color-text) 7%, transparent); }

/* ── Chat panel ─────────────────────────────────────────────────────────── */

/* Welcome wind line — the day's wind, surfaced in the first assistant message. */
.welcome-wind {
  display: inline-flex; align-items: center; margin-top: 8px; padding: 4px 12px;
  background: var(--color-tint); color: var(--color-on-tint);
  font-size: 0.8125rem; font-weight: 600; border-radius: var(--radius-pill);
}
.welcome-wind[hidden] { display: none; }   /* inline-flex would beat the hidden attribute */

/* Strava chat-exclusion note in the welcome message — quiet, informational. */
.welcome-strava-note {
  margin-top: 12px; font-size: 0.75rem; line-height: 1.5;
  color: var(--color-neutral-600);
}

.chat-mode { flex: 1; min-height: 0; display: none; flex-direction: column; overflow: hidden; position: relative; }
.chat-mode.active { display: flex; }

/* "Jump to latest" pill — shown while the reader has scrolled away from the
   live reply, so following it again is one tap. Sits just above the composer. */
.jump-latest {
  position: absolute; left: 50%; bottom: calc(78px + env(safe-area-inset-bottom) + var(--kb-inset, 0px)); transform: translateX(-50%);
  display: inline-flex; align-items: center; gap: 5px; z-index: 5;
  padding: 6px 12px; font-size: 0.75rem; font-weight: 600;
  color: var(--color-bg); background: var(--color-text);
  border: none; border-radius: 999px; cursor: pointer;
  box-shadow: var(--shadow-md);
}
.jump-latest[hidden] { display: none; }
.jump-latest svg { display: block; }

/* `hidden auto` like #routes-list: a y-only auto computes x to auto too, so
   one unbreakable token in a reply (a pasted URL, a hashtag-style word)
   would make the whole transcript pannable sideways. .msg carries
   word-break so the clip never hides what the token says. Every vertical
   scroll container in this file pins x the same way — tests/
   test_scroll_containers.py refuses one that does not. */
#messages {
  flex: 1; overflow: hidden auto; overscroll-behavior-y: contain;
  padding: 18px; display: flex; flex-direction: column; gap: 14px;
}

.msg { font-size: 0.875rem; line-height: 1.5; white-space: pre-wrap; word-break: break-word; }
.msg.user {
  max-width: 82%; align-self: flex-end;
  background: var(--color-tint); color: var(--color-on-tint); padding: 11px 14px;
  border-radius: var(--radius-lg);
}
.msg.assistant {
  max-width: 90%; align-self: flex-start;
  background: var(--color-card); padding: 13px 15px;
  border-radius: var(--radius-lg); box-shadow: var(--shadow-sm);
}
.msg.tool {
  align-self: flex-start; max-width: 90%;
  background: var(--color-neutral-100); color: var(--color-neutral-700);
  border: 1px solid var(--color-hairline); border-radius: var(--radius-md);
  font-size: 0.75rem; padding: 6px 10px;
  font-family: ui-monospace, "SF Mono", "Fira Code", monospace;
}
/* The waiting placeholder, and — where the model produces one — the live
   reasoning trace inside it. Two rules do the work:

   It is a FIXED TWO-LINE BOX. A trace arrives token by token and would
   otherwise grow the element from one line to many, reflowing the whole
   conversation above it on every delta, which is the opposite of the
   reassurance it exists to give. Clamped, the tail scrolls through a box
   that never changes size. min-height holds those two lines from the
   first token, so the box does not jump when the trace starts.

   And the trace is styled as MACHINERY, not as an answer — dimmer and
   lighter than the reply it precedes, the way .msg.tool is. It has passed
   none of the app's copy rules (a model's scratchpad may well say
   "gritted"), so it must never read as something the app is telling the
   rider. It is removed the moment a real answer or tool call arrives. */
.msg.thinking {
  align-self: flex-start; max-width: 90%;
  color: var(--color-neutral-600); font-size: 0.8125rem; padding: 2px 0;
}
/* The box is scoped to .has-trace, added by the shell when the first
   reasoning delta arrives. Most models emit none at all, and their riders
   must go on seeing the one-line "Thinking…" this has always been rather
   than a two-line box held open for something that never comes.
   3em is the two lines: .msg sets line-height 1.5, so 2 x 1.5em. */
.msg.thinking.has-trace {
  display: -webkit-box; -webkit-box-orient: vertical;
  -webkit-line-clamp: 2; line-clamp: 2;
  overflow: hidden; min-height: 3em;
}
.msg.thinking .think-label { color: var(--color-neutral-700); }
/* normal, not the .msg pre-wrap it inherits: a trace is full of its own
   line breaks, and each one would spend a line of a two-line box. */
.msg.thinking .think-trace { white-space: normal; opacity: 0.75; }
.msg.thinking .think-trace:not(:empty)::before { content: " "; }
/* The welcome is authored MARKUP, not streamed text: its children are
   block elements with plain sentences in them, so `.msg`'s pre-wrap (which
   is there to keep a reply's own line breaks) was rendering the template's
   indentation between those blocks as a blank line each. Every gap in the
   card was one newline in index.html. */
#welcome-msg { white-space: normal; }
.msg strong { font-weight: 700; }
.msg code { background: var(--color-neutral-200); padding: 1px 5px; font-family: ui-monospace, "SF Mono", monospace; font-size: 0.9em; }
.msg a { color: var(--color-accent); text-decoration: underline; }

/* — route card (rendered under an assistant reply, or in the Routes tab) — */
.route-card { align-self: flex-start; width: 100%; max-width: 100%; padding: 0; gap: 0; overflow: hidden; background: var(--color-card); }
/* color: the real course outline strokes in currentColor (the fallback
   squiggle keeps its own --color-text stroke). */
.route-card .rc-map { height: 120px; position: relative; background: var(--color-surface); border-bottom: 1px solid var(--color-hairline); color: color-mix(in srgb, var(--color-text) 72%, transparent); }
.route-card .rc-map svg { position: absolute; inset: 0; width: 100%; height: 100%; }
.route-card .rc-map .tag { position: absolute; top: 10px; right: 10px; }
.route-card .rc-body { padding: 13px; display: flex; flex-direction: column; gap: 12px; }
.route-card .rc-title { font-family: var(--font-heading); font-weight: var(--font-heading-weight); font-size: 1.0625rem; line-height: 1.2; }
.route-card .rc-sub { font-size: 0.75rem; color: var(--color-neutral-600); margin-top: 2px; }

.rc-stats { display: flex; border-top: 1px solid var(--color-hairline); border-bottom: 1px solid var(--color-hairline); }
.rc-stat { flex: 1; padding: 9px 0; }
.rc-stat + .rc-stat { border-left: 1px solid var(--color-hairline); padding-left: 12px; }
.rc-stat .v { font-family: var(--font-heading); font-weight: var(--font-heading-weight); font-size: 1.125rem; }
.rc-stat .v small { font-size: 0.6875rem; font-weight: 800; }
.rc-stat .k { font-size: 0.6875rem; letter-spacing: 0.1em; text-transform: uppercase; color: var(--color-neutral-600); margin-top: 1px; }

.rc-note { display: flex; align-items: flex-start; gap: 8px; background: var(--color-tint); padding: 9px 11px; border-radius: var(--radius-md); }
.rc-note svg { flex: none; margin-top: 1px; color: var(--color-accent); }
.rc-note div { font-size: 0.75rem; line-height: 1.45; color: var(--color-on-tint); }
.rc-actions { display: flex; gap: 8px; }

/* — chips — */
.chip-row { display: flex; flex-wrap: wrap; gap: 8px; }
.chip {
  font-family: var(--font-heading); font-weight: var(--font-ui-weight); font-size: 0.8125rem;
  padding: 7px 13px; cursor: pointer; user-select: none;
  background: var(--color-surface); color: var(--color-neutral-800);
  border: 1px solid transparent; border-radius: var(--radius-pill);
  /* the Routes tab's exclude state is a double-tap / long-press on a filter chip
     (see makeChip), and both gestures mean something to the browser first:
     `manipulation` drops double-tap-to-zoom so the second tap reaches the
     chip, and the callout suppression keeps a long press from raising a
     selection menu over it. */
  touch-action: manipulation; -webkit-touch-callout: none;
}
@media (hover: hover) { .chip:hover { background: var(--color-neutral-200); } }
/* Uniform pressed state (mobile-native-ux): the finger-down acknowledgement
   the transparent tap highlight on #app removed. Selected/accent chips keep
   their own fill — the press feedback is for resting controls. */
.chip:not(.selected):active { background: color-mix(in srgb, var(--color-text) 14%, transparent); }
/* A chip offering something already taken (an adopted club profile, a
   control its row has switched off) must READ as unavailable — without
   this a disabled chip is pixel-identical to a live one. */
.chip:disabled { opacity: 0.45; pointer-events: none; }
.chip.selected { background: var(--color-accent); color: var(--color-bg); }
/* A chip tapped past "include" into "exclude": show everything BUT this.
   Outlined rather than filled (it isn't a selection — it's a subtraction),
   struck through, and dimmed, so an excluded chip reads as the opposite of
   a selected one at a glance rather than as another shade of "on". */
.chip.excluded {
  background: transparent; color: var(--color-neutral-600);
  border-color: var(--color-neutral-600); border-style: dashed;
  text-decoration: line-through;
}
@media (hover: hover) { .chip.excluded:hover { background: color-mix(in srgb, var(--color-text) 7%, transparent); } }
.chip.ghost { background: transparent; border-color: var(--color-divider); color: var(--color-text); font-size: 0.8125rem; }
/* :not(.selected) for the same reason the active rule above carries it — a
   ghost chip that can be SELECTED (the Filters pill, on both Routes and
   Stops) otherwise loses its accent fill to this wash on hover while keeping
   the selected state's pale text, which leaves the open pill's glyphs almost
   invisible until the pointer moves away. */
@media (hover: hover) { .chip.ghost:not(.selected):hover { background: color-mix(in srgb, var(--color-text) 7%, transparent); } }

/* — the chat opt-in card (docs/chat-data-egress.md) —
   Sits directly above the composer and reads as part of it: the same
   divider line and horizontal padding, so the two are one block of chrome
   at the foot of the transcript rather than a floating notice. It scrolls
   INSIDE itself if a small phone in landscape cannot fit the paragraph,
   because the one control it exists for must never be pushed off screen by
   the words explaining it. */
.chat-consent {
  flex: none; padding: 14px var(--space-4) var(--space-3);
  border-top: 1px solid var(--color-divider); background: var(--color-card);
  max-height: 55vh; overflow: hidden auto;
}
/* The head takes the card-title face (.tc-name's), not body-bold: every
   titled block in this app is set in the heading family, and this one is
   asking for a decision. */
.chat-consent .cc-head {
  font-family: var(--font-heading); font-weight: var(--font-heading-weight);
  font-size: 0.9375rem; line-height: 1.2; margin-bottom: 6px;
}
/* The panel's own muted-note style (.welcome-strava-note, three rows up in
   the same transcript) rather than a fourth muted size. */
.chat-consent .cc-body,
.chat-consent .cc-foot {
  margin: 0; font-size: 0.75rem; line-height: 1.5; color: var(--color-neutral-600);
}
/* The one line the rider acts on, so it is NOT muted description text: the
   .lib-check geometry is the shared one (top-aligned, so a wrapped label
   still lines up under its box), and only the size and colour step up. */
.chat-consent .cc-check { margin: var(--space-3) 0; font-size: 0.8125rem; line-height: 1.45; }

/* — composer — */
.composer {
  display: flex; align-items: flex-end; gap: 10px; flex-shrink: 0;
  padding: 12px 16px; border-top: 1px solid var(--color-divider); background: var(--color-bg);
  /* --kb-inset: the height the iOS keyboard obscures (visualViewport
     handler in index.html) — 100dvh alone doesn't track the keyboard. */
  padding-bottom: calc(12px + max(env(safe-area-inset-bottom), var(--kb-inset, 0px)));
}
.composer textarea { flex: 1; max-height: 120px; min-height: 42px; }

/* ── Routes panel ───────────────────────────────────────────────────────── */
/* The forecast STATUS row (mobile-native-ux): a 44pt line — wind glyph, the
   summary (conditions · area), and a trailing PILL wearing the day and
   roll-out hour (.wb-adjust) — on the tint, with the editor sheet behind
   it. No credit here: attribution rides the EXPANDED views and Data
   sources (see .wb-credit below). Replaces the always-open scrubber;
   fitWindSelects and the 480px trim block are gone with it. ONE line
   wherever the row has the width for it; where it hasn't, the summary
   wraps by clause and the pill holds its shape, rather than the row
   truncating what it came to say (the .wb-line and @container rules
   below). */
.wind-banner { background: var(--color-tint); border-bottom: 1px solid var(--color-hairline); flex-shrink: 0; }
.wind-banner b { font-weight: var(--font-heading-weight); }
/* A size CONTAINER, for the same reason .list-meta is one: the trailing
   affordance keys its compact form on the room this ROW has rather than on
   the viewport, which is also what lets the gallery's 343px pressure box
   render that form in a desktop screenshot. */
.wb-status {
  display: flex; align-items: center; gap: 8px; width: 100%; min-height: 44px;
  padding: 8px 16px; background: none; border: none; cursor: pointer;
  font: inherit; color: var(--color-on-tint); text-align: left;
  container-type: inline-size; container-name: wb-status;
}
.wb-status .wb-glyph { flex: none; color: var(--color-accent); }
/* While a /wind fetch is in flight the spinner stands in the glyph's slot
   (renderWindBanner's `lead`), at the glyph's size — the rescore row's
   12px spinner is the same element in another place. */
.wb-status .wb-spin { width: 16px; height: 16px; }
/* The summary takes a SECOND LINE rather than truncating. The items around
   it don't shrink, so when the row is tight the summary is what yields —
   never a mid-word cut ("NE 12 km/h · Today from 1…"), which lost the day,
   the roll-out and the rain in one go and is the bug the clamp exists for.
   The budget has since widened: the Open-Meteo credit used to sit in this
   row and took ~60px of it, so the furniture measured ~190px of a 390px
   phone against a summary wanting ~280px. With the credit moved to the
   expanded views only, that ~60px comes back to the line and the common
   phone case fits on one. The clamp still earns its place — it stops a long
   string growing the banner without bound, and the narrowest screens still
   in use (320px) are where a two-line clamp remains the one size that would
   go on losing the last clause. The wrap thresholds above are the measured
   pre-removal figures, kept as the reason the rule exists; they have not
   been re-measured since.

   Re-measured for the area clause (Chromium, both faces): at 12px the common
   line — wind & rain · day + roll-out · area — is ~258px in Bricolage
   Grotesque and ~292px in the fallback, so it holds one row from 375px up
   either way. Below that it wraps, and a cold day carrying ice as well wraps
   at every phone width, which is what the clamp is for. */
.wb-line {
  flex: 0 1 auto; min-width: 0; font-size: 0.8125rem; overflow: hidden;
  display: -webkit-box; -webkit-box-orient: vertical;
  -webkit-line-clamp: 3; line-clamp: 3;
}
/* One clause of the summary. Wrapping is allowed only BETWEEN clauses (the
   separator carries the sole break opportunity, and the dot is bound to the
   clause it closes with an nbsp), so the second line starts at "rain 40%"
   rather than at "40%" — a break inside a clause reads as damage. */
.wb-part { white-space: nowrap; }
/* The row's control: a PILL wearing the day and roll-out hour the row is
   scored for ("Today 09:00", "Sat 12 09:00 NZST"), closed by the shared
   disclosure caret at the wind glyph's 16px. THE VALUE IS THE CONTROL —
   a caret on a date says "tap to change it" without a label, and the
   outline (35% of the on-tint ink, the tint's own hairline weight) is what
   makes the row's end read as a button on a band that otherwise reads as
   status. It replaced a trailing "Adjust ⌄": a word hidden on every phone
   by the container rule below, and a text chevron in a 12px font — ~4px
   of ink, set in whatever face the glyph fell back to — which was the
   row's one visible cue that it opened anything, and the one disclosure
   in the app not drawn with .disc-caret. Same ink as the summary, not the
   accent: the accent is for the glyph that says what the row is about,
   and a green word here read as a link out rather than a control in.
   The word survives screen-reader-only (.wb-adjust-word), so the button's
   accessible name still says what a tap does. */
.wb-adjust {
  flex: none; margin-left: auto; display: inline-flex; align-items: center; gap: 4px;
  padding: 4px 8px 4px 10px; border-radius: var(--radius-pill);
  border: 1px solid color-mix(in srgb, var(--color-on-tint) 35%, transparent);
  font-size: 0.75rem; font-weight: 600; line-height: 1.25; color: var(--color-on-tint); white-space: nowrap;
}
/* line-height 1.25, not the body's 1.55: at 1.55 the pill is 29px and
   pushes the 44px row to 45 (8px padding each side leaves 28 for the
   tallest item), which the phone-width e2e test measures. */
.wb-adjust .disc-caret { color: inherit; }
.wb-adjust-word {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0;
}
/* The summary drops a point when the row is tight, for the one-line
   budget. Measured in Chromium with the pill carrying the day: the common
   line — wind · area — is ~118px at 12px, the pill ~112px, and with rain
   on the line ~184px, so a 390px phone (358px inside) holds one row with
   room for a zone suffix; the widest ordinary case, a later day abroad
   with rain, is the one that wraps, by clause, under the clamp above. The
   day and hour used to be IN this line (~258px common, ~292px in the
   fallback face), and this rule is what got it onto one row then; it is
   kept because a long area name still spends the same budget. 13px stays
   wherever the row has the width for it. */
@container wb-status (max-width: 480px) {
  .wb-line { font-size: 0.75rem; }
}
.wb-low { font-size: 0.625rem; letter-spacing: 0.06em; text-transform: uppercase; color: var(--color-neutral-700); }
/* Open-Meteo credit — quiet but legible on the tint. Used by the sheet's
   summary alone (.fs-sum, built by windDetailHtml): the closed status row
   carries no credit, so attribution lives with the detail it belongs to and
   in full under Library → Data sources. It rides INLINE in the summary text
   rather than sitting beside it as a flex child, so it costs the row no
   width of its own; nowrap is what keeps the wordmark from breaking across
   the wrap. (`flex: none` went with the move — .fs-sum is not a flex
   container, so it no longer applied.) */
.wb-credit { font-size: 0.625rem; color: inherit; opacity: 0.75; text-decoration: underline; text-underline-offset: 2px; white-space: nowrap; }
/* Forecast update in flight: a small spinner beside the summary; numbers
   that still describe a different day/time dim (wb-stale) until the answer
   lands. */
.wb-spin { flex: none; width: 12px; height: 12px; border-radius: 50%;
  border: 2px solid var(--color-divider); border-top-color: var(--color-accent);
  animation: wb-spin 1s linear infinite; }
@keyframes wb-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .wb-spin { animation-duration: 1.6s; animation-timing-function: steps(8); } }
.wb-line.wb-stale { opacity: 0.45; transition: opacity 0.15s; }

/* The forecast sheet's riding-area rail: a labelled chip rail under the
   wheels. Chips are the ≥40pt targets the old 24px steppers never were:
   bg resting, accent selected. */
.wb-ed-label { font-size: 0.6875rem; letter-spacing: 0.1em; text-transform: uppercase; color: var(--color-neutral-600); margin-bottom: 5px; }
.wb-ed-rail .chip { font-size: 0.8125rem; padding: 9px 16px; background: var(--color-bg); }
/* The places/clusters trial toggle under the Where-you-start rail. */
.wb-ed-mode { display: flex; gap: 6px; margin-top: 6px; }
.wb-ed-rail .chip.selected { background: var(--color-accent); color: var(--color-bg); }
/* Inside a sheet the card ground IS --color-bg, so resting chips take the
   surface fill to stay visible. */
.sheet-card .wb-ed-rail .chip:not(.selected) { background: var(--color-surface); }
.sheet-card .wb-ed-label { color: var(--color-neutral-600); }
/* The sheet's tinted summary card + full-width Done row. */
.fs-sum { background: var(--color-tint); color: var(--color-on-tint); border-radius: var(--radius-md); padding: 10px 12px; font-size: 0.75rem; line-height: 1.45; }
/* Under the summary: the explicit Update beside the forecast's age — the
   one fetch the "when I ask" freshness rung allows, and a revalidate the
   automatic rungs never offered. */
.fs-update { display: flex; align-items: center; gap: 10px; margin-top: 6px; min-height: 32px; }
.fs-update-note { font-size: 0.75rem; color: var(--color-neutral-600); }
/* The Save-as-defaults row renders empty while the wheels match what is
   saved; an empty row must not hold its 32px. */
.fs-defaults:empty { display: none; }
.fs-foot { padding: 10px 18px 6px; }

/* The sheet's day/hour/speed pickers are WHEELS. The three questions are
   asked at once, side by side, and each answer is a short scroll rather than
   a horizontal hunt through a 24-item rail — which is what the sheet's
   height buys over the in-place accordion it beat in the mobile-native-ux
   A/B, where 190px of controls would have had to stay out of the route
   list's way. */
.fs-wheels-head { display: flex; padding: 0 2px; }
.fs-wheels-head > div {
  font-size: 0.6875rem; letter-spacing: 0.1em; text-transform: uppercase;
  color: var(--color-neutral-600); text-align: center;
}
.fs-wheels { position: relative; display: flex; height: 190px; }
/* The rider's own STOP — how long, how far round — is two more wheels in
   a FOLD under the three above (the route sheet's .rs-fold, closed by
   default with the stop as its digest): a set-once pair for most riders
   (saved as defaults from the sheet), not the day's main question. Open,
   the pair sits under the Roll out and Speed columns — the spacer is the
   Day column's width, and the band starts where it ends (1.4 of 3.5) —
   at the source sheet's 146px rather than another 190. Same padding rule:
   (146 − 34) / 2. */
.fs-stop-spacer { flex: 1.4; }
.fs-wheels-stop { height: 146px; }
.fs-wheels-stop .cwheel { padding: 56px 0; }
.fs-wheels-stop .fs-wheel-band { left: 40%; }
/* Under "No stop" the position wheel has nothing to place: dimmed and
   deaf to picks, its value kept for when a stop is back. */
.cwheel.is-off { opacity: 0.35; pointer-events: none; }
/* The source sheet's single "Routes to sync" wheel: one column, so it
   keeps a column's width rather than spanning the sheet with its labels
   centred in the middle of nowhere. Same scaffold, same band — but SHORTER
   than the forecast sheet's three: that one is the page's main event and
   asks three questions at once, while this is one set-and-forget number
   inside a sheet that also carries the library state, the sync button and
   two switches. At 190px it was the largest thing in the sheet. The
   padding is (height − 34) / 2, the same rule as .cwheel's 78px: get it
   wrong and the ends of the list are unpickable. */
.srcs-wheels { max-width: 180px; height: 146px; }
.srcs-wheels .cwheel { padding: 56px 0; }
/* The selection band is the wheel's whole affordance — it says WHERE the
   answer is read, so the rider knows a scroll commits rather than browses.
   Under the columns (pointer-events: none) so it can never eat a drag. */
.fs-wheel-band {
  position: absolute; left: 0; right: 0; top: 50%; height: 36px;
  transform: translateY(-50%); border-radius: var(--radius-md);
  background: color-mix(in srgb, var(--color-text) 6%, transparent);
  pointer-events: none;
}
/* 78px = (190 − 34) / 2: the padding that lets the FIRST and LAST item reach
   the band. Get it wrong and the ends of every list are unpickable. The mask
   is what makes the column read as a wheel rather than a clipped list. */
.cwheel {
  overflow: hidden auto; scroll-snap-type: y proximity; padding: 78px 0;
  touch-action: pan-y; cursor: grab; scrollbar-width: none;
  mask-image: linear-gradient(transparent, #000 30%, #000 70%, transparent);
  -webkit-mask-image: linear-gradient(transparent, #000 30%, #000 70%, transparent);
}
.cwheel::-webkit-scrollbar { display: none; }
.wc {
  flex: none; width: 100%; height: 34px; padding: 0;
  display: flex; align-items: center; justify-content: center;
  border: none; background: transparent; cursor: pointer;
  font-family: inherit; font-size: 0.875rem; font-weight: 600;
  color: var(--color-text); scroll-snap-align: center; opacity: 0.55;
}
.wc.sel { opacity: 1; font-weight: 800; }
.wc:focus-visible { opacity: 1; }
/* A wheel item the rider's PLAN withholds (forecast-plan-gate.md): still a
   row — the wheel's length is the calendar, and a calendar with days cut
   out reads as a bug — dimmed past the resting items, worded "· Premium",
   and refused by the pick handler rather than by the wheel. */
.wc.locked { opacity: 0.3; font-weight: 500; cursor: default; }
.wc.locked.sel { opacity: 0.5; }

/* — weather chips (the "why" behind a route's dated forecast scores) — pills
   in the shared .tag language: accent when the day works for you, neutral
   when it doesn't. Green + neutrals only; the phrase itself carries the
   detail. */
.wind-tag { gap: 5px; font-weight: 600; }
.wind-tag svg { flex: none; }

.list-controls { padding: 12px 18px; display: flex; flex-direction: column; gap: 11px; border-bottom: 1px solid var(--color-divider); flex-shrink: 0; }
/* Horizontal chip rail — deliberate touch hygiene (mobile-native-ux):
   snap points so a flick settles on a chip boundary, x-containment so an
   edge-bounce can't hand the gesture to the browser's back-swipe, and
   pan-x pan-y so a vertical swipe STARTING on the rail still scrolls the
   page. Scrollbars are hidden (the edge fade below is the affordance). */
.filter-scroll {
  display: flex; align-items: stretch; gap: 8px; overflow-x: auto; padding-bottom: 1px;
  scroll-snap-type: x proximity; overscroll-behavior-x: contain;
  touch-action: pan-x pan-y; scrollbar-width: none;
}
.filter-scroll::-webkit-scrollbar { display: none; }
.filter-scroll > .chip { scroll-snap-align: start; }
/* Edge fade — "there's more" affordance for off-screen chips. Applied by
   JS (updateRailFades) only when the rail actually scrolls, so a short row
   never dims its last chip for no reason. */
.filter-scroll.has-fade {
  -webkit-mask-image: linear-gradient(to right, transparent, #000 14px, #000 calc(100% - 32px), transparent);
  mask-image: linear-gradient(to right, transparent, #000 14px, #000 calc(100% - 32px), transparent);
}
.filter-scroll[hidden] { display: none; }   /* flex would beat the hidden attribute */
.filter-scroll .chip { flex: none; font-size: 0.75rem; padding: 6px 12px; }
/* The trailing editor chip closing an authored group (collections, route
   profiles): the way into the rules behind those chips, sitting at the end
   of the chips it authors. Ghost styling — the same "not a filter" language
   the Duplicates view's controls use — so it never reads as selectable.
   .filter-scroll .chip already sets the row's size (it wins the tie with
   .chip.ghost's 13px on source order); this only adds the glyph alignment. */
.filter-scroll .chip-edit { display: inline-flex; align-items: center; gap: 5px; }
.filter-scroll .chip-edit svg { flex: none; }
/* The chip rows sit tight under one another — one cluster, one row per
   question (what kind of ride / how big & hilly / where from). */
.filter-scroll + .filter-scroll { margin-top: -4px; }
/* Thin rule marking the boundary between filter groups (distance | terrain | …). */
.filter-div { flex: none; align-self: stretch; width: 1px; margin: 3px 2px; background: var(--color-divider); }
/* — the meta row: the search field, the route count beside it, and the
   Filters + Sort pills anchored at the far end (mobile-native-ux; the
   Filters pill moved up from its own row so the resting controls are ONE
   line). The count sits immediately after the field rather than on a line
   of its own, so the number and the query that produced it read together.
   The search is the only flexible item; the pills are `flex: none`, so the
   row has exactly one thing that can yield and nothing that can be crushed
   — the failure class the old four-control sort cluster carried is gone
   with it. The row is a size CONTAINER so the pills can key their
   compact form on the room the row actually has (the @container rule
   below) rather than on the viewport — which is also what lets the
   gallery's 343px pressure box render the compact form in a desktop
   screenshot. */
.list-meta { display: flex; align-items: center; gap: 10px; container-type: inline-size; container-name: list-meta; }
/* — the free-text search slot at the head of the meta row (the Routes tab's
   route search; the Stops tab's own meta row holds its stop filter in the
   same position). Static (never re-rendered), so typing doesn't lose focus
   when the list repaints. font-size is left to the base .input rule so the
   iOS no-zoom bump still applies. */
.list-search { position: relative; display: flex; flex: 1 1 120px; min-width: 0; max-width: 340px; }
.list-search[hidden] { display: none; }
.list-search .input { min-height: 34px; padding: 8px 12px; border-radius: 10px; background: var(--color-surface); border-color: transparent; }
.list-search .input:focus-visible { border-color: var(--color-accent); }
/* — the field's own clear button, overlaid at the trailing edge. It exists
   because the two other ways out of a typed query are both desktop-only:
   Escape needs a keyboard, and the native type="search" cancel button is
   absent in Firefox and on iOS Safari — so on a phone the field could only
   be backspaced. The native one is suppressed rather than styled, since
   where it DOES render (Chrome, desktop Safari) two ✕s would sit side by
   side. Reserved padding on the input keeps the text from running under
   it, and 24px is the tap target the narrow-width sweep holds every
   control to. */
.list-search .input[type="search"] { padding-inline-end: 32px; }
.list-search .input[type="search"]::-webkit-search-cancel-button { display: none; }
.ls-clear {
  position: absolute; inset-inline-end: 5px; top: 50%; transform: translateY(-50%);
  width: 24px; height: 24px; flex: none;
  display: flex; align-items: center; justify-content: center;
  padding: 0; border: none; border-radius: 999px; background: transparent;
  font: inherit; font-size: 0.75rem; line-height: 1; color: var(--color-neutral-600);
  cursor: pointer;
}
@media (hover: hover) { .ls-clear:hover { background: color-mix(in srgb, var(--color-text) 7%, transparent); color: var(--color-text); } }
.ls-clear:active { background: color-mix(in srgb, var(--color-text) 14%, transparent); }
.ls-clear[hidden] { display: none; }
/* The count register — one rule for every "what this list adds up to"
   line: the Routes and Stops meta rows (.list-count) and the Duplicates
   screen's summary (.dups-summary). Keeping them one declaration is what
   stops the two drifting a pixel apart. On the meta rows the span is
   HIDDEN at rest — the count rides the search field's placeholder then
   ("Search 330 routes…", renderListCount) — and shows while a query is
   typed or the list is empty. */
.list-count, .dups-summary { font-size: 0.6875rem; letter-spacing: 0.08em; text-transform: uppercase; color: var(--color-neutral-600); }
.list-count { flex: none; white-space: nowrap; }
/* (A .list-note caption lived here for one release — a full-width line
   under the meta row carrying the Stops tab's riding-area scope. The scope
   is no longer stated on that tab at all, and an unused component in a
   shared stylesheet is an invitation to state it again.) */
/* The Filters pill — unfolds the chip rails in the panel below. Sits in
   the meta row (margin-inline-start: auto is what pushes the pill pair to
   the far end; the Sort pill just follows it). */
.filters-pill {
  flex: none; margin-inline-start: auto;
  display: inline-flex; align-items: center; gap: 6px;
  border-color: var(--color-accent); color: var(--color-accent);
  font-size: 0.8125rem; padding: 7px 13px;
}
.filters-pill.selected { background: var(--color-accent); color: var(--color-bg); border-color: transparent; }
.filters-pill svg { flex: none; }
.filters-caret { line-height: 0; }
/* One disclosure caret (ui-consolidation step 3): every disclosure draws
   the rs-fold chevron and TURNS it — the ⌄/⌃ text swap and the
   border-drawn triangle are gone. Orientation carries the state: a fold
   or pill points down and flips up; a tree level points right and drops
   open. The rotation keys off the state the markup already declares
   (aria-expanded, [open]), so no script repaints a caret. */
.disc-caret { flex: none; display: inline-block; vertical-align: middle;
  transition: transform 0.28s cubic-bezier(0.32, 0.72, 0, 1); }
.filters-pill[aria-expanded="true"] .disc-caret,
.src-summary[aria-expanded="true"] .disc-caret { transform: rotate(180deg); }
@media (prefers-reduced-motion: reduce) { .disc-caret { transition: none; } }
/* The Sort pill — opens the sort sheet. ONE static face by design: the
   generic sort arrows and the word "Sort", never the current key, so the
   control is always found in the same place looking the same way. What
   the list is ordered by shows where the data is — the sheet's ticked
   row and the accented stat on each route card (.bs-on below). A GHOST
   outline like the Filters pill (the markup carries .ghost): it used to
   take the chip's default surface fill, which is the search field's fill
   too, so at phone width — arrows only, no word — it read as a second
   field rather than a button. Outlined, the two pills read as one pair of
   controls and the field as the one thing you type into. */
.sort-pill { flex: none; display: inline-flex; align-items: center; gap: 5px; font-size: 0.8125rem; padding: 7px 13px; }
.sort-pill[hidden] { display: none; }   /* inline-flex would beat the hidden attribute */
.sort-pill svg { flex: none; }
/* Small numeric badge shared by the Sort and Filters pills and by the
   header menu's unread-reply count. Accent-filled: on the menu trigger the
   fill is the point — an unread reply IS news, and the badge is the only
   thing saying so. */
.pill-count {
  display: inline-flex; align-items: center; justify-content: center;
  min-width: 16px; height: 16px; padding: 0 4px; border-radius: var(--radius-pill);
  background: var(--color-accent); color: var(--color-bg);
  font-size: 0.625rem; font-weight: 700; line-height: 1;
}
.pill-count[hidden] { display: none; }
/* On the FILTER and SORT pills the same badge is OUTLINED, never filled.
   A fill there spends the app's one answer colour (the accented stat, the
   wind glyph, a selected chip) on a state a rider may simply have left at
   its default — one row under the wind band, competing with it. Unlike a
   reply, a filter being on is not news. The ring is drawn from the pill's
   OWN ink, so one rule reads correctly on the resting ghost pill AND on
   the accent-filled open one, with no second override to keep in step;
   17px, not 16, because a 10px digit inside a ring needs the pixel it used
   to get from having no border. */
.filters-pill .pill-count, .sort-pill .pill-count {
  min-width: 17px; height: 17px;
  background: transparent; color: inherit;
  border: 1px solid color-mix(in srgb, currentColor 45%, transparent);
}
/* Compact pills: when the meta ROW is tight (a phone, a split-screen
   window, the gallery's pressure box) the pill words go screen-reader-
   only and the two static glyphs — funnel, sort arrows — carry the
   meaning. Word-and-icon returns the moment the row has room; browsers
   without container queries simply keep the words (the search field
   yields, nothing breaks). */
@container list-meta (max-width: 480px) {
  .pill-word {
    position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
    overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0;
  }
  .filters-pill, .sort-pill { padding: 7px 10px; }
}

/* (The standing active-filters row lived here — one removable chip per
   selection, below the panel, on screen whether the panel was open or
   shut. It is gone: collapsing the panel now collapses the selection with
   it, and the row's three jobs moved INSIDE the panel as .fp-foot and
   .fp-via below. The chip styling it defined lives on for those.) */

/* — the expanded Filters panel: a recessed strip holding the labelled chip
   rails. Animated open/closed with grid-template-rows (0fr → 1fr) — the
   one way to animate to auto height without animating height. Reduced
   motion snaps instead. */
.filters-panel {
  display: grid; grid-template-rows: 0fr;
  transition: grid-template-rows 0.28s cubic-bezier(0.32, 0.72, 0, 1);
  margin: 0 -18px;             /* full-bleed recessed strip inside the padded block */
}
.filters-panel.open { grid-template-rows: 1fr; }
@media (prefers-reduced-motion: reduce) { .filters-panel { transition: none; } }
.fp-clip { overflow: hidden; min-height: 0; }
/* The panel sits in the controls block ABOVE the list scroller, which is
   flex-shrink: 0 — so an uncapped panel takes the viewport and the list
   under it collapses to nothing (the Stops tab reached eleven rows once
   its kinds became rows, and the whole tab became chips). Open, the clip
   is capped to a share of the viewport and scrolls INSIDE itself; the
   grid track still animates to the content height, the cap just stops it
   winning. Closed it stays a plain clip, so the closing animation shows
   no scrollbar. */
.filters-panel.open .fp-clip {
  max-height: min(44vh, 420px); max-height: min(44dvh, 420px);
  overflow: hidden auto; -webkit-overflow-scrolling: touch; overscroll-behavior-y: contain;
}
.fp-inner {
  display: flex; flex-direction: column; gap: 10px;
  background: color-mix(in srgb, var(--color-surface) 55%, var(--color-bg));
  border-top: 1px solid var(--color-hairline); border-bottom: 1px solid var(--color-hairline);
  padding: 11px 18px; margin-top: 11px;
}
.fp-group[hidden] { display: none; }
.fp-label {
  font-size: 0.6875rem; letter-spacing: 0.1em; text-transform: uppercase;
  color: var(--color-neutral-600); margin-bottom: 5px;
}
/* Expanded-panel chips are the full-size targets the collapsed rail can't
   afford (≥40pt with the row padding). */
.fp-group .chip { font-size: 0.8125rem; padding: 8px 14px; }

/* The rows host is layout-transparent: the labelled groups stay DIRECT
   flex children of .fp-inner, so the panel's rhythm is unchanged now that
   the via group and the foot are siblings of the rails rather than the
   rails being the whole strip. It also keeps chipRowHosts' contract — it
   owns exactly the children of this element and counts them. */
.fp-rows { display: contents; }

/* — the via group: a home in the panel for a filter with NO rail chip.
   The via-stop filter is set by tapping a stop on the Stops tab, so no
   rail offers it and nothing in the panel could light up for it. While
   the standing chips row existed that row was its one surface; removing
   the row without this would leave a rider's list narrowed by something
   said nowhere on screen. It is also what lets the pill's badge count it:
   the count deliberately skipped it, because a number that sent a rider
   into the panel looking for a chip that was not there is worse than no
   number. Given a chip here, it counts like the rest. */
/* — the bounds group: the same home, for the same reason, for the metric
   min/max limits. They came from the sort sheet, where their only trace on
   the list they narrowed was a count on the wrong pill
   (advanced-route-filters.md); here each is a chip that says what it is and
   removes itself. It sits BELOW the rails and above the fold that sets
   them, so appearing and disappearing with the selection moves the fold and
   the foot and never a chip — the exclude gesture is a double-tap, and
   ground that shifts between the taps is ground the second one misses. */
.fp-sel { display: flex; flex-wrap: wrap; gap: 8px; }
.fp-via .active-chip, .fp-bounds .active-chip { display: inline-flex; align-items: center; gap: 6px; }
.fp-via .active-chip .ac-x, .fp-bounds .active-chip .ac-x { font-size: 0.625rem; opacity: 0.8; }

/* — a chip row's own footnote. Near me is the one group that has to admit
   something about itself: when the fix was taken, and how rough it is (or,
   in the danger ink, why there is no fix). Small and quiet on purpose — a
   caveat, not a heading, under a panel already capped at a share of the
   viewport. It sits in the .fp-group after the rail, so it moves with
   whichever row the rider's layout puts the group on. */
.fp-note {
  margin-top: 7px; font-size: 0.75rem; line-height: 1.4;
  color: var(--color-neutral-600);
}
.fp-note[hidden] { display: none; }
/* A refusal is not a caveat — it is the answer, and it has to be readable
   as one. Same weight as the note, the app's own danger ink. */
.fp-note.fp-note-bad { color: var(--color-danger-text); }

/* — the panel's foot: how many are on, and the way out. At the END of the
   rails deliberately — it appears and disappears with the selection, and
   anything that moves above a chip would move the ground under the second
   tap of the exclude gesture (a double-tap). Hidden entirely at rest, so
   an unfiltered panel is the rails and nothing else. */
.fp-foot {
  display: flex; align-items: center; gap: 10px;
  padding-top: 10px; border-top: 1px solid var(--color-hairline);
}
.fp-foot[hidden] { display: none; }   /* flex would beat the hidden attribute */
.fp-foot-n { font-size: 0.75rem; color: var(--color-neutral-600); }
.fp-foot .filters-clear { margin-left: auto; color: var(--color-neutral-700); }

/* — the sort sheets' key list: grouped 44pt radio rows, the ticked one
     carrying its own direction. Both sheets, one grammar. — */
.sort-groups { display: flex; flex-direction: column; }
.sort-keys { display: flex; flex-direction: column; }
/* The group headings are .section-label — the app's own section word, not
   a third copy of it — pulled tight to the list they head. */
.sort-groups .section-label { margin: 11px 0 3px; }
.sort-groups > .section-label:first-child { margin-top: 0; }
/* The hairline belongs to the ITEM, not the row: a ticked key is two lines
   (its row and its direction pill) and a rule on the row alone cut that
   pair in half. */
.sk-item { border-bottom: 1px solid var(--color-hairline); }
.sk-item:last-child { border-bottom: none; }
.sk-row {
  display: flex; align-items: center; gap: 10px; min-height: 44px; width: 100%;
  padding: 6px 2px; background: none; border: none;
  cursor: pointer; font: inherit; color: var(--color-text); text-align: left;
}
/* The key's glyph leading its row — the metric vocabulary the route
   cards' stat lines echo (METRIC_ICONS), taught here where the keys are
   named. Quiet by default, accented on the ticked row. */
.sk-row .sk-ic { flex: none; display: inline-flex; color: var(--color-neutral-600); }
.sk-row .sk-ic svg { display: block; }
.sk-row.selected .sk-ic { color: var(--color-accent); }
.sk-row .sk-label { flex: 1; font-family: var(--font-heading); font-weight: var(--font-ui-weight); font-size: 0.875rem; }
.sk-row .sk-check { flex: none; color: var(--color-accent); font-weight: 700; visibility: hidden; }
.sk-row.selected .sk-check { visibility: visible; }
@media (hover: hover) { .sk-row:hover { background: color-mix(in srgb, var(--color-text) 4%, transparent); } }
/* The direction, worded in the ticked key's own terms — what replaced the
   abstract Descending / Ascending seg that used to sit above a list the
   rider had not chosen from yet.

   Rendered on every row and revealed only on the ticked one, so flipping
   it never rebuilds the row under the finger that is tapping. Indented to
   the label's own left edge (13px glyph + the row's 10px gap), so it hangs
   off the key it belongs to rather than starting a new column. Tinted
   rather than filled: the accent fill is the app's SELECTED state and this
   is not a selection, it is the selected row's setting. */
.sk-dir { display: none; }
.sk-item.selected .sk-dir {
  display: inline-flex; align-items: center; gap: 6px; align-self: flex-start;
  margin: 0 0 6px 23px; min-height: 34px; padding: 4px 11px 4px 9px;
  border-radius: var(--radius-pill); border: 1px solid var(--color-accent);
  background: var(--color-tint); color: var(--color-on-tint);
  font-family: var(--font-heading); font-weight: var(--font-ui-weight);
  font-size: 0.8125rem; cursor: pointer; text-align: left;
}
.sk-dir svg { display: block; flex: none; }
@media (hover: hover) {
  .sk-item.selected .sk-dir:hover { background: var(--color-accent-300); }
}
/* Both sort sheets run a touch tighter than the default sheet body — the
   groups and the radio list read as one control group. */
#sort-sheet .ss-body, #stops-sort-sheet .ss-body { gap: 12px; }

/* — the numeric bounds, inside the sort sheet's own .rs-fold — */
.dp-hint { font-size: 0.6875rem; line-height: 1.4; color: var(--color-neutral-600); }
.dp-rows { display: flex; flex-direction: column; gap: 7px; }
.dp-row { display: grid; grid-template-columns: 1fr 62px 62px 44px; gap: 8px; align-items: center; }
/* The metric's name, plain: it led with a CHIP while the same row also
   toggled the metric onto the route cards, and that half is gone. */
.dp-name { font-size: 0.8125rem; font-family: var(--font-heading); font-weight: var(--font-ui-weight); }
.input.dp-bound { min-height: 30px; padding: 4px 8px; font-size: 0.8125rem; }
.dp-unit { font-size: 0.625rem; letter-spacing: 0.04em; color: var(--color-neutral-600); white-space: nowrap; }
/* Number spinners eat most of the tiny inputs — hide them. */
.input.dp-bound::-webkit-inner-spin-button, .input.dp-bound::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
.input.dp-bound { -moz-appearance: textfield; appearance: textfield; }

#routes-list[hidden] { display: none; }
/* `hidden auto`, not `overflow-y: auto` alone: per spec an overflow-y of
   auto forces the unspecified overflow-x to compute to auto TOO, so the
   moment any card content got wider than the screen the entire route list
   became horizontally pannable — sideways scroll on the cards, invisible
   to the document-level overflow check because the document never moved.
   Pinning x to hidden makes the list uncrossable by construction; the
   overflow-wrap rules on the card text below are what keep that clip from
   ever hiding content. */
#routes-list { flex: 1; overflow: hidden auto; overscroll-behavior-y: contain; }

/* The whole row is tappable (a pointer tap anywhere opens the route detail
   sheet); the hover/active tints double as the affordance. The row itself
   carries NO button role: the title is the real <button>, so a screen
   reader reads the stats, chips and source line as content rather than
   having them replaced by one aria-label, and the thumbnail button sits
   beside it as a sibling control instead of nested inside a button. */
.route-row { display: flex; border-bottom: 1px solid var(--color-hairline); color: var(--color-text); cursor: pointer; }
@media (hover: hover) { .route-row:hover { background: color-mix(in srgb, var(--color-text) 4%, transparent); } }
.route-row:active { background: color-mix(in srgb, var(--color-text) 7%, transparent); }
.route-row:focus-visible { outline: 2px solid var(--color-accent); outline-offset: -2px; }
/* flow-root, not a flex column, so the outline preview can float into the
   corner (floats are ignored on flex items) and so the card grows to
   contain it. The child margin reproduces the flex `gap: 7px` exactly —
   the outline itself is skipped, since a float sets its own margins and
   takes no row of its own. */
/* 18px, not 16: the header, the list controls and the search field above
   this all inset by 18, so a 16px body started every route name 2px inside
   the field it sits under — a soft ragged left edge down the whole scroll.
   The page gutter is one measurement, and this is the row that was off it. */
.route-row .br-body { flex: 1; min-width: 0; padding: 13px 18px; display: flow-root; }
.route-row .br-body > * + *:not(.br-thumb) { margin-top: 7px; }
.route-row .br-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; }
/* `overflow-wrap: anywhere` on the two free-text lines (title, tags): a
   route name with an unbreakable token — a pasted URL, a hashtag-style
   word, Word_With_Underscores — otherwise widens the card past the screen,
   which the list's x-clip above would answer by CUTTING the token off.
   Breaking mid-token is ugly; hiding half of it is wrong. `anywhere` and
   not `break-word` because only `anywhere` also shrinks the min-content
   width the flex layout sizes .br-body by. */
.route-row .br-name { font-family: var(--font-heading); font-weight: var(--font-heading-weight); font-size: 1rem; line-height: 1.2; overflow-wrap: anywhere; }
/* The title as a button: unstyled to read exactly as the heading it was,
   left-aligned and full-width so the wrap is unchanged, with the focus ring
   on the row's inset (the row is what a keyboard user sees selected). */
.route-row button.br-name {
  all: unset; display: block; width: 100%; box-sizing: border-box; cursor: pointer;
  min-height: 24px; /* the crush sweep's floor; one 16px line is 19px */
  font-family: var(--font-heading); font-weight: var(--font-heading-weight);
  font-size: 1rem; line-height: 1.2; overflow-wrap: anywhere; color: inherit; text-align: left;
}
.route-row:has(button.br-name:focus-visible) { outline: 2px solid var(--color-accent); outline-offset: -2px; }
/* The pin mark, inline at the head of the title (pinned-routes.md) — on a
   route card, and on a Stops tab row it leads its section. It is the reason
   the row is where it is, so it reads BEFORE the name — and it is drawn in
   the accent rather than the text colour because a solid black glyph at
   title weight competes with the title itself. Sized off the title's own
   line box (translateY matches .src-lead's optical nudge), and carries a
   hidden word: colour and shape alone say "pinned" to nobody using a screen
   reader. */
/* .br-fav is the favourite's heart in the same place, for the same reasons
   (ratings-and-favourites.md) — a favourite is membership, not position,
   but the row is still the rider's own and the mark says so before the
   name. */
.route-row .br-pin, .sv-row .br-pin,
.route-row .br-fav, .sv-row .br-fav { color: var(--color-accent); margin-right: 5px; }
.route-row .br-pin svg, .sv-row .br-pin svg,
.route-row .br-fav svg, .sv-row .br-fav svg { display: inline-block; width: 13px; height: 13px; vertical-align: -1px; }
.route-row .br-pin-lbl, .sv-row .br-pin-lbl {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0;
}
/* Folded content-duplicates (duplicate-route-folding.md): one heading per
   card — an identically-named copy just adds its mark/badge to it — and
   the differently-named copies on a quiet "Also saved as" meta line in the
   tags-row style, so alternate titles read as metadata rather than a
   second, smaller heading. .br-dup-own is the small inline ownership
   badge on a copy (Personal / a club's name). */
.route-row .br-also { margin-top: 1px; }
/* .rs-also is the route detail sheet's home for the same "Also saved as"
   block (alsoSavedHtml renders both), so the .br-* chrome applies there
   too — one selector list per rule, never a copied block that can drift. */
/* An item WRAPS. It was nowrap — mark, name and badges held on one line —
   and on the Duplicates screen, where the body sits beside a thumbnail
   column, a copy's title plus its two badges ran 10px past the list at
   375px and was cut off (the list pins overflow-x, so the tail simply was
   not there). The badges stay whole (.src-own is nowrap); the name breaks
   at its spaces, and anywhere at all for an unbroken token, like every
   other free-text line on the card. */
.route-row .br-also-item, .rs-also .br-also-item { white-space: normal; overflow-wrap: anywhere; }
/* Two or more folded copies stack as a list inside the card (one inline
   entry stays on the "Also saved as" line itself). */
.route-row .br-also-list, .rs-also .br-also-list {
  list-style: none; margin: 2px 0 0; padding: 0 0 0 12px;
  display: flex; flex-direction: column; gap: 3px;
  font-size: 0.75rem; color: var(--color-neutral-600);
}
/* The Duplicates screen's copies list: every copy of the route as a row
   at card scale (the sheet keeps the .br-also meta line above). Mark in a
   fixed first column so the titles align whether or not a copy has one
   (GPX has none); the text column wraps like every other free-text line
   on the card. The shown copy's tag takes the accent — it is the one the
   Routes tab renders, and the "Which copy to show" setting points at it. */
.route-row .br-copies { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.route-row .br-copy { display: grid; grid-template-columns: minmax(18px, auto) minmax(0, 1fr);
  align-items: start; column-gap: 5px; font-size: 0.8125rem; line-height: 1.4; min-height: 24px; }
.route-row .br-copy .src-lead { margin-right: 0; transform: none; padding-top: 3px; display: flex; }
.route-row .br-copy .src-badge-logo.is-rwgps { height: 12px; }
.route-row .br-copy .src-badge-logo.is-strava, .route-row .br-copy .src-badge-logo.is-komoot { height: 13px; }
.route-row .br-copy .br-copy-text { min-width: 0; overflow-wrap: anywhere; }
.route-row .br-copy .br-copy-text > * + * { margin-left: 6px; }
.route-row .br-copy .br-dup-own { margin-left: 0; }
.route-row .br-copy .br-own { font-size: 0.75rem; color: var(--color-neutral-600); }
.route-row .br-copy .is-shown { color: var(--color-accent); border-color: var(--color-accent); }
/* Provider marks shrink to the meta line's scale (13-15px heading marks
   would dominate a 12px line). */
.route-row .br-also .src-badge-logo, .rs-also .src-badge-logo { height: 11px; }
.route-row .br-also .src-badge-logo.is-strava, .rs-also .src-badge-logo.is-strava { height: 12px; }
.route-row .br-also .src-badge-logo.is-komoot, .rs-also .src-badge-logo.is-komoot { height: 12px; }
.route-row .br-also .src-lead, .rs-also .src-lead { margin-right: 4px; transform: translateY(-1px); }
.route-row .br-dup-own, .rs-also .br-dup-own { font-size: 0.6875rem; color: var(--color-neutral-600);
  margin-left: 6px; padding: 1px 5px; border: 1px solid var(--color-divider);
  border-radius: 4px; vertical-align: 1px; }
/* Only the title links out to the route's external page — the rest of the
   row stays inert (free for in-app interactions like the stop chip). */
.route-row .br-link, .rs-also .br-link { color: inherit; text-decoration: none; }
@media (hover: hover) { .route-row .br-link:hover, .rs-also .br-link:hover { text-decoration: underline; } }
.route-row .br-ext, .rs-also .br-ext { font-size: 0.6875rem; color: var(--color-neutral-600); margin-left: 4px; }
/* Wrapping matters since the thumbnail column narrowed the body: a
   phone-width card with four shown metrics flows onto a second line
   instead of overflowing into the outline. */
.route-row .br-stats { display: flex; flex-wrap: wrap; gap: 4px 16px; }
.route-row .br-stats span { font-size: 0.8125rem; white-space: nowrap; }
.route-row .br-stats b { font-family: var(--font-heading); font-weight: var(--font-heading-weight); }
.route-row .br-stats .u { font-size: 0.6875rem; color: var(--color-neutral-600); }
/* Each stat's metric glyph (METRIC_ICONS — the vocabulary the sort sheet
   teaches), quiet beside the value it names. */
.route-row .br-stats .bs-ic { display: inline-block; vertical-align: -1px; margin-right: 3px; color: var(--color-neutral-600); }
.route-row .br-stats .bs-ic svg { display: block; }
/* The stat the list is currently SORTED by — the accent is what says
   "this column orders the list" now that the Sort pill keeps one static
   face. */
.route-row .br-stats .bs-on b, .route-row .br-stats .bs-on .bs-ic { color: var(--color-accent); }
.route-row .br-tags { font-size: 0.75rem; color: var(--color-neutral-600); overflow-wrap: anywhere; }
/* On a route card these chips sit BESIDE the floated course outline, so the
   row establishes its own formatting context — a flex container, like a
   block formatting context, is NARROWED by a float instead of flowing under
   it. Without that each chip is an atomic inline box: one too wide for the
   line box the float shortens drops WHOLE onto the next line, leaving a hole
   beside the outline and a card taller by a row whenever a sentence ran
   long. Narrowed, the chip shrinks into the width that is actually free.
   The gap is the other half of the same rule: the rain and ice chips follow
   the lead chip with no whitespace between them in the markup (template
   concatenation), so as inline boxes they touched. The route detail sheet
   uses the same row and gets the gap for free — it has no float, so the
   narrowing simply never applies there. */
.wind-reason-row { display: flex; flex-wrap: wrap; gap: 6px; min-width: 0; }
/* Shrinking has to be allowed for the narrowing to mean anything: a flex
   item refuses to go below its content by default, which would put the chip
   back under the outline. The sentence WRAPS rather than clipping or
   ellipsising — `.tag` is an inline-flex box, so its text sits in an
   anonymous item that `text-overflow` cannot reach, and a half-sentence
   ("Rain arriving on the w") is worse than a two-line pill. */
.wind-reason-row .tag { min-width: 0; max-width: 100%; white-space: normal; }
/* The course-outline preview: a flex SIBLING of .br-body (never an
   absolute overlay — the stats/wind/tags rows are variable-height and
   full-width, so an overlay collides with whichever comes last at phone
   width; a reserved column can't). Bottom-right per the card's free
   corner; quiet ink so the accent start dot reads. Rows without a sketch
   simply don't render the slot. */
/* The course-outline preview FLOATS into the card's right-hand corner,
   below the title. It was a reserved flex column at first, which took its
   width off every line in the card — the title wrapped early and the
   stats crowded, i.e. a decoration squeezed the two things a rider
   actually reads. A float takes width only from the lines it is actually
   beside: the title (above it in source order) always keeps the full
   width, and on a card whose text is shorter than the outline nothing is
   narrowed at all.
   An absolute corner box was the other candidate and is worse: on a
   short card the stats row IS the bottom-right corner, so it either
   collides with the outline or pays a permanent right padding it only
   needs sometimes.
   flow-root is what contains the float — it makes the card grow to hold
   the outline instead of letting it spill past the bottom edge. */
/* A <button>, so it is stripped back to the box the float needs: the
   picture is its own destination (the full-screen map) rather than a
   decoration on the card's. */
.route-row .br-thumb {
  float: right; width: 56px; height: 56px;
  /* The 7px top matches the sibling gap, so the picture's top edge lines
     up with the stats row it sits beside rather than hugging the title. */
  margin: 7px 0 8px 14px; padding: 0;
  appearance: none; border: 0; background-color: transparent;
  cursor: zoom-in; touch-action: manipulation;
  color: color-mix(in srgb, var(--color-text) 62%, transparent);
}
.route-row .br-thumb:focus-visible { color: var(--color-text); }
@media (hover: hover) { .route-row .br-thumb:hover { color: var(--color-text); } }
.route-row .br-thumb svg { display: block; width: 100%; height: 100%; position: relative; }
/* The card's map background (basemap.js at card zoom) is painted in as a
   data URI once per route and cached — see paintCardThumbs. Absent, the
   outline sits on the card as before. */
.route-row .br-thumb { background-repeat: no-repeat; background-size: 100% 100%; }
.route-row .br-thumb.has-basemap { color: var(--color-text); border-radius: 3px; }

/* ── Help (the retired overlay's shared survivors) ──────────────────────── */
.section-label { font-size: 0.6875rem; letter-spacing: 0.1em; text-transform: uppercase; color: var(--color-neutral-600); margin-bottom: 12px; }
/* Data-source credits — Help's full licence statement and the Map data
   card's footer line. Quiet reference text; links stay accent-coloured. */
.data-credits-card { font-size: 0.75rem; line-height: 1.55; color: var(--color-neutral-700); display: flex; flex-direction: column; gap: 6px; padding: 0 2px; }
.lib-meta.data-credits { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--color-hairline); }

/* ── Cafe stop chip + stop sheet (cafe-ux Phase 1) ──────────────────────────
   The chip names a route's headline stop on route rows and chat route
   cards. On a ROW it is a plain label: the row's own tap opens the route
   sheet, whose Stops fold carries the tiered list this chip used to open,
   and a 19px control inside a full-width button was winning taps aimed at
   the route. On a CHAT card it stays a button — nothing behind that card
   opens the stop list otherwise — so the affordances are scoped to the
   element that actually has an action. */
.stop-chip-row { margin-top: 2px; min-width: 0; display: flex; }
/* The pill look comes from the shared .tag/.wind-tag classes (same language
   as the wind/rain/ice reason chips); this adds only the button reset on
   top, with the tap affordances on the interactive one alone. */
.stop-chip { min-width: 0; max-width: 100%; border: none; font-family: inherit; text-align: left; }
button.stop-chip { cursor: pointer; }
@media (hover: hover) { button.stop-chip:hover { background: var(--color-neutral-200); } }
.stop-chip .sc-icon { flex: none; }
.stop-chip .sc-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Three fields, three priorities. The distance is held OUT of the ellipsised
   text and never shrinks — it is what a rider compares between routes, and
   the old single text run threw it away first ("Average Doughs Bakery ·
   Leybourne Chase @ 52 km" truncating to "…Leybourn"). The town goes the
   other way: it is dropped WHOLE by fitStopChips when the text doesn't fit,
   which is what buys the name its room back. Only if the name alone still
   overflows does it ellipsise. */
.stop-chip .sc-at { flex: none; color: var(--color-neutral-700); }
.stop-chip.sc-tight .sc-town { display: none; }
.stop-chip .sc-more { flex: none; color: var(--color-neutral-600); }
/* Stops view (cafe-ux Phase 2): every café stop across the visible routes,
   one tappable row per stop — tap filters the route list to rides via it.
   The panel clips (overflow hidden), so like #routes-list the view brings
   its own scroller: #stops-list scrolls endlessly (rows appended in batches
   as the sentinel nears). The controls are NOT here — the tab reuses
   the Routes tab's own .list-controls block above the divider (meta row with
   .list-search + .list-count + .filters-pill + .sort-pill, then the
   .filters-panel rails, whose foot carries the count and Clear all), so
   both tabs share one set of rules rather than growing a parallel one. */
#stops-view { flex: 1; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
#stops-view[hidden] { display: none; }
/* Top padding replaces what the filter row used to contribute above the
   first section label, now that the field has moved up into the controls. */
/* `hidden auto` for the same reason as #routes-list: y-only auto computes
   x to auto too, and a stop row pushed wide by an unbreakable token would
   make this whole list pannable sideways. */
#stops-list { flex: 1; min-height: 0; overflow: hidden auto; overscroll-behavior-y: contain; padding: 12px 18px 24px; display: flex; flex-direction: column; gap: 16px; }
.sv-row {
  display: flex; align-items: center; gap: 10px; width: 100%;
  padding: 9px 2px; background: none; border: none; cursor: pointer;
  font: inherit; color: var(--color-text); text-align: left;
  border-radius: var(--radius-md);
}
.sv-row + .sv-row { border-top: 1px solid var(--color-hairline); }
@media (hover: hover) { .sv-row:hover { background: var(--color-surface); } }
.sv-row:active { background: color-mix(in srgb, var(--color-text) 7%, transparent); }
.sv-icon { flex: none; }
.sv-info { min-width: 0; display: flex; flex-direction: column; }
/* overflow-wrap for the same reason as .br-name: an OSM name or town with
   an unbreakable token must break rather than push the row past the
   list's x-clip. */
.sv-name { font-family: var(--font-heading); font-weight: var(--font-heading-weight); font-size: 0.875rem; line-height: 1.3; overflow-wrap: anywhere; }
.sv-meta { font-size: 0.75rem; color: var(--color-neutral-600); margin-top: 1px; overflow-wrap: anywhere; }
/* The destination badge on a stop row — the stop sheet's .ss-badge recipe,
   named for its own surface: the Destinations chips select exactly the rows
   wearing one, so it has to read at meta size without shouting. */
.sv-dest { font-size: 0.625rem; padding: 1px 8px; margin-left: 2px; vertical-align: middle; white-space: nowrap; }
/* The route count IS the button that filters the route list to rides via
   this stop — the action the whole row used to perform silently, now named
   and counted, with the row's own tap freed for the stop detail sheet. A
   quiet outlined pill: it is the row's secondary action (the row itself is
   the primary one), and it must not out-shout the stop's name. Its own
   min-height keeps it over the 24px target floor inside a 14px-text row. */
.sv-routes {
  margin-left: auto; flex: none; font: inherit; font-size: 0.75rem;
  min-height: 26px; padding: 3px 10px; white-space: nowrap;
  color: var(--color-neutral-700); background: var(--color-surface);
  border: 1px solid var(--color-divider); border-radius: var(--radius-pill);
  cursor: pointer; font-variant-numeric: tabular-nums;
  touch-action: manipulation;
}
@media (hover: hover) { .sv-routes:hover { background: var(--color-neutral-200); color: var(--color-text); } }
.sv-routes:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
.sv-go { flex: none; color: var(--color-neutral-600); font-size: 1rem; line-height: 1; }
.sv-sentinel { flex: none; height: 1px; }
/* Stop enrichment line (v6 metadata): hours, amenity badges, links — shared
   by the stop sheet rows and the Stops view rows. */
.stop-extra { display: flex; flex-wrap: wrap; gap: 4px 10px; align-items: center; margin-top: 4px; font-size: 0.6875rem; color: var(--color-neutral-700); }
.se-hours { overflow-wrap: anywhere; }
/* The open/shut verdict in front of a stop's raw opening_hours. Weight
   and colour only — never a colour ALONE, since "open" and "closed" are
   exactly the pair a red/green split fails for, so both stay bold and the
   words carry the meaning on their own. Only a value the evaluator read
   gets one of these; an unparsed string wears no verdict at all. */
.se-open { color: var(--color-accent); font-weight: 700; }
.se-shut { color: var(--color-neutral-600); font-weight: 700; }
.se-badge {
  border: 1px solid var(--color-divider); border-radius: var(--radius-pill);
  background: var(--color-surface); color: var(--color-neutral-700);
  padding: 1px 8px; font-size: 0.625rem; white-space: nowrap;
}
.se-link { color: var(--color-accent); text-decoration: none; font-weight: 600; white-space: nowrap; }
@media (hover: hover) { .se-link:hover { text-decoration: underline; } }
/* ── The screen chassis (guidelines/surfaces.html rung 4): a full-screen
   in-shell overlay that needs the viewport but stays in the page because it
   reads or feeds live state — Duplicates, Diagnostics, the map viewer. ONE
   scaffold, the way .sheet is the one sheet chassis (ui-consolidation step
   2): each screen used to carry its own verbatim fixed/inset/display block
   under its id, which is how three of them came to agree by discipline
   rather than by construction. `.open` is the state screenOpened() toggles;
   `.screen-controls` is the strip under the page head (chips, segs, a
   slider); `.screen-body` the one scrolling region beneath it. ── */
.screen {
  position: fixed; inset: 0; z-index: 40; background: var(--color-bg);
  display: none; flex-direction: column;
  touch-action: pinch-zoom; /* pinned chrome — see #app */
}
.screen.open { display: flex; }
.screen-controls { padding: 12px 18px; border-bottom: 1px solid var(--color-divider); flex-shrink: 0; }
.screen-body { flex: 1; overflow: hidden auto; overscroll-behavior-y: contain; }
/* The maintenance screens' control strip (Diagnostics; the Duplicates
   screen's controls sit in a fold now). Reuses the shared .chip toggle
   language; the slider takes the accent so it reads as part of the system
   on both themes. */
.near-row { display: none; align-items: center; gap: 8px 14px; flex-wrap: wrap; }
.near-row.open { display: flex; }
/* ── The Duplicates screen's Settings fold ────────────────────────────────
   The five controls sit in ONE .rs-fold (the route sheet's own fold: label,
   peek, caret, device-local memory) inside the controls strip, closed by
   default so the copies lead — the strip used to lay them out in the open
   as ✓-prefixed ghost chips under two labelled groups plus a <details>
   explanation, ~230px of a phone before the first copy. The strip's padding
   drops to 16px because the fold head carries 2px of its own, which lands
   the label on the 18px page gutter every other line here sits on. */
.dups-controls { padding: 0 16px; }
.dups-controls .rs-fold-body { padding: 0 2px 10px; gap: 0; }
/* Inside: the /settings row grammar (.pref-rows / .pref-row — name over
   one sentence, the control right-aligned) under two run subheads that say
   WHERE each answer is kept. The aside is lower case and un-bold on
   purpose: an aside to the heading, not a second heading. */
.dup-set-head { margin: 4px 0 2px; }
.dup-set-head + .pref-rows > :first-child { border-top: 0; }
.dup-set-scope { font-weight: 400; }
.dup-row .lib-name { font-size: 0.8125rem; }
.dup-meta { font-size: 0.75rem; color: var(--color-neutral-600); margin-top: 1px; line-height: 1.35; }
/* The two rows that hang off "Match close copies" dim with it — the
   whole row, since the control is disabled and the sentence describes a
   choice that cannot be made. */
.dup-row.is-dim, .dup-block.is-dim { opacity: 0.45; }
/* /settings' How far in row under "No stop": the same dim as the Duplicates
   rows that hang off a switched-off control. */
.pref-row.is-dim { opacity: 0.45; }
.dup-row.is-dim .lib-source-toggle { pointer-events: none; }
/* The threshold takes the block shape (name and value on one line, the
   sentence, then the control full width): 128px of range beside a label
   was the one control on the old strip a thumb could not work. */
.dup-block { padding: 10px 0 12px; border-top: 1px solid var(--color-hairline);
             display: flex; flex-direction: column; gap: 3px; font-size: 0.8125rem; }
.dup-block .db-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
.dup-block .db-val { font-family: var(--font-heading); font-weight: var(--font-heading-weight);
                     font-size: 0.8125rem; font-variant-numeric: tabular-nums; }
.dup-block input[type="range"] { width: 100%; accent-color: var(--color-accent); margin: 8px 0 0; display: block; cursor: pointer; }
/* The count line: what the list adds up to. Its register IS the Routes
   tab's .list-count (declared beside it, one rule for both) — only the
   padding and the wrap are this screen's own, since a summary sentence has
   to fold at phone width where a meta-row count never may. */
.dups-summary { padding: 12px 18px 4px; flex-shrink: 0; }
.dups-summary[hidden] { display: none; }
/* ── Empty states (ui-consolidation step 5): ONE shape for every empty
   list. The message says WHICH emptiness — nothing exists yet, the
   filters removed everything, or the search missed — counts against the
   honest pool, and at most ONE action follows: the one that undoes it,
   honouring the scope (guidelines/empty-states.html). ── */
.empty-state {
  padding: 24px 18px; color: var(--color-neutral-600); font-size: 0.875rem;
  display: flex; flex-direction: column; gap: 10px; align-items: flex-start;
}
.empty-state.tight { padding: 12px 0 24px; }  /* inside an already-padded host */
/* The Duplicates screen's message slot — the empty-state shape plus the
   measure a longer loading/error sentence wants. */
.dups-empty { line-height: 1.5; max-width: 46ch; }
.near-slider {
  display: flex; align-items: center; gap: 7px; cursor: pointer;
  font-family: var(--font-heading); font-weight: var(--font-ui-weight);
  font-size: 0.75rem; color: var(--color-neutral-800); white-space: nowrap;
}
.near-slider input[type="range"] {
  width: 128px; accent-color: var(--color-accent); cursor: pointer;
}
.near-slider.disabled { opacity: 0.45; pointer-events: none; }
/* The lead-copy seg (Oldest | Newest) rides the shared .seg kit, compacted
   to the row's 12px control scale. `border: 0` because these options are
   real <button>s here (they were spans, so the pair could not be reached by
   keyboard) and a button carries a UA border the .seg's own rounded clip
   then shows a sliver of. */
/* min-height rather than padding arithmetic: the option used to clear WCAG
   2.5.8's 24px floor only by accident — a span's line-height plus 8px of
   padding came to exactly 24, and dropping the UA border above took the
   button variant to 22. A floor states the requirement instead of
   depending on a font metric to keep meeting it. */
.near-row .seg-opt { padding: 4px 10px; font-size: 0.75rem; border: 0; min-height: 24px; }
.near-row .seg-opt + .seg-opt { border-left: 1px solid var(--color-divider); }
.near-row .seg-opt:focus-visible { outline: 2px solid var(--color-accent); outline-offset: -2px; }
/* On/off must read at a glance: .chip.ghost's transparent fill would
   otherwise OUTRANK .chip.selected (same specificity, later in the file),
   leaving an enabled toggle looking identical to a disabled one — so the
   selected state is re-asserted here, with a ✓ for good measure. */
.near-row .chip.ghost.selected {
  background: var(--color-accent); color: var(--color-bg);
  border-color: transparent;
}
.near-row .chip.ghost.selected::before { content: "✓ "; }
/* (.near-slider, the compact seg and the ✓-chip above serve the
   Diagnostics screen's strip now; the Duplicates screen's controls moved
   into its Settings fold, .dups-controls.) */

/* ── Diagnostics (full-screen, Library → Maintenance; perf-metrics.md) ──
   Performance measurements + debug log, recorded and read on this device.
   Same full-screen grammar as Duplicates — a maintenance surface, not a
   browse mode — and the same .chip control language in its header row, so
   the two screens read as one system. The one thing it adds is a DATA
   TABLE: measurements only mean something next to each other, and a table
   is the honest shape for count/p50/p95/max. It scrolls inside its own
   container (the page body never scrolls sideways) and uses tabular
   figures so columns of numbers line up. */
#perf-body {
  padding: 16px 18px 40px;
  display: flex; flex-direction: column; gap: 18px;
}
.perf-note { font-size: 0.6875rem; line-height: 1.45; color: var(--color-neutral-600); }
.perf-sec-head {
  display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap;
  margin-bottom: 8px;
}
.perf-sec-head h2 {
  font-family: var(--font-heading); font-weight: var(--font-heading-weight);
  font-size: 0.9375rem; margin: 0;
}
.perf-sec-head .hint { font-size: 0.6875rem; color: var(--color-neutral-600); }
/* Vitals/stat tiles — the admin console's stat row as a wrapping grid, so a
   phone gets two or three per line instead of eight crushed ones.
   The dividers are drawn as inset shadows on each tile rather than as grid
   gaps over a coloured backdrop: auto-fit means the last row is usually
   short, and a gap-drawn grid paints those empty cells as a grey slab. The
   wrapper clips the first row's and column's lines (the -1px offset), so
   only the lines BETWEEN tiles survive however many there are. */
.perf-tiles { border-radius: var(--radius-lg); overflow: hidden; box-shadow: var(--shadow-sm); background: var(--color-card); }
.perf-grid {
  display: grid; margin: -1px 0 0 -1px;
  grid-template-columns: repeat(auto-fit, minmax(108px, 1fr));
}
.perf-tile {
  background: var(--color-card); padding: 12px 14px; min-width: 0;
  box-shadow: inset 1px 1px 0 var(--color-hairline);
}
.perf-tile .v {
  font-family: var(--font-heading); font-weight: var(--font-heading-weight);
  font-size: 1.1875rem; font-variant-numeric: tabular-nums;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* Tiles whose value is a WORD, not a measurement — they wrap instead of
   ellipsising, because "Brow…" and "v2026…" tell you nothing. */
.perf-tile .v.txt {
  font-size: 0.8438rem; line-height: 1.3; white-space: normal;
  overflow-wrap: anywhere; font-variant-numeric: normal;
}
.perf-tile .k {
  font-size: 0.6875rem; letter-spacing: 0.1em; text-transform: uppercase;
  color: var(--color-neutral-600); margin-top: 2px;
}
.perf-tile .v.warn { color: var(--color-warn-text); }
.perf-tile .v.bad { color: var(--color-danger); }
/* The measurement table. Wide by nature (7 numeric columns), so it gets its
   own horizontal scroller rather than squeezing the numbers. */
.perf-tablewrap {
  overflow-x: auto;
  background: var(--color-card); border-radius: var(--radius-lg);
  box-shadow: var(--shadow-sm);
}
table.perf-table { width: 100%; border-collapse: collapse; font-size: 0.75rem; }
.perf-table th, .perf-table td {
  padding: 7px 10px; text-align: right; white-space: nowrap;
  font-variant-numeric: tabular-nums;
}
.perf-table th {
  background: var(--color-card);
  font-size: 0.6875rem; letter-spacing: 0.08em; text-transform: uppercase;
  color: var(--color-neutral-600); font-weight: 600;
  border-bottom: 1px solid var(--color-divider);
  user-select: none;
}
/* Only the sortable table's headers are pressable — the Network rollup
   reuses the same table but has nothing to sort by. */
.perf-table th[onclick] { cursor: pointer; }
@media (hover: hover) { .perf-table th[onclick]:hover { color: var(--color-text); } }
.perf-table th.sorted { color: var(--color-accent); }
.perf-table th:first-child, .perf-table td:first-child {
  text-align: left; white-space: normal; min-width: 150px;
}
.perf-table tbody tr + tr td { border-top: 1px solid var(--color-hairline); }
.perf-table td.op { font-family: var(--font-heading); font-weight: 600; }
.perf-table td.op .scope { color: var(--color-neutral-600); font-weight: 400; }
/* Relative-cost bar under the operation name — where the time actually
   went, without a chart library. */
.perf-bar { height: 3px; border-radius: 2px; background: var(--color-accent); opacity: 0.55; margin-top: 3px; }
/* Log lines: monospace, level-coloured, wrapping (a stack trace must be
   readable, not truncated). */
.perf-log { display: flex; flex-direction: column; }
.perf-line {
  display: flex; gap: 8px; padding: 6px 10px; font-size: 0.7188rem;
  line-height: 1.45; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  border-bottom: 1px solid var(--color-hairline); align-items: baseline;
}
.perf-line .t { flex: none; color: var(--color-neutral-600); font-variant-numeric: tabular-nums; }
.perf-line .lv {
  flex: none; text-transform: uppercase; font-size: 0.6875rem; letter-spacing: 0.06em;
  padding-top: 2px; min-width: 34px;
}
.perf-line .m { min-width: 0; overflow-wrap: anywhere; }
.perf-line .mt { color: var(--color-neutral-600); }
.perf-line.error .lv, .perf-line.error .m { color: var(--color-danger); }
.perf-line.warn .lv { color: var(--color-warn-text); }
.perf-line.info .lv { color: var(--color-accent); }
.perf-line.debug .lv, .perf-line.debug .t { color: var(--color-neutral-600); }
.perf-empty { padding: 22px 4px; font-size: 0.7812rem; color: var(--color-neutral-600); line-height: 1.5; }
/* The header row's controls share the Duplicates screen's chip language. */
.perf-controls .chip.ghost.selected {
  background: var(--color-accent); color: var(--color-bg); border-color: transparent;
}
.perf-controls .chip.ghost.selected::before { content: "✓ "; }
.perf-controls .seg-opt { padding: 4px 10px; font-size: 0.75rem; }

/* ── Route profile / Smart Collections editors — the shared .sheet chassis
   (ui-consolidation step 2; the scrim, motion and close idiom come from
   .sheet/.sheet-card/.ss-head). One variant survives: a wider card, the
   clause rows being the widest content any sheet carries. ── */
.sheet-card.profile-sheet { max-width: 640px; }
.pe-profile {
  border: 1px solid var(--color-hairline); border-radius: var(--radius-md);
  padding: 12px; margin: 10px 0; display: flex; flex-direction: column; gap: 8px;
}
.pe-clause { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
.pe-clause select { flex: 1; min-width: 150px; font-size: 0.8125rem; }
.pe-num { width: 76px; font-size: 0.8125rem; }
.pe-flags { display: flex; gap: 14px; flex-wrap: wrap; font-size: 0.75rem; color: var(--color-neutral-600); }
.pe-count { font-size: 0.75rem; color: var(--color-accent); }

/* ── Shared sheet primitive (mobile-native-ux) ─────────────────────────────
   One bottom-sheet grammar for the sort sheet, the forecast editor, and the
   retrofitted stop sheet + profile/collections editors: a grabber,
   drag-to-dismiss (past 30% height or a velocity flick), scrim tap,
   safe-area padding, and a compositor-safe translateY-only animation on the
   iOS sheet curve. wireSheet()/openSheet()/closeSheet() in index.html own
   the behaviour (focus trap, Android-back history state); these classes own
   the look and the motion. ≥700px the container centres the card — the
   #stop-sheet precedent, now shared. */
.sheet {
  position: fixed; inset: 0; z-index: 60; display: none;
  background: var(--color-scrim);
  align-items: flex-end; justify-content: center;
  /* Pinned chrome (see #app): a drag on the scrim, or on the card outside
     its scroller, must not move the page behind the sheet. .ss-body is a
     scroll container, so it keeps its own pans; the heads below narrow
     this further for the dismiss gesture. */
  touch-action: pinch-zoom;
}
.sheet.open { display: flex; }
.sheet-card {
  background: var(--color-bg); width: 100%; max-width: 560px; max-height: 86dvh;
  border-radius: var(--radius-lg) var(--radius-lg) 0 0; box-shadow: var(--shadow-lg);
  display: flex; flex-direction: column;
  padding-bottom: max(env(safe-area-inset-bottom), var(--kb-inset, 0px));
}
@media (min-width: 700px) {
  .sheet { align-items: center; padding: 24px; }
  .sheet-card { border-radius: var(--radius-lg); }
}
/* The grabber: both the affordance ("this pulls down") and the drag handle.
   touch-action: none so the pointer stream reaches the drag logic instead
   of becoming a scroll. */
.sheet-grab {
  flex: none; width: 36px; height: 5px; border-radius: var(--radius-pill);
  background: var(--color-neutral-300); margin: 8px auto 2px;
  touch-action: none;
}
/* The head drags too (wireSheet gives touch both handles), so it needs the
   same declaration — and it is the handle a finger actually lands on, being
   the whole title block rather than a 5px bar. Without this the browser
   claimed the gesture on the FIRST move: it saw an ordinary touch on
   ordinary content, started its own pan, and fired pointercancel — so the
   card stopped following the finger and the page behind the scrim was what
   moved instead. Vertical is ours and only vertical: pinch-zoom keeps the
   page zoomable, which the dismiss gesture has no need to own. (pan-x was
   declared here too until the scrim above pinned the page: the
   intersection with .sheet's value is pinch-zoom either way, and nothing
   behind a sheet can pan sideways, so the one value now says what
   happens.) */
.sheet-drag { touch-action: pinch-zoom; }
/* translateY is the ONLY animated property (compositor-safe); the curve is
   the iOS sheet spring. sheet-dragging kills the transition so the card
   follows the finger 1:1; reduced motion falls back to an opacity fade
   (the .wb-spin precedent). */
.sheet-anim { transition: transform 0.38s cubic-bezier(0.32, 0.72, 0, 1); }
/* The compositor layer only while the sheet is up: permanently promoted, every closed sheet card held one. */
.sheet.open .sheet-anim { will-change: transform; }
.sheet-anim.sheet-closed { transform: translateY(105%); }
.sheet-anim.sheet-dragging { transition: none; }
@media (prefers-reduced-motion: reduce) {
  .sheet-anim { transition: opacity 0.2s ease; }
  .sheet-anim.sheet-closed { transform: none; opacity: 0; }
}

/* The stop and source sheets ride the shared .sheet/.sheet-card scaffold
   above (ui-consolidation step 2) — the private #stop-sheet/#source-sheet
   scrim and the .ss-card twin that used to sit here are gone. What stays
   .ss-* is the sheet CONTENT grammar (.ss-head/.ss-body/.ss-row …), which
   every sheet shares. */
/* ── The source sheet (library-mobile redesign) ────────────────────────────
   What a source row's caret opens, and what pays for the row being two
   lines: the secondary actions and the long-form state — Sync now, Rescore,
   Import, Disconnect, the connection line, the rate-limit explanation —
   moved here with their wording intact. Reuses the sheet chrome
   (.sheet-card/.ss-head/.ss-body); only the labelled blocks are its own. No
   folds, deliberately: the route sheet folds because its blocks are dense,
   and these are three short ones, so a peek row would hide a single line
   behind a 44px tap. */
.srcs-block { display: flex; flex-direction: column; gap: 7px; }
.srcs-block + .srcs-block { padding-top: 13px; border-top: 1px solid var(--color-hairline); }
.srcs-label { font-size: 0.6875rem; letter-spacing: 0.1em; text-transform: uppercase;
              font-weight: var(--font-ui-weight); color: var(--color-neutral-600); }
.srcs-line { font-size: 0.8125rem; line-height: 1.5; }
.srcs-line.lib-meta { font-size: 0.75rem; }
.srcs-actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-top: 3px; }
.srcs-actions .btn { min-height: 40px; }
/* The one control the sheet shares with the row behind it: the same
   toggle, restated so the sheet stands alone. */
.srcs-switch { display: flex; align-items: center; gap: 12px; padding: 2px 0 4px; }
.srcs-switch .lib-main { margin-right: auto; min-width: 0; }
.ss-head { display: flex; align-items: flex-start; gap: 10px; padding: 16px 18px 10px; }
.ss-title { font-family: var(--font-heading); font-weight: var(--font-heading-weight); font-size: 1rem; line-height: 1.3; overflow-wrap: anywhere; }
.ss-sub { font-size: 0.75rem; color: var(--color-neutral-600); margin-top: 2px; }
.ss-body { overflow: hidden auto; overscroll-behavior-y: contain; padding: 0 18px 18px; display: flex; flex-direction: column; gap: 14px; }
.ss-row { display: flex; align-items: baseline; gap: 10px; padding: 8px 0; }
.ss-row + .ss-row { border-top: 1px solid var(--color-hairline); }
.ss-info { min-width: 0; }
.ss-name { font-family: var(--font-heading); font-weight: var(--font-heading-weight); font-size: 0.875rem; line-height: 1.3; }
.ss-meta { font-size: 0.75rem; color: var(--color-neutral-600); margin-top: 1px; }
.ss-badge { font-size: 0.625rem; padding: 1px 8px; margin-left: 6px; vertical-align: middle; }
.ss-dist { margin-left: auto; flex: none; font-size: 0.75rem; color: var(--color-neutral-700); font-variant-numeric: tabular-nums; }

/* ── Stop detail sheet (opened by tapping a stop row) ────────────────────
   Reuses the sheet chrome (.sheet/.ss-head/.ss-body) and the route sheet's
   metric grid wholesale; the only new chrome is the route rows, which are
   real <button>s — unlike a stop row they carry no links to nest. Plain
   class selectors, not #stop-detail-sheet-scoped, so the gallery can show
   the rows flat (the .rs-* rule, same reason). */
/* The BUTTON variant of .ss-row (ui-consolidation follow-on): same row
   grammar (ss-info/name/meta/dist), plus the resets, tap affordance and
   target padding a real <button> row needs. */
.sd-route {
  width: 100%; padding: 9px 2px; background: none; border: none;
  cursor: pointer; font: inherit; color: var(--color-text);
  text-align: left; border-radius: var(--radius-md);
}
@media (hover: hover) { .sd-route:hover { background: var(--color-surface); } }
.sd-route:active { background: color-mix(in srgb, var(--color-text) 7%, transparent); }
.sd-info { display: flex; flex-direction: column; }

/* ── "Elsewhere": the stop, opened outside this app ──────────────────────
   Deliberately the .sd-route shape rather than a row of .se-link chips: a
   link with a line of explanation under it is what these are, the same as
   the route rows above them, and the shared shape means one 44pt target
   language on the sheet instead of two. .sd-route can't just be reused —
   it is a button reset with a baseline row, and these stack a label over a
   hint — but the padding, the hairline between rows and the press states
   are its, so the two blocks read as one list.

   The 44px floor is the reason for the explicit min-height: these are the
   sheet's smallest targets and they sit at the bottom of a scroll, where a
   mis-tap costs the rider the sheet. Well clear of the 24px the phone-width
   sweep enforces (tests/e2e/app.spec.mjs), which is a floor and not a goal. */
.stop-links { display: flex; flex-direction: column; }
.stop-link {
  display: flex; flex-direction: column; gap: 1px; min-height: 44px;
  justify-content: center; padding: 7px 2px;
  border-radius: var(--radius-md);
  color: var(--color-text); text-decoration: none;
}
.stop-link + .stop-link { border-top: 1px solid var(--color-hairline); }
@media (hover: hover) { .stop-link:hover { background: var(--color-surface); } }
.stop-link:active { background: color-mix(in srgb, var(--color-text) 7%, transparent); }
.sl-label { font-size: 0.875rem; font-weight: 600; color: var(--color-accent); }
/* The arrow marks the rows that LEAVE for the web; the app rows have none,
   because they don't open a page. aria-hidden in the markup — it decorates
   the label, it isn't part of it. */
.sl-ext { font-weight: 400; }
.sl-hint { font-size: 0.75rem; color: var(--color-neutral-600); line-height: 1.35; }
/* Only ever revealed after an app hand-off visibly did nothing, so it reads
   as an answer to something the rider just did rather than a warning
   attached to the rows in advance. */
.sl-note { font-size: 0.75rem; line-height: 1.35; color: var(--color-neutral-700); padding-top: 8px; }

/* ── Route detail sheet (opened by tapping a route card) ─────────────────
   The sheet body's own blocks; the "Also saved as" section reuses the
   card's .br-* chrome via the shared .rs-also selectors above. Plain
   class selectors (not #route-sheet-scoped) so the gallery can show the
   sheet's rows flat — the names are unique to this surface. */
/* 2:1, not the old 160px strip: with a map behind it the frame is a MAP,
   and a very wide short box shows mostly countryside either side of a
   route that is usually roughly square. Taller lets the course fill it. */
.rs-map {
  flex: none; aspect-ratio: 2 / 1; position: relative; overflow: hidden;
  background: var(--color-surface); border: 1px solid var(--color-hairline);
  border-radius: var(--radius-md);
  color: color-mix(in srgb, var(--color-text) 72%, transparent);
}
.rs-map svg { display: block; width: 100%; height: 100%; position: relative; }
/* The offline map background (basemap.js) sits UNDER the course outline.
   Canvas can't read CSS custom properties, so these two tokens are the
   palette the painter resolves and hands to it — one set of inks for the
   page and the picture, per theme. */
.rs-basemap { position: absolute; inset: 0; width: 100%; height: 100%; }
/* With a map behind it the line needs full contrast to stay the subject. */
.rs-map.has-basemap { color: var(--color-text); }
.rs-map { cursor: zoom-in; touch-action: manipulation; }
.rs-map:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }

/* ── Route map viewer (full-screen, from any thumbnail) ─────────────────── */
/* Above the route sheet (60), since that is one of the places it opens
   from. .admin-head carries the top inset itself, as on every full-screen
   surface; the footer takes the bottom one. */
#map-screen { z-index: 62; }
#map-screen .admin-head h1 { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* The stage is the whole remaining screen. touch-action none because the
   pinch and the drag are ours — leaving it to the browser scrolls the page
   under the map instead of panning it. */
.rm-stage {
  position: relative; flex: 1; min-height: 0; overflow: hidden;
  touch-action: none; cursor: grab; transform-origin: 0 0;
  background: var(--color-surface);
  color: color-mix(in srgb, var(--color-text) 78%, transparent);
}
.rm-stage:active { cursor: grabbing; }
.rm-stage.has-basemap { color: var(--color-text); }
.rm-basemap, .rm-course { position: absolute; inset: 0; width: 100%; height: 100%; }
/* The basemap canvas carries its own transform between rasters: the last
   frame moved into place under the redrawn course while the next one
   paints (rmDrawBasemap). Origin 0 0 because that is the frame the maths
   is written in. will-change only for the length of a gesture — a
   permanent layer for a full-screen canvas is memory for nothing. */
.rm-basemap { transform-origin: 0 0; }
.rm-stage.gesturing, .rm-stage.gesturing .rm-basemap { will-change: transform; }
.rm-foot {
  flex: none; display: flex; align-items: center; gap: 12px;
  padding: 8px 16px calc(10px + env(safe-area-inset-bottom));
  font-size: 0.6875rem; color: var(--color-neutral-600);
  border-top: 1px solid var(--color-hairline);
}
/* A scale bar, because a map you can zoom without one invites reading
   detail into a zoom level that has none. */
.rm-scalebar { flex: none; display: inline-flex; align-items: center; gap: 7px; }
.rm-scalebar i {
  display: block; height: 6px; min-width: 20px;
  border: 1px solid var(--color-neutral-600); border-top: 0;
}
.rm-scalebar b { font-weight: 500; font-variant-numeric: tabular-nums; }
/* A class, not #rm-note: the gallery renders this row flat and cannot
   reuse an id (the sheet rows' rule, same reason). */
.rm-note { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* The map style editor (static/map-style.js): one labelled chip row per
   choice, rendered identically in the viewer's sheet and on the Settings
   page's Map card. Above the viewer (62) because that is where its sheet
   opens from. */
#map-style-sheet { z-index: 64; }
.map-style { display: flex; flex-direction: column; gap: 14px; }
.map-style .ms-row { display: flex; flex-direction: column; gap: 4px; }
.map-style .ms-reset { align-items: flex-start; }
.map-style .ms-swatch {
  display: inline-block; width: 10px; height: 10px; border-radius: 50%;
  margin-right: 6px; vertical-align: -1px;
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-text) 25%, transparent);
}
.map-style .chip.selected .ms-swatch { box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-bg) 60%, transparent); }
/* The metric grid packs 2–3 per row (auto-fill: two on a phone, three at
   the sheet's full width) — one metric per line ran a screen tall now the
   sheet is the full set's home, pushing the stops and map off screen. Each
   cell stacks label over value; the hairline separators went with the
   single column (a per-cell border can't know which cells lead a row). */
.rs-metrics { display: grid; grid-template-columns: repeat(auto-fill, minmax(118px, 1fr)); gap: 10px 14px; }
.rs-mrow { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
/* A cell whose value is a sentence, not a number (the stop sheet's
   Address) takes the whole row rather than wrapping inside one column. */
.rs-mwide { grid-column: 1 / -1; }
.rs-mk { font-size: 0.75rem; color: var(--color-neutral-700); }
.rs-mk .bs-ic { display: inline-block; vertical-align: -1px; margin-right: 6px; color: var(--color-neutral-600); }
.rs-mv b { font-family: var(--font-heading); font-weight: var(--font-heading-weight); font-size: 0.875rem; }
.rs-mv .u { font-size: 0.6875rem; color: var(--color-neutral-600); }
.rs-osm { font-size: 0.6875rem; color: var(--color-neutral-600); }
.rs-osm a { color: inherit; }
.rs-also { font-size: 0.75rem; color: var(--color-neutral-600); }
/* The route sheet's Description fold: the rider's text as written, line
   breaks kept (pre-wrap), any unbroken run (a URL) wrapped rather than
   panning the sheet sideways — the no-sideways-pan rule, on a block whose
   content the app does not author. */
.rs-desc {
  font-size: 0.875rem; line-height: 1.5; color: var(--color-neutral-700);
  white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word;
}
/* A URL in the text is a tap: underlined as well as coloured, since it
   sits inside a run of prose where colour alone is a weak signal, and the
   rest of the block is deliberately not interactive. */
.rs-desc a { text-decoration: underline; }

/* ── The route sheet's collapsible sections (route-sheet-overhaul.md) ──────
   The sheet had grown into one long scroll: thirteen metrics, the whole
   weather breakdown, the stop list and the copies, all open, before a
   rider had decided anything. Each dense block now folds behind a row
   carrying the section label it already had, a one-line DIGEST of what is
   inside, and a caret — so the sheet still reads at a glance without
   opening anything.

   Closed is the default the first time; after that this device's own open
   sections come back (`rd-sheet-sections`), because closed on every open
   costs a tap per route forever to anyone who reads the weather on all of
   them.

   Animated on grid-template-rows (0fr → 1fr), never height — the
   .filters-panel precedent, and the one accordion rule in this sheet. */
.rs-folds { display: flex; flex-direction: column; }
.rs-fold { border-top: 1px solid var(--color-hairline); }
.rs-fold:first-child { border-top: none; }
.rs-fold-head {
  display: flex; align-items: center; gap: 10px;
  width: 100%; min-height: 44px; padding: 11px 2px;
  background: none; border: 0; cursor: pointer; text-align: left;
  font-family: var(--font-body); color: var(--color-text);
  border-radius: var(--radius-sm);
}
@media (hover: hover) { .rs-fold-head:hover { background: color-mix(in srgb, var(--color-text) 7%, transparent); } }
.rs-fold-head:active { background: color-mix(in srgb, var(--color-text) 14%, transparent); }
/* The fold row is a real <button>, so it takes keyboard focus and must show
   it. This is the app's standard focus treatment, and it was authored with
   the component — it went missing on the way from the design canvas into
   this sheet, which left the route sheet's only keyboard-operable rows with
   :hover and :active and nothing at all for a Tab. */
.rs-fold-head:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
/* The label keeps .section-label's own treatment — this IS that label, now
   with a control around it, so it must not read as a different thing. */
.rs-fold-label {
  flex: none;
  font-size: 0.6875rem; letter-spacing: 0.1em; text-transform: uppercase;
  font-weight: var(--font-ui-weight); color: var(--color-neutral-600);
}
/* The digest: muted, tabular, ellipsised, and the first thing to go when
   the section opens — the content below says it better than a summary. */
.rs-fold-peek {
  flex: 1; min-width: 0; text-align: right;
  font-size: 0.75rem; color: var(--color-neutral-700);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  font-variant-numeric: tabular-nums;
  transition: opacity 0.2s ease;
}
.rs-fold-caret {
  flex: none; color: var(--color-neutral-600); display: block;
  transition: transform 0.28s cubic-bezier(0.32, 0.72, 0, 1);
}
.rs-fold.is-open .rs-fold-caret { transform: rotate(180deg); }
.rs-fold.is-open .rs-fold-peek { opacity: 0; }
/* visibility, not the 0fr clip alone: without it the controls inside a
   closed fold stay focusable and stay in the a11y tree, so a screen reader
   lands inside a section the row says is collapsed. Delayed on the way
   closed so the content is not blanked mid-animation. */
.rs-fold-wrap {
  display: grid; grid-template-rows: 0fr; visibility: hidden;
  transition: grid-template-rows 0.28s cubic-bezier(0.32, 0.72, 0, 1),
              visibility 0s linear 0.28s;
}
.rs-fold.is-open .rs-fold-wrap {
  grid-template-rows: 1fr; visibility: visible;
  transition: grid-template-rows 0.28s cubic-bezier(0.32, 0.72, 0, 1),
              visibility 0s linear 0s;
}
.rs-fold-inner { overflow: hidden; min-height: 0; }
/* Top and side padding are the focus ring's clearance: .rs-fold-inner keeps
   its overflow clip when open, and a 2px outline at offset 2 on the first
   control inside needs 4px to survive it. */
.rs-fold-body { padding: 6px 4px 16px; display: flex; flex-direction: column; gap: 12px; }
@media (prefers-reduced-motion: reduce) {
  .rs-fold-wrap, .rs-fold-caret, .rs-fold-peek { transition: none; }
}
/* wrap is insurance, not layout: Share · Open in <provider> · Export fit
   three-up at 375px with today's provider labels (measured, ~0 slack), so
   a longer one must drop to its own full-width line, never spill. */
.rs-actions { display: flex; flex-wrap: wrap; gap: 8px; padding-top: 2px; }
/* Buttons + their note as one block, so the sheet body's 14px column gap
   falls either side of the pair rather than between them — the note reads
   as a caption on the buttons, which is what it is. */
.rs-share-block { display: flex; flex-direction: column; }
/* What the Share button is about to hand over: whether this route's page
   opens for anyone else. Quiet by default — the public case is the one
   nobody needs to think about — and only the caveats (private, friends-only,
   not checked) take the emphasised colour, because those are the ones worth
   reading before the link reaches a group chat. Deliberately a sentence
   under the row rather than a badge on the button: the button says what it
   does, this says what will happen. */
.rs-share-note { font-size: 0.75rem; line-height: 1.35; color: var(--color-neutral-600); padding-top: 6px; }
.rs-share-note.is-caveat { color: var(--color-text); }
/* The route sheet's Pin toggle (pinned-routes.md). It sits in the sheet
   HEAD beside Close, not in .rs-actions: those three hand the route to
   something and a pin says where it sits in the rider's own list — and the
   row above has no room, holding its three worded buttons on ONE flex line
   at 375px with nothing to spare, so a fourth control drops Export onto a
   second line.

   Un-pinned it is an ordinary secondary icon button; pinned, the pin fills
   AND takes the accent, so the state is carried by shape as well as colour
   — the pair a rider reads in bright sun, and the one a red-green rider
   reads at all. aria-pressed carries the same fact for a screen reader;
   nothing here is the only channel. */
.rs-pin.is-on, .rs-fav.is-on { color: var(--color-accent); border-color: var(--color-accent); }
/* The pin cap's one sentence, under the title the pin it refused belongs
   to (route sheet and stop detail sheet alike) — the only outcome the
   toggle cannot show by changing its own state, since a refused pin leaves
   the button exactly as it was. */
.rs-pin-note { font-size: 0.75rem; line-height: 1.35; color: var(--color-text); margin-top: 6px; }
/* The route sheet's Weather section (route-weather-story.md): one .ss-section
   holding the frame line, the day's tinted summary (reusing .fs-sum), the
   per-factor blocks (chip row + a .rs-metrics grid of the breakdown numbers
   + a caption), the penalty-budget bar and the 8-direction strip. Nothing
   here is interactive, so the 24px target sweep has no stake in it — but the
   direction strip must survive 375px: eight flexed cells of two-or-three
   tabular figures each. */
.rs-weather { display: flex; flex-direction: column; gap: 10px; }
.rs-wx-frame { font-size: 0.75rem; color: var(--color-neutral-600); }
.rs-wx-prov { font-style: italic; }
.rs-wx-fact { display: flex; flex-direction: column; gap: 8px; }
.rs-wx-cap { font-size: 0.6875rem; line-height: 1.4; color: var(--color-neutral-600); }
.rs-wx-budget {
  display: flex; gap: 2px; height: 8px; border-radius: 4px; overflow: hidden;
  background: var(--color-neutral-100);
}
.rs-wx-seg { display: block; height: 100%; background: var(--color-neutral-400); min-width: 2px; }
.rs-wx-score { background: var(--color-accent); }
.rs-wx-dirs { display: flex; gap: 4px; }
.rs-wx-dir {
  flex: 1 1 0; min-width: 0; display: flex; flex-direction: column;
  align-items: center; gap: 1px; padding: 6px 2px 5px;
  border-radius: var(--radius-sm); background: var(--color-surface);
}
.rs-wx-dir.is-today { background: var(--color-tint); color: var(--color-on-tint); }
.rs-wx-dl { font-size: 0.6875rem; letter-spacing: 0.06em; color: var(--color-neutral-600); }
.rs-wx-dir b { font-size: 0.75rem; font-variant-numeric: tabular-nums; }
.rs-wx-ds { font-size: 0.625rem; color: var(--color-neutral-600); font-variant-numeric: tabular-nums; }
.rs-wx-dir.is-today .rs-wx-dl, .rs-wx-dir.is-today .rs-wx-ds { color: inherit; opacity: 0.8; }

/* ── The weather GRAPH (docs/proposals/route-weather-graph.md) ─────────────
   The Weather fold's body below the day card: two controls on one row (the
   x-axis, worded; the layout, a glyph pair) over one chassis — n lanes on
   ONE shared x-axis, one crosshair, one readout, one numbers table.

   The two controls share a row deliberately. A second worded .seg measured
   38px of a 375px screen against the pair's 70px, and every lane below is
   fighting for the same height: the sheet lands at ~800px closed and four
   lanes are ~545px of body.

   MEASURED at 375px: the worded pair is 225px and the glyph pair 73px, so
   with the gap they need 306px — inside the gallery's 311px host and well
   inside the sheet's ~343px. Below ~306px the row WRAPS rather than
   shrinking anything, which is the right fallback: the alternative is
   truncating "Across the days" into a label nobody can read.

   Colour carries NO identity here — one series per lane, the lane's own
   label says which measure it is. That is the small-multiples rule, and it
   is also forced: the brand palette fails a categorical validation (chroma
   floor, and green against the danger red at dE 7.0 for a protan viewer),
   so a legend of five colours could not be read. */
.rs-wg { display: flex; flex-direction: column; gap: 10px; }
.rs-wg-controls {
  display: flex; align-items: center; justify-content: space-between;
  gap: 8px; flex-wrap: wrap;
}
.rs-wg-axis .seg-opt { padding: 8px 10px; }
/* The layout pair: two icon buttons in the .seg chassis, so the group reads
   as one control beside the worded one rather than as loose furniture. */
.rs-wg-lay .seg-opt { padding: 8px 10px; }
.rs-wg-lay .seg-opt svg { display: block; }
.rs-wg-plot { width: 100%; height: auto; display: block; touch-action: pan-y; }
/* Three type sizes inside the plot and no more, each with a job: the lane's
   NAME, the lane's one-line summary, and the AXIS ticks. They were 8/8/7,
   which put the name and the summary at the same weight-adjusted size and
   left neither enough room against the 12px band above the plot. */
.rs-wg-lane-label {
  font-size: 9px; font-family: var(--font-heading);
  font-weight: var(--font-ui-weight); fill: var(--color-text);
}
.rs-wg-lane-sum { font-size: 8px; fill: var(--color-neutral-600); }
.rs-wg-tick { font-size: 7.5px; fill: var(--color-neutral-600); }
.rs-wg-rule { stroke: var(--color-divider); stroke-width: 0.5; }
.rs-wg-zero { stroke: var(--color-neutral-400); stroke-width: 0.75; }
.rs-wg-freeze { stroke: var(--color-neutral-400); stroke-width: 0.75; stroke-dasharray: 2 2; }
.rs-wg-ink { fill: var(--color-neutral-400); }
.rs-wg-up { fill: var(--wg-tail); }
.rs-wg-down { fill: var(--wg-head); }
.rs-wg-line { fill: none; stroke: var(--color-text); stroke-width: 1.25;
              stroke-linejoin: round; stroke-linecap: round; }
/* The gust band stays neutral: it is the wind's MAGNITUDE, which the lane
   above already says is good or bad, so it needs no opinion of its own. */
/* The diverging wind response as a curve about its zero: filled to the
   baseline on each side and cut exactly at the crossing, so the hue is
   redundant reinforcement and POSITION still carries the sign (see the
   dE note above — on dark the two hues are all but identical to a protan
   reader, and the baseline is what tells them apart). The stroke is what
   makes the shape readable where a section is only just off neutral and
   its fill is a sliver. */
.rs-wg-up-area { fill: var(--wg-tail); opacity: 0.34; }
.rs-wg-down-area { fill: var(--wg-head); opacity: 0.34; }
.rs-wg-up-line { fill: none; stroke: var(--wg-tail); stroke-width: 1.5;
  stroke-linejoin: round; stroke-linecap: round; }
.rs-wg-down-line { fill: none; stroke: var(--wg-head); stroke-width: 1.5;
  stroke-linejoin: round; stroke-linecap: round; }
.rs-wg-band { fill: var(--color-neutral-300); opacity: 0.55; }
.rs-wg-rain-area { fill: var(--wg-rain); opacity: 0.20; }
.rs-wg-rain-line { fill: none; stroke: var(--wg-rain); stroke-width: 1.6;
                   stroke-linejoin: round; stroke-linecap: round; }
.rs-wg-rain-rate { fill: none; stroke: var(--wg-rain-deep); stroke-width: 1.4;
                   stroke-dasharray: 3 2; stroke-linejoin: round; }
.rs-wg-cold { fill: var(--color-neutral-400); opacity: 0.25; }
/* The frost band runs through every lane — a STATE of the ride, not a
   measure of its own. HATCH, not a colour: the app has no amber, and an
   invented status colour would be the one mark here carrying meaning by
   hue alone. A label names it on the top lane, so the pattern is read
   rather than guessed at. */
.rs-wg-frost { fill: url(#rs-wg-hatch); }
.rs-wg-frost-wash { fill: var(--wg-frost); opacity: 0.10; }
.rs-wg-hatch-line { stroke: var(--wg-frost); stroke-width: 1; opacity: 0.5; }
.rs-wg-bandlabel { font-size: 7px; fill: var(--wg-frost); }
.rs-wg-mark { stroke: var(--color-neutral-500); stroke-width: 0.75;
              stroke-dasharray: 2 2; }
.rs-wg-cross { stroke: var(--color-text); stroke-width: 0.75; opacity: 0.5; }
.rs-wg-arrow { stroke: var(--color-text); stroke-width: 0.9; fill: none;
               stroke-linecap: round; opacity: 0.7; }
/* ── The series palette ──────────────────────────────────────────────────
   Each colour here does ONE of the jobs a chart colour can do, and which
   job it is decides the rule it answers to.

   WIND is DIVERGING — polarity about a zero baseline, headwind below and
   tailwind above — so it takes two opposed hues with a neutral midpoint,
   never one hue and a grey. Grey is the MIDDLE of a diverging scale; using
   it as an arm says "this end is nothing", which is the opposite of what a
   headwind is.

   Measured, and the reason nothing here rests on hue: the red/green pair is
   dE 7.0 protan on light and dE 1.6 protan on DARK — all but identical to a
   protanopic reader. What carries the sign is POSITION, above or below the
   baseline, which every reader gets for free; the hue is redundant
   reinforcement for everyone else. Same rule as the status palette's icon +
   label pairing: a colour never carries meaning alone.

   RAIN is IDENTITY — it shares a plot with the wind in the combined view —
   and takes the basemap's own water blue, so the association the map
   already teaches (this blue is water) carries over rather than being
   invented. It measures 2.61:1 on the light surface, under the 3:1 mark
   floor; the relief is the numbers table under every plot, which is present
   by construction rather than as a promise.

   FROST is STATUS, and status is amber here (`--color-warn-text`, the token
   the diagnostics tiles already use) plus a hatch and the word "frost". An
   earlier pass claimed this app had no amber and shipped the band in
   neutral: that was a grep for `--color-warning`, which does not exist,
   against a token that does.

   TEMPERATURE stays text ink on purpose. A fourth hue would push the
   combined plot past three series, which is the point where no palette can
   keep every pair separable and the answer is fewer series or facets — the
   lanes view IS that facet. Form separates it instead: a bare line, no
   fill, directly labelled. */
.rs-wg { --wg-tail: var(--color-accent); --wg-head: var(--color-danger-text);
         --wg-rain: rgb(var(--basemap-water));
         --wg-rain-deep: color-mix(in srgb, rgb(var(--basemap-water)) 62%, var(--color-text));
         --wg-frost: var(--color-warn-text); }

/* The three penalty greys of the day view. A sequential ramp, and the LIGHT
   end of the neutral scale is unreadable on the dark ground (neutral-300
   measures ~1.7:1 on #151816), so the ramp steps down a rung per theme
   rather than inverting. */
.rs-wg { --wg-pen-1: var(--color-neutral-300); --wg-pen-2: var(--color-neutral-400);
         --wg-pen-3: var(--color-neutral-500); }
[data-theme="dark"] .rs-wg,
html:not([data-theme="light"]) .rs-wg {
  --wg-pen-1: var(--color-neutral-500); --wg-pen-2: var(--color-neutral-600);
  --wg-pen-3: var(--color-neutral-700);
}
.rs-wg-pen-wind { fill: var(--wg-pen-1); }
.rs-wg-pen-rain { fill: var(--wg-pen-2); }
.rs-wg-pen-ice { fill: var(--wg-pen-3); }
.rs-wg-score { fill: var(--color-accent); }
.rs-wg-today { stroke: var(--color-accent); stroke-width: 1; fill: none; }
/* A legend is NOT optional once two series share a plot: identity must
   never be colour alone. The lanes view needs none — one measure per lane,
   and the lane's own label is the legend — so this renders for the combined
   view only, which is exactly where the hues start doing identity work. */
/* The swatch mirrors the MARK, not merely its hue: a block for a measure
   drawn as a filled area, a rule for one drawn as a bare line, the same
   hatch for the frost band. Identity then reads the same way in the legend
   as it does in the plot, which six identical 10x3 slivers did not — they
   made the legend a row of dashes the eye had to match by colour alone,
   the one thing the palette here is measured as unable to carry. */
.rs-wg-key { display: flex; flex-wrap: wrap; gap: 6px 14px; font-size: 0.75rem;
             line-height: 1.5; color: var(--color-neutral-600); }
.rs-wg-key span { display: inline-flex; align-items: center; gap: 6px; }
.rs-wg-key i { width: 12px; height: 6px; border-radius: 2px; flex: none;
               background: currentColor; }
.rs-wg-key .k-tail i { background: var(--wg-tail); }
.rs-wg-key .k-head i { background: var(--wg-head); }
.rs-wg-key .k-rain i { background: var(--wg-rain); }
.rs-wg-key .k-speed i { background: var(--color-neutral-300); }
/* Temperature is the one bare LINE on the plot, so it is a rule here. */
.rs-wg-key .k-temp i { background: var(--color-text); height: 2px; }
.rs-wg-key .k-frost i {
  background:
    repeating-linear-gradient(45deg, var(--wg-frost) 0 1px, transparent 1px 3.5px),
    color-mix(in srgb, var(--wg-frost) 12%, transparent);
}
.rs-wg-readout {
  font-size: 0.75rem; line-height: 1.4; color: var(--color-text);
  min-height: 1.4em; font-variant-numeric: tabular-nums;
}
.rs-wg-readout .k { color: var(--color-neutral-600); }
/* The numbers table is the graph's accessible twin, not a second drawing of
   it: the SVG is aria-hidden and this is what a screen reader reads. It is
   the one thing here allowed to pan sideways, so it declares its own rail
   (tests/test_scroll_containers.py's HORIZONTAL_RAILS). */
.rs-wg-tablewrap { overflow-x: auto; overflow-y: hidden; }
.rs-wg-table { width: 100%; border-collapse: collapse; font-size: 0.6875rem;
               font-variant-numeric: tabular-nums; }
.rs-wg-table th, .rs-wg-table td {
  padding: 4px 6px; text-align: right; white-space: nowrap;
  border-bottom: 1px solid var(--color-divider);
}
.rs-wg-table th:first-child, .rs-wg-table td:first-child { text-align: left; }
.rs-wg-table th { color: var(--color-neutral-600); font-weight: var(--font-ui-weight); }
.rs-wg-empty { font-size: 0.75rem; line-height: 1.45; color: var(--color-neutral-600); }

/* ── Library tab (sources · maintenance · advanced) ─────────────────────────
   Was two full-screen overlays (Libraries + Admin); now one panel that reuses
   the admin shell (head / tool-card / stats) so it reads as the same system. */
.lib-head { padding-top: 16px; }
.lib-scroll { flex: 1; min-height: 0; overflow: hidden auto; overscroll-behavior-y: contain; }
.lib-col { max-width: 720px; width: 100%; margin: 0 auto; padding: 20px; display: flex; flex-direction: column; gap: 14px; }
/* Section headers set the tiers apart; the accent one flags Maintenance as a
   first-class core section rather than an admin footnote. */
.lib-section { margin: 10px 0 0; }
.lib-section:first-child { margin-top: 0; }
/* An empty slot takes no room. The Library cards are built from fixed
   containers the renderers fill in (#mapdata-areas, #mapdata-rescore,
   #mapdata-actions, #mapdata-info, #local-lib-rows, #connect-rows, the
   per-card message lines), and an empty one still collected the card's
   12px gap on both sides — a freshly-loaded Map data card carried ~60px
   of blank space between its status line and its credits. Scoped to this
   panel so no other page's spacing moves. */
#panel-library .tool-card > div:empty { display: none; }
.lib-section-accent { color: var(--color-accent); }
.lib-col .admin-stats { border-top: 0; }
/* The .lib-row family is ABSORBED into .lib-source (the proposal's Part 3
   winner table): connections, clubs and the feature-flags rows all ride
   the one row family now. What survives of it: the stacked variant, the
   name-and-controls line inside one, and the shrink/no-shrink rules —
   re-stated against .lib-source below.

   Every row child may shrink: min-width:0 is what lets a long group name
   wrap instead of forcing the row wider than the card. .lib-main is the
   text column that also takes the slack, and it carries min-width ITSELF
   rather than inheriting it from the row: it is used in sheet and screen
   heads too (.ss-head, the map screen), where there is no row to supply
   it, and a text column that cannot shrink pushes its own head wider than
   the viewport. */
.lib-source > div, .lib-line > div { min-width: 0; }
.lib-main { margin-right: auto; min-width: 0; }
/* Row controls never shrink or wrap their label: a manager's row carries
   Sync now + Rescore + the On/Off chip, and at 375px "Sync now" was
   breaking onto two lines while the name column kept its width. The chip
   already carried flex:none; the buttons beside it never did. */
.lib-source > .btn, .lib-line > .btn { flex: none; white-space: nowrap; }
/* The stacked variant: a row whose body is a form or a manage block
   rather than a name-and-controls line. .lib-line is that name-and-controls
   line WITHIN one — same layout, none of the row's own divider or padding,
   so a stacked row doesn't have to fake it with inline overrides. */
.lib-source.is-stack { flex-direction: column; align-items: stretch; gap: 8px; }
.lib-line { display: flex; align-items: center; gap: 10px; }
/* The connection line under a device library's name (state, the Strava
   attribution mark where one is owed, Disconnect). */
.lib-conn { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 4px; }
/* Disconnect goes to the END of that line: at phone width the state text
   fills it and the button wraps, where left-aligned it read as the next
   item in the card's list rather than as this row's own action. */
.lib-conn .btn { margin-left: auto; }
/* A card-internal subhead ("Add a source"). Deliberately NOT
   .section-label, which names the PAGE's own sections: wearing that style
   inside a card put two levels of hierarchy into one look, so a divider
   between two runs of rows read as loud as "Sources" above the card. Told
   apart by CASE rather than by size — sentence case against the page
   labels' uppercase — because at 11px a letter-spacing tweak is not a
   difference anyone reads as one. */
.lib-subhead { font-family: var(--font-heading); font-weight: var(--font-heading-weight);
               font-size: 0.75rem; color: var(--color-neutral-600); margin: 14px 0 2px; }
/* A disclosure summary, wherever a row hides an optional form behind one
   (the paste-credentials fallbacks, both Komoot forms). */
.lib-more { cursor: pointer; }
.lib-more + * { margin-top: 8px; }   /* the body a disclosure reveals */
/* One vertical stack for those forms: inputs, consent line, actions. */
.field-stack { display: flex; flex-direction: column; gap: 8px; }
.field-stack[hidden] { display: none; }   /* flex would beat the hidden attribute */
/* A checkbox beside its sentence, aligned on the sentence's FIRST line so
   a consent paragraph that wraps doesn't centre the box against the block. */
.lib-check { display: flex; gap: 7px; align-items: flex-start; cursor: pointer; }
.lib-check input { flex: none; margin-top: 2px; }
/* The one line a rider ACTS on (an option beside a job or a reset), so it
   steps up from .lib-meta's muted 12px to body colour at the size
   .chat-consent .cc-check settled on. Same geometry, only size and colour
   differ; the label text sits in a <span> so the gap above applies. */
.lib-check.is-choice { font-size: 0.8125rem; line-height: 1.45; color: var(--color-text); }
.lib-name { font-family: var(--font-heading); font-weight: var(--font-heading-weight); font-size: 0.875rem; line-height: 1.25; }
.lib-meta { font-size: 0.75rem; color: var(--color-neutral-600); margin-top: 2px; overflow-wrap: anywhere; }
.lib-meta.err { color: var(--color-danger-text); }
/* Library attention dot — an enabled source carries an at-rest sync error
   (state, not an unread count; wired by updateLibAttention). Two-level:
   on the ⋯ menu trigger, and inline on the menu's Library entry. */
.attn-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--color-danger-text); flex: none; }
.attn-dot[hidden] { display: none; }
/* The dot rides the Library tab's icon (mobile-native-ux): source trouble
   is one level deep, not two behind the ⋯ menu. */
.tab { position: relative; }
.tab .attn-dot {
  position: absolute; top: 7px; left: calc(50% + 8px);
  border: 2px solid var(--color-bg);
}
/* One chip sizing for every row family (the absorbed .lib-row's rule and
   the source toggle's were byte-identical). */
.lib-source .chip, .lib-line .chip, .dup-row .chip { flex: none; font-size: 0.75rem; padding: 6px 14px; min-width: 52px; text-align: center; }
/* ── The Library panel's source row (library-mobile redesign) ──────────────
   Namespaced `lib-source*` and deliberately NOT `src-*`: that prefix belongs
   to the route card's multi-source badge, and an earlier `.src-row` here
   collided with its rule further down this file. The badge's rule wins on
   source order, so it silently handed these rows `align-items: baseline`
   and `text-transform: uppercase` — visible only on the two rows whose body
   is a plain div, since a <button> gets `text-transform: none` from the UA
   sheet and masked it everywhere else.
   One shape for every list on the panel: name plus one status line on the
   left, one trailing control on the right, TOP-aligned so the control sits
   on the name's line whatever the text below it does. Measured before the
   change, .lib-row's centred layout put the On chip 131px below the name it
   toggles, and its flex:none buttons squeezed the text column to 165px of a
   375px viewport (110px at 320). Everything a row cannot say in two lines
   lives in the source sheet the row opens — same wording, one tap deeper. */
.lib-source { display: flex; align-items: flex-start; gap: 10px; padding: 9px 0;
              border-top: 1px solid var(--color-hairline); }
.lib-source[hidden] { display: none; }   /* flex would beat the hidden attribute */
.lib-source:first-child { border-top: 0; }
/* The tappable body is a real <button> so the sheet is keyboard-reachable;
   font/text resets undo the UA button styling without inventing a look. */
.lib-source-open { flex: 1; min-width: 0; display: block; text-align: left;
                   background: none; border: 0; padding: 2px 4px; margin-left: -4px;
                   cursor: pointer; font: inherit; color: inherit;
                   border-radius: var(--radius-sm); }
@media (hover: hover) { .lib-source-open:hover { background: color-mix(in srgb, var(--color-text) 7%, transparent); } }
.lib-source-open:active { background: color-mix(in srgb, var(--color-text) 14%, transparent); }
.lib-source-open:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
/* Two lines of status, then the sheet: the clamp is what keeps a row a row.
   Errors are exempt — an error must be readable without a tap. */
.lib-source-open .lib-meta:not(.err) { display: -webkit-box; -webkit-line-clamp: 2;
                                       -webkit-box-orient: vertical; overflow: hidden; }
/* A non-navigating row's text column — the same box as .lib-source-open with
   none of its tap affordance (used where the trailing button is the action). */
.lib-source-main { flex: 1; min-width: 0; padding: 2px 0; }
.lib-source-side { display: flex; align-items: center; gap: 8px; flex: none; min-height: 40px; }
/* The row's navigation chevron. An inline SVG, not a "\203a" glyph: the text
   glyph's advance width is 4px, so it landed as a 4px box flush against the
   card's content edge with its ink overhanging on both sides — which reads
   as a caret cut in half rather than as a chevron. This is the route
   sheet's own idiom (.rs-fold-caret) and the iconography rule besides:
   feather-style inline SVG, 24-viewBox, stroke 2, drawn small. The box is
   sized and centred so the glyph can never be the thing that touches the
   card edge. */
.lib-source-caret { width: 18px; height: 18px; flex: none; display: flex;
                    align-items: center; justify-content: center;
                    color: var(--color-neutral-500); }
.lib-source-caret svg { display: block; }
/* The pill keeps its 30px look; the padding takes the tap target to 44px
   (the system's own floor for a primary control — every toggle on the old
   rows measured 28px). The chip inside is presentation only. */
.lib-source-toggle { background: none; border: 0; padding: 7px 0; margin: 0;
                     cursor: pointer; display: flex; align-items: center; }
.lib-source-toggle:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
.lib-source-toggle .chip { pointer-events: none; }
/* A quiet in-card run heading over a group of source rows ("On this
   device", "Shared with you") — one word said once per group, in place of
   the src-badge the old rows repeated on every line. The first row under a
   heading drops its rule: the heading already divides. */
.lib-subhead + .lib-source { border-top: 0; }
/* Map data card: one row per named coverage area (held / downloading /
   will download / no routes here) — a proper list, not a truncated inline
   run — each with its own Download/Remove action. Dim de-emphasises the
   text of inactive rows without greying the button beside it. */
.area-row { display: flex; align-items: center; gap: 10px; padding: 7px 0; border-top: 1px solid var(--color-hairline); }
.area-row:first-child { border-top: 0; }
.area-row.is-dim .area-name, .area-row.is-dim .area-status { opacity: 0.55; }
.area-name { font-family: var(--font-heading); font-weight: var(--font-heading-weight); font-size: 0.8125rem; line-height: 1.25; margin-right: auto; min-width: 0; }
.area-status { font-size: 0.75rem; color: var(--color-neutral-600); text-align: right; }
.area-status.ok { color: var(--color-accent); }
/* min-height keeps the per-area action on the WCAG 2.5.8 floor: at 11px
   with 4px padding the box lands ~23px, and this is a real tap target on a
   phone. (It only became visible once the card reached /__gallery, which
   is the whole argument for putting it there.) */
.area-row .btn { flex: none; font-size: 0.6875rem; padding: 4px 10px; min-height: 24px; }
/* The COVERAGE list stacks name over status (.area-main wraps the pair):
   three columns — name, right-aligned status, button — could not fit at
   phone width. Both text cells wrapped and the status broke mid-value
   ("6.2 / MB"); measured at 320px one status ran to three lines against a
   two-line name. Costs ~7px a row and buys a row that holds at 320. The
   one-line form above survives for the provenance and history rows, which
   are short and live behind a disclosure. */
.area-main { flex: 1; min-width: 0; }
.area-main .area-name { margin-right: 0; }
.area-main .area-status { display: block; text-align: left; margin-top: 1px; }
/* Continent and country GROUPS over a run of areas — each rendered ONLY
   when the footprint spans more than one of that level, so a
   single-country deployment sees the plain list it always had and a
   Europe-only one never reads "EUROPE" over everything. Two levels, told
   apart typographically rather than by indent: the rows are already tight
   at 375px and an indent would come out of the area name.
   Each level is a <details>, because global coverage turned a heading into
   a lid: one country's build lattice is dozens of rects, and the rows a
   rider wants — their own area, and the one they are about to download —
   were at the top of a screen-and-a-half of places they have never been.
   The groups holding those stand open (renderOnDeviceRow's wantsOpen);
   the rest are a tap. */
.area-all > summary, .area-cont > summary, .area-country > summary {
  display: flex; align-items: center; gap: 8px;
  min-height: 36px; padding: 6px 0; cursor: pointer;
  border-top: 1px solid var(--color-hairline);
  list-style: none;
}
.area-all > summary,
.area-cont:first-child > summary, .area-country:first-child > summary { border-top: 0; }
/* The second level is indented on its SUMMARY alone. The rows keep the full
   width they have always had — they are tight at 375px and an indent would
   come out of the area name — so the hierarchy is carried by the two
   headings, which are the only things that need to show it. */
.area-cont > .area-country > summary { padding-left: 14px; }
.area-all > summary::-webkit-details-marker,
.area-cont > summary::-webkit-details-marker,
.area-country > summary::-webkit-details-marker { display: none; }
/* The disclosure's own arrow: the shared chevron (.disc-caret), pointing
   right while shut and dropping open — the tree-level orientation. */
.area-all > summary .disc-caret,
.area-cont > summary .disc-caret, .area-country > summary .disc-caret {
  color: var(--color-neutral-600); transform: rotate(-90deg);
}
.area-all[open] > summary .disc-caret,
.area-cont[open] > summary .disc-caret, .area-country[open] > summary .disc-caret {
  transform: none;
}
.area-all > summary:focus-visible,
.area-cont > summary:focus-visible, .area-country > summary:focus-visible {
  outline: 2px solid var(--color-accent); outline-offset: 2px;
}
/* The outer fold over the whole coverage list — closed by default, so the
   card leads with what is held rather than with every place that could be.
   Its own rows sit flush under it; the nested continent/country levels keep
   their indent from the rules above. */
.area-all > summary { margin-top: 2px; }
.area-all > .area-row:first-of-type { border-top: 1px solid var(--color-hairline); }
.area-continent {
  font-family: var(--font-heading); font-weight: var(--font-heading-weight);
  font-size: 0.75rem; letter-spacing: 0.04em; color: var(--color-text);
  min-width: 0; overflow-wrap: anywhere;
}
.area-group {
  font-size: 0.625rem; letter-spacing: 0.09em; text-transform: uppercase;
  color: var(--color-neutral-600); min-width: 0; overflow-wrap: anywhere;
}
/* What a closed group says about itself — its cost on this device, or how
   many places are inside. Body face at body weight: it is a fact about the
   group, not a second name for it. */
.area-count {
  margin-left: auto; flex: none;
  font-family: var(--font-body); font-weight: 400;
  font-size: 0.6875rem; letter-spacing: 0; text-transform: none;
  color: var(--color-neutral-600);
}
/* The line standing in for the places that hold no map data at all (the
   lattice's open-sea rects): one sentence rather than thirty rows whose
   every action is a download of nothing. */
.area-note {
  font-size: 0.6875rem; color: var(--color-neutral-600);
  padding: 7px 0; border-top: 1px solid var(--color-hairline);
}
/* The rescore status line: is a pass running, and when did the last one
   run — sitting directly above the buttons that start one. When there is
   a history to show, the whole row is the tap target (44pt, WCAG-sized),
   which is why the link variant is a <button> reset to look like the
   plain one rather than a link glued to the end of the sentence. */
.rescore-row {
  display: flex; align-items: center; gap: 8px; width: 100%;
  min-height: 44px; padding: 6px 0; margin-top: 2px;
  font-size: 0.75rem; color: var(--color-neutral-600); text-align: left;
  background: none; border: 0; font-family: inherit;
  border-top: 1px solid var(--color-hairline);
}
.rescore-row.is-link { cursor: pointer; }
@media (hover: hover) { .rescore-row.is-link:hover { color: var(--color-text); } }
.rescore-row.is-link:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
.rescore-more { margin-left: auto; flex: none; color: var(--color-accent); white-space: nowrap; }
.rescore-note { font-size: 0.75rem; color: var(--color-accent); padding-bottom: 4px; }
/* The run history's own second line: what started the pass and what it
   found, under the count. */
.rescore-why { font-family: var(--font-body); font-weight: 400; font-size: 0.6875rem;
  color: var(--color-neutral-600); margin-top: 2px; }
#rescore-body .area-status { font-size: 0.6875rem; white-space: nowrap; }

/* Map data provenance: the collapsed "against what am I scored?" detail —
   the two clocks (checked / rescored) and a row per cached layer. */
.mapdata-details { margin-top: 10px; }
.mapdata-details > summary {
  font-size: 0.75rem; color: var(--color-neutral-600);
  cursor: pointer; padding: 6px 0; min-height: 24px;
}
.mapdata-details > summary:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
.src-badge {
  display: inline-block; font-size: 0.6875rem; letter-spacing: 0.09em; text-transform: uppercase;
  border: 1px solid var(--color-divider); color: var(--color-neutral-600);
  border-radius: var(--radius-pill);
  padding: 1px 7px; margin-left: 8px; vertical-align: middle; white-space: nowrap;
}
/* Provider source marks in the Routes tab — official vendor logos (never recoloured,
   kept visually separate from our own branding per the providers' guidelines).
   They lead the route title, wrapped in .src-lead. */
.src-badge-logo {
  display: inline-block; vertical-align: middle;
  width: auto; flex: none;
}
/* The leading provider mark, sitting to the left of the route title. Zeroes
   the badge chip's trailing-position margin when GPX's text chip stands in
   for a logo. vertical-align:middle centres on the x-height, which leaves
   the mark hanging below the title's baseline; the nudge re-centres it on
   the cap band without disturbing the line box. */
.src-lead { display: inline-block; margin-right: 7px; vertical-align: middle;
  transform: translateY(-1.5px); }
.src-lead .src-badge { margin-left: 0; }
/* Sized as small source markers, not competing with the route title —
   kept compact so they don't dominate the narrow PWA/mobile card. */
.src-badge-logo.is-rwgps { height: 13px; }
/* Strava's official square brandmark — a compact source glyph on its own
   orange field, legible on both themes (no recolour, per Strava's rules).
   Set a touch taller than the RWGPS mark: a solid square reads optically
   smaller than the wide cyclist line-icon at equal height, so 15px balances. */
.src-badge-logo.is-strava { height: 15px; }
/* Komoot's official square logomark — same compact-glyph role as Strava's, so
   the same 15px. Monochrome, so both theme variants render and only the one
   that reads on the active theme is shown (green on light, cream on dark);
   the swap keys off the stamped data-theme with a prefers-color-scheme
   fallback, mirroring the token blocks at the top of this file. Never
   recoloured — the CSS just picks the correct pre-authored file. */
.src-badge-logo.is-komoot { height: 15px; }
.src-badge-logo.is-komoot-dark { display: none; }
:root[data-theme="dark"] .src-badge-logo.is-komoot-light { display: none; }
:root[data-theme="dark"] .src-badge-logo.is-komoot-dark { display: inline-block; }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .src-badge-logo.is-komoot-light { display: none; }
  :root:not([data-theme="light"]) .src-badge-logo.is-komoot-dark { display: inline-block; }
}

/* Ownership descriptor (Personal / club name) — leads the tags row in the
   same plain style as the type/terrain descriptor text (it inherits the
   row's font and colour; the class exists as a styling hook and so the
   multi-source summary text matches too). */
.src-own { white-space: nowrap; }

/* Multi-source badge — a route folded from more than one library. The
   representative shows once in the Routes tab; the badge names every library it
   lives in and expands to list each copy. */
.src-multi { display: inline-flex; flex-direction: column; align-items: flex-start;
  vertical-align: top; }
.src-summary { display: inline-flex; align-items: center; cursor: pointer;
  border-radius: 3px; }
.src-summary:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
/* First label chip already carries margin-left:8px; the count sits snug after it. */
.src-count {
  display: inline-flex; align-items: center; gap: 3px;
  font-size: 0.6875rem; letter-spacing: 0.06em; margin-left: 6px;
  color: var(--color-neutral-600); white-space: nowrap;
}
/* The glyph itself swaps ⌄/⌃ in __srcToggle — the app's one caret idiom —
   so no rotation and nothing to guard for reduced motion. */
.src-caret { display: inline-block; font-size: 0.625rem; }
.src-list {
  display: flex; flex-direction: column; gap: 2px;
  margin: 4px 0 2px 8px; padding-left: 8px;
  border-left: 1px solid var(--color-divider);
}
/* display:flex above would otherwise override the hidden attribute the
   collapsed state relies on. */
.src-list[hidden] { display: none; }
.src-row {
  display: flex; align-items: baseline; gap: 8px;
  font-size: 0.625rem; letter-spacing: 0.06em; text-transform: uppercase;
  color: var(--color-neutral-600);
}
.src-when { font-size: 0.6875rem; letter-spacing: 0; text-transform: none;
  color: var(--color-neutral-500); }

/* Official "Connect with Strava" button — an image asset, so preserve its
   aspect ratio (Strava prohibits distorting or recolouring the mark). */
.strava-connect-btn { display: inline-flex; line-height: 0; flex: none; }
.strava-connect-btn img { display: block; height: 40px; width: auto; }
/* "Powered by Strava" attribution marks (static/vendor/README.md) — official
   assets at native aspect ratio (no recolour/distort); the orange variant
   reads on both themes. .list-attrib is the quiet end-of-scroll footer on
   Routes; .conn-attrib sits inline on the connected Strava card's action row. */
.list-attrib { padding: 26px 18px 34px; }
.list-attrib img { display: block; height: 20px; width: auto; }
.conn-attrib { display: block; height: 16px; width: auto; flex: none; }
/* A branded tool-card glyph carries a real provider mark rather than a
   line-icon: drop the outline box and let the logo fill the slot. */
.tool-card .tc-glyph.tc-glyph-brand { border: none; }
.tool-card .tc-glyph.tc-glyph-brand img { display: block; width: 30px; height: auto; }
.admin-head {
  display: flex; align-items: center; gap: var(--space-3);
  padding: calc(16px + env(safe-area-inset-top)) 20px 16px;
  border-bottom: 1px solid var(--color-divider); flex-shrink: 0;
}
/* The back button never shrinks under a long title — this one line used
   to be pasted into three pages' own <style> blocks. */
.admin-head .btn { flex: none; }

.admin-head h1 { font-size: 1.375rem; overflow-wrap: anywhere; }
/* The title block is the flex item that shrinks (the glyph and the back
   button are fixed), so it must be allowed to. */
.admin-head > div { min-width: 0; }
.admin-head .sub { font-size: 0.6875rem; letter-spacing: 0.09em; text-transform: uppercase; color: var(--color-accent); margin-top: 3px; }
.admin-stats { display: flex; background: var(--color-card); border-radius: var(--radius-lg); box-shadow: var(--shadow-sm); overflow: hidden; flex-shrink: 0; }
/* min-width: 0 and overflow-wrap: anywhere on the label: three equal
   stats fit a 375px phone with room to spare, but the label is tracked
   uppercase and its longest word ("heartbeat") is the item's automatic
   minimum width, so under iOS Larger Text on a 320px screen the row ran
   past the card and the page with it. Neither declaration moves a pixel
   while the word fits; they only let it break instead of spill. */
.admin-stat { flex: 1; min-width: 0; padding: 14px 18px; }
.admin-stat + .admin-stat { border-left: 1px solid var(--color-hairline); }
.admin-stat .v { font-family: var(--font-heading); font-weight: var(--font-heading-weight); font-size: 1.5rem; }
.admin-stat .k { font-size: 0.6875rem; letter-spacing: 0.1em; text-transform: uppercase; color: var(--color-neutral-600); margin-top: 2px; overflow-wrap: anywhere; }
.admin-body { flex: 1; overflow: hidden auto; padding: 20px; display: grid; grid-template-columns: 1fr; gap: 20px; }
@media (min-width: 780px) { .admin-body { grid-template-columns: 1.3fr 1fr; } }
.admin-tools { display: flex; flex-direction: column; gap: 16px; min-width: 0; }
/* The feature-flag cards render inside one wrapper div (#feature-cards in
   admin.html), so they don't sit as direct children of .admin-tools —
   mirror its column gap or multiple cards visually join. */
#feature-cards { display: flex; flex-direction: column; gap: 16px; min-width: 0; }
.tool-card { background: var(--color-card); border-radius: var(--radius-lg); box-shadow: var(--shadow-sm); padding: 15px 16px; display: flex; flex-direction: column; gap: 12px; }
.tool-card[hidden] { display: none; }   /* flex would beat the hidden attribute */
/* Sub-blocks inside a tool card that appear conditionally (the Komoot
   audience controls). Their display lives here rather than inline, because
   an inline style beats the hidden attribute outright. */
.tc-sub { display: flex; flex-direction: column; gap: 8px; }
.tc-sub[hidden] { display: none; }
.tool-card .tc-head { display: flex; align-items: flex-start; gap: 11px; }
/* The head's text column may shrink so a long card name wraps rather than
   widening the card; the glyph beside it is flex:none and unaffected.
   One rule in place of style="min-width:0" on ~33 card heads. */
.tool-card .tc-head > div { min-width: 0; }
.tool-card .tc-glyph { width: 34px; height: 34px; flex: none; border: 2px solid var(--color-accent); border-radius: var(--radius-md); display: flex; align-items: center; justify-content: center; }
.tool-card .tc-glyph svg { color: var(--color-accent); }
.tool-card .tc-name { font-family: var(--font-heading); font-weight: var(--font-heading-weight); font-size: 0.9375rem; line-height: 1.2; }
.tool-card .tc-desc { font-size: 0.75rem; color: var(--color-neutral-600); line-height: 1.4; margin-top: 3px; }
.tool-card .tc-actions, .profile-sheet .tc-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
/* A note that belongs to a row of actions sits left of them; a row with
   no note pushes its actions to the end rather than leaving them adrift
   at the start. */
.tool-card .tc-actions .ta-note, .profile-sheet .tc-actions .ta-note { margin-right: auto; }
.tool-card .tc-actions.is-end, .profile-sheet .tc-actions.is-end { justify-content: flex-end; }
/* Same trap as .btn[hidden]: the flex above beats the hidden attribute, so a
   collapsible action row (#pref-days) never actually collapsed. Written at
   this specificity so it wins wherever it sits in the file. */
.tool-card .tc-actions[hidden], .profile-sheet .tc-actions[hidden] { display: none; }
/* ── Settings rows (templates/settings.html) ──────────────────────────────
   One grammar for every control on the page: the label on the left, the
   control right-aligned, one hairline between rows, 48px tall — the
   Library tab's source-row shape with a select (or a chip row) where the
   toggle sits. The page used to lay its selects out in a wrapping grid,
   which on a phone put each on its own line starting wherever its label
   ended, so seven weekday selects sat ragged down the card; right-aligned
   their edges line up whatever the label says. A .pref-row that is a
   <label> wraps its select, so the whole row is the select's label. */
.pref-rows { display: flex; flex-direction: column; }
.pref-rows > :first-child { border-top: 0; }
.pref-rows.is-ruled > :first-child { border-top: 1px solid var(--color-hairline); }
.pref-row { display: flex; align-items: center; justify-content: space-between; gap: 12px;
            min-height: 48px; padding: 4px 0; border-top: 1px solid var(--color-hairline);
            color: var(--color-text); font-size: 0.8125rem; }
.pref-row .pr-label { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.pref-row .admin-select { width: auto; flex: none; max-width: 60%; }
.pref-row .chip-row { flex: none; flex-wrap: nowrap; }
/* A block in the same column that is more than one line — a name, a
   sentence, then its control — for the switches and the two-step rows
   that a single line cannot hold. */
.pref-block { padding: 12px 0 0; border-top: 1px solid var(--color-hairline); display: flex; flex-direction: column; gap: 6px; }
.pref-block + .pref-block { margin-top: 12px; }
.pref-block .lib-source { border-top: 0; padding: 0; }
.pref-block .lib-source-main { padding: 0; }
/* The two-step rows hide their quiet button while the confirmation is
   open (#reset-start, #del-start): the same trap as .btn[hidden] — the
   side column's flex beats the hidden attribute without this. */
.pref-block .lib-source-side[hidden] { display: none; }
/* A fold sitting directly in a tool card: the head's own 2px side padding
   is the focus ring's clearance, so the fold reaches to the card's
   content edge and its body keeps the ring's 4px inset. */
.tool-card > .rs-fold .rs-fold-body { padding: 2px 4px 8px; }

.admin-console-wrap { display: flex; flex-direction: column; min-height: 0; }
#admin-console {
  flex: 1; min-height: 200px; background: var(--color-neutral-900); color: var(--color-neutral-200);
  border-radius: var(--radius-md);
  font-family: ui-monospace, "SF Mono", "Fira Code", monospace; font-size: 0.75rem; line-height: 1.55;
  padding: 12px 14px; overflow: hidden auto; overscroll-behavior-y: contain; white-space: pre-wrap; word-break: break-word;
}
#admin-console .add { color: #7fd77f; }
#admin-console .remove { color: #f0938a; }
#admin-console .info { color: var(--color-neutral-500); }
#admin-console .head { color: var(--color-accent-400); font-weight: 700; }

/* ══════════════════════════════════════════════════════════════════════════
   Dark-theme component overrides — a couple of components deliberately pin a
   ramp step assuming a light base (the console wants a dark terminal, danger
   wants a deep fill). Both are re-anchored here for dark so they don't invert.
   ════════════════════════════════════════════════════════════════════════ */
:root[data-theme="dark"] #admin-console { background: #0e110f; color: #cacfc9; }
:root[data-theme="dark"] #admin-console .info { color: #848a83; }
:root[data-theme="dark"] #admin-console .head { color: var(--color-accent-700); }

/* .btn-danger's dark re-anchoring now rides --color-danger (defined per theme
   in the token blocks), so it needs no component override here. */

/* ══════════════════════════════════════════════════════════════════════════
   PWA install affordances — a quiet header button + Help-panel card that fire
   the native install sheet where the browser supports it, and a coached
   overlay for iOS Safari (which has no install API). Hidden until JS decides
   they're relevant, and gone once the app is installed.
   ════════════════════════════════════════════════════════════════════════ */
.install-btn { position: relative; }
/* One-time "new" dot until the button is first used. */
.install-btn.has-dot::after {
  content: ""; position: absolute; top: -2px; right: -2px; width: 9px; height: 9px;
  background: var(--color-accent); border: 2px solid var(--color-bg); border-radius: 50%;
}

/* Help-panel card — mirrors the admin tool-card, but Help-panel scoped. */
.install-card {
  background: var(--color-card); border-radius: var(--radius-lg); box-shadow: var(--shadow-sm);
  padding: 15px 16px;
  display: flex; flex-direction: column; gap: 12px;
}
.install-card .ic-head { display: flex; align-items: flex-start; gap: 11px; }
.install-card .ic-head > div { min-width: 0; }   /* was inline; same reason as .tc-head's */
.install-card .ic-glyph {
  width: 34px; height: 34px; flex: none; border: 2px solid var(--color-accent); border-radius: var(--radius-md);
  display: flex; align-items: center; justify-content: center; color: var(--color-accent);
}
.install-card .ic-name {
  font-family: var(--font-heading); font-weight: var(--font-heading-weight);
  font-size: 0.9375rem; line-height: 1.2;
}
.install-card .ic-desc { font-size: 0.75rem; color: var(--color-neutral-600); line-height: 1.4; margin-top: 3px; }

/* The browser-tab wall (iOS, signed-in, not standalone — templates/
   index.html #pwa-wall). Sits UNDER the install coach (z 50) so tapping
   its Install button opens the ordinary steps over it, and over everything
   else. Opaque bg token: it replaces the app, it doesn't dim it. */
.pwa-wall {
  position: fixed; inset: 0; z-index: 45; background: var(--color-bg);
  display: flex; align-items: center; justify-content: center;
  touch-action: pinch-zoom; /* pinned chrome — see #app */
  padding: 24px calc(16px + env(safe-area-inset-right)) calc(24px + env(safe-area-inset-bottom)) calc(16px + env(safe-area-inset-left));
}
.pwa-wall[hidden] { display: none; }
.pwa-wall .pw-card { max-width: 340px; text-align: center; }
.pwa-wall .pw-icon { width: 64px; height: 64px; border-radius: 14px; box-shadow: var(--shadow-sm); }
.pwa-wall .pw-title {
  font-family: var(--font-heading); font-weight: var(--font-heading-weight);
  font-size: 1.1875rem; line-height: 1.25; margin-top: 14px;
}
.pwa-wall .pw-desc { font-size: 0.8125rem; color: var(--color-neutral-600); line-height: 1.45; margin: 10px 0 0; }
.pwa-wall .pw-open { font-size: 0.8125rem; line-height: 1.45; margin: 12px 0 0; }
.pwa-wall .pw-links { display: flex; flex-direction: column; gap: 10px; margin-top: 16px; font-size: 0.8125rem; line-height: 1.45; }
.pwa-wall .pw-links a { color: var(--color-accent); text-decoration: underline; text-underline-offset: 2px; min-height: 24px; }
.pwa-wall .pw-install {
  display: inline-flex; align-items: center; justify-content: center;
  min-height: 44px; margin-top: 16px; padding: 10px 18px;
}

/* iOS coach overlay — the install steps as a bottom sheet. */
.ios-install {
  position: fixed; inset: 0; z-index: 50; display: none;
  flex-direction: column; justify-content: flex-end;
}
.ios-install.open { display: flex; }
.ios-install-scrim { position: absolute; inset: 0; background: var(--color-scrim); }
.ios-install-sheet {
  position: relative;
  margin: 0 calc(12px + env(safe-area-inset-right)) calc(16px + env(safe-area-inset-bottom)) calc(12px + env(safe-area-inset-left));
  background: var(--color-card); border-radius: var(--radius-lg);
  box-shadow: var(--shadow-lg); padding: 16px 16px 17px;
}
/* Deliberately NO pointer arrow on the sheet. There was one — a ::after
   triangle at a fixed right offset, aimed at where ⋯ sits on Safari's iOS 26
   Compact toolbar — and it failed both ways at once: on any other toolbar
   layout that offset points at nothing in particular (in practice the login
   card's own Install button, peeking out under the sheet's corner, which
   read as the arrow's target), and in dark mode a card-coloured triangle on
   a near-black page barely renders at all. The toolbar guidance lives in
   the steps and the pre-line above them instead. */
/* 4px of padding around the 16px glyph, not 2px: that is what carries the
   box to WCAG 2.5.8's 24px target. The offsets absorb the extra padding so
   the glyph itself does not move. */
.ios-install-x {
  position: absolute; top: 9px; right: 9px; padding: 4px;
  background: none; border: none; cursor: pointer; color: var(--color-neutral-600); display: flex;
}
.ios-install-title {
  font-family: var(--font-heading); font-weight: var(--font-heading-weight);
  font-size: 0.9375rem; line-height: 1.25; padding-right: 24px;
}
.ios-install-note {
  font-size: 0.75rem; line-height: 1.4; margin-top: 10px; padding: 8px 10px;
  background: var(--color-tint); color: var(--color-on-tint); border-radius: var(--radius-md);
}
/* The one-tap escape to Safari, directly under the note that asks for it.
   Full width: it is the sheet's primary action wherever it is shown at all. */
.ios-install-open {
  display: flex; align-items: center; justify-content: center; gap: 7px;
  width: 100%; margin-top: 10px;
}
.ios-install-open[hidden] { display: none; }
.ios-install-open svg { flex: none; }
.ios-install-fallback {
  font-size: 0.75rem; line-height: 1.4; margin-top: 8px;
  color: var(--color-neutral-600);
}
.ios-install-fallback[hidden] { display: none; }
.ios-install-fallback .lf-or { display: block; margin-top: 6px; }
.ios-install-steps {
  list-style: none; margin: 12px 0 0; padding: 0;
  display: flex; flex-direction: column; gap: 10px;
  /* The numbers stay counted, not written, even now nothing is conditional:
     typed digits are the thing that rots silently if a step is ever added
     or dropped. */
  counter-reset: ios-step;
}
/* The list is `display: flex`, which beats the UA's [hidden] rule — so off
   Safari (where the script hides the whole list) it would otherwise stay on
   screen. */
.ios-install-steps[hidden] { display: none; }
.ios-install-steps li { display: flex; align-items: center; gap: 10px; font-size: 0.8125rem; line-height: 1.35; counter-increment: ios-step; }
.ios-install-steps .num::before { content: counter(ios-step); }
.ios-install-steps .num {
  width: 20px; height: 20px; flex: none; background: var(--color-accent); color: var(--color-bg);
  border-radius: 50%;
  display: flex; align-items: center; justify-content: center;
  font-family: var(--font-heading); font-weight: var(--font-ui-weight); font-size: 0.75rem;
}
/* The "Can't see it?" bail-out under the Share step — quieter than the
   instruction it qualifies, because it must not read as a second
   instruction, and IN the step because a rider stuck on step one reads
   nothing below the list. */
.ios-install-steps .step-alt { font-size: 0.75rem; color: var(--color-neutral-600); }
.ios-install-steps .step-alt strong { color: var(--color-neutral-600); }
.ios-install-steps .step-fallback { display: block; margin-top: 3px; }
/* A step carrying a second line centres its number mid-block under the
   default alignment; pin it to the first line instead. */
.ios-install-steps li.has-fallback { align-items: flex-start; }
.ios-install-steps svg { flex: none; vertical-align: -3px; color: var(--color-accent); }

/* The loud path, offered only after the quiet one visibly failed. Reads as
   an action rather than a sentence, because pressing it is a decision: it
   is the navigation that leaves an error alert behind in this browser. */
.safari-direct {
  display: inline-flex; align-items: center; min-height: 34px;
  margin: 6px 0 0; padding: 7px 13px;
  border: 1px solid var(--color-accent); border-radius: var(--radius-md);
  background: none; cursor: pointer;
  font: inherit; font-size: 0.75rem; font-weight: 600; color: var(--color-accent);
}
@media (hover: hover) { .safari-direct:hover { background: var(--color-accent); color: var(--color-bg); } }

/* ── Doc pages (/help, /guide) — the shared typography, moved here from
   two byte-identical page <style> blocks (ui-consolidation step 7); the
   pages keep only their page-local scaffolding. The public pages'
   .doc-table/.callout in public.css stay that sheet's own — the split
   that remains is the public/app boundary, not a fork inside it. ── */
.doc-wrap { max-width: 760px; margin: 0 auto; padding: 20px 18px 56px; }
.doc-wrap h1 { font-size: 1.875rem; line-height: 1.1; margin: 22px 0 8px; }
.doc-sub { font-size: 0.8125rem; color: var(--color-neutral-600); margin-bottom: 26px; }
.doc-wrap h2 {
  font-family: var(--font-heading); font-weight: 800;
  font-size: 1.1875rem; line-height: 1.2; margin: 34px 0 10px;
  padding-top: 22px; border-top: 1px solid var(--color-hairline);
}
.doc-wrap h2:first-of-type { border-top: none; padding-top: 0; }
.doc-wrap h3 { font-family: var(--font-heading); font-weight: 800; font-size: 0.875rem; margin: 20px 0 8px; }
.doc-wrap p { font-size: 0.875rem; line-height: 1.65; color: var(--color-neutral-800); margin-bottom: 12px; }
.doc-wrap ul { margin: 0 0 14px; padding-left: 20px; }
.doc-wrap li { font-size: 0.875rem; line-height: 1.65; color: var(--color-neutral-800); margin-bottom: 7px; }
.doc-wrap strong { font-weight: 600; color: var(--color-text); }
.doc-wrap a { color: var(--color-accent); }
/* More specific than .btn-primary's own color, so without this a
   primary-styled link paints accent-on-accent — invisible text. */
.doc-wrap a.btn-primary { color: var(--color-bg); }
.doc-wrap code {
  font-family: ui-monospace, "SF Mono", "Fira Code", monospace;
  font-size: 0.7812rem; color: var(--color-accent);
  background: var(--color-accent-100); border-radius: 4px; padding: 1px 5px;
}
/* Section shortcuts under the title. */
.doc-toc { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 10px; }
.doc-toc a {
  display: inline-block; text-decoration: none;
  font-family: var(--font-heading); font-weight: 600; font-size: 0.75rem;
  color: var(--color-neutral-800);
  border: 1px solid var(--color-divider); border-radius: 999px;
  padding: 5px 12px;
}
/* Gated like every hover in this sheet — the page-local fork this rule
   moved from predated the hover-affordances check. */
@media (hover: hover) {
  .doc-toc a:hover { border-color: var(--color-accent); color: var(--color-accent); }
}
.doc-wrap h2 { scroll-margin-top: 18px; }
/* Wide tables scroll inside their own box — the page body never does.
   The min-width is a DESKTOP floor: it stops a two-column table
   collapsing to a thin ribbon in a wide column; the floor lifts below the
   phone breakpoint, where wrapping reads better than a scroller with no
   affordance. */
.doc-table { overflow-x: auto; margin: 0 0 16px; }
.doc-table table { border-collapse: collapse; width: 100%; min-width: 420px; }
@media (max-width: 560px) { .doc-table table { min-width: 0; } }
.doc-table th, .doc-table td {
  text-align: left; vertical-align: top;
  font-size: 0.8125rem; line-height: 1.55; padding: 9px 12px 9px 0;
  border-bottom: 1px solid var(--color-hairline);
}
.doc-table th {
  font-family: var(--font-heading); font-weight: 800;
  font-size: 0.6875rem; letter-spacing: 0.08em; text-transform: uppercase;
  color: var(--color-neutral-600); border-bottom-color: var(--color-divider);
}
.doc-table td { color: var(--color-neutral-800); }
.doc-table td:first-child { color: var(--color-text); font-weight: 600; padding-right: 18px; }
.callout {
  background: var(--color-tint); color: var(--color-on-tint);
  border-radius: var(--radius-md); padding: 14px 16px;
  font-size: 0.8438rem; line-height: 1.6; margin: 0 0 16px;
}
.callout strong { color: var(--color-on-tint); }
.doc-foot {
  margin-top: 34px; padding-top: 18px;
  border-top: 1px solid var(--color-hairline);
  font-size: 0.75rem; color: var(--color-neutral-600); line-height: 1.6;
  display: flex; flex-direction: column; gap: 6px;
}
/* The four public pages — About · Privacy · Terms · Contact — as one row,
   the same set on every in-app foot, under the Library tab's credits and
   in the public footer (templates/_shell.html's doc_links). Flex + gap so
   the row wraps at phone width without a middle-dot chain, and each link
   is a tap target in its own right (the crush sweep's floor) rather than
   a word inside a sentence of small type. */
.doc-links { display: flex; flex-wrap: wrap; gap: 2px 14px; }
.doc-links a {
  display: inline-flex; align-items: center; min-height: 24px;
  font-size: 0.8125rem; font-weight: var(--font-ui-weight); color: var(--color-accent);
}
@media (hover: hover) { .doc-links a:hover { text-decoration: underline; } }
/* On the Library tab the row sits under the credits, hairline above. */
.lib-links { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--color-hairline); }

/* ── The chip-layout editor (/settings' filter_chips card) — moved out of
   the page's own <style> so the gallery renders these too
   (ui-consolidation step 8): the .chip-mini WCAG-floor bug existed
   precisely because these were controls no specimen covered. ── */
.chip-pref-group { padding: 10px 0; border-top: 1px solid var(--color-divider); display: flex; flex-direction: column; gap: 8px; }
.chip-pref-group:first-child { border-top: 0; padding-top: 0; }
.chip-pref-head { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
/* The name takes the slack so the controls stay right-aligned as a
   cluster, the way the single Shown/Hidden toggle used to sit. */
.chip-pref-name { font-size: 0.8125rem; font-weight: 600; color: var(--color-text); flex: 1; min-width: 0; }
/* The Passkeys row's list of keys (templates/settings.html): plain rows,
   one per credential, under the row that adds them. */
.pref-list { list-style: none; margin: 8px 0 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
.chip-pref-list { display: flex; flex-wrap: wrap; gap: 6px; }
.chip-pref-list.dim { opacity: .45; }
/* Row markers: which filter chip row each category lands in. */
.chip-pref-rowhdr {
  font-size: 0.6875rem; letter-spacing: 0.08em; text-transform: uppercase;
  color: var(--color-neutral-600); margin-top: 14px;
}
.chip-pref-rowhdr:first-child { margin-top: 0; }
/* The row-layout nudges: square, glyph-only, quieter than the state
   chips beside them — they rearrange, they don't switch anything on. */
/* The ↑ ↓ ⏎ nudges in the chip-layout editor. min-height holds them at
   the WCAG 2.5.8 target size: the padding alone left them 22px tall,
   and these are the controls a rider taps REPEATEDLY to reorder a row,
   which is the worst place to make someone aim. */
/* min-WIDTH as well as min-height: these are icon buttons whose label is
   a single arrow, and in Bricolage that glyph is narrow enough that
   padding alone left them ~23px across — under the 24px target
   app.spec.mjs sweeps for. It went unseen because the browser suite
   blocked the webfont and measured the wider fallback glyph; serving the
   font from our own origin (docs/proposals/seo.md, F6) is what surfaced
   it. The height was always pinned here; the width was the half nobody
   had a reason to think about. */
.chip-mini {
  padding: 4px 8px; font-size: 0.75rem; line-height: 1;
  min-height: 24px; min-width: 24px;
}
.chip-mini:disabled { opacity: .3; cursor: default; }
@media (hover: hover) {
  .chip-mini:disabled:hover { background: var(--color-surface); }
}
/* A pre-selected chip: on when the app opens. Selected styling plus the
   ★ the renderer prefixes, so it reads as "more on" than plain shown. */
.chip-pinned { box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--color-bg) 55%, transparent); }

/* IOS_ZOOM_GUARD — iOS Safari zooms the page when a focused control's font
   is under 16px, and never zooms back. `.input` is covered near its own
   rule; these are the controls that shrink below it later in the cascade
   (and native selects, which iOS zooms on focus too). Last in the file on
   purpose: source order is what beats the shrinking rules. */
@media (pointer: coarse) {
  select, textarea, .admin-select,
  .input.dp-bound, .pe-clause select, .pe-num,
  .fbm-compose .input { font-size: 16px; }
}

/* ── Phone ergonomics (ux-review, Medium) ─────────────────────────────────── */

/* iOS Larger Text and browser default-size changes reach the app: the type
   scale is in rem now (converted mechanically from the px design values, so
   nothing moves at the default 16px root), and Safari's landscape text
   inflation is switched off so the scale is the only thing that sets size. */
html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }

/* The tagline wraps to two lines under ~420px and doubles the header's
   height on exactly the screens with the least room; the brand mark and the
   login page already say it. */
@media (max-width: 480px) { .nav-brand .tagline { display: none; } }

/* Landscape on a notched phone: the panels and the tab bar keep their
   content clear of the sensor housing and the home-indicator corners. The
   header and the bars already pad their own insets; these are the surfaces
   that did not. Zero in portrait, so nothing moves there. */
.panel, .tabbar { padding-left: env(safe-area-inset-left); padding-right: env(safe-area-inset-right); }

/* Hit targets on touch. Apple's floor is 44pt; the repo's own crush sweep
   asserts 24px, which everything met while most primary controls sat at
   32–36px. Text buttons, segments, menu items and tabs grow their box; the
   header's icon buttons keep their 36px face and grow their HIT AREA (an
   inset pseudo-element), so the header's height does not change; the 24px
   icon buttons do the same. Chips stay at 40px: three chip rails in the
   filters panel at 44px would push the first route a further 33px down.

   The pseudo-element needs a POSITIONED parent, and `position: relative`
   is only the right way to get one for a button in normal flow. Two of
   these are already positioned — the install sheet's X (absolute, top
   right of the sheet) and the search box's clear button (absolute, centred
   on the field's end) — and putting them in the relative list overrode
   that: on a phone the X dropped into flow ABOVE the title, offset by its
   own top/right, and the clear button left its field. Both stay out of
   the relative list and in the ::before one; an absolute box contains its
   pseudo-element just the same. */
@media (pointer: coarse) {
  .btn, .seg-opt, .hm-item { min-height: 44px; }
  .tab { min-height: 44px; }
  .chip { min-height: 40px; }
  .btn-icon, .chip-mini, .area-row .btn, .update-bar-dismiss, .lib-source-caret {
    position: relative;
  }
  .btn-icon::before, .ls-clear::before, .ios-install-x::before, .chip-mini::before,
  .area-row .btn::before, .update-bar-dismiss::before, .lib-source-caret::before {
    content: ""; position: absolute; inset: -8px; border-radius: inherit;
  }
}

/* Press feedback and long-press. The tap highlight is transparent on the
   shell, so a control with no :active state gives no feedback at all; and
   without touch-callout off, holding a button or a row raises iOS's copy
   callout on its label. */
#app button:active, #app [role="button"]:active, #app .tab:active, #app .hm-item:active,
#app .sk-row:active, #app .wb-status:active, #app .rescore-row.is-link:active {
  background-color: color-mix(in srgb, var(--color-text) 10%, transparent);
}
#app button, #app [role="button"], #app .tab, #app .hm-item, #app .sk-row, #app .rs-fold-head,
#app .sv-row, #app .lib-source-open, #app .route-row {
  -webkit-touch-callout: none; user-select: none;
}
#app .hm-version { user-select: text; }

/* Keyboard focus inside overflow-clipped parents (segments, chip rails) was
   cut to a sliver by the global 2px outline offset. */
.seg-opt:focus-visible, .filter-scroll .chip:focus-visible {
  outline-offset: -2px;
}

/* Visually hidden, still read: the per-surface h1s and the live regions. */
.vh { position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
/* The <main> landmark is layout-transparent: the panels stay #app's flex children. */
main.panels { display: contents; }
/* The active tab's second cue (beside colour): a 2px bar and a heavier glyph. */
.tab.active { box-shadow: inset 0 2px 0 var(--color-accent); }
.tab.active svg { stroke-width: 2.5; }
/* The Routes-tab import line shares the forecast banner's chassis. */
.routes-import-note { padding: 9px 18px; font-size: 0.8125rem; line-height: 1.4; color: var(--color-on-tint, var(--color-text)); }

/* ── Pull to refresh ──────────────────────────────────────────────────────
   The strip a pull on the route or stop list opens above it
   (static/pull-refresh.js, which explains why the gesture is hand-built).

   It takes NO height in the flex column: .ptr is a zero-height positioning
   context and .ptr-inner is absolutely positioned inside it, so growing the
   strip reflows nothing in the list below — the list is moved by transform
   instead, and .panel's own `overflow: hidden` clips what that pushes past
   the tab bar. `overflow: hidden` here is a clip and not a scroller, which
   is what keeps it out of the sideways-pan sweeps.

   pointer-events are off throughout, the .fs-wheel-band precedent: the
   strip sits at the top of the surface, and scroll-top.js finds the
   scroller a rider is reading by asking what is under the middle of the
   viewport. A strip that answered that probe would send the tap-the-header
   gesture somewhere with nothing to scroll.

   It is aria-hidden, deliberately. The outcome is spoken once through the
   shell's one polite announcer (announce()), where a second role="status"
   beside #routes-import-note would have both regions talking over a rider
   at the moment they most need one sentence. */
.ptr { position: relative; height: 0; flex: none; }
.ptr-inner {
  position: absolute; top: 0; left: 0; right: 0; height: 0;
  display: flex; align-items: center; justify-content: center; gap: 8px;
  overflow: hidden; pointer-events: none;
  background: var(--color-tint); color: var(--color-on-tint, var(--color-text));
  font-size: 0.8125rem; line-height: 1.4; white-space: nowrap;
}
/* The label is the one thing that could outgrow the strip on a narrow
   phone ("Waiting for Strava, carries on by itself"), and the strip clips.
   Shrink it rather than letting it push at the edges. */
.ptr-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.ptr-spin[hidden] { display: none; }
/* Armed: the accent says the release will do something, and the word says
   what. Colour is never the only cue. */
.ptr-armed .ptr-inner { color: var(--color-accent); }
/* While the finger is down the strip follows it 1:1 and must NOT transition;
   the class is added only for the spring back, the settle and the collapse.
   The .sheet-dragging precedent. */
.ptr-animating .ptr-inner { transition: height 220ms ease-out; }
.ptr-animating + #routes-list, .ptr-animating + #stops-list { transition: transform 220ms ease-out; }
@media (prefers-reduced-motion: reduce) {
  .ptr-animating .ptr-inner,
  .ptr-animating + #routes-list, .ptr-animating + #stops-list { transition: none; }
}

/* ── Low: odds and ends (ux-review) ───────────────────────────────────── */
/* The grey tap flash was suppressed on #app alone; Settings, Help and the
   admin pages still flashed. */
body { -webkit-tap-highlight-color: transparent; }
/* Balanced headings, prettier running copy where the browser can. */
.doc-wrap h1, .doc-wrap h2, .admin-head h1, .pw-title, .ss-title { text-wrap: balance; }
.doc-wrap p { text-wrap: pretty; }
@media print { .admin-head .btn, .tabbar, .update-bar, .header-menu-wrap { display: none !important; } }
/* The filters panel's one line of gesture help — the exclude gesture lived
   only in title= tooltips, which touch never shows. */
.fp-hint { font-size: 0.75rem; color: var(--color-neutral-600); padding: 2px 18px 10px; }
/* First paint: three quiet rows where the routes will be, so the list is
   never a blank area under a "Loading…" count. Replaced by the first render. */
.skel-row { border-bottom: 1px solid var(--color-hairline); padding: 14px 18px; display: flex; flex-direction: column; gap: 10px; }
.skel-row span { display: block; height: 12px; border-radius: 6px; background: color-mix(in srgb, var(--color-text) 7%, transparent); }
.skel-row span:nth-child(1) { width: 78%; height: 16px; }
.skel-row span:nth-child(2) { width: 40%; }
.skel-row span:nth-child(3) { width: 55%; }
/* /guide reads through .doc-wrap rather than public.css: the same 70ch measure. */
.doc-wrap p, .doc-wrap li { max-width: 70ch; }
