/* Self-hosted type — see docs/branding/04-brand-plan-2026.html.
   Bricolage Grotesque for display, IBM Plex Sans for interface text and
   IBM Plex Mono for every time, distance and count. Served from /fonts so the
   app works offline and no font request leaves for a third party (which also
   keeps the Datenschutzerklärung honest). Both families are OFL-licensed;
   the licences sit beside the files. */
@font-face{ font-family:'Bricolage Grotesque'; font-weight:600; font-style:normal; font-display:swap; src:url('/fonts/BricolageGrotesque-SemiBold.woff2') format('woff2'); }
@font-face{ font-family:'Bricolage Grotesque'; font-weight:800; font-style:normal; font-display:swap; src:url('/fonts/BricolageGrotesque-ExtraBold.woff2') format('woff2'); }
@font-face{ font-family:'IBM Plex Sans'; font-weight:400; font-style:normal; font-display:swap; src:url('/fonts/IBMPlexSans-Regular.woff2') format('woff2'); }
@font-face{ font-family:'IBM Plex Sans'; font-weight:400; font-style:italic; font-display:swap; src:url('/fonts/IBMPlexSans-Italic.woff2') format('woff2'); }
@font-face{ font-family:'IBM Plex Sans'; font-weight:500; font-style:normal; font-display:swap; src:url('/fonts/IBMPlexSans-Medium.woff2') format('woff2'); }
@font-face{ font-family:'IBM Plex Sans'; font-weight:600; font-style:normal; font-display:swap; src:url('/fonts/IBMPlexSans-SemiBold.woff2') format('woff2'); }
@font-face{ font-family:'IBM Plex Mono'; font-weight:400; font-style:normal; font-display:swap; src:url('/fonts/IBMPlexMono-Regular.woff2') format('woff2'); }
@font-face{ font-family:'IBM Plex Mono'; font-weight:500; font-style:normal; font-display:swap; src:url('/fonts/IBMPlexMono-Medium.woff2') format('woff2'); }

  :root{
    /* The one width for everything that docks to the left edge on a desktop:
       the panel and Route details. Two rails of two widths read as two
       different kinds of thing, and they are not. */
    --rail-w:400px;
    /* ---- Hatchure palette (2026) — docs/branding/04-brand-plan-2026.html ----
       Ink is the ground the app has always had. Chalk is the topo-sheet
       white that replaces the warmer paper. The one accent is amber: the
       glow of a lit vending machine at dusk, chosen because green, orange
       and blue are already komoot's, Strava's and Shelto's. Open, closed
       and water are STATE colours — on a stop row colour means state, so
       they are never used decoratively. Every text pairing below passes
       WCAG AA on its intended ground; the -text variants exist because the
       fill colours do not. */
    --ink: #14201A;
    --ink-2: #22332B;
    --chalk: #F6F7F2;
    --chalk-dim: #EDEFE8;
    --amber: #F0B33A;
    --amber-dark: #D89A1E;
    --amber-text: #8F5E05;
    --amber-tint: #FBF0D2;
    --on-amber: #14201A;   /* text on an amber fill, in both themes */
    --on-day: #FFFFFF;     /* text on a day colour: white on the light set */
    /* The pills and banners that float over the map (busy, placing,
       drawing, boot status, toast, nudges, tooltips). Ink on the light
       map; on the dark map a raised panel surface with a hairline, not
       Chalk — a bright pill over a dark map reads as a dialog, and these
       are asides. */
    --pill-bg: var(--ink);
    --pill-fg: var(--paper);
    --pill-border: transparent;
    /* The browser's own widgets — the calendar and time pickers, number
       spinners, select menus, scrollbars — draw themselves for the scheme
       named here, not for the colours around them. Without it the picker
       under a dark input pops up white. */
    color-scheme: light;
    --sepia: #7A5E43;
    --open: #22703F;
    --open-tint: #DFF0E4;
    --closed: #B33A15;
    --closed-tint: #F9E9E3;
    --water: #22658A;

    /* Legacy names, kept so the ~300 existing references keep working.
       New rules should use the names above. */
    --paper: var(--chalk);
    --paper-dim: var(--chalk-dim);
    --ink-soft: #5F6C65;
    --line: #CFD4CA;
    --blaze: var(--amber);
    --blaze-dark: var(--amber-dark);
    --blaze-text: var(--amber-text);
    /* A softened amber for large areas (the corridor band) rather than controls. */
    --blaze-light: #F3C464;
    --moss: var(--open);
    --mustard: #D9A441;
    --steel: var(--water);
    --white: #FFFFFF;

    /* Tighter than they were (8/12/16): the brand's mocks sit at 4–10px,
       and a tool reads squarer than a consumer app. --radius-pill stays for
       the controls that are round on purpose — the FAB, the search field,
       the chips and the state pills. */
    --radius-sm: 6px;
    --radius-md: 8px;
    --radius-lg: 12px;
    --radius-pill: 999px;

    --shadow-sm: 0 1px 4px rgba(20,32,26,0.18);
    --shadow-md: 0 4px 16px rgba(20,32,26,0.18);
    --shadow-lg: 0 8px 32px rgba(20,32,26,0.32);
    --shadow: var(--shadow-md);

    /* Surfaces that are not simply "the paper": the ground behind the map
       (visible only in the iOS address-bar gap), the header's fade, the
       map's own background before tiles arrive, and the amber and green
       hover tints. Tokens so dark mode can swap them without touching the
       rules that use them. */
    --app-ground: var(--ink);
    --header-fade: linear-gradient(to bottom, rgba(20,32,26,0.97), rgba(20,32,26,0.0));
    --map-ground: #E4E7DF;
    --planned-hover: #E4E7DF;
    --proposed-hover: #F6E5BD;
    --amber-hover: #FFE7C2;
    --tile-filter: none;
    /* Day colours for a split route — the same values as DAY_COLORS in
       route-days.js, which is the source; the map and the sidebar read
       these so a theme switch recolours a tour without redrawing it. */
    --day-0: #14201A;
    --day-1: #7A3E6B;
    --day-2: #22658A;
    --day-3: #4A4F9B;

    --fs-xs: 11px;
    --fs-sm: 12px;
    --fs-base: 13px;
    --fs-md: 14px;
    --fs-lg: 16px;
    --fs-xl: 18px;
    --fs-xxl: 20px;

    --error: var(--closed);
    --error-dark: #8C2C0F;
    --error-bg: var(--closed-tint);

    font-family: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
    /* Digits line up in columns everywhere — ETAs, distances, counts. */
    font-variant-numeric: tabular-nums;
  }

  :root[data-theme="dark"]{
    /* ---- Dark: Ink becomes the surface, Chalk the text. ----
       Same names, swapped roles — so the 300-odd rules that say
       "background:var(--white); color:var(--ink)" read correctly in both
       themes without a second stylesheet. Where a rule used Ink AS a
       background (the header buttons' open state, the chosen night's disc),
       it now gets Chalk, which is the dark theme's "chosen" colour — see
       the brand plan's rule: selected is Ink in light, Chalk in dark. */
    --chalk: #1A2620;
    --chalk-dim: #0F1813;
    --white: #243429;
    --ink: #EDEFE8;
    --ink-soft: #A7B2A9;
    --line: #3A4A40;
    --amber-text: #F0B33A;
    --amber-tint: #3A2E12;
    --open: #5FC48A;
    --open-tint: #1B3326;
    --closed: #F07A55;
    --closed-tint: #3A2119;
    --water: #63B1D8;
    --sepia: #C9A57F;
    --error-dark: #F59A7C;
    --blaze-light: #B8842A;
    --shadow-sm: 0 1px 4px rgba(0,0,0,0.35);
    --shadow-md: 0 4px 16px rgba(0,0,0,0.4);
    --shadow-lg: 0 8px 32px rgba(0,0,0,0.55);
    --app-ground: #0F1813;
    --header-fade: linear-gradient(to bottom, rgba(15,24,19,0.97), rgba(15,24,19,0.0));
    --map-ground: #0F1813;
    --planned-hover: #223129;
    --proposed-hover: #4A3B18;
    --amber-hover: #3A2E12;
    /* Not a plain inversion. OSM draws roads white on a near-white land
       tint, six points apart; inverted as-is they become black on near-
       black and vanish. Darkening FIRST (brightness .6) then inverting and
       stretching contrast (x3) turns those six points into a readable gap:
       land settles at a dark grey, roads a shade darker, labels white, water
       a muted blue after the hue turn. The final brightness keeps the map
       under the Chalk route line, which stays the brightest thing on it. */
    --tile-filter: brightness(0.6) invert(1) contrast(3) hue-rotate(200deg) saturate(0.3) brightness(0.7);
    --day-0: #EDEFE8;
    --day-1: #C98BB9;
    --day-2: #63B1D8;
    --day-3: #9A9BE0;
    --on-day: #14201A;
    --pill-bg: #243429;
    --pill-fg: #EDEFE8;
    --pill-border: #3A4A40;
    color-scheme: dark;
  }
  @media (prefers-color-scheme: dark){
    :root:not([data-theme="light"]){
    /* ---- Dark: Ink becomes the surface, Chalk the text. ----
         Same names, swapped roles — so the 300-odd rules that say
         "background:var(--white); color:var(--ink)" read correctly in both
         themes without a second stylesheet. Where a rule used Ink AS a
         background (the header buttons' open state, the chosen night's disc),
         it now gets Chalk, which is the dark theme's "chosen" colour — see
         the brand plan's rule: selected is Ink in light, Chalk in dark. */
      --chalk: #1A2620;
      --chalk-dim: #0F1813;
      --white: #243429;
      --ink: #EDEFE8;
      --ink-soft: #A7B2A9;
      --line: #3A4A40;
      --amber-text: #F0B33A;
      --amber-tint: #3A2E12;
      --open: #5FC48A;
      --open-tint: #1B3326;
      --closed: #F07A55;
      --closed-tint: #3A2119;
      --water: #63B1D8;
      --sepia: #C9A57F;
      --error-dark: #F59A7C;
      --blaze-light: #B8842A;
      --shadow-sm: 0 1px 4px rgba(0,0,0,0.35);
      --shadow-md: 0 4px 16px rgba(0,0,0,0.4);
      --shadow-lg: 0 8px 32px rgba(0,0,0,0.55);
      --app-ground: #0F1813;
      --header-fade: linear-gradient(to bottom, rgba(15,24,19,0.97), rgba(15,24,19,0.0));
      --map-ground: #0F1813;
      --planned-hover: #223129;
      --proposed-hover: #4A3B18;
      --amber-hover: #3A2E12;
      /* Invert and rotate the tiles back to their own hues, then pull the
         brightness down so the Chalk route line stays the brightest thing on
         the map. Tuned by eye against OSM's default style. */
      --tile-filter: brightness(0.6) invert(1) contrast(3) hue-rotate(200deg) saturate(0.3) brightness(0.7);
      --day-0: #EDEFE8;
      --day-1: #C98BB9;
      --day-2: #63B1D8;
      --day-3: #9A9BE0;
      --on-day: #14201A;
      --pill-bg: #243429;
      --pill-fg: #EDEFE8;
      --pill-border: #3A4A40;
      color-scheme: dark;
    }
  }

  *{ box-sizing: border-box; }
  html, body{ margin:0; padding:0; height:100%; width:100%; overflow:hidden; background: var(--app-ground); color: var(--ink); }
  /* 100vh on iOS Safari is sized as if the address bar is always
     collapsed, so it doesn't shrink to match the actual visible viewport
     when the bar is expanded (e.g. right after load) — #app ends up
     shorter than the real viewport in that state, and the bare `body`
     background (--ink, dark green) shows through behind the address bar
     instead of the map. 100dvh tracks Safari's true visible area as the
     bar shows/hides; 100vh stays as a fallback for browsers that don't
     support dvh at all. */
  #app{ position:relative; height:100vh; height:100dvh; width:100vw; }

  #map{ position:absolute; inset:0; z-index:1; }
  .leaflet-container{ background: var(--map-ground); font-family: 'IBM Plex Sans', sans-serif; }
  /* Dark mode turns the cached OSM tiles into a dark basemap with a filter
     rather than a second tile set: one cache serves both themes, offline
     included. Overlays (markers, the route) live in other panes and are
     untouched. */
  .leaflet-tile-pane{ filter: var(--tile-filter); }
  /* The route, per day. A stylesheet stroke beats the attribute Leaflet
     writes from the color option, which is what lets the theme recolour a
     drawn line (see buildRouteLayer in route-ui.js). */
  .leaflet-overlay-pane path.route-day-0{ stroke:var(--day-0); }
  .leaflet-overlay-pane path.route-day-1{ stroke:var(--day-1); }
  .leaflet-overlay-pane path.route-day-2{ stroke:var(--day-2); }
  .leaflet-overlay-pane path.route-day-3{ stroke:var(--day-3); }

  /* ---------- Header ---------- */
  #header{
    position:absolute; top:0; left:0; right:0; z-index:500;
    display:flex; flex-direction:column; gap:10px;
    /* env(safe-area-inset-top) is 0 on devices without a notch/Dynamic
       Island, so this is a no-op there — on an iPhone, without it the
       logo/search/icon row would sit partly under the status bar, and
       viewport-fit=cover (needed so the header's own dark background
       fills that area instead of the bare page background showing
       through) is what makes the inset apply at all. */
    padding:calc(12px + env(safe-area-inset-top)) 16px 14px;
    background: var(--header-fade);
    pointer-events:none;
  }
  /* flex-end rather than center: the search bar (37px, padding+border)
     and the round icon buttons (38px fixed) differ by a hair, so
     center-alignment left the icons looking a half-pixel low relative to
     the search bar's bottom edge — bottom-aligning both fixes that
     regardless of any small height difference between them. */
  #headerTop{ display:flex; flex-wrap:wrap; align-items:flex-end; justify-content:space-between; gap:12px 12px; min-width:0; }
  #headerTop > *{ pointer-events:auto; }
  #brand{ display:flex; flex-direction:column; line-height:1; min-width:0; order:1; }
  #brand{ flex-direction:row; align-items:flex-end; gap:6px; }
  #brand .mark{
    height:34px; width:auto; display:block;
    filter: drop-shadow(0 2px 6px rgba(0,0,0,0.35));
  }
  #brand .hatchMark{ height:34px; width:auto; color:var(--on-ink, #EDEFE8); filter: drop-shadow(0 2px 6px rgba(0,0,0,0.35)); }

  /* ---------- The mark, drawn ----------
     The brand's one motion idea: the hachure strokes draw in left to right,
     staggered, the way a plotter would draw them, then the stop lands. Used
     on the mark at app load — the header lock-up once, the boot pill on a
     loop while stops are loading — and nowhere else. Everything is stroke
     dash on the SVG lines, so it costs nothing per frame. */
  .hatchMark line{ stroke-dasharray:120; stroke-dashoffset:0; }
  .hatchMark .hatchStop{ fill:var(--amber); transform-origin:204px 74px; }
  @media (prefers-reduced-motion: no-preference){
    .hatchMark.draw line{ stroke-dashoffset:120; animation: hatchDraw .55s cubic-bezier(.2,.7,.2,1) forwards; }
    .hatchMark.draw .hatchStop{ transform:scale(0); animation: hatchLand .3s cubic-bezier(.2,.7,.2,1) .5s forwards; }
    .hatchMark.loop line{ animation: hatchLoop 2.4s cubic-bezier(.2,.7,.2,1) infinite; }
    .hatchMark.loop .hatchStop{ animation: hatchLoopStop 2.4s ease-in-out infinite; }
    .hatchMark line:nth-child(1){ animation-delay:0s }   .hatchMark line:nth-child(2){ animation-delay:.07s }
    .hatchMark line:nth-child(3){ animation-delay:.14s } .hatchMark line:nth-child(4){ animation-delay:.21s }
    .hatchMark line:nth-child(5){ animation-delay:.28s } .hatchMark line:nth-child(6){ animation-delay:.35s }
  }
  @keyframes hatchDraw{ to{ stroke-dashoffset:0; } }
  @keyframes hatchLand{ to{ transform:scale(1); } }
  /* Draw in over the first third, hold, then fade the strokes out together
     before the next pass — a plotter lifting its pen, not a wipe. */
  @keyframes hatchLoop{
    0%{ stroke-dashoffset:120; opacity:1; } 30%{ stroke-dashoffset:0; opacity:1; }
    75%{ stroke-dashoffset:0; opacity:1; } 90%{ stroke-dashoffset:0; opacity:0; } 100%{ stroke-dashoffset:120; opacity:0; }
  }
  @keyframes hatchLoopStop{ 0%,25%{ transform:scale(0); } 40%,75%{ transform:scale(1); } 90%,100%{ transform:scale(0); } }
  /* On a phone: the mark alone, on the top row's left, with the search
     bar and the icons sharing the rest of the row. The full lock-up used
     to take a whole row above the search bar and was dropped for it; the
     mark on its own costs 30px of the search bar's width and keeps the
     brand — and its one draw-in on load — where every other screen has
     it. The wordmark stays on the welcome page and in the account sheet.
     Below 360px even that is too much beside the search field. */
  @media (max-width:720px){
    #brand{ align-self:center; margin-bottom:1px; }
    #brand .mark{ display:none; }
    #brand .hatchMark{ height:30px; }
  }
  @media (max-width:359px){
    #brand{ display:none; }
  }

  #headerIcons{ display:flex; align-items:center; gap:8px; flex-shrink:0; order:3; }
  @media (max-width:420px){ #headerIcons{ gap:6px; } }
  #accountBtn{
    width:38px; height:38px; border-radius:50%; background:var(--white); border:1px solid var(--line);
    /* The icons inherit this (stroke="currentColor" in index.html), so they
       follow the theme: Ink on the light face, Chalk on the dark one. */
    color:var(--ink);
    box-shadow: var(--shadow); display:flex; align-items:center; justify-content:center; cursor:pointer;
    flex-shrink:0; position:relative;
  }
  /* 38px is under the 44px minimum (WCAG 2.5.5 / iOS HIG), and these are
     the app's primary controls — used one-handed, outdoors, sometimes
     through gloves. Rather than grow the circles (which would reflow the
     header and unbalance them against the search field), an invisible
     pseudo-element extends the TOUCH area to 44px while the drawn button
     stays 38px. Purely additive: no layout box changes, so nothing moves.
     The 8px/6px flex gap between them is wider than the 3px of overhang
     per side, so neighbouring hit areas still never overlap. */
  #accountBtn::before{
    content:''; position:absolute; top:50%; left:50%; transform:translate(-50%,-50%);
    width:44px; height:44px; border-radius:50%;
  }
  #accountBtn:hover{ background: var(--paper); }
  /* Open state — the button inverts to dark, matching how an active filter
     chip reads, so it's clear which menu the panel below belongs to. The
     icon also swaps to an X, since color alone is easy to miss at a glance. */
  #accountBtn.menu-open{
    background: var(--ink); border-color: var(--ink);
  }
  #accountBtn.menu-open svg{ stroke: var(--paper); }
  #accountBtn.menu-open:hover{ background: var(--ink); }
  /* The button's icon turns into the × and back: both icons sit on the
     same spot, one turning away and shrinking as the other turns in — a
     quarter turn each way, over about a fifth of a second. Shared by the
     account button and the search field's filter button; both are
     position:relative so the icons can be centred absolutely. */
  .menuDefaultIcon, .menuOpenIcon{
    display:block; position:absolute; top:50%; left:50%;
    transition:transform 0.22s cubic-bezier(.2,.7,.2,1), opacity 0.16s ease;
  }
  .menuDefaultIcon{ transform:translate(-50%,-50%) rotate(0) scale(1); opacity:1; }
  .menuOpenIcon{ transform:translate(-50%,-50%) rotate(-90deg) scale(0.6); opacity:0; }
  .menu-open .menuDefaultIcon, .cancelling .menuDefaultIcon{ transform:translate(-50%,-50%) rotate(90deg) scale(0.6); opacity:0; }
  .menu-open .menuOpenIcon, .cancelling .menuOpenIcon{ transform:translate(-50%,-50%) rotate(0) scale(1); opacity:1; }
  #accountBtn{ transition:background 0.18s ease, border-color 0.18s ease; }
  #accountBtn svg{ transition:transform 0.22s cubic-bezier(.2,.7,.2,1), opacity 0.16s ease, stroke 0.18s ease; }
  @media (prefers-reduced-motion: reduce){
    .menuDefaultIcon, .menuOpenIcon, #accountBtn, #accountBtn svg{ transition:none; }
  }

  /* Mobile (default): full-width row below the logo/icons line — same
     look as before this was folded into #headerTop, achieved by flex-wrap
     plus a 100% flex-basis forcing it onto its own line. */
  #headerSearchRow{ pointer-events:none; order:2; flex-basis:100%; }
  #headerSearchRow > *{ pointer-events:auto; }
  /* On a phone the mark, the search bar and the icon buttons share one
     row (see #brand above) rather than the search bar wrapping onto a row
     of its own. Must come after the 100% basis above: same specificity,
     and the later rule is the one that applies. */
  @media (max-width:720px){
    #headerSearchRow{ flex:1 1 auto; min-width:0; }
    #headerTop{ gap:10px; }
  }
  #searchWrapOuter{ position:relative; width:100%; max-width:480px; min-width:0; }
  /* ---------- Category filters, inside the search field ----------
     The lens on the left, a filter button on the right. Pressing it slides
     the chips out leftwards over the field — one round glyph chip per
     category, and a clearing chip (×, "All") at the end once something is
     filtered. Only the active chip shows its label. The field keeps its
     place underneath, so closing brings it straight back; while the chips
     are away, a dot on the button says something is filtered. */
  #searchWrap{ position:relative; }
  #filtersToggleBtn{
    flex-shrink:0; position:relative; width:32px; height:32px; margin:-7px -9px -7px 4px;
    border:0; border-radius:50%; background:none; color:var(--ink-soft); cursor:pointer;
    display:flex; align-items:center; justify-content:center; font-family:inherit;
  }
  /* 44px hit areas (WCAG 2.5.5 / iOS HIG) on a 32px face: the field is
     37px tall, so the extra reach is an invisible pseudo-element, the
     same trick the account button uses. */
  #filtersToggleBtn::before, #filterStrip .chip::before{
    content:''; position:absolute; top:50%; left:50%; transform:translate(-50%,-50%);
    width:44px; height:44px; border-radius:50%;
  }
  #filterStrip .chip.active::before, #filterStrip .chipAll::before{ width:calc(100% + 12px); border-radius:var(--radius-pill); }
  #filtersToggleBtn:hover{ background:var(--paper-dim); color:var(--ink); }
  #filtersToggleBtn{ transition:background 0.18s ease, color 0.18s ease; }
  #filtersToggleBtn.menu-open{ background:var(--ink); color:var(--paper); }
  #filtersBadge{
    display:none; position:absolute; top:3px; right:3px; width:8px; height:8px; border-radius:50%;
    background:var(--amber); border:1.5px solid var(--white);
  }
  #filtersBadge.show{ display:block; }
  #filterStrip{
    position:absolute; top:50%; right:38px; transform:translateY(-50%) scaleX(0.6); transform-origin:right center;
    max-width:calc(100% - 50px); display:flex; gap:8px; align-items:center; justify-content:flex-end;
    padding:8px 6px 8px 8px; overflow-x:auto; overflow-y:hidden; scrollbar-width:none; -webkit-overflow-scrolling:touch;
    opacity:0; pointer-events:none; transition:transform 0.18s ease, opacity 0.14s ease;
  }
  #filterStrip::-webkit-scrollbar{ display:none; }
  #searchWrap.filtersOpen #filterStrip{ transform:translateY(-50%) scaleX(1); opacity:1; pointer-events:auto; }
  /* The field's text and placeholder step back while the chips are out. */
  #searchWrap.filtersOpen input{ opacity:0; pointer-events:none; }
  #filterStrip .chip{
    position:relative; width:32px; height:32px; padding:0; justify-content:center; gap:0; border-radius:50%; flex-shrink:0;
    background:var(--paper); border-color:var(--line); font-size:var(--fs-xs);
    transition:padding 0.18s ease, border-radius 0.18s ease;
  }
  #filterStrip .chip .catGlyph{ width:16px; height:16px; flex-shrink:0; }
  /* The label is in the markup for the accessible name. On the ACTIVE chip
     it is drawn inline and the chip opens into a pill. Under a pointer it
     appears as a small tag floating below the chip instead (below, since
     the field sits at the top of the screen and above would be off it) — the chip
     itself does not grow. It used to, and a chip that widens on hover
     shoves its neighbours along the row, so leaving one chip sideways
     landed the pointer on a chip that had just moved. */
  #filterStrip .chip > span{
    max-width:0; opacity:0; overflow:hidden; white-space:nowrap;
    transition:max-width 0.18s ease, opacity 0.12s ease, margin-left 0.18s ease;
  }
  #filterStrip .chip.active{ width:auto; padding:0 9px 0 7px; border-radius:var(--radius-pill); }
  #filterStrip .chip.active > span{ max-width:160px; opacity:1; margin-left:5px; transition-delay:0s, 0.06s, 0s; }
  /* Only the active chip is filled: hover names a chip, it does not press it. */
  #filterStrip .chip.active{ background:var(--ink); color:var(--paper); border-color:var(--ink); }
  @media (hover:hover){
    #filterStrip .chip:not(.active):hover > span, #filterStrip .chip:not(.active):focus-visible > span{
      position:absolute; left:50%; top:calc(100% + 8px); transform:translateX(-50%);
      max-width:none; opacity:1; margin:0; padding:3px 7px; border-radius:var(--radius-sm);
      background:var(--ink); color:var(--paper); font-size:var(--fs-xs); font-weight:600; pointer-events:none;
      transition:opacity 0.12s ease 0.15s;
    }
    #filterStrip .chip:not(.active):hover{ border-color:var(--ink); }
    /* The floating tag sits below the row, so the row must not clip it.
       With a pointer the row is not scrolled by touch anyway; a strip
       wider than the field (a tenant with many categories) wraps instead. */
    #filterStrip{ overflow:visible; flex-wrap:wrap; }
  }
  /* A glyph the set does not know falls back to the category's emoji. */
  #filterStrip .chip .catGlyphText{ font-size:14px; line-height:1; }
  /* The clearing chip is a word, always open, never filled. */
  #filterStrip .chipAll{ width:auto; padding:0 10px; border-radius:var(--radius-pill); font-weight:600; }
  #filterStrip .chipAll > span{ max-width:none; opacity:1; margin-left:0; }
  #filterStrip .chip:focus-visible{ outline:2px solid var(--blaze); outline-offset:2px; }
  /* Desktop: sits inline between the logo and the icon menu, centered in
     the space between them rather than pinned to either side. */
  @media (min-width:721px){
    #headerSearchRow{ order:2; flex:1; flex-basis:auto; display:flex; justify-content:center; }
    #searchWrapOuter{ width:100%; }
  }
  #searchWrap{ color:var(--ink-soft);
    display:flex; align-items:center; min-width:0;
    background: var(--white); border-radius:var(--radius-pill); padding:8px 14px;
    box-shadow: var(--shadow); border:1px solid var(--line);
  }
  #searchWrap input{
    border:none; outline:none; background:transparent; flex:1; min-width:0; font-size:var(--fs-md); color:var(--ink); font-family:inherit;
  }
  #searchWrap svg{ flex-shrink:0; }

  #searchResults{
    position:absolute; top:calc(100% + 6px); left:0; right:0; z-index:510;
    background:var(--white); border:1px solid var(--line); border-radius:var(--radius-md); box-shadow:var(--shadow);
    max-height:280px; overflow-y:auto; display:none;
  }
  .searchResultItem{
    display:flex; align-items:center; gap:10px; padding:10px 12px; cursor:pointer; border-bottom:1px solid var(--line);
  }
  .searchResultItem:last-child{ border-bottom:none; }
  .categoryResultItem{
    background:var(--amber-tint); border-left:3px solid var(--blaze); border-bottom:1px solid var(--line);
  }
  .categoryResultItem:hover{ background:var(--amber-hover); }
  .categoryResultItem .name{ color:var(--blaze-text); font-weight:700; }
  .categoryResultItem .cat{ color:var(--blaze-text); }
  .searchResultItem:hover{ background: var(--paper); }
  .searchResultItem .icon{
    width:26px; height:26px; border-radius:50%; display:flex; align-items:center; justify-content:center;
    font-size:var(--fs-base); flex-shrink:0; color:#fff;
  }
  .searchResultItem .icon .catGlyph{ width:14px; height:14px; }
  .searchResultItem .info{ min-width:0; flex:1; }
  .searchResultItem .name{ font-size:var(--fs-base); font-weight:600; color:var(--ink); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
  .searchResultItem .cat{ font-size:var(--fs-xs); color:var(--ink-soft); }
  .searchResultsMore{ padding:8px 12px; font-size:var(--fs-sm); color:var(--ink-soft); text-align:center; }
  .searchResultsEmpty{ padding:12px; font-size:var(--fs-base); color:var(--ink-soft); text-align:center; }
  /* The same tick button the sidebar list and the map marker use, added to
     each result while a route is active — this row's own padding already
     insets it, so it just needs to not force the row taller than its
     icon+text content already makes it. */
  .searchResultItem .routeStopPlan{ height:26px; margin:0 -2px 0 -4px; }

  #accountMenu{
    position:absolute; top:104px; right:16px; z-index:520; background:var(--white); border:1px solid var(--line);
    border-radius:var(--radius-md); box-shadow: var(--shadow); min-width:220px; padding:8px; display:none;
  }
  /* Identity row: address or lead-in on the left, the tier chip on the
     right; then one mono status line when there is something to say. */
  #accountMenu .menuWho{ display:flex; align-items:center; justify-content:space-between; gap:10px; }
  #accountMenu .menuWho .who, #accountMenu .menuWho .menuSectionLead{ min-width:0; flex:1; }
  .menuTier{
    flex-shrink:0; margin-right:10px; padding:2px 7px; border-radius:var(--radius-pill);
    font-family:'IBM Plex Mono', ui-monospace, Menlo, monospace; font-size:10.5px; letter-spacing:0.08em;
    text-transform:uppercase; color:var(--ink-soft); border:1px solid var(--line);
  }
  .menuTier.isPaid{ color:var(--blaze-text); border-color:var(--blaze-text); }
  .menuTier[hidden]{ display:none; }
  .menuStatus{
    margin:0 10px 6px; padding:5px 8px; border-radius:var(--radius-sm);
    font-family:'IBM Plex Mono', ui-monospace, Menlo, monospace; font-size:var(--fs-xs); letter-spacing:0.02em;
    color:var(--ink-soft); background:var(--paper-dim);
  }
  .menuStatus.isPending{ color:var(--blaze-text); background:var(--amber-tint); }
  /* The figure at a row's trailing edge: mono, quiet, right-aligned. */
  #accountMenu .menuCount{
    margin-left:auto; font-family:'IBM Plex Mono', ui-monospace, Menlo, monospace; font-size:var(--fs-xs);
    color:var(--ink-soft); font-variant-numeric:tabular-nums; white-space:nowrap;
  }
  #accountMenu .menuCount[hidden]{ display:none; }
  #accountMenu .menuTwoLine{ display:flex; flex-direction:column; gap:1px; min-width:0; }
  #accountMenu .menuSub{ font-size:var(--fs-xs); color:var(--ink-soft); line-height:1.3; }
  #accountMenu .menuSub:empty{ display:none; }
  /* Appearance, inline: a label and a three-way segment on one row. */
  #accountMenu .menuThemeRow{ display:flex; align-items:center; justify-content:space-between; gap:10px; padding:9px 10px 4px; }
  .menuThemeLabel{ font-size:var(--fs-sm); color:var(--ink-soft); }
  .menuThemeSeg{ display:inline-flex; background:var(--paper-dim); border-radius:var(--radius-sm); padding:2px; gap:2px; }
  #accountMenu .menuThemeBtn{
    width:auto; padding:4px 8px; font-size:var(--fs-xs); font-weight:600; color:var(--ink-soft);
    border-radius:4px; background:none;
  }
  #accountMenu .menuThemeBtn:hover{ background:none; color:var(--ink); }
  #accountMenu .menuThemeBtn.active{ background:var(--white); color:var(--ink); box-shadow:0 1px 3px rgba(20,32,26,0.12); }
  /* ---------- The account panel ----------
     Cards, in the same voice as the account menu: a label in mono, the
     figures in mono, the one word that matters in the display face. */
  .acctCard{
    background:var(--paper); border:1px solid var(--line); border-radius:var(--radius-md);
    padding:14px; margin-bottom:12px;
  }
  .acctCard[hidden]{ display:none; }
  .acctCardLabel{
    font-family:'IBM Plex Mono', ui-monospace, Menlo, monospace; font-size:10.5px; letter-spacing:0.12em;
    text-transform:uppercase; color:var(--ink-soft); margin-bottom:8px;
  }
  .acctWho{ display:flex; align-items:center; justify-content:space-between; gap:10px; }
  .acctEmail{
    font-family:'Bricolage Grotesque', sans-serif; font-weight:600; font-size:var(--fs-lg); color:var(--ink);
    min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
  }
  .acctIdentity .menuTier{ margin-right:0; }
  .acctSince{
    font-family:'IBM Plex Mono', ui-monospace, Menlo, monospace; font-size:var(--fs-xs); color:var(--ink-soft);
    margin:2px 0 8px;
  }
  .acctIdentity .fieldHint{ margin-bottom:0; }
  .acctPlanHead{ display:flex; align-items:baseline; justify-content:space-between; gap:10px; }
  .acctPlanName{ font-family:'Bricolage Grotesque', sans-serif; font-weight:600; font-size:var(--fs-xxl); color:var(--ink); }
  .acctPlanWhen{ font-family:'IBM Plex Mono', ui-monospace, Menlo, monospace; font-size:var(--fs-xs); color:var(--ink-soft); }
  .acctUsage{ margin:10px 0 8px; }
  .acctUsageRow{ display:flex; justify-content:space-between; gap:10px; font-size:var(--fs-sm); color:var(--ink); }
  .acctUsageSub{ color:var(--ink-soft); margin-top:6px; }
  .acctUsageNum{ font-family:'IBM Plex Mono', ui-monospace, Menlo, monospace; font-variant-numeric:tabular-nums; }
  .acctBar{ display:block; height:6px; border-radius:3px; background:var(--paper-dim); overflow:hidden; margin-top:5px; }
  .acctBar > span{ display:block; height:100%; background:var(--ink); border-radius:3px; }
  /* The plan's capabilities: name left, value right, one hairline per
     row. Off rows are muted, not hidden — what a plan lacks is part of
     what it is. */
  .acctCaps{ margin-top:10px; border-top:1px solid var(--line); }
  .acctCap{
    display:flex; justify-content:space-between; gap:12px; padding:6px 0;
    border-bottom:1px solid var(--line); font-size:var(--fs-sm);
  }
  .acctCapName{ color:var(--ink); }
  .acctCapVal{ font-family:'IBM Plex Mono', ui-monospace, Menlo, monospace; font-size:var(--fs-xs); color:var(--ink); white-space:nowrap; }
  .acctCap.isOff .acctCapName, .acctCap.isOff .acctCapVal{ color:var(--ink-soft); }
  #accountPlan .fieldHint{ margin:6px 0 0; }
  #accountPlan .linkBtn{ margin-top:8px; }
  .acctLapsed{ color:var(--blaze-text); }
  .acctFigures{ display:grid; grid-template-columns:repeat(2, minmax(0,1fr)); gap:10px 12px; }
  .acctFigure{ display:flex; flex-direction:column; gap:1px; }
  .acctFigureValue{
    font-family:'IBM Plex Mono', ui-monospace, Menlo, monospace; font-size:var(--fs-xl); font-weight:500;
    color:var(--ink); font-variant-numeric:tabular-nums; line-height:1.15;
  }
  .acctFigureLabel{ font-size:var(--fs-xs); color:var(--ink-soft); }
  .acctAlt{ margin-top:4px; }
  .acctAlt .linkBtn{ font-size:inherit; }
  #accountMenu .who{
    font-size:var(--fs-sm); color:var(--ink-soft); padding:8px 10px 4px;
    overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
  }
  #accountMenu button{
    width:100%; text-align:left; background:none; border:none; padding:9px 10px; font-size:var(--fs-md);
    font-family:inherit; color:var(--ink); cursor:pointer; border-radius:var(--radius-sm); display:flex; align-items:center; gap:8px;
  }
  #accountMenu button:hover{ background: var(--paper); }
  /* Quieter than the label it sits beside — the icon is there to make a row
     findable at a glance, not to compete with its text. Inheriting
     currentColor is the whole reason these are SVG rather than emoji, so
     they dim and darken with the row. */
  #accountMenu button svg{ flex-shrink:0; opacity:0.65; }
  #accountMenu button:hover svg{ opacity:1; }

  /* Every group in the menu: a divider, and usually a small label. Shared by
     the item groups and the language block below, which was the only part of
     this menu that had ever been set apart this way.

     Tight on purpose. Grouping is meant to make the menu scannable, and a
     menu that grew 80px to say so would have traded one problem for another
     — the divider carries the separation, so the spacing around it doesn't
     have to. */
  #accountMenu .menuSection{
    margin-top:4px; padding-top:5px; border-top:1px solid var(--line);
  }
  /* The first group is the identity one, and there is nothing above it to be
     divided from. */
  #accountMenu .menuSection:first-of-type{ margin-top:0; padding-top:0; border-top:none; }
  #accountMenu .menuSectionLabel{
    font-size:var(--fs-xs); font-weight:600; letter-spacing:0.03em; text-transform:uppercase;
    color:var(--ink-soft); padding:4px 10px 2px; display:flex; align-items:center; gap:6px;
  }
  /* The signed-out lead-in. Sentence case and not uppercase, because unlike a
     section label this is a sentence to be read — it is what makes the two
     options under it read as two answers to one question. */
  #accountMenu .menuSectionLead{
    font-size:var(--fs-sm); color:var(--ink-soft); padding:4px 10px 6px; line-height:1.4;
  }
  #accountMenu .menuLanguageRow{
    display:flex; gap:2px; background:var(--paper-dim); border-radius:var(--radius-sm); padding:2px; margin:0 10px 4px;
    position:relative;
  }
  #accountMenu .menuLanguageBtn{
    flex:1; width:auto; justify-content:center; padding:7px 8px; font-size:var(--fs-base); font-weight:600;
    border-radius:var(--radius-sm); color:var(--ink-soft); transition:color 0.15s ease, background 0.15s ease;
  }
  #accountMenu .menuLanguageBtn:hover{ background:none; color:var(--ink); }
  #accountMenu .menuLanguageBtn.active{ background:var(--white); color:var(--ink); box-shadow:var(--shadow-sm); }
  #accountMenu .menuLanguageBtn.active:hover{ background:var(--white); }
  #accountMenu .menuLanguageBtn:disabled{ opacity:0.6; cursor:default; }
  #accountMenu .menuLanguageBtn .spinner-dot{
    display:inline-block; width:11px; height:11px; margin-left:6px; border-radius:50%;
    border:2px solid currentColor; border-right-color:transparent; animation:menuLangSpin 0.6s linear infinite;
    vertical-align:middle;
  }
  @keyframes menuLangSpin{ to{ transform:rotate(360deg); } }

  /* Settings panel's language switcher — same pill-row treatment as the
     account menu's used to carry, now scoped to #panelBody since it moved
     into its own panel (see panel-settings.js). */
  #panelBody .settingsSectionLabel{
    font-size:10.5px; font-weight:600; letter-spacing:0.03em; text-transform:uppercase;
    color:var(--ink-soft); padding:0 0 6px;
  }
  /* Every settings section: a label, then its control(s). The first three
     (appearance, language) are segmented rows; the rest are a hint with a
     switch or a link beside it. */
  #panelBody .settingsSection{ margin-top:18px; padding-top:14px; border-top:1px solid var(--line); }
  #panelBody .settingsLanguageSection{ margin-top:18px; padding-top:14px; border-top:1px solid var(--line); }
  #panelBody .settingsRow{ display:flex; align-items:flex-start; justify-content:space-between; gap:14px; }
  #panelBody .settingsRow + .settingsRow{ margin-top:10px; }
  #panelBody .settingsRow .fieldHint{ margin:0; flex:1; }
  #panelBody .settingsRow .linkBtn{ flex-shrink:0; white-space:nowrap; padding-top:1px; }
  #panelBody .settingsSwitchRow{ display:flex; align-items:flex-start; justify-content:space-between; gap:14px; cursor:pointer; }
  #panelBody .settingsSwitchText{ display:flex; flex-direction:column; gap:2px; flex:1; }
  #panelBody .settingsSwitchLabel{ font-size:var(--fs-md); font-weight:600; color:var(--ink); }
  #panelBody .settingsSwitchText .fieldHint{ margin:0; }
  #panelBody .settingsSwitchRow .switch{ flex-shrink:0; margin-top:2px; }

  /* ---------- The Help panel ----------
     Same voice as the account panel and Settings: a mono label over each
     block, one card of tour rows, then a plain list of topics that fold
     open. Nothing is amber here — the tours are the only action, and a
     column of six amber rows would be a wall, not an accent. */
  #panelBody .helpLabel{
    font-family:'IBM Plex Mono', ui-monospace, Menlo, monospace; font-size:10.5px; letter-spacing:0.12em;
    text-transform:uppercase; color:var(--ink-soft); margin:0 0 8px;
  }
  /* The search box: the same input as everywhere else in the app, with a
     lens in the leading edge. Sits above both blocks. */
  #panelBody .helpSearch{ position:relative; margin-bottom:16px; }
  #panelBody .helpSearchInput{
    width:100%; box-sizing:border-box; border:1px solid var(--line); background:var(--white);
    border-radius:var(--radius-sm); padding:9px 11px 9px 34px; font-size:var(--fs-base); font-family:inherit;
    color:var(--ink); outline:none; -webkit-appearance:none;
  }
  #panelBody .helpSearchInput:focus{ border-color:var(--blaze); }
  #panelBody .helpSearch::before{
    content:''; position:absolute; left:11px; top:50%; width:14px; height:14px; margin-top:-7px; pointer-events:none;
    background:currentColor; color:var(--ink-soft);
    -webkit-mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23000' stroke-width='1.75' stroke-linecap='round'%3E%3Ccircle cx='7' cy='7' r='4.5'/%3E%3Cpath d='m10.5 10.5 3.5 3.5'/%3E%3C/svg%3E") center/contain no-repeat;
    mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23000' stroke-width='1.75' stroke-linecap='round'%3E%3Ccircle cx='7' cy='7' r='4.5'/%3E%3Cpath d='m10.5 10.5 3.5 3.5'/%3E%3C/svg%3E") center/contain no-repeat;
  }
  #panelBody .helpNoMatches{ margin:0 0 16px; }
  #panelBody .helpTour[hidden], #panelBody .helpTopic[hidden], #panelBody .helpTours[hidden], #panelBody .helpLabel[hidden]{ display:none; }
  #panelBody .helpTours{
    background:var(--paper); border:1px solid var(--line); border-radius:var(--radius-md);
    padding:0 0 2px; margin-bottom:20px; overflow:hidden;
  }
  #panelBody .helpTour{
    display:flex; align-items:center; gap:12px; width:100%; padding:10px 12px;
    background:none; border:0; border-top:1px solid var(--line); text-align:left;
    font-family:inherit; color:var(--ink); cursor:pointer;
    /* The first visible row's rule hides under the card's own edge (the
       card clips overflow), whichever row is first once a search has
       hidden some. */
    margin-top:-1px;
  }
  #panelBody .helpTour:hover{ background:var(--paper-dim); }
  #panelBody .helpTour:focus-visible{ outline:2px solid var(--blaze); outline-offset:-2px; border-radius:var(--radius-sm); }
  #panelBody .helpTourNum{
    font-family:'IBM Plex Mono', ui-monospace, Menlo, monospace; font-size:var(--fs-xs); color:var(--ink-soft);
    font-variant-numeric:tabular-nums; flex-shrink:0;
  }
  .helpTourText, .helpTopicText{ flex:1; min-width:0; display:flex; flex-direction:column; gap:2px; }
  #panelBody .helpTourTitle{ font-size:var(--fs-md); font-weight:600; line-height:1.25; }
  #panelBody .helpTourHint, #panelBody .helpTopicLead{ font-size:var(--fs-xs); color:var(--ink-soft); line-height:1.35; }
  .helpChev{ width:16px; height:16px; flex-shrink:0; color:var(--ink-soft); transition:transform 0.15s ease; }
  #panelBody .helpTour:hover .helpChev{ color:var(--ink); transform:translateX(2px); }
  /* Topics: one hairline between entries, the title in the display face,
     a one-line lead under it so the list reads without opening anything.
     The chevron turns down when open; no native marker. */
  #panelBody .helpTopics{ border-top:1px solid var(--line); }
  #panelBody .helpTopic{ border-bottom:1px solid var(--line); }
  #panelBody .helpTopic > summary{
    display:flex; align-items:center; gap:12px; padding:11px 0; cursor:pointer; list-style:none;
  }
  #panelBody .helpTopic > summary::-webkit-details-marker{ display:none; }
  #panelBody .helpTopic > summary:focus-visible{ outline:2px solid var(--blaze); outline-offset:2px; border-radius:var(--radius-sm); }
  #panelBody .helpTopic[open] > summary .helpChev{ transform:rotate(90deg); color:var(--ink); }
  #panelBody .helpTopicTitle{
    font-family:'Bricolage Grotesque', sans-serif; font-weight:600; font-size:var(--fs-md); color:var(--ink); line-height:1.25;
  }
  #panelBody .helpTopicBody{ padding:0 28px 14px 0; font-size:var(--fs-sm); line-height:1.5; color:var(--ink); }
  #panelBody .helpTopicBody p{ margin:0 0 8px; }
  #panelBody .helpTopicBody p:last-child{ margin-bottom:0; }
  #panelBody .helpTopicBody strong{ font-weight:600; }
  #panelBody .helpTopicBody a{ color:var(--ink); text-decoration:underline; text-underline-offset:2px; }
  #panelBody .helpTopicBody a:hover{ color:var(--amber-text); }
  /* Every figure in a topic (30 days, 3 routes) in mono, as everywhere. */
  #panelBody .helpTopicBody .num{
    font-family:'IBM Plex Mono', ui-monospace, Menlo, monospace; font-size:0.95em; font-variant-numeric:tabular-nums;
  }
  /* A "where to find it" path: Account → My routes. */
  #panelBody .helpTopicBody .path{
    font-family:'IBM Plex Mono', ui-monospace, Menlo, monospace; font-size:0.9em; background:var(--paper-dim);
    padding:1px 5px; border-radius:var(--radius-sm); white-space:nowrap;
  }
  #panelBody .settingsLanguageRow, #panelBody .settingsThemeRow{
    display:flex; gap:2px; background:var(--paper-dim); border-radius:8px; padding:2px;
  }
  #panelBody .settingsLanguageBtn, #panelBody .settingsThemeBtn{
    flex:1; width:auto; justify-content:center; padding:9px 10px; font-size:13.5px; font-weight:600;
    border-radius:6px; color:var(--ink-soft); transition:color 0.15s ease, background 0.15s ease;
    /* Explicit, so the UA's grey button face never shows through — in dark
       mode it would be the brightest thing in the row. */
    background:none; border:none; font-family:inherit; cursor:pointer;
  }
  #panelBody .settingsLanguageBtn:hover, #panelBody .settingsThemeBtn:hover{ background:none; color:var(--ink); }
  #panelBody .settingsLanguageBtn.active, #panelBody .settingsThemeBtn.active{ background:var(--white); color:var(--ink); box-shadow:0 1px 3px rgba(20,32,26,0.12); }
  #panelBody .settingsLanguageBtn.active:hover, #panelBody .settingsThemeBtn.active:hover{ background:var(--white); }
  #panelBody .settingsLanguageBtn:disabled, #panelBody .settingsThemeBtn:disabled{ opacity:0.6; cursor:default; }
  #panelBody .settingsLanguageBtn .spinner-dot{
    display:inline-block; width:11px; height:11px; margin-left:6px; border-radius:50%;
    border:2px solid currentColor; border-right-color:transparent; animation:menuLangSpin 0.6s linear infinite;
    vertical-align:middle;
  }

  /* ---------- The account menu as a bottom sheet (phones only) ----------
     Same shape as #routeSidebar: docked to the bottom edge, full width,
     rounded top, moss rule, grabber. A dropdown anchored under the header is
     a desktop shape — on a phone it hangs from the top of the screen, which
     is the far end from the thumb that opened it, and every item is a reach.

     The desktop rule above is untouched: over 720px this is still the
     dropdown it has always been.

     Not draggable, deliberately. The stops sheet earns a drag because it is
     a working surface you size against the map behind it; this is a short
     list you tap once and dismiss. The grabber is still drawn — it is what
     says "this came from the bottom and goes back there", and tapping it
     closes the sheet — but there are no snap heights to drag between. */
  @media (max-width:720px){
    #accountMenu.menuSheet{
      top:auto; left:0; right:0; bottom:0;
      min-width:0; width:auto;
      border:none; border-top:4px solid var(--ink);
      border-radius:var(--radius-lg) var(--radius-lg) 0 0;
      padding:22px 10px calc(10px + env(safe-area-inset-bottom));
      max-height:80vh; overflow-y:auto; overscroll-behavior:contain;
      box-shadow:0 -6px 24px rgba(20,32,26,0.18);
      /* Above the route sheet's resting layers (540) so it is never opened
         behind the thing it was summoned over. */
      z-index:600;
    }
    /* Slides up rather than appearing, so where it came from is legible;
       and follows a pull down (sheet.js), springing back or leaving. The
       pill itself is .sheetGrab, shared with the panel. */
    #accountMenu.menuSheet{
      animation:sheetIn 0.24s cubic-bezier(0.32, 0.72, 0, 1);
      transition:transform 0.22s cubic-bezier(0.32, 0.72, 0, 1);
      touch-action:pan-y;
    }
    #accountMenu.menuSheet[data-dragging]{ transition:none; }
    /* Roomier rows: a sheet has the width for a proper touch target, and
       these were sized for a 220px dropdown. */
    #accountMenu.menuSheet .who{ padding:4px 12px 8px; font-size:var(--fs-base); }
    #accountMenu.menuSheet button{ padding:13px 12px; font-size:var(--fs-md); gap:10px; border-radius:var(--radius-sm); }
    #accountMenu.menuSheet .menuSection{ margin-top:8px; padding-top:12px; }
    #accountMenu.menuSheet .menuSection:first-of-type{ margin-top:0; padding-top:0; }
    #accountMenu.menuSheet .menuSectionLabel{ padding:0 12px 6px; font-size:var(--fs-sm); }
    #accountMenu.menuSheet .menuSectionLead{ padding:4px 12px 8px; font-size:var(--fs-base); }
    #accountMenu.menuSheet button svg{ width:18px; height:18px; }
    #accountMenu.menuSheet .menuLanguageBtn{ padding:10px 8px; font-size:var(--fs-md); }
  }
  @keyframes sheetIn{ from{ transform:translateY(100%); } to{ transform:translateY(0); } }
  @media (prefers-reduced-motion: reduce){
    #accountMenu.menuSheet{ animation:none; transition:none; }
  }

  #routeMenu{
    position:absolute; top:104px; right:16px; z-index:520; background:var(--white); border:1px solid var(--line);
    border-radius:var(--radius-md); box-shadow: var(--shadow); width:min(320px, calc(100vw - 32px));
    padding:12px 12px 4px; display:none;
    /* Bottom padding is intentionally smaller than the other three sides:
       whichever button ends up last (varies by state — cancelPlanningBtn
       or the corridor-width select) commonly carries its own
       margin-bottom:8px via .routeAction. Without this, that 8px stacks on
       top of full padding, leaving ~20px below the last button instead of
       the intended ~12px that matches the menu's other edges. */

    /* Grows downward from a fixed top with no natural stop, so a tall
       state (active route + save form open) used to run off the bottom of
       a short screen and — on mobile, where #routeSidebar is a bottom
       sheet up to 55vh — slide underneath it, leaving the Save button
       unclickable with nothing on screen to say why. Capping the height
       and scrolling the overflow keeps every state reachable no matter
       how many controls it holds. 16px matches the menu's own right
       inset, so the gap under it reads as the same margin. */
    max-height:calc(100vh - var(--route-menu-top, 104px) - 16px);
    overflow-y:auto; overscroll-behavior:contain;
  }
  /* The title bar is the drag handle (enableRouteMenuDrag, route-ui.js). */
  #routeMenu .routeMenuGrab{ cursor:grab; touch-action:none; user-select:none; -webkit-user-select:none; }
  #routeMenu.dragging{ cursor:grabbing; opacity:0.96; }
  #routeMenu.dragging .routeMenuGrab{ cursor:grabbing; }
  @media (max-width:720px){
    /* Stop where the stops sheet starts, so the two never overlap.
       --route-menu-max-h is written by syncRouteMenuHeight() from the two
       edges that actually decide it: the menu's own top (set at open time
       from the header's rendered height, header-menus.js) and the sheet's
       top (which moves with its snap).

       It used to be a flat 45vh. That was a guess from two directions at
       once — it assumed the menu began at the 104px in the rule above, when
       the menu is really positioned at runtime and sat at 167px, and it
       assumed the sheet left 45vh free, when at 55vh it leaves 45vh from the
       BOTTOM, not from the menu's top. The two errors compounded: on a
       six-day route the menu ran 151px past the sheet's top edge, and the
       sections at its end took taps meant for the sheet's header.

       The fallback keeps the old value for the moment before the first
       sync — a menu that opens fractionally short is invisible; one that
       opens too tall is the bug. */
    #routeMenu{ max-height:var(--route-menu-max-h, calc(45vh - 16px)); }
    /* Still above the sheet where they meet. The cap means they should not,
       but the menu is a transient popover over a persistent list — the same
       relationship the filters and account menus have with what is beneath
       them — so if anything ever does overlap, the menu wins. Scoped to this
       width: above it the sheet is a --rail-w left rail and the two never
       touch.
       Exception: a sheet snapped to full takes 570 and covers the menu on
       purpose (see #routeSidebar[data-snap="full"]) — at that point the
       sheet is the surface being used. */
    #routeMenu{ z-index:560; }
    /* The route menu drops down from the same spot these icons occupy, but
       its trigger (the FAB) sits at the bottom of the screen — so while
       planning is active the icons just clutter the space the menu is
       using. Hidden rather than left to compete with it; see
       syncPrimaryAction() in route-ui.js for the class this responds to. */
    body.route-planning-active #headerIcons{ display:none; }
  }

  /* ---------- Corridor width slider ---------- */
  /* The label and the live value share a line, so the number sits where the
     eye already is rather than under the track. */
  .routeRadiusHead{
    display:flex; align-items:baseline; justify-content:space-between;
    gap:8px; margin-top:4px;
  }
  .routeRadiusHead .reportLabel{ margin:0; }
  #routeRadiusValue{
    font-family:'IBM Plex Mono', ui-monospace, Menlo, monospace; font-weight:500; font-size:var(--fs-base);
    color:var(--ink); font-variant-numeric:tabular-nums;
  }
  #routeRadius{
    -webkit-appearance:none; appearance:none;
    display:block; width:100%; margin:6px 0 10px; height:20px;
    background:transparent; cursor:pointer;
  }
  /* Track and thumb need vendor-prefixed rules that cannot be combined into
     one selector list — a browser drops the whole rule on an unknown
     pseudo-element, so WebKit's and Firefox's have to be written separately
     even though the declarations are identical. */
  #routeRadius::-webkit-slider-runnable-track{
    height:5px; border-radius:3px; background:var(--paper-dim);
  }
  #routeRadius::-moz-range-track{
    height:5px; border-radius:3px; background:var(--paper-dim);
  }
  #routeRadius::-webkit-slider-thumb{
    -webkit-appearance:none; appearance:none;
    width:20px; height:20px; margin-top:-7.5px; border-radius:50%;
    background:var(--blaze); border:3px solid var(--white);
    box-shadow:var(--shadow-sm);
  }
  #routeRadius::-moz-range-thumb{
    width:14px; height:14px; border-radius:50%;
    background:var(--blaze); border:3px solid var(--white);
    box-shadow:var(--shadow-sm);
  }
  #routeRadius:focus-visible{ outline:2px solid var(--ink); outline-offset:3px; }
  #routeRadius:disabled{ opacity:0.5; cursor:default; }

  /* The band drawn around the route while the slider moves. Breathes gently
     so it reads as a live preview rather than as part of the map, then does
     one firmer pulse and fades when the value settles (.settling, added by
     settleCorridorPreview).
     Animating opacity on the SVG <path> Leaflet renders — not the layer's
     own stroke-opacity, which the polyline's style already owns. */
  /* The band keeps the width the slider chose — it IS the corridor, so
     animating its size would make the shape lie about the number beside
     it. What moves is the COLOUR inside it: a darker core at the route
     that spreads outward to the edges and fades, so the motion reads as
     "this far, either side" without the outline ever shifting.
     Driven by animating the gradient's own stops (see #corridorPulseGrad
     in route-ui.js) rather than anything on this element, because
     stroke-width and the paint server are the two things that must not be
     touched here. Opacity still breathes gently underneath. */
  .corridorPreview{ animation: corridorBreathe 1.8s ease-in-out infinite; }
  .corridorPreview.settling{ animation: corridorSettle 0.9s ease-out forwards; }
  @keyframes corridorBreathe{
    0%, 100%{ opacity:0.85; }
    50%     { opacity:1; }
  }

  /* The inner stroke: a darker orange that swells from the route out to the
     corridor edge and fades as it arrives, then starts again. This is the
     only thing whose width moves — the band above keeps the slider's width
     throughout, so the corridor's outline never disagrees with the number
     beside it.
     --corridor-w is the band's current width in px, written by
     setCorridorPulseWidth() on every slider step and on zoom, so the pulse
     always travels exactly as far as the corridor actually reaches. */
  .corridorPreviewPulse{
    stroke-width:0;
    animation: corridorPulseOut 1.8s ease-out infinite;
  }
  @keyframes corridorPulseOut{
    0%  { stroke-width:0;                            opacity:0.55; }
    70% { stroke-width: var(--corridor-w, 0px);      opacity:0.12; }
    100%{ stroke-width: var(--corridor-w, 0px);      opacity:0; }
  }
  /* Mid-pulse when the value is committed, so it fades out with the band
     rather than continuing to travel over a corridor that is now settled. */
  .corridorPreviewPulse.settling{ animation: corridorSettle 0.9s ease-out forwards; }
  @media (prefers-reduced-motion: reduce){
    /* No travelling pulse at all — the band alone carries the width. */
    .corridorPreviewPulse{ animation:none; stroke-width:0; }
  }
  @keyframes corridorSettle{
    0%  { opacity:1; }
    35% { opacity:1.4; }
    100%{ opacity:0; }
  }
  /* Reduced motion: keep the band (it is the information) and drop only the
     movement. The settle still fades, since that is what removes it. */
  @media (prefers-reduced-motion: reduce){
    .corridorPreview{ animation:none; }
    .corridorPreview.settling{ animation: corridorSettle 0.9s linear forwards; }
  }

  #routeMenu .filtersHead{ display:flex; align-items:center; justify-content:space-between; margin-bottom:6px; }
  /* Only ever drawn on the collapsed phone tab. */
  .routeMenuTabIcon{ display:none; flex-shrink:0; }
  /* Collapsed, the title bar is the whole menu — drop the bottom margin
     that normally separates it from the body, so the panel shrinks to just
     the bar rather than leaving a stripe of empty white beneath it. */
  #routeMenu.collapsed .filtersHead{ margin-bottom:0; }
  #routeMenu.collapsed{ padding-bottom:12px; }
  #routeMenuCollapse{
    background:none; border:none; cursor:pointer; color:var(--ink-soft);
    padding:2px; margin:-2px; flex-shrink:0; border-radius:var(--radius-sm); line-height:0;
  }
  #routeMenuCollapse:hover{ background:var(--paper); color:var(--ink); }
  #routeMenuCollapse:focus-visible{ outline:2px solid var(--blaze); outline-offset:2px; }
  /* Chevron flips to point down once collapsed — down being "bring the
     body back", the direction it will reappear from. */
  #routeMenu.collapsed .routeMenuChevron{ transform:rotate(180deg); }
  /* On a phone the collapsed menu tucks into the right edge as a tab, so
     the map underneath is free: the title goes, only the chevron stays,
     now pointing left — the direction the menu comes back from. A dragged
     position keeps its height but not its left, since the whole point of
     the tab is the edge. */
  @media (max-width:720px){
    #routeMenu.collapsed{
      left:auto !important; right:0 !important; width:auto; min-width:0;
      padding:6px 4px 6px 8px; border-right:0; border-radius:var(--radius-md) 0 0 var(--radius-md);
    }
    #routeMenu.collapsed .filtersTitle, #routeMenu.collapsed .viewOnlyNote{ display:none; }
    #routeMenu.collapsed #routeMenuCollapse{ margin:0; padding:8px; color:var(--ink); }
    #routeMenu.collapsed .routeMenuChevron{ display:none; }
    #routeMenu.collapsed .routeMenuTabIcon{ display:block; }
  }
  /* #riderPanelForm shares these because its controls came OUT of the route
     menu (Account -> Rider & bike, panel-rider.js) and should look exactly as
     they did there — same stack of selects and short numeric fields, just in
     a panel now. Listing it here rather than restating the rules keeps one
     definition: the two surfaces cannot drift apart. */
  #routeMenu select,
  #riderPanelForm select{
    width:100%; border:1px solid var(--line); background:var(--white); border-radius:var(--radius-sm); padding:9px 10px;
    font-size:var(--fs-base); font-family:inherit; color:var(--ink); outline:none; margin-bottom:10px;
  }
  #routeMenu .reportLabel{
    display:block; font-size:var(--fs-xs); font-weight:700; text-transform:uppercase; letter-spacing:0.06em;
    color:var(--ink-soft); margin-bottom:6px;
  }
  /* Matches the select above rather than inheriting the form's .field input
     styling — these sit in the route menu, not in a panel form, and should
     read as part of the same stack of controls as the corridor-width and
     profile selects. */
  #routeMenu input[type=datetime-local], #routeMenu input[type=number],
  #riderPanelForm input[type=number]{
    flex:1; min-width:0; border:1px solid var(--line); background:var(--white); border-radius:var(--radius-sm);
    padding:9px 10px; font-size:var(--fs-base); font-family:inherit; color:var(--ink); outline:none;
  }
  /* Times and figures in mono, like everywhere else a number is read. */
  #routeMenu input[type=datetime-local], #routeMenu input[type=number], #riderPanelForm input[type=number]{
    font-family:'IBM Plex Mono', ui-monospace, Menlo, monospace; font-variant-numeric:tabular-nums;
  }
  #routeMenu input[type=datetime-local]:focus, #routeMenu input[type=number]:focus,
  #riderPanelForm input[type=number]:focus{ border-color:var(--blaze); }
  /* Input grows, the reset button sits at its natural width beside it. The
     row carries the margin the inputs deliberately don't, so the hint under
     it can sit tight against the control it explains. */
  .routeStartRow{ display:flex; gap:6px; align-items:center; margin-bottom:6px; }
  .routeStartRow button{
    flex-shrink:0; background:transparent; border:1.5px solid var(--line); border-radius:var(--radius-sm);
    padding:9px 12px; font-size:var(--fs-sm); font-weight:600; color:var(--ink-soft); cursor:pointer;
    font-family:inherit;
  }
  .routeStartRow button:hover{ background:var(--paper); border-color:var(--ink); color:var(--ink); }
  .routeStartRow button:focus-visible{ outline:2px solid var(--blaze); outline-offset:2px; }
  /* The hints under these two controls are the only thing between them and
     the next label, so they carry the gap that the labels' own margin-top
     would otherwise have to fight. Empty most of the time — collapse to
     nothing rather than leaving a stripe of dead space. */
  #routeStartHint, #routeSpeedHint{ margin-bottom:10px; }
  #routeStartHint:empty, #routeSpeedHint:empty{ margin-bottom:4px; }

  /* ---- Splitting into days ----
     Its own block above the corridor width, no longer buried in the
     "Timing & days" disclosure: whether a route is a ride or a trip is a
     bigger decision than how fast you ride it. */
  #routeDaysBlock{
    margin-top:12px; padding-top:12px; border-top:1px solid var(--line);
  }
  .routeStepperRow{
    display:flex; align-items:center; gap:10px; margin-bottom:6px;
  }
  .routeStepper{
    display:flex; align-items:center; border:1.5px solid var(--line);
    border-radius:var(--radius-sm); overflow:hidden; flex-shrink:0;
  }
  .routeStepperBtn{
    background:transparent; border:none; cursor:pointer; font-family:inherit;
    /* 40px square: these are the two controls someone taps repeatedly to
       find the right number of days, and they have to survive a thumb. */
    width:40px; height:40px; font-size:var(--fs-xl); font-weight:700; color:var(--ink-soft);
    display:flex; align-items:center; justify-content:center;
  }
  .routeStepperBtn:hover:not(:disabled){ background:var(--paper); color:var(--ink); }
  .routeStepperBtn:disabled{ opacity:0.35; cursor:default; }
  .routeStepperBtn:focus-visible{ outline:2px solid var(--blaze); outline-offset:-2px; }
  .routeStepperValue{
    min-width:74px; text-align:center; font-size:var(--fs-base); font-weight:700; color:var(--ink);
    border-left:1.5px solid var(--line); border-right:1.5px solid var(--line);
    padding:0 8px; line-height:40px;
  }
  /* The km/day figure — a consequence of the day count, not an input. Muted
     so it reads as an answer rather than as a second thing to set. */
  .routeStepperAside{ font-size:var(--fs-sm); color:var(--ink-soft); }
  .routeCheckRow{
    display:flex; align-items:center; gap:8px; font-size:var(--fs-base);
    color:var(--ink-soft); margin-bottom:6px; cursor:pointer;
  }
  .routeCheckRow input{ flex-shrink:0; }
  #routeDaysMore{ margin:6px 0 0; }
  #routeDaysMore summary{
    font-size:var(--fs-sm); color:var(--ink-soft); cursor:pointer; padding:4px 0;
  }
  /* Its two rows are alternatives to the stepper, not to each other, so the
     second gets a little air rather than reading as a continuation.
     Sentence case and un-tracked, unlike .reportLabel's usual uppercase:
     these carry a clause explaining what each row DOES ("replaces the
     split"), and set in caps at that length they shouted over the section
     heading above them. */
  #routeDaysMore .reportLabel{
    margin-top:10px; text-transform:none; letter-spacing:0; font-size:var(--fs-sm);
    font-weight:600;
  }
  #routeDaysMore .reportLabel:first-of-type{ margin-top:4px; }
  /* Now ABOVE the stepper, and carrying what the separate prompt used to
     say — so it keeps that line's weight rather than the muted grey of a
     hint underneath a control. */
  #routeDaysHint{ margin-bottom:8px; color:var(--ink); font-weight:600; }
  #routeDaysHint:empty{ margin-bottom:0; }
  #routeSaveStatus{
    font-size:var(--fs-sm); font-weight:600; margin-bottom:8px; display:flex; align-items:center; gap:6px;
  }
  #routeSaveStatus.saved{ color:var(--ink); }
  #routeSaveStatus.unsaved{ color:var(--ink-soft); }
  .routeAction{
    display:block; width:100%; text-align:center; background:transparent; border:1.5px solid var(--line);
    border-radius:var(--radius-sm); padding:10px; font-size:var(--fs-base); font-weight:600; color:var(--ink); cursor:pointer;
    font-family:inherit; margin-bottom:8px; box-sizing:border-box;
  }
  .routeAction:hover{ background:var(--paper); border-color:var(--ink); }
  .routeAction.primary{
    background:var(--blaze); border-color:var(--blaze); color:var(--on-amber); font-weight:700;
  }
  .routeAction.primary:hover{ background:var(--blaze-dark); border-color:var(--blaze-dark); }
  .routeAction.primary:disabled{ opacity:0.6; cursor:default; }
  /* For the one confirm button in the app that commits an irreversible
     delete (account deletion) rather than a route-planning action — same
     shape as .primary, red instead of the brand accent so it never reads as
     "the normal thing to click here". */
  .routeAction.danger{
    background:var(--error); border-color:var(--error); color:#fff; font-weight:700;
  }
  .routeAction.danger:hover{ background:var(--error-dark); border-color:var(--error-dark); }
  .routeAction.danger:disabled{ opacity:0.5; cursor:default; background:transparent; color:var(--ink); border-color:var(--line); font-weight:600; }
  /* Plain (non-primary) actions can be disabled too — "Plan a route" is
     unavailable offline, since it's the one route entry point that needs
     the network. Hover must not still light up as if it were clickable. */
  .routeAction:disabled{ opacity:0.5; cursor:default; }
  .routeAction:disabled:hover{ background:transparent; border-color:var(--line); }
  #routeMenu select:disabled{ opacity:0.5; cursor:default; }
  #routeSaveForm{
    border:1px solid var(--line); border-radius:var(--radius-md); padding:12px; background:var(--paper); margin-bottom:8px;
  }
  #routeNameInput{
    width:100%; border:1px solid var(--line); background:var(--white); border-radius:var(--radius-sm);
    padding:9px 11px; font-size:var(--fs-md); font-family:inherit; color:var(--ink); outline:none;
    box-sizing:border-box; margin-bottom:10px;
  }
  #routeNameInput:focus{ border-color:var(--blaze); }
  .exportHelp{ margin-bottom:8px; }
  .exportHelp summary{
    font-size:var(--fs-sm); font-weight:700; color:var(--blaze-text); cursor:pointer;
    list-style:none; padding:2px 0;
  }
  .exportHelp summary::-webkit-details-marker{ display:none; }
  .exportHelp summary::before{ content:'\25B8 '; }
  .exportHelp[open] summary::before{ content:'\25BE '; }
  .exportHelp summary:focus-visible{ outline:2px solid var(--blaze); outline-offset:2px; border-radius:var(--radius-sm); }
  .exportHelpBody{
    font-size:var(--fs-sm); line-height:1.5; color:var(--ink-soft);
    border-left:2px solid var(--line); padding:6px 0 2px 10px; margin-top:6px;
  }
  .exportHelpBody p{ margin:0 0 7px; }
  .exportHelpBody p:last-child{ margin-bottom:0; }
  .exportHelpBody ul{ margin:0 0 7px; padding-left:16px; }
  .exportHelpBody li{ margin-bottom:5px; }
  .exportHelpBody code{
    background:var(--paper-dim); padding:1px 4px; border-radius:var(--radius-sm); font-size:var(--fs-xs);
    font-family:'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
  }
  /* The folded-away timing/day controls in the route menu. Styled to match
     #routeStats — the app's other collapsible section of a panel — rather
     than .exportHelp, which is a help note rather than a group of controls.
     A rule of its own instead of a shared class because the two sit in
     different containers with different padding. */
  /* Every foldable group in the route menu shares one look, via .routeSection
     on the <details> itself. Previously each had its own rules keyed to its
     id, which is how the menu drifted: the day block was a bare div with a
     label, the export block another bare div, and only two of the five had
     chevrons — so five groups of the same KIND of thing read as five
     different kinds of control. One class means adding a section can't
     reintroduce that.

     The id selectors stay alongside for the two that predate the class, so
     this rule holds even if one loses its class attribute. */
  #routeAdvanced, #routeRider, .routeSection{ margin:8px 0 4px; }
  .routeSection > summary,
  #routeAdvanced > summary, #routeRider > summary{
    font-family:'Bricolage Grotesque', sans-serif; font-size:var(--fs-sm); font-weight:600;
    text-transform:uppercase; letter-spacing:0.04em; color:var(--ink-soft);
    cursor:pointer; list-style:none; display:flex; align-items:center; gap:6px;
    padding:4px 0;
  }
  .routeSection > summary::-webkit-details-marker,
  #routeAdvanced > summary::-webkit-details-marker,
  #routeRider > summary::-webkit-details-marker{ display:none; }
  /* Own chevron rather than the native marker, which can't be styled
     consistently across browsers. Rotates to point down when open. */
  .routeSection > summary::before,
  #routeAdvanced > summary::before, #routeRider > summary::before{
    content:''; width:0; height:0; flex-shrink:0;
    border-left:4px solid currentColor; border-top:3.5px solid transparent; border-bottom:3.5px solid transparent;
    transition:transform 0.15s ease;
  }
  .routeSection[open] > summary::before,
  #routeAdvanced[open] > summary::before,
  #routeRider[open] > summary::before{ transform:rotate(90deg); }
  .routeSection > summary:focus-visible,
  #routeAdvanced > summary:focus-visible,
  #routeRider > summary:focus-visible{ outline:2px solid var(--blaze); outline-offset:2px; border-radius:var(--radius-sm); }

  /* The pinned action row: undo / fit / clear.
     ==========================================
     Three equal columns so the row is stable — a disabled Undo must not let
     the other two slide sideways, since a control that moves when state
     changes is one you have to re-find every time.

     Icon only, three across: the tooltip and aria-label carry the words.
     40px tall keeps each a comfortable target without the row being the
     tallest thing in the menu. */
  .routeActionRow{
    display:grid; grid-template-columns:repeat(3, 1fr); gap:6px;
    margin:0 0 10px;
  }
  .routeIconBtn{
    display:flex; align-items:center; justify-content:center;
    padding:0; background:transparent; border:1.5px solid var(--line); border-radius:var(--radius-sm);
    font-family:inherit; color:var(--ink-soft); cursor:pointer; height:40px;
    transition:background 0.12s ease, border-color 0.12s ease, color 0.12s ease;
  }
  .routeIconBtn svg{ flex-shrink:0; }
  .routeIconBtn:hover:not(:disabled){ background:var(--paper); border-color:var(--ink); color:var(--ink); }
  .routeIconBtn:focus-visible{ outline:2px solid var(--blaze); outline-offset:2px; }
  /* Disabled rather than hidden. Undo is unavailable on a route that has not
     been edited yet, and a button that vanishes and reappears as you work
     teaches nothing about when it applies — where a greyed one says "this
     exists, there is just nothing to undo". */
  .routeIconBtn:disabled{ opacity:0.4; cursor:default; }
  /* Clear closes the route. Not red until hovered: a permanently red button
     in a row of three reads as a warning about the whole row. */
  .routeIconBtn.danger:hover:not(:disabled){
    background:var(--error-bg); border-color:var(--error); color:var(--error);
  }

  /* Discard beside Save, once there is something to discard.
     ========================================================
     Flex rather than a two-column grid on purpose: Discard is display:none
     for most of a route's life (updateRouteMenu only shows it while dirty),
     and a grid would hold its empty column open, leaving Save stranded at
     two-thirds width with a gap beside it. With flex the hidden child takes
     no space at all and Save fills the row on its own, so the common case
     looks exactly as it did before this pair existed.

     The ratio only applies when both are present: Save is what people
     actually want, and giving a destructive action equal weight overstates
     it. .routeAction carries its own bottom margin, so the row doesn't add
     one. */
  .routeSaveRow{ display:flex; gap:8px; align-items:stretch; }
  /* width:auto overrides .routeAction's width:100%, which would otherwise
     fight flex-basis:0 and make both children claim the full row. */
  .routeSaveRow #saveRouteBtn{ flex:2 1 0; width:auto; margin-bottom:8px; }
  .routeSaveRow #cancelRouteChangesBtn{ flex:1 1 0; width:auto; margin-bottom:8px; white-space:nowrap; }
  /* Reads as the way back rather than as damage: this discards edits to a
     route that still exists, where the Clear in the action row closes the
     route itself. Warm on hover, not permanently red. */
  .routeSaveRow #cancelRouteChangesBtn:hover{
    background:var(--error-bg); border-color:var(--error); color:var(--error);
  }

  /* An indented run of controls inside a section, with its own label and
     explanation — used for the detailed-estimate block, which is a distinct
     action within "Rider & bike" rather than another field of it. */
  .routeSubGroup{
    margin-top:10px; padding-top:10px; border-top:1px solid var(--line);
  }
  .routeSubGroup .fieldHint{ margin-bottom:8px; }
  #routeDetailedResult:empty{ display:none; }
  /* Four short numeric fields, two per row. They're all the same kind of
     answer (a number about you or the bike), so a grid keeps them together
     as one block rather than four full-width rows that read as four
     unrelated questions. Collapses to one column where there isn't room. */
  .routeRiderGrid{
    display:grid; grid-template-columns:repeat(2, minmax(0, 1fr));
    gap:6px 8px; margin-bottom:8px;
  }
  @media (max-width: 360px){
    .routeRiderGrid{ grid-template-columns:1fr; }
  }
  /* The grid's inputs are full-width in their cell — the flex:1 above is for
     the .routeStartRow layout, which doesn't apply here. */
  .routeRiderGrid input[type=number]{ width:100%; }
  #routeRiderIntro{ margin-bottom:8px; }
  #routeRiderHint{ margin-bottom:8px; }
  #routeRiderHint:empty{ margin-bottom:2px; }
  /* Sits under the full-width "Detailed estimate" action as a quieter
     secondary, matching the reset button beside the pace field. */
  /* ---------- Rider panel: sections, segmented choices, units, footer ---- */
  .riderSection{ margin-bottom:14px; }
  .riderSection > .reportLabel{ margin-bottom:6px; }
  /* A row of choices in one bordered track, the chosen one filled — the
     bike and the surface are each a pick from a handful, and a row of
     words shows all of them at once where a select showed one. */
  .riderSeg{
    display:flex; border:1px solid var(--line); border-radius:var(--radius-sm); background:var(--white);
    padding:3px; gap:3px;
  }
  .riderSegBtn{
    flex:1 1 0; min-width:0; padding:8px 4px; border:0; border-radius:calc(var(--radius-sm) - 2px);
    background:transparent; font-family:inherit; font-size:var(--fs-sm); font-weight:600; color:var(--ink-soft);
    cursor:pointer; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
  }
  .riderSegBtn:hover{ color:var(--ink); background:var(--paper-dim); }
  .riderSegBtn[aria-checked="true"]{ background:var(--ink); color:var(--paper); }
  .riderSegBtn:focus-visible{ outline:2px solid var(--blaze); outline-offset:-2px; }
  /* The unit inside the box, at the right, so "78" reads as "78 kg" without
     the label having to say so. */
  .riderUnitField{ position:relative; }
  .riderUnitField input[type=number]{ padding-right:38px; -moz-appearance:textfield; appearance:textfield; }
  /* No native spinner: it sat on top of the unit. Arrow keys still step. */
  .riderUnitField input[type=number]::-webkit-inner-spin-button,
  .riderUnitField input[type=number]::-webkit-outer-spin-button{ -webkit-appearance:none; margin:0; }
  .riderUnit{
    position:absolute; right:11px; top:50%; transform:translateY(-50%);
    font-family:'IBM Plex Mono', ui-monospace, Menlo, monospace; font-size:var(--fs-xs); letter-spacing:0.04em;
    color:var(--ink-soft); pointer-events:none;
  }
  /* The fatigue switch: a row with the sentence on the left and a toggle on
     the right, the checkbox itself hidden behind it. */
  .riderSwitchRow{
    display:flex; align-items:center; justify-content:space-between; gap:12px;
    padding:10px 0; margin:2px 0 6px; border-top:1px solid var(--line); border-bottom:1px solid var(--line);
    cursor:pointer; font-size:var(--fs-base); color:var(--ink);
  }
  .riderSwitchRow input{ position:absolute; opacity:0; width:0; height:0; }
  .riderSwitch{
    flex-shrink:0; width:38px; height:22px; border-radius:var(--radius-pill); background:var(--line);
    position:relative; transition:background 0.15s ease;
  }
  .riderSwitch::after{
    content:''; position:absolute; top:3px; left:3px; width:16px; height:16px; border-radius:50%;
    background:var(--white); box-shadow:0 1px 2px rgba(0,0,0,0.25); transition:transform 0.15s ease;
  }
  .riderSwitchRow input:checked + .riderSwitch{ background:var(--ink); }
  .riderSwitchRow input:checked + .riderSwitch::after{ transform:translateX(16px); }
  .riderSwitchRow input:focus-visible + .riderSwitch{ outline:2px solid var(--blaze); outline-offset:2px; }
  /* The result, as a figure rather than a footnote. */
  .riderResult{ padding:12px 14px; background:var(--paper-dim); border-radius:var(--radius-md); }
  .riderResultLabel{
    display:block; font-family:'IBM Plex Mono', ui-monospace, Menlo, monospace; font-size:10.5px; letter-spacing:0.12em;
    text-transform:uppercase; color:var(--ink-soft); margin-bottom:4px;
  }
  #routeRiderPanelHint{ font-size:var(--fs-base); color:var(--ink); line-height:1.45; }
  /* The footer: status on the left, Reset and Save on the right. */
  .riderFooter{
    display:flex; align-items:center; justify-content:space-between; gap:12px;
    padding:12px 20px; background:var(--paper); border-top:1px solid var(--line);
    box-shadow:0 -4px 10px rgba(0,0,0,0.05);
  }
  .riderFooterActions{ display:flex; align-items:center; gap:14px; flex-shrink:0; }
  .riderFooter .routeAction{ width:auto; margin:0; padding:9px 18px; }
  #routeRiderClear{ color:var(--ink-soft); text-decoration:none; }
  #routeRiderClear:hover{ color:var(--ink); text-decoration:underline; }

  /* ---------- Rider panel: validation, autosave, the position preview ------ */
  /* Per-field error, directly under the input it belongs to. Collapsed to
     nothing while empty so four always-present slots don't push the grid
     apart — :empty is what makes an error slot free when there is no error. */
  .riderFieldError{
    font-size:var(--fs-xs); line-height:1.35; color:var(--error); font-weight:600; margin-top:4px;
  }
  .riderFieldError:empty{ display:none; }
  /* The field itself, so the eye lands on the input rather than only on the
     sentence under it. Same red as the message. */
  #riderPanelForm input.isInvalid{ border-color:var(--error); }
  #riderPanelForm input.isInvalid:focus{ border-color:var(--error); }

  /* The autosave line. Three states, each carrying its own colour: green for
     a confirmed write (matching #routeSaveStatus.saved, which answers the
     same question for a route), muted for "nothing to save yet", red for a
     refused edit. */
  .riderSaveStatus{
    font-size:var(--fs-sm); font-weight:600; min-width:0; flex:1; line-height:1.35;
    transition:opacity 0.15s ease;
  }
  .riderSaveStatus.isSaved{ color:var(--ink); }
  .riderSaveStatus.isPending{ color:var(--ink-soft); font-weight:500; }
  .riderSaveStatus.isInvalid{ color:var(--error); }

  /* The position/surface picture. Art on the left, the words it stands for on
     the right — side by side rather than stacked, because the drawing is a
     glance and the text is the detail, and putting the detail underneath made
     the block tall enough to push the save line off a phone screen. */
  /* Art above, words below.
     =======================
     These sat side by side, which on a ~250px panel left the text column about
     130px wide — narrow enough that "Upright and comfortable — most air
     resistance." wrapped onto four ragged lines and the resistance bars were
     squeezed to about 90px, too short to compare against each other.
     Stacking gives both the full width: the drawing gets to be a drawing, and
     the sentences and bars get a line length that reads. */
  .riderPreview{
    margin:10px 0 4px; padding:11px 12px 12px;
    background:var(--paper); border:1px solid var(--line); border-radius:var(--radius-md);
  }
  .riderPreviewArt{
    display:block; width:100%; max-width:180px; height:auto;
    margin:0 auto 9px; color:var(--ink);
  }
  /* The ground is context, not the subject — held back so the rider reads as
     the foreground at a glance. */
  .riderPreviewGround{ color:var(--ink-soft); opacity:0.75; }
  /* The machine, held back behind its rider. Drawn at the same weight as the
     body the two read as one tangle — the frame's main triangle sits directly
     behind the rider's back and arm, and nothing tells the eye which triangle
     is the bicycle and which is the person. Same colour, less of it. */
  .riderPreviewBike{ opacity:0.5; stroke-width:1.35; }
  .riderPreviewRider{ color:var(--ink); }

  .riderPreviewText{ flex:1; min-width:0; }
  /* The two description lines. They are full sentences, so they get real line
     spacing rather than the tight default .fieldHint carries for one-liners,
     and a gap under the pair to separate them from the scales below. */
  .riderPreviewLine{ margin:0 0 3px; line-height:1.45; }
  .riderPreviewLine + .riderPreviewLine{ margin-bottom:9px; }

  /* One resistance scale: label above, then the track with its two end-words
     beneath.
     ===================================================================
     Stacked rather than label-beside-track. Side by side, the label column was
     sized to its own content, so "Air resistance" and "Rolling resistance"
     produced two different column widths and the two tracks started at
     different x — the one thing a pair of bars must not do, since comparing
     their lengths is the entire point. Putting the label on its own line makes
     both tracks full width and therefore directly comparable, and it stops the
     German labels (which are longer) squeezing the bars to a stub. */
  .riderScale{ margin-top:10px; }
  .riderScaleLabel{
    display:block; font-size:var(--fs-xs); font-weight:700; text-transform:uppercase;
    letter-spacing:0.05em; color:var(--ink-soft); margin-bottom:3px;
  }
  .riderScaleTrack{
    display:block; height:6px; border-radius:3px; background:var(--paper-dim);
    overflow:hidden;
  }
  .riderScaleFill{
    display:block; height:100%; border-radius:3px; background:var(--blaze-light);
    transition:width 0.18s ease;
  }
  /* "low" and "high" pushed to opposite ends, so they label the ends of the
     track they sit under rather than reading as the words "low – high" placed
     arbitrarily beneath it. */
  .riderScaleEnds{
    display:flex; justify-content:space-between;
    font-size:var(--fs-xs); color:var(--ink-soft); letter-spacing:0.03em;
    margin-top:2px; opacity:0.8;
  }
  @media (prefers-reduced-motion: reduce){
    .riderScaleFill{ transition:none; }
  }
  .routeExportRow{ display:flex; gap:8px; }
  .routeExportRow .routeAction{ margin-bottom:8px; }
  .routeSaveFormActions{ display:flex; gap:8px; }
  .routeSaveFormActions .routeAction{ margin-bottom:0; }
  /* Marks an active route. Lives on the FAB now (the header route icon it
     used to sit on was removed as a duplicate), so it is sized and coloured
     for that: bigger, because the FAB is a large button where a 9px dot
     would read as a rendering artefact, and ink-on-blaze rather than
     moss-on-white, which would have almost no contrast against orange. */
  #routeBadge{
    display:none; position:absolute; top:-3px; right:-3px; width:12px; height:12px;
    border-radius:50%; background: var(--ink); border:2px solid var(--blaze);
  }
  #routeBadge.show{ display:block; }
  /* The FAB inverts while its menu is open (see #fab.menu-open), so the
     badge's ring has to follow or it would keep a blaze outline on ink. */
  #fab.menu-open #routeBadge{ border-color:var(--on-amber); background: var(--blaze); }
  #drawBanner{
    position:absolute; top:106px; left:50%; transform:translateX(-50%); z-index:600;
    background:var(--pill-bg); color:var(--pill-fg); border:1px solid var(--pill-border); padding:10px 12px 10px 18px; border-radius:var(--radius-md);
    font-size:var(--fs-base); font-weight:600; box-shadow: var(--shadow); display:none;
    align-items:center; gap:10px; max-width:calc(100vw - 32px);
  }
  #drawBanner .pulse{
    width:9px; height:9px; border-radius:50%; background: var(--blaze); flex-shrink:0;
    animation: pulse 1.2s infinite ease-in-out;
  }
  #drawBanner span:nth-child(2){ white-space:nowrap; }
  /* The planning banner carries a longer message than the straight-line one
     ("N waypoints added · drag to move, tap to remove"), which would push
     Done/Cancel off a phone screen while nowrap holds it on one line. Let it
     wrap on narrow viewports rather than truncating the hint — but give the
     text a floor to wrap against, otherwise flex shrinks it to a ~6-line
     ribbon beside the buttons. Full width on the banner keeps the two
     buttons in a stable position instead of shifting with the text length. */
  @media (max-width:560px){
    #drawBanner{ width:calc(100vw - 32px); padding-left:14px; }
    #drawBanner span:nth-child(2){ white-space:normal; flex:1; min-width:11ch; line-height:1.35; }
  }
  /* The hint's own dismiss. Quieter than Done/Cancel beside it — this closes
     a tip rather than ending anything, and the two are never on screen
     together. Sized past the 14px glyph so it is a real touch target. */
  #dismissHintBtn{
    background:none; border:none; padding:6px; margin:-6px -4px -6px 0;
    color:var(--pill-fg); opacity:0.65; cursor:pointer; flex-shrink:0;
    display:flex; align-items:center; justify-content:center; border-radius:var(--radius-sm);
    -webkit-tap-highlight-color:transparent;
  }
  #dismissHintBtn[hidden]{ display:none; }
  #dismissHintBtn:hover{ opacity:1; background:rgba(255,255,255,0.12); }
  #dismissHintBtn:focus-visible{ outline:2px solid var(--pill-fg); outline-offset:1px; opacity:1; }
  /* Finish is the banner's action, so it is amber like every other primary
     action; green is a state colour and was never right here. */
  #finishDrawBtn{
    background: var(--blaze); color: var(--on-amber); border:none; border-radius:var(--radius-sm); padding:8px 14px;
    font-size:var(--fs-base); font-weight:700; cursor:pointer; font-family:inherit; flex-shrink:0;
  }

  /* Shown while a route is being recalculated after a stop is ticked into
     the plan or a waypoint is dragged. Same banner family as #drawBanner
     above (and the same top offset), since it occupies the same slot — the
     two are never visible at once: drawing has ended by the time a route
     exists to recalculate. */
  #routeBusyBanner{
    position:absolute; top:106px; left:50%; transform:translateX(-50%); z-index:600;
    background:var(--pill-bg); color:var(--pill-fg); border:1px solid var(--pill-border); padding:10px 18px; border-radius:var(--radius-md);
    font-size:var(--fs-base); font-weight:600; box-shadow: var(--shadow); display:none;
    align-items:center; gap:10px; max-width:calc(100vw - 32px);
  }
  #routeBusyBanner .pulse{
    width:9px; height:9px; border-radius:50%; background: var(--blaze); flex-shrink:0;
    animation: pulse 1.2s infinite ease-in-out;
  }

  .filtersTitle{ font-size:var(--fs-sm); font-weight:700; text-transform:uppercase; letter-spacing:0.06em; color:var(--ink-soft); }
  .filtersClear{ background:none; border:none; font-size:var(--fs-sm); font-weight:700; color:var(--blaze-text); cursor:pointer; padding:0; font-family:inherit; }

  .chip{
    flex-shrink:0; display:flex; align-items:center; gap:6px;
    padding:7px 13px; border-radius:var(--radius-pill); font-size:var(--fs-base); font-weight:600;
    background: var(--paper); color: var(--ink); border:1px solid var(--line);
    cursor:pointer; user-select:none; white-space:nowrap;
    font-family:inherit; line-height:1.2;
  }
  .chip .catGlyph{ width:14px; height:14px; flex-shrink:0; }
  .chip.active{ background: var(--ink); color: var(--paper); border-color: var(--ink); }

  /* Anchored to #searchWrapOuter (position:relative) the same way
     #searchResults is, rather than pinned to the viewport — so it never
     exceeds the search box's own width, on mobile or the narrower
     centered desktop layout. #searchResults and this banner are mutually
     exclusive (both driven by whether there's a text-search match), so
     sharing the same top-offset anchor never causes overlap. */
  #searchElsewhereBanner{
    position:absolute; top:calc(100% + 6px); left:0; right:0; z-index:495;
    background: rgba(251,251,248,0.97); border:1px solid var(--line); border-radius:var(--radius-md);
    padding:9px 13px; display:none; align-items:center; justify-content:space-between; gap:10px;
    box-shadow: var(--shadow); font-size:var(--fs-base); color:var(--ink);
  }
  #searchElsewhereBanner span{ overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
  #searchElsewhereBanner button{
    background:none; border:none; color:var(--blaze-text); font-weight:700; font-size:var(--fs-base);
    cursor:pointer; white-space:nowrap; font-family:inherit; flex-shrink:0;
  }
  #searchElsewhereBanner button:disabled{ opacity:0.6; cursor:default; }

  /* ---------- FAB ---------- */
  /* The primary action: plan a route.
     =================================
     Was a 60px diamond containing a bare "+", for adding a stop. Two things
     changed and both were deliberate:

     The diamond is gone. It was a distinctive shape, but clip-path meant
     nothing could sit safely inside it except a centred glyph — no label
     could fit without being clipped at the corners. A pill can carry the
     words, and the words are what make a primary action legible to someone
     who has just arrived and does not yet know what the app is for.

     Still bottom-right, still brand orange, still the largest control on
     screen: none of the prominence was given up, only the ambiguity. */
  #fab{
    position:absolute; right:18px; bottom:calc(28px + env(safe-area-inset-bottom)); z-index:500;
    min-height:52px; padding:0 20px 0 16px;
    background: var(--blaze); color:var(--on-amber);
    display:flex; align-items:center; justify-content:center; gap:9px;
    border-radius:var(--radius-pill);
    font-family:'Bricolage Grotesque', sans-serif; font-size:var(--fs-md); font-weight:600;
    letter-spacing:0.01em; white-space:nowrap;
    box-shadow: var(--shadow); cursor:pointer; border:none;
    transition: transform 0.15s ease, background 0.15s ease;
  }
  #fab:hover{ transform: scale(1.04); background: var(--blaze-dark); }
  #fab:active{ transform: scale(0.97); }
  /* Inverts while its menu is open, the same way the header buttons do —
     it is that menu's trigger now, and without this the only sign the menu
     belongs to it would be proximity. */
  /* Inverts while its menu is open, the same way the header buttons do —
     it is that menu's trigger now, and without this the only sign the menu
     belongs to it would be proximity.

     background-color longhand rather than the `background` shorthand, and
     a button#fab selector rather than #fab: #fab above sets `background`
     and `#fab:hover` overrides it, so the longhand at higher specificity
     is the version that cannot be second-guessed. */
  button#fab.menu-open{ background-color: var(--ink); color: var(--paper); }
  button#fab.menu-open:hover{ background-color: var(--ink); }
  button#fab.menu-open svg{ stroke: var(--paper); }
  #fab:focus-visible{ outline:2px solid var(--ink); outline-offset:3px; }
  #fab svg{ width:21px; height:21px; flex-shrink:0; }
  #fab .fabIcon{ position:relative; width:21px; height:21px; flex-shrink:0; display:block; }
  #fab .menuOpenIcon{ width:18px; height:18px; }
  /* Drawing a route: the FAB is the ✕ that cancels it — a round ink button
     the same size, label gone, until the second waypoint makes a route. */
  button#fab.cancelling{
    width:52px; padding:0; background-color:var(--ink); color:var(--paper);
    transition:transform 0.15s ease, background-color 0.18s ease, width 0.18s ease;
  }
  button#fab.cancelling:hover{ background-color:var(--ink); }
  button#fab.cancelling svg{ stroke:var(--paper); }
  #fab.cancelling #fabLabel, #fab.cancelling #routeBadge{ display:none; }
  /* Add a stop: the secondary button of the cluster, a plain round face
     above the amber FAB and lined up with its right edge. Inverts and
     turns into a ✕ while a pin is being placed, the way the menu buttons
     do while open — pressing it then cancels, so the banner needs no
     Cancel of its own. position:relative for the icon morph. */
  #addStopBtn{
    position:absolute; right:22px; bottom:calc(28px + 52px + 12px + env(safe-area-inset-bottom)); z-index:500;
    width:44px; height:44px; border-radius:50%; border:1px solid var(--line);
    background:var(--white); color:var(--ink); box-shadow:var(--shadow);
    display:flex; align-items:center; justify-content:center; cursor:pointer; padding:0;
    transition:transform 0.15s ease, background 0.15s ease;
  }
  #addStopBtn:hover{ background:var(--paper); transform:scale(1.04); }
  #addStopBtn:active{ transform:scale(0.97); }
  #addStopBtn.menu-open{ background:var(--ink); color:var(--paper); border-color:var(--ink); }
  #addStopBtn.menu-open:hover{ background:var(--ink); }
  #addStopBtn:focus-visible{ outline:2px solid var(--blaze); outline-offset:3px; }
  /* The empty-search offer: no stop by that name — add it. */
  .searchResultsAdd{ display:inline-block; margin-top:6px; }
  /* The label stays on narrow phones.
     =====================================================================
     This used to collapse to a bare 56px circle below 420px, on the
     reasoning that the icon is enough "once someone is already in the
     app" — but that is exactly the assumption a first-time user cannot
     meet. The route planner is the app's primary job, this is its only
     entry point, and on a phone the icon is an abstract squiggle with no
     hover tooltip and no long-press affordance most people will try. An
     unlabelled button on the platform with the least room to explain
     itself is where the label is worth most, not least.
     Kept compact instead: tighter padding and a slightly smaller face, so
     it still clears the zoom controls and the attribution strip. */
  @media (max-width: 420px){
    #fab{ padding:0 16px 0 13px; min-height:50px; border-radius:var(--radius-pill); font-size:var(--fs-md); gap:7px; }
    #fab svg{ width:19px; height:19px; }
    /* Cap the text rather than dropping it — "Your route" and its German
       equivalents are all short, but a longer translation should ellipsize
       inside the pill instead of pushing it across the screen. */
    #fabLabel{ overflow:hidden; text-overflow:ellipsis; max-width:40vw; }
  }
  /* No label to lay out while drawing (the FAB is hidden then anyway), and
     the placement pin flow keeps its own compact circle. */
  @media (max-width: 360px){
    #fab{ padding:0 13px 0 11px; font-size:var(--fs-base); }
    #fabLabel{ max-width:34vw; }
  }

  #fab.cancel{ background: var(--ink); color: var(--white); }

  /* ---------- Placement banner ---------- */
  #placeBanner{
    position:absolute; top:106px; left:50%; transform:translateX(-50%); z-index:600;
    background:var(--pill-bg); color:var(--pill-fg); border:1px solid var(--pill-border); padding:10px 12px 10px 18px; border-radius:var(--radius-md);
    font-size:var(--fs-base); font-weight:600; box-shadow: var(--shadow); display:none;
    align-items:center; gap:10px; max-width:calc(100vw - 32px);
  }
  #placeBanner .pulse{
    width:9px; height:9px; border-radius:50%; background: var(--blaze); flex-shrink:0;
    animation: pulse 1.2s infinite ease-in-out;
  }
  #placeBanner span:nth-child(2){ white-space:nowrap; }
  #confirmPlaceBtn{
    background: var(--blaze); color:var(--on-amber); border:none; border-radius:var(--radius-sm); padding:8px 14px;
    font-size:var(--fs-base); font-weight:700; cursor:pointer; font-family:inherit; flex-shrink:0;
  }
  #confirmPlaceBtn:hover{ background: var(--blaze-dark); }
  @keyframes pulse{ 0%,100%{ opacity:1; transform:scale(1);} 50%{ opacity:0.4; transform:scale(1.5);} }

  /* Live-collaboration indicator: shown in the route menu whenever more than
     one person is connected to the route. Blue rather than the moss/blaze
     used elsewhere — those already mean "planning" and "needs attention",
     and this is neither. Reuses the same #2E7DA6 the water-source category
     uses rather than introducing another hue. */
  #collabPresence{
    display:flex; align-items:center; gap:7px; margin-bottom:8px;
    font-size:var(--fs-sm); font-weight:600; color:var(--water);
  }
  .collabDot{
    width:9px; height:9px; border-radius:50%; background:var(--water); flex-shrink:0;
    animation: pulse 1.2s infinite ease-in-out;
  }
  /* Respect a stated preference for less motion: the dot still marks the
     state by being present and coloured, it just stops pulsing. */
  @media (prefers-reduced-motion: reduce){
    .collabDot{ animation:none; }
  }

  /* ---------- Route planning: waypoint cursor and pins ----------
     While planning, the map is a place to drop waypoints rather than
     something to pan around, so the cursor says so. Leaflet's own
     .leaflet-grab (cursor:grab) and .leaflet-interactive (cursor:pointer)
     both sit on the container/children, so this mirrors the specificity of
     Leaflet's built-in .leaflet-crosshair rule to win against them. The
     SVG is inlined as a data URI rather than a separate file so it can't
     miss the service worker's precache list. Falls back to `copy`, which
     every platform has, on the rare browser that rejects the URI. */
  .planning-map, .planning-map .leaflet-grab, .planning-map .leaflet-interactive{
    cursor: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='32' height='32' viewBox='0 0 32 32'><path d='M12 2C7.6 2 4 5.6 4 10c0 5.5 8 16 8 16s8-10.5 8-16c0-4.4-3.6-8-8-8z' fill='%23F0B33A' stroke='%23FFFFFF' stroke-width='2'/><circle cx='12' cy='10' r='3' fill='%23FFFFFF'/></svg>") 12 26, copy;
  }
  /* A waypoint already on the map is draggable, so it gets the grab cursor
     instead of the drop-a-new-pin one. Leaflet adds .leaflet-marker-draggable
     to these; .leaflet-dragging (set on the container mid-drag) already
     supplies `grabbing` from Leaflet's own stylesheet. */
  .planning-map .leaflet-marker-draggable{ cursor:grab; }

  /* Clicking a stop doesn't drop a waypoint — it adds that stop to the
     route and re-routes through it — so the drop-a-pin cursor above would
     promise the wrong thing. A plus-shaped cursor reads as "add this one",
     matching the ✓ badge the marker gains once it's planned. The rule sits
     on the marker's own children too because the emoji <span> and the
     badges each sit above the pin and would otherwise inherit the
     container's pin cursor on hover.
     `cell` is the fallback: it's the closest thing to a plus that every
     platform ships, so a browser rejecting the data URI still shows
     something clearly distinct from the pin. */
  .planning-map .poi-marker,
  .planning-map .poi-marker *,
  .planning-map .fav-badge,
  .planning-map .plan-badge,
  .planning-map .open-badge,
  .planning-map .unknown-badge{
    cursor: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='26' height='26' viewBox='0 0 26 26'><circle cx='13' cy='13' r='10' fill='%230E8F49' stroke='%23FFFFFF' stroke-width='2'/><path d='M13 8v10M8 13h10' stroke='%23FFFFFF' stroke-width='2.5' stroke-linecap='round'/></svg>") 13 13, cell;
  }
  /* A stop already in the plan: clicking removes it again, so it shouldn't
     keep advertising "add". Minus instead of plus, same shape and size.
     The badges are siblings of .poi-marker (they're positioned over it by
     the wrapper div, not nested inside), so :has() is what lets the wrapper
     switch them too — without it the ✓ badge, which only ever appears on a
     planned stop, would show "add" while the pin beneath it shows "remove". */
  .planning-map .poi-marker.is-planned,
  .planning-map .poi-marker.is-planned *,
  .planning-map div:has(> .poi-marker.is-planned) .fav-badge,
  .planning-map div:has(> .poi-marker.is-planned) .plan-badge,
  .planning-map div:has(> .poi-marker.is-planned) .open-badge,
  .planning-map div:has(> .poi-marker.is-planned) .unknown-badge{
    cursor: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='26' height='26' viewBox='0 0 26 26'><circle cx='13' cy='13' r='10' fill='%23B0402A' stroke='%23FFFFFF' stroke-width='2'/><path d='M8 13h10' stroke='%23FFFFFF' stroke-width='2.5' stroke-linecap='round'/></svg>") 13 13, cell;
  }
  /* Clusters let taps through while planning.
     =====================================================================
     Outside planning a cluster is a button that zooms in. During planning
     it was still that button, and at the opening zoom the map is carpeted
     with 44px cluster bubbles — so "tap the map to add waypoints" landed
     on a bubble far more often than on the map, silently zooming instead
     of dropping a waypoint. Two deliberate taps on apparently-open map
     could both be swallowed with no feedback at all, which reads as an
     unresponsive app rather than a rejected tap.
     Zooming stays available the ordinary ways (scroll, +/- control,
     pinch), none of which this affects, and the count stays visible as
     the useful thing it is — an indication of where the stops are, to
     plan towards. pointer-events on the wrapper rather than removing the
     handler in JS so it is purely presentational and reverts the moment
     the .planning-map class comes off.

     The rule targets the Leaflet marker element that WRAPS the bubble,
     not just .poi-cluster itself: Leaflet puts its click handling and
     `pointer-events:auto` on .leaflet-marker-icon, so disabling events on
     the inner div alone would still leave the wrapper catching the tap. */
  .planning-map .leaflet-marker-icon:has(> .poi-cluster),
  .planning-map .poi-cluster{ cursor:inherit; pointer-events:none; }

  /* The route line can be grabbed and pulled to route via somewhere else
     (enableRouteLineDragging in route-ui.js), so it gets the grab cursor
     rather than the drop-a-pin one — same signal the draggable waypoint
     pins already use. Leaflet renders the line as an SVG <path> inside the
     overlay pane, which .planning-map .leaflet-interactive would otherwise
     claim for the pin cursor. */
  .planning-map .leaflet-overlay-pane path.leaflet-interactive{ cursor:grab; }
  .planning-map .leaflet-overlay-pane path.leaflet-interactive:active{ cursor:grabbing; }

  .waypoint-pin{
    width:22px; height:22px; border-radius:50%;
    background:var(--blaze); border:3px solid var(--white);
    box-shadow:var(--shadow-sm);
    display:flex; align-items:center; justify-content:center;
    font-size:var(--fs-xs); font-weight:700; color:var(--on-amber); line-height:1;
    box-sizing:border-box;
  }
  /* The endpoints are what someone reasons about ("from here to there"), so
     they're visually distinct from the intermediate points they dragged in. */
  .waypoint-pin.endpoint{ background:var(--ink); color:var(--paper); }
  /* The start is the app's accent orange rather than the endpoint moss: it's
     the point everything else is measured from, and while drawing it's the
     only cue to which way round the route runs. --ink for the digit, since
     white on orange fails contrast at 11px. */
  /* .endpoint.start rather than just .start: the start IS an endpoint, so
     both rules match it and equal specificity would leave the winner to
     source order — a trap for whoever reorders this block later. */
  .waypoint-pin.endpoint.start{ background:var(--blaze); color:var(--on-amber); }
  /* The finish carries a flag glyph rather than a digit — slightly smaller
     type so the emoji sits on the same visual weight as the numbers.

     Orange like every other waypoint, not the moss it inherited from
     .endpoint. The flag already says "this is the end" far more directly
     than a colour does, so the green was carrying no information the glyph
     wasn't — it only made the last pin look like a different KIND of thing
     from the numbered ones it sits in sequence with.
     .endpoint.finish for the same reason .endpoint.start is written that
     way: the finish IS an endpoint, so both rules match and equal
     specificity would leave the winner to source order. */
  .waypoint-pin.endpoint.finish{ background:var(--blaze); color:var(--on-amber); font-size:var(--fs-sm); }
  /* A waypoint that has been tapped but whose route hasn't come back yet.
     BRouter takes ~1.3s on a short route and several seconds on a long one,
     during which the pin used to sit there looking identical to the settled
     ones — so a slow route was indistinguishable from a tap that missed.

     The pulse is a halo on box-shadow rather than transform:scale, matching
     .placing-pin: Leaflet positions a divIcon by its anchor, and scaling the
     element moves its visual centre off the point it marks. Colour follows
     the pin's own role, so the start stays orange and an intermediate point
     stays blaze — this says "still working", not "something is wrong". */
  .waypoint-pin.pending{ animation: waypointPending 1.4s infinite ease-in-out; }
  @keyframes waypointPending{
    0%,100%{ box-shadow:var(--shadow-sm), 0 0 0 0 rgba(240,179,58,0.55); }
    50%{ box-shadow:var(--shadow-sm), 0 0 0 9px rgba(240,179,58,0); }
  }
  /* A moss endpoint needs a moss halo — an orange ring around a green pin
     reads as a second, overlapping marker. The start and the finish are
     both orange, so they take the orange halo back. */
  .waypoint-pin.endpoint.pending{ animation-name: waypointPendingMoss; }
  .waypoint-pin.endpoint.start.pending,
  .waypoint-pin.endpoint.finish.pending{ animation-name: waypointPending; }
  @keyframes waypointPendingMoss{
    0%,100%{ box-shadow:var(--shadow-sm), 0 0 0 0 rgba(14,143,73,0.55); }
    50%{ box-shadow:var(--shadow-sm), 0 0 0 9px rgba(14,143,73,0); }
  }
  /* Tapping a waypoint that can't be removed — a route needs at least two —
     used to do nothing at all, which reads as an unresponsive pin rather
     than a refused action. A brief shake says the tap registered and was
     declined, without a toast/banner infrastructure this is the one thing
     on screen that can react instantly, right where the tap happened. Not
     `pending`'s animation-name/duration — this runs once and stops, it
     doesn't loop while something is in flight. */
  .waypoint-pin.denied{ animation: waypointDenied 0.4s ease-in-out; }
  @keyframes waypointDenied{
    0%, 100%{ transform:translateX(0); }
    20%, 60%{ transform:translateX(-3px); }
    40%, 80%{ transform:translateX(3px); }
  }
  /* The whole route line while a re-route is in flight — changing the route
     type, say, where the line on screen is still the OLD one until BRouter
     answers. Without this the map looks settled and the only sign anything
     is happening is the busy banner, which is nowhere near where the eye is.

     Opacity rather than stroke-width or a dash offset: width makes the line
     jump against the day-colour segments beside it, and an animated dash
     reads as "this is a draft line" — which is what the drawing layer's
     dashed preview already means. Fading says "not final yet" without
     borrowing another state's vocabulary.

     Bottoms out at 0.3 rather than 0 so the route never disappears: it is
     still the best answer available until the new one lands, and a line
     blinking out of existence would read as an error. */
  .leaflet-overlay-pane path.route-line-pending{
    animation: routeLinePulse 1.4s infinite ease-in-out;
  }
  @keyframes routeLinePulse{
    0%,100%{ stroke-opacity:0.75; }
    50%{ stroke-opacity:0.3; }
  }

  /* Motion is the whole signal here, so with it suppressed the state still
     has to be visible: a solid ring stands in for the pulse rather than the
     pin simply looking settled. */
  @media (prefers-reduced-motion: reduce){
    .waypoint-pin.pending{
      animation:none;
      box-shadow:var(--shadow-sm), 0 0 0 4px rgba(240,179,58,0.45);
    }
    /* Held at the faded end rather than the solid one: a route that looks
       exactly like a settled route is the thing this state exists to
       distinguish itself from. */
    .leaflet-overlay-pane path.route-line-pending{
      animation:none;
      stroke-opacity:0.4;
    }
    .waypoint-pin.endpoint.pending{
      box-shadow:var(--shadow-sm), 0 0 0 4px rgba(14,143,73,0.45);
    }
    .waypoint-pin.endpoint.start.pending{
      box-shadow:var(--shadow-sm), 0 0 0 4px rgba(240,179,58,0.45);
    }
  }

  /* Where a riding day ends for the night.
     Square-ish (6px radius) rather than round, so it never reads as one of
     the circular waypoint pins or POI markers it sits among — the shape
     carries the distinction at a glance, before any colour does. The
     background is set inline, per day, from DAY_COLORS. */
  .day-break-pin{
    width:24px; height:24px; border-radius:var(--radius-sm);
    border:2.5px solid var(--white);
    box-shadow:var(--shadow-sm);
    display:flex; align-items:center; justify-content:center;
    /* The digit sits on the day's colour: white on the light set, Ink on
       the light dark-theme set. Same token pair the amber fills use. */
    font-size:var(--fs-xs); font-weight:700; color:var(--on-day); line-height:1;
    box-sizing:border-box;
  }
  /* A night actually spent somewhere — a campsite, a guesthouse — carries the
     crescent, matching the control in the sidebar that placed it. Slightly
     larger, since the glyph is thinner than a digit at the same size. */
  .day-break-pin.overnight{ font-size:var(--fs-md); }
  /* Grabbable, and visibly so. Leaflet puts .leaflet-marker-draggable on the
     icon's own element only when dragging is actually enabled, so this can't
     advertise a gesture a viewer (or a collaborator without the tier) does
     not have. */
  .leaflet-marker-draggable .day-break-pin{ cursor:grab; }
  .leaflet-marker-draggable .day-break-pin:active{ cursor:grabbing; }
  .leaflet-dragging .day-break-pin{ cursor:grabbing; }

  /* Direction chevrons along the route. Drawn as a rotated CSS triangle
     rather than a glyph so it stays crisp at any size and needs no font.
     pointer-events:none matters: these sit on top of the route line, which
     is itself draggable, and must never intercept that gesture. */
  /* Where the elevation profile's cursor is, on the route itself.
     ============================================================
     Hollow, so the line stays visible straight through it — the thing being
     pointed at IS the line, and a solid dot would hide the very bend the
     reader is trying to find.

     A ring rather than a pin: pins in this app are things you placed and can
     drag (waypoints, night stops), where this is a readout that follows the
     cursor and vanishes with it. Borrowing their shape would promise an
     interaction that isn't there.

     No transition on position. It is driven by pointermove, so a transition
     would make it lag the cursor it is meant to correspond to — the one
     thing it must not do. */
  .route-profile-cursor-dot{
    width:14px; height:14px; margin:2px; box-sizing:border-box;
    border-radius:50%; pointer-events:none;
    border:3px solid var(--blaze);
    background:rgba(255,255,255,0.35);
    box-shadow:0 0 0 1.5px rgba(20,26,20,0.55), var(--shadow-sm);
  }
  @media (prefers-reduced-motion: reduce){
    /* Nothing animates here, but the dot appearing and disappearing under a
       moving pointer is itself motion — keep it, since removing it would
       remove the feature, and it is directly cursor-driven rather than
       autonomous. */
    .route-profile-cursor-dot{ transition:none; }
  }

  .route-arrow{
    width:0; height:0; pointer-events:none;
    border-left:5px solid transparent;
    border-right:5px solid transparent;
    border-bottom:9px solid var(--white);
    filter: drop-shadow(0 0 1.5px rgba(20,26,20,0.85));
    margin:3.5px 3px;
  }

  /* Wrapper so the ground ring can be a sibling of the pin rather than a
     child of it — .placing-pin carries a -45deg rotation to form its
     teardrop, which any descendant would inherit. */
  .placing-wrap{ position:relative; width:40px; height:40px; }

  /* The ring on the map surface at the pin's tip. iconAnchor is [20,37], so
     the tip is 3px above the icon box's bottom edge; the ring is centred
     there rather than under the whole 40px square.
     Behind the pin (z-index/order) and inert, so it never takes a drag that
     was meant for the pin itself. */
  .placing-ground{
    position:absolute; left:50%; bottom:-4px; transform:translateX(-50%);
    width:14px; height:14px; border-radius:50%;
    background:rgba(240,179,58,0.35); border:2px solid var(--blaze);
    pointer-events:none; z-index:0;
    animation: placingGround 1.4s infinite ease-in-out;
  }
  /* Same 1.4s period as the pin's own halo, so the two read as one signal
     rather than two things blinking at each other. */
  @keyframes placingGround{
    0%, 100%{ transform:translateX(-50%) scale(0.7); opacity:0.9; }
    50%     { transform:translateX(-50%) scale(1.5); opacity:0.25; }
  }
  @media (prefers-reduced-motion: reduce){
    .placing-ground{ animation:none; }
  }

  .placing-pin{
    position:relative; z-index:1;
    width:40px; height:40px; border-radius:50% 50% 50% 0; transform:rotate(-45deg);
    background:var(--blaze); border:3px solid var(--white); box-shadow:var(--shadow-lg);
    display:flex; align-items:center; justify-content:center; cursor:grab;
    animation: placingPulse 1.4s infinite ease-in-out;
  }
  .placing-pin:active{ cursor:grabbing; animation:none; }
  .placing-pin.confirmed{ background:var(--ink); cursor:default; animation:none; }
  .placing-pin span{ transform:rotate(45deg); font-size:var(--fs-xl); font-weight:700; color:var(--on-amber); line-height:1; }
  .placing-pin.confirmed span{ color:var(--white); }
  @keyframes placingPulse{
    0%,100%{ box-shadow:var(--shadow-lg), 0 0 0 0 rgba(240,179,58,0.5); }
    50%{ box-shadow:var(--shadow-lg), 0 0 0 10px rgba(240,179,58,0); }
  }

  /* ---------- Route stops sidebar ---------- */
  #routeSidebar{
    position:absolute; z-index:540; background:var(--white); box-shadow:var(--shadow);
    display:flex; flex-direction:column; border-right:4px solid var(--ink); overflow:hidden;
  }
  #routeSidebar[hidden]{ display:none; }
  @media (min-width:721px){
    #routeSidebar{ top:0; left:0; bottom:0; width:var(--rail-w); }
  }
  @media (max-width:720px){
    /* A draggable bottom sheet with three resting heights.

       Why it exists: the four blocks above the list (header, day strip,
       stats, filters) are all flex-shrink:0, so they take their space first
       and the list gets the remainder — which on a 6-day route was 474px of
       blocks against a 461px budget, i.e. the stop list was squeezed to ZERO
       pixels. The panel named "Stops along your route" showed no stops.

         peek — the header, and nothing else
         half — 55% of the viewport, the height this sheet has always opened
                at. #routeMenu's cap is computed against it
                (syncRouteMenuHeight), so moving it moves the menu too
         full — the viewport less the map furniture at the top

       ONE height, driven from --sheet-h and written by JS in px, rather than
       a max-height per snap. A drag has to move the sheet to arbitrary
       intermediate heights, and a snap is just a drag that ends somewhere
       specific — two mechanisms would have to agree about a value that is
       changing 60 times a second. The px values come from
       routeSheetHeights(), which measures rather than guesses: peek used to
       be a hardcoded 96px against a header that is really 78px, and the
       19px of difference rendered as a sliced-off "Route stats" strip below
       the header. */
    #routeSidebar{
      left:0; right:0; bottom:0; border-right:none;
      border-top:4px solid var(--ink); border-radius:var(--radius-lg) var(--radius-lg) 0 0;
      height:var(--sheet-h, 55vh); max-height:none;
    }
    /* Only between snaps — never while a finger is down, where a transition
       is latency between the thumb and the sheet. The curve decelerates
       hard at the end, the way a released sheet settles; `ease` reads as a
       panel being resized. */
    #routeSidebar:not([data-dragging]){
      transition:height 0.26s cubic-bezier(0.32, 0.72, 0, 1);
    }
    /* Above #routeMenu (560) whenever the sheet is the thing being used:
       while a drag is in progress, and at full height once it settles.

       The menu is anchored over the map and does not move, so a sheet that
       grows past it slides UNDERNEATH — mid-drag that put the menu's Save
       button over the sheet being dragged, which reads as the sheet going
       behind the furniture just as you pull it up. data-snap still holds
       the OLD value during the gesture, so keying on it alone left the
       whole drag at 540 and only flipped the layer after release.

       At peek and half, released, the sheet sits below the menu again —
       the established relationship the menu's own comment describes, and
       what its computed cap is built on. */
    #routeSidebar[data-snap="full"],
    #routeSidebar[data-dragging]{ z-index:570; }

    /* The drag surface. touch-action:none stops the browser claiming the
       gesture as a scroll before the pointermove handler sees it — scoped
       to the header alone, never the map, which needs its own panning. */
    #routeSidebarHeader{
      position:relative; padding-top:22px; touch-action:none;
      cursor:grab; user-select:none;
    }
    #routeSidebar[data-dragging] #routeSidebarHeader{ cursor:grabbing; }
    /* Peeked, the header IS the sheet — its bottom rule would draw a line
       across the bottom of the screen with nothing under it. */
    #routeSidebar[data-snap="peek"] #routeSidebarHeader{ border-bottom:none; }

    /* The grabber: a real button, so the gesture it advertises is also
       reachable by keyboard and announced by a screen reader. Positioned
       over the header's top edge and stretched wide — the hit area is the
       full width of the sheet's top, not the 36px pill you can see. */
    #routeSheetGrab{
      position:absolute; top:0; left:0; right:0; height:22px;
      display:flex; align-items:center; justify-content:center;
      background:none; border:none; padding:0; cursor:grab;
      touch-action:none; -webkit-tap-highlight-color:transparent;
    }
    #routeSheetGrab::before{
      content:''; width:36px; height:4px; border-radius:var(--radius-pill);
      background:var(--line); transition:background 0.15s ease, width 0.15s ease;
    }
    #routeSheetGrab:hover::before{ background:var(--ink-soft); }
    #routeSidebar[data-dragging] #routeSheetGrab::before{
      background:var(--ink); width:44px;
    }
    #routeSheetGrab:focus-visible{ outline:2px solid var(--blaze); outline-offset:-2px; border-radius:var(--radius-sm); }

    /* The list scrolls, and at its top edge a downward pull becomes a sheet
       drag instead (see enableRouteSheetDrag). `contain` keeps that pull
       from also bouncing the page behind the sheet. */
    #routeStopList{ overscroll-behavior:contain; }
  }
  @media (max-width:720px) and (prefers-reduced-motion: reduce){
    #routeSidebar:not([data-dragging]){ transition:none; }
    #routeSheetGrab::before{ transition:none; }
  }
  @media (min-width:721px){
    /* Sheet-only furniture: the desktop rail is a fixed-width column with
       no heights to drag between. */
    #routeSheetGrab{ display:none; }
  }
  #routeSidebarHeader{
    display:flex; align-items:flex-start; justify-content:space-between; gap:10px;
    padding:18px 16px 10px; border-bottom:1px solid var(--line); flex-shrink:0;
  }
  /* The panel's title, so the two left rails (and the two phone sheets)
     read as the same kind of surface. */
  #routeSidebarTitle{
    font-family:'Bricolage Grotesque', sans-serif; font-weight:600; letter-spacing:0.01em;
    font-size:var(--fs-xxl); margin:0; color:var(--ink); text-transform:uppercase;
  }
  #routeSidebarSummary{ font-size:var(--fs-sm); color:var(--ink-soft); margin:3px 0 0; }
  #routeSidebarCollapse{
    background:none; border:none; cursor:pointer; color:var(--ink-soft); padding:4px; flex-shrink:0; border-radius:var(--radius-sm);
  }
  #routeSidebarCollapse:hover{ background:var(--paper-dim); color:var(--ink); }
  #routeSidebarCollapse svg{ width:20px; height:20px; }

  /* The collapsed stop list: a thin tab against the same edge the sidebar
     docks to, carrying the stop count so collapsing doesn't hide how many
     there are. Same z-index as the sidebar it replaces. */
  #routeSidebarExpand{
    position:absolute; z-index:540; display:flex; align-items:center; justify-content:center;
    gap:4px; cursor:pointer; background:var(--white); color:var(--ink);
    border:none; border-right:4px solid var(--ink); box-shadow:var(--shadow);
    font-family:inherit; font-size:var(--fs-sm); font-weight:700; padding:0;
  }
  #routeSidebarExpand[hidden]{ display:none; }
  #routeSidebarExpand:hover{ background:var(--paper); }
  #routeSidebarExpand:focus-visible{ outline:2px solid var(--blaze); outline-offset:2px; }
  #routeSidebarExpandCount{
    background:var(--ink); color:var(--paper); border-radius:var(--radius-sm);
    min-width:18px; padding:1px 5px; font-size:var(--fs-xs); line-height:16px;
  }
  @media (min-width:721px){
    /* Full-height strip down the left edge, mirroring the sidebar's dock. */
    #routeSidebarExpand{
      top:0; left:0; bottom:0; width:38px; flex-direction:column; gap:6px;
      border-radius:0 var(--radius-md) var(--radius-md) 0;
    }
  }
  @media (max-width:720px){
    /* Bottom-docked, so the collapsed form is a handle along the bottom
       edge rather than a side strip — matching where the sheet lives. */
    #routeSidebarExpand{
      left:0; right:0; bottom:0; height:calc(38px + env(safe-area-inset-bottom));
      padding-bottom:env(safe-area-inset-bottom);
      border-right:none; border-top:4px solid var(--ink); border-radius:var(--radius-lg) var(--radius-lg) 0 0;
    }
    /* Chevron points up on mobile: the list expands upward from the bottom. */
    #routeSidebarExpand svg{ transform:rotate(-90deg); }
  }
  /* Route stats block. Sits between the sidebar header and the category
     filters, matching their padding and divider so the sidebar still reads
     as one stack of sections rather than a panel with something bolted on. */
  #routeStats{
    padding:10px 16px 12px; border-bottom:1px solid var(--line); flex-shrink:0;
  }
  #routeStats > summary{
    font-family:'Bricolage Grotesque', sans-serif; font-size:var(--fs-sm); font-weight:600;
    text-transform:uppercase; letter-spacing:0.04em; color:var(--ink-soft);
    cursor:pointer; list-style:none; display:flex; align-items:center; gap:6px;
  }
  #routeStats > summary::-webkit-details-marker{ display:none; }
  /* Own chevron rather than the native marker, which can't be styled
     consistently across browsers. Rotates to point down when open. */
  #routeStats > summary::before{
    content:''; width:0; height:0; flex-shrink:0;
    border-left:4px solid currentColor; border-top:3.5px solid transparent; border-bottom:3.5px solid transparent;
    transition:transform 0.15s ease;
  }
  #routeStats[open] > summary::before{ transform:rotate(90deg); }
  #routeStats > summary:focus-visible{ outline:2px solid var(--blaze); outline-offset:2px; border-radius:var(--radius-sm); }
  /* Wraps rather than shrinking: three stats fit one line on a desktop
     sidebar, and on a narrow bottom sheet the third dropping to its own row
     reads better than three cramped columns. */
  .routeStatRow{ display:flex; flex-wrap:wrap; gap:9px 18px; margin-top:9px; }
  .routeStat{ display:flex; flex-direction:column; gap:1px; }
  .routeStatLabel{
    font-size:var(--fs-xs); font-weight:700; text-transform:uppercase; letter-spacing:0.03em;
    color:var(--ink-soft);
  }
  .routeStatValue{
    font-family:'IBM Plex Mono', ui-monospace, Menlo, monospace; font-size:var(--fs-xl); font-weight:500; color:var(--ink);
    font-variant-numeric:tabular-nums;
  }
  .routeStatsNote{ font-size:var(--fs-sm); color:var(--ink-soft); margin:8px 0 0; }

  /* ---------- What the route runs on ---------- */
  /* A stacked bar plus a legend naming each share. Sits below the elevation
     profile in #routeStats, and is absent entirely for a route with no way
     tags to bucket (see compositionHtml in route-stats.js). */
  .routeMakeup{ margin-top:14px; padding-top:12px; border-top:1px solid var(--line); }
  .routeMakeupHead{
    font-size:var(--fs-xs); font-weight:700; text-transform:uppercase; letter-spacing:0.06em;
    color:var(--ink-soft); margin-bottom:8px;
  }
  /* overflow:hidden so the segments' square edges are clipped to the bar's
     rounded ends rather than poking out of them. */
  .routeMakeupBar{
    display:flex; height:10px; border-radius:var(--radius-pill);
    overflow:hidden; background:var(--paper-dim);
  }
  .routeMakeupSeg{ display:block; height:100%; }
  .routeMakeupLegend{ margin-top:10px; display:flex; flex-direction:column; gap:5px; }
  .routeMakeupRow{ display:flex; align-items:center; gap:8px; font-size:var(--fs-sm); }
  /* A row is a <button> when the route carries spans to highlight (see
     compositionHtml), so it needs the button chrome stripped back to what the
     non-clickable <div> version looks like — then given its own affordance. */
  button.routeMakeupRow{
    width:100%; background:none; border:none; font-family:inherit;
    text-align:left; cursor:pointer; padding:3px 6px; margin:-3px -6px;
    border-radius:var(--radius-sm);
  }
  /* The row under the pointer, whose stretches are previewed on the map.
     Same ground as the pinned state below, deliberately: hovering and pinning
     show the same thing, and the difference between "passing over" and "fixed
     here" is carried by the label weight rather than by a second background. */
  button.routeMakeupRow:hover{ background:var(--paper-dim); }
  /* No hover to preview with, so the row only responds to a real tap. */
  @media (hover: none){
    button.routeMakeupRow:hover{ background:none; }
    button.routeMakeupRow.isActive{ background:var(--paper-dim); }
  }
  button.routeMakeupRow:focus-visible{ outline:2px solid var(--blaze); outline-offset:1px; }
  /* The lit row. The swatch already carries the bucket's colour, so the active
     state is carried by weight and ground rather than by a second colour,
     which would compete with the map. */
  button.routeMakeupRow.isActive{ background:var(--paper-dim); }
  button.routeMakeupRow.isActive .routeMakeupLabel{ font-weight:700; }
  .routeMakeupSwatch{
    flex-shrink:0; width:10px; height:10px; border-radius:3px;
  }
  .routeMakeupLabel{ color:var(--ink); }
  /* Pushed to the right edge and tabular, so the four figures line up as a
     column that can be read down rather than hunted for. */
  .routeMakeupPct{
    margin-left:auto; font-weight:700; color:var(--ink);
    font-variant-numeric:tabular-nums;
  }
  /* The distance behind the share. Muted and lighter than the percentage:
     the share is what the block is answering, and the km is the supporting
     figure — giving both the same weight would make the row read as two
     competing numbers. */
  .routeMakeupKm{ font-weight:400; color:var(--ink-soft); }
  /* Ordered by traffic exposure, and coloured to match: the accent orange
     for main roads (the thing worth noticing), moss for the car-free
     buckets, and mustard for unpaved — which is not about traffic at all but
     about whether the surface suits the bike. */
  /* Pushed back while one road type is lit, so the highlight reads as the
     subject and the rest of the line as context. Opacity rather than a grey
     colour: a split tour's day colours stay recognisable, just quieter. */
  .leaflet-overlay-pane path.route-line-dimmed{ opacity:0.25; }
  .routeMakeupSeg.is-main,    .routeMakeupSwatch.is-main{ background:var(--blaze); }
  .routeMakeupSeg.is-quiet,   .routeMakeupSwatch.is-quiet{ background:#7E9AA8; }
  .routeMakeupSeg.is-pathway, .routeMakeupSwatch.is-pathway{ background:var(--water); }
  .routeMakeupSeg.is-unpaved, .routeMakeupSwatch.is-unpaved{ background:var(--mustard); }
  /* The profile stretches to whatever width the sidebar gives it —
     preserveAspectRatio="none" on the SVG means the path distorts with it,
     which is what a profile chart wants (the x axis is "along the route",
     not a real distance the aspect ratio has to preserve). */
  .routeProfile{ margin-top:10px; }
  .routeProfile svg{
    display:block; width:100%; height:52px; overflow:visible;
  }
  /* Translucent moss rather than a new palette entry: the fill only ever
     sits on the sidebar's white, and deriving it keeps the profile in step
     if --moss is ever retuned. */
  .routeProfile path{ fill:color-mix(in srgb, var(--ink) 16%, transparent); stroke:var(--ink); stroke-width:1.5; }
  /* Gradient bands. The same translucent treatment the single path used, so
     the chart reads as one shape that changes colour rather than four
     stacked shapes — and a gentle route, which is all g1, looks exactly as
     it did before.
     Thresholds and the uphill-only rule are in route-stats.js; these are
     only the colours. Drawn from the app's own palette rather than a
     borrowed green-to-red ramp, with one red added for the steepest band
     since nothing in the palette says "hard" on its own. */
  /* Bands FILL only. Each is a closed shape — up the hillside, down to the
     baseline, back along it — so stroking one outlines its vertical closing
     edges too. On a rolling route that is dozens of vertical rules painted
     across the chart, dominating the shape they annotate. */
  .routeProfile path.band{ stroke:none; }
  .routeProfile path.band.g1{ fill:color-mix(in srgb, var(--ink) 16%, transparent); }
  .routeProfile path.band.g2{ fill:color-mix(in srgb, var(--mustard) 32%, transparent); }
  .routeProfile path.band.g3{ fill:color-mix(in srgb, var(--blaze) 32%, transparent); }
  .routeProfile path.band.g4{ fill:color-mix(in srgb, var(--error) 34%, transparent); }
  /* The terrain outline, stroked once over the bands. Traces only the
     terrain, exactly as the single-path version did. */
  .routeProfile path.line{ fill:none; stroke:var(--ink); stroke-width:1.5; }

  /* The chart and its stop markers share one positioned box, so a marker's
     `left` percentage is the same 0..1 fraction the path's x axis uses. */
  .routeProfilePlot{ position:relative; }
  /* Inset horizontally by half a marker's width. Nothing above this clips,
     so a stop at fraction 0 or 1 would otherwise hang off the panel edge;
     insetting the band a marker can occupy keeps both extremes inside the
     chart without lying about where they are by more than a few pixels. */
  .routeProfileMarkers{ position:absolute; inset:0 9px; pointer-events:none; }
  .routeProfileMarker{
    position:absolute;
    /* `left` is the fraction along the route and `top` is the elevation
       there, so this element's origin sits exactly ON the line.

       The pin then hangs entirely ABOVE that point, tip touching it — the
       way a map pin marks a place rather than covering it.

       The Y shift is -100% MINUS 3.31px, not -50%. Rotation is visual only,
       so the layout box stays 16x16, but the teardrop's sharp corner (the
       one border-radius leaves square) swings to point straight down and
       ends up 16 x sqrt(2) / 2 = 11.31px below the box's centre — 3.31px
       past its bottom edge. Shifting up by the box height alone would leave
       that overhang crossing the line, which is what "the markers sit too
       low" looked like. */
    transform:translate(-50%, calc(-100% - 3.31px));
    line-height:1; pointer-events:auto; cursor:default;
  }
  /* The map's own marker (.poi-marker), scaled to fit a 52px chart. Same
     teardrop, same category colour on the background, same white border,
     same counter-rotated emoji — a different shape here would read as a
     different kind of thing rather than as "that stop, on this climb". */
  .routeProfilePin{
    display:flex; align-items:center; justify-content:center;
    width:16px; height:16px;
    border-radius:50% 50% 50% 0; transform:rotate(-45deg);
    border:1.5px solid var(--white);
    box-shadow:var(--shadow-sm);
  }
  .routeProfilePin > span{ transform:rotate(45deg); font-size:var(--fs-xs); }
  .routeProfilePin > .catGlyph{ transform:rotate(45deg); width:9px; height:9px; color:#fff; }

  /* Hover readout. The plot takes the pointer so the whole chart is a
     target, rather than only the 1px line itself. */
  /* pan-y: a finger sliding along the chart reads the profile (see
     wireProfileHover); a finger moving up or down still scrolls the sheet. */
  .routeProfilePlot{ cursor:crosshair; touch-action:pan-y; }
  /* Dotted rather than solid: it marks where you're reading, which is a
     different kind of thing from the route's own line and the day breaks'
     rules, and shouldn't compete with either. */
  .routeProfileCursor{
    position:absolute; top:0; bottom:0; width:0;
    border-left:1px dashed var(--ink-soft);
    transform:translateX(-50%); pointer-events:none;
  }
  .routeProfileTip{
    /* Inside the chart, not above it. `bottom:100%` put this over the
       distance/ascent row, which is the one part of the block you might be
       cross-referencing while reading a point off the profile. */
    position:absolute; top:2px;
    background:var(--ink); color:var(--white);
    font-size:var(--fs-xs); line-height:1.4; white-space:nowrap;
    padding:3px 6px; border-radius:var(--radius-sm);
    pointer-events:none; z-index:1;
    /* translateX is set inline as the cursor moves — it carries a nudge
       that keeps the tooltip inside the chart at both ends. */
  }
  /* An overnight break is structural rather than a place, so it's a rule
     down the chart in its day's colour — matching the map pin and the
     sidebar's day headers — not another emoji competing with the stops. */
  .routeProfileMarker.isBreak{
    top:0; bottom:0; width:2px; border-radius:1px;
    transform:translateX(-50%); text-shadow:none; opacity:0.75;
  }
  .routeProfileAxis{
    display:flex; justify-content:space-between; margin-top:3px;
    font-size:var(--fs-xs); color:var(--ink-soft);
  }
  /* The one control above the list: "Show all stops". Its own strip, in
     the sidebar's rhythm of hairline-separated sections. */
  #routeSidebarTools{ padding:8px 16px; border-bottom:1px solid var(--line); flex-shrink:0; }
  #routeSidebarTools[hidden]{ display:none; }
  .plannedOnlyToggle{
    display:flex; align-items:center; gap:8px; font-size:var(--fs-sm); color:var(--ink);
    cursor:pointer; user-select:none; min-height:24px;
  }
  .plannedOnlyToggle input{ accent-color: var(--ink); margin:0; cursor:pointer; flex-shrink:0; }
  /* Disabled while nothing is ticked: the control would empty the view with
     no way to tell why. The title attribute carries the reason. */
  .plannedOnlyToggle.isDisabled{ color:var(--ink-soft); cursor:default; }
  .plannedOnlyToggle.isDisabled input{ cursor:default; }
  .plannedOnlyToggle input:focus-visible{ outline:2px solid var(--blaze); outline-offset:2px; }
  .plannedOnlyCount{ margin-left:auto; color:var(--ink-soft); font-variant-numeric:tabular-nums; }
  #routeSidebarFooter{
    flex-shrink:0; padding:10px 16px calc(10px + env(safe-area-inset-bottom)); border-top:1px solid var(--line); background:var(--white);
  }
  #routeSidebarFooter .routeAction{ margin-bottom:0; }
  #routeStopList{
    list-style:none; margin:0; padding:0; overflow-y:auto;
    flex:1; min-height:0; /* min-height:0 is required — without it a flex item won't shrink below its content, pushing the footer out of view */
  }
  /* The trailing padding matters: the row's last control used to be the
     favorite star, whose 44px box left the glyph visually inset from the
     panel edge for free. Adding the overnight button after it put that
     button hard against the edge instead, where its crescent read as
     clipped. Pad the row rather than the button so the two trailing
     controls keep identical boxes. */
  .routeStopRow{
    display:flex; align-items:stretch; border-bottom:1px solid var(--line);
    padding-right:6px;
  }
  .routeStopRow:hover{ background:var(--paper); }
  /* The day's colour, continuing the header's bar down every stop in that
     day so the two read as one band. See .routeDayHeaderRow for why this is
     an edge rather than a tint.
     A ::before rather than a border-left, for two reasons: the row is a
     flex container whose children are full-height buttons, so a border
     would sit outside the hover/planned background rather than beside it;
     and .proposed already owns border-left, which this has to coexist with
     rather than fight over (see below). position:relative on the row is
     what the absolutely-positioned stripe hangs off.
     Only on .hasDay — an unsplit route has one day, and a bar that is the
     same colour for the whole list says nothing. */
  .routeStopRow.hasDay{ position:relative; padding-left:4px; }
  .routeStopRow.hasDay::before{
    content:''; position:absolute; left:0; top:0; bottom:0; width:4px;
    background:var(--dayColor, transparent); pointer-events:none;
  }
  .routeStop{
    display:flex; align-items:flex-start; gap:10px; flex:1; min-width:0; text-align:left;
    padding:11px 4px 11px 2px; border:none;
    background:none; cursor:pointer; font-family:inherit;
  }
  .routeStop:focus-visible{ outline:2px solid var(--blaze); outline-offset:-2px; }
  /* Chosen, not "open": the plan tint is the chalk-dim ground, and the tick
     is ink. Green is reserved for the pill's open state. */
  .routeStopRow.planned{ background:var(--chalk-dim); }
  .routeStopRow.planned:hover{ background:var(--planned-hover); }
  /* A proposal: offered, not chosen (patch 60).
     Deliberately NOT the planned row's solid green — that colour means "this
     is in your plan", and a proposal is precisely the thing that is not yet.
     A dashed left edge in the accent colour reads as provisional, and keeps
     the row visually a sibling of the planned rows it sits between rather
     than a separate list. */
  .routeStopRow.proposed{
    background:var(--amber-tint);
    border-left:3px dashed var(--amber);
  }
  .routeStopRow.proposed:hover{ background:var(--proposed-hover); }
  /* A proposed stop on a split route wants both edges: the dashed blaze
     border says "decide about this", the day bar says "this is Tuesday".
     The border wins the outer edge — a decision to make now outranks
     context — and the day stripe sits just inside it rather than being
     dropped, so the band down the list stays unbroken.
     The row's own padding-left drops to 1px so the 3px border plus that
     padding still totals the 4px a plain day row spends on its stripe:
     without it the content jogs 3px sideways the moment a row becomes a
     proposal, which is exactly the kind of movement a list being read
     should not do. */
  .routeStopRow.proposed.hasDay{ padding-left:1px; }
  .routeStopRow.proposed.hasDay::before{ left:0; width:4px; }
  /* Why the stop is being offered — the gap it fills. Set in the accent's
     darker text tone rather than --ink-soft so it reads as the row's reason
     rather than as more metadata. */
  .routeStopWhy{
    display:block; font-size:var(--fs-sm); line-height:1.4;
    color:var(--blaze-text); font-weight:600;
  }
  /* Rejecting a proposal. Same 44px column as the star and crescent beside
     it, so the trailing controls stay one strip whatever state a row is in. */
  .routeStopDismiss{
    flex-shrink:0; width:44px; border:none; background:none; cursor:pointer;
    font-size:var(--fs-xl); line-height:1; color:var(--ink-soft); font-family:inherit;
    display:flex; align-items:center; justify-content:center; padding:0;
  }
  .routeStopDismiss:hover{ color:var(--error); }
  .routeStopDismiss:focus-visible{ outline:2px solid var(--blaze); outline-offset:-2px; }
  .routeStopPlan{
    /* 44px, matching .routeStopFav/.routeStopDismiss beside it — the visible
       checkbox stays an 18px square (::before below), only the tap target
       widened. This is the row's primary "add this stop to route" control,
       so it should be at least as easy to hit as its neighbours, not the
       smallest of the row's buttons. */
    position:relative; flex-shrink:0; width:44px;
    display:flex; align-items:center; justify-content:center;
    border:none; background:none; cursor:pointer; padding:0;
    font-family:inherit; font-size:var(--fs-xs); font-weight:700; line-height:1;
    color:var(--white); z-index:0;
  }
  .routeStopPlan::before{
    content:''; position:absolute; width:18px; height:18px; border-radius:var(--radius-sm);
    border:1.5px solid var(--line); background:var(--white); z-index:-1;
  }
  .routeStopPlan.active::before{ background:var(--ink); border-color:var(--ink); }
  .routeStopPlan:focus-visible{ outline:2px solid var(--blaze); outline-offset:-2px; }
  .routeStopPlan:disabled{ opacity:0.5; cursor:default; }
  /* Open, Export and Share, in that order of weight. align-items:flex-start
     so the export menu growing on open pushes its own body down rather than
     stretching the two buttons beside it to match. */
  .savedRouteActions{
    display:flex; flex-wrap:wrap; align-items:flex-start; gap:8px; margin-top:12px;
  }
  /* .routeAction is full-width by default (it's a stacked button elsewhere);
     in the card's action row the three controls sit side by side. */
  .savedRouteActions .routeAction{ width:auto; margin-bottom:0; padding:9px 13px; }
  /* Open on its own line, then the three toggles — Export, Share, Move
     to… — as one row of equal boxed buttons. Three alike, rather than two
     boxed and one as text: the odd one out read as a different kind of
     thing, and four in one row did not fit a phone. */
  .savedRouteActions .openRouteBtn{ flex:1 0 100%; }
  .savedRouteActions .routeToggleBtn{ flex:1 1 0; justify-content:center; min-width:0; padding:9px 8px; white-space:nowrap; }
  /* Export and Share: the two toggles of the action row, alike. A chevron
     says they open something, and it turns once they have; the open one
     inverts to ink so the block below is visibly its. */
  .routeToggleBtn{ display:inline-flex; align-items:center; gap:6px; }
  .routeToggleChev{
    width:0; height:0; border-left:4px solid transparent; border-right:4px solid transparent;
    border-top:5px solid currentColor; opacity:0.7; transition:transform 0.15s ease;
  }
  .routeToggleBtn[aria-expanded="true"]{ background:var(--ink); color:var(--paper); border-color:var(--ink); }
  .routeToggleBtn[aria-expanded="true"] .routeToggleChev{ transform:rotate(180deg); }
  /* The export block's choices: a row of secondary buttons that wraps. */
  .exportOptions{ display:flex; flex-wrap:wrap; gap:8px; margin-bottom:8px; }
  .exportOptions .routeAction{ width:auto; margin:0; padding:8px 12px; font-size:var(--fs-sm); }
  /* Retention warning on a route flagged for deletion (see
     db/migrations/030_retention.sql). Uses the same amber treatment as the
     other "act on this" notices rather than a red/destructive one: nothing
     has been lost yet, and the route still works normally — it's a prompt,
     not an error. */
  .expiryNotice{
    display:flex; align-items:baseline; flex-wrap:wrap; gap:8px; margin-top:6px;
    padding:7px 9px; border-radius:var(--radius-sm);
    background:var(--amber-tint); border-left:3px solid var(--blaze);
  }
  .expiryNoticeText{ font-size:var(--fs-sm); color:var(--blaze-text); font-weight:600; }
  .expiryNotice .keepRouteBtn{ flex-shrink:0; }
  /* The card itself stays visually normal — greying it out would suggest the
     route is already unusable, which it isn't. */
  .expiringRouteItem .savedRouteName{ color:var(--ink); }
  .linkBtn{
    background:none; border:none; padding:0; font-family:inherit; font-size:var(--fs-sm); font-weight:700;
    color:var(--blaze-text); cursor:pointer; text-decoration:underline;
  }
  .linkBtn:hover{ color:var(--blaze-dark); }
  .linkBtn:disabled{ opacity:0.6; cursor:default; text-decoration:none; }
  .linkBtn:focus-visible{ outline:2px solid var(--blaze); outline-offset:2px; border-radius:var(--radius-sm); }
  /* Stacks the Routes topic's two walkthrough links (plan a route / upload a
     GPX) — .linkBtn is inline, so without this they'd sit on one line and
     read as a single run-on sentence. align-items keeps each underline the
     width of its own label rather than the full panel. */
  .helpTourLinks{ display:flex; flex-direction:column; align-items:flex-start; gap:8px; }
  /* .deleteRouteBtn's own rules live further down, after .restoreBtn — it
     builds on that class, and same-specificity rules need to come later to
     win. See the "Recently deleted" section. */
  .shareRouteBlock, .routeExportBlock{ margin-top:10px; padding-top:4px; border-top:1px dashed var(--line); }
  .routeExportBlock[hidden]{ display:none; }
  /* One shape for the three share sections (view link, route card, edit
     link): a head row, one input row, one hint. Each section is a sibling
     with its own divider — the two links are independently revocable and
     the card is its own thing, and they should look it. */
  .shareSection{ padding:10px 0 4px; }
  .shareSection + .shareSection{ border-top:1px dashed var(--line); }
  .editShareBlock:empty{ display:none; }
  .shareHead{
    display:flex; align-items:baseline; justify-content:space-between; gap:12px;
    margin-bottom:8px;
  }
  .shareHead > span:first-child{
    font-family:'Bricolage Grotesque', sans-serif; font-size:var(--fs-sm); font-weight:600;
    text-transform:uppercase; letter-spacing:0.06em; color:var(--ink-soft);
  }
  /* The section's one action, right-aligned in the head: revoke, or create.
     Quiet — a link, not a button — because it is the rarer of the section's
     two actions and the copy button already carries the weight. */
  .shareHead .linkBtn{ font-weight:600; text-decoration:none; white-space:nowrap; }
  .shareHead .linkBtn:hover{ text-decoration:underline; }
  /* The edit link's state, on one mono line: expiry, then who has joined. */
  .shareMeta{
    display:flex; flex-wrap:wrap; gap:4px 14px; margin-bottom:6px;
    font-family:'IBM Plex Mono', ui-monospace, Menlo, monospace;
    font-size:var(--fs-xs); color:var(--ink-soft); font-variant-numeric:tabular-nums;
  }
  .editShareExpiry{ color:var(--ink); }
  .shareMeta span + span::before{ content:'\00b7\00a0'; color:var(--line); }
  .editShareUpsell{ display:flex; flex-direction:column; align-items:flex-start; gap:6px; }
  .shareRouteRow{ display:flex; gap:8px; margin-bottom:8px; }
  .shareRouteLink{
    flex:1; min-width:0; font-size:var(--fs-sm); font-family:inherit; color:var(--ink-soft);
    background:var(--paper-dim); border:1px solid var(--line); border-radius:var(--radius-sm); padding:8px 10px;
  }
  .shareRouteBlock .routeAction{ flex-shrink:0; width:auto; margin-bottom:0; padding:8px 14px; }
  .shareRouteBlock .fieldHint{ margin-bottom:8px; }
  /* The route card row: the optional "shared by" field, then the three
     formats as one segmented control. Writable, so unlike the link inputs
     beside it the field is white, not paper-dim — the one cue that this
     box takes typing. On a narrow sheet the formats wrap under the field. */
  /* Hung off a rail under the view link: the card is part of that link's
     section, not a section of its own, and the indent plus the connector
     stub at the top say so before any words do. */
  .routeCardSection{
    position:relative; margin:10px 0 2px 10px; padding:8px 0 0 14px;
    border-left:2px solid var(--line);
  }
  .routeCardSection::before{
    content:''; position:absolute; left:-2px; top:-10px; width:2px; height:10px; background:var(--line);
  }
  .routeCardHead{ margin-bottom:6px; }
  .routeCardHead > span:first-child{ color:var(--ink); }
  /* "with the view link", with the pictogram — set in the amber text tone
     so the pairing reads as the section's point rather than as more meta. */
  .routeCardWith{
    display:inline-flex; align-items:center; gap:6px; font-size:var(--fs-xs); font-weight:600;
    color:var(--blaze-text); white-space:nowrap;
  }
  .routeCardRow{ flex-wrap:wrap; }
  .routeCardByInput{ background:var(--white); color:var(--ink); min-width:140px; }
  .routeCardByInput:focus{ border-color:var(--blaze); outline:none; }
  .routeCardFormats{
    display:flex; flex-shrink:0; border:1px solid var(--line); border-radius:var(--radius-sm);
    overflow:hidden; background:var(--white);
  }
  .routeCardBtn{
    border:none; background:none; cursor:pointer; font-family:inherit;
    font-size:var(--fs-sm); font-weight:600; color:var(--ink); padding:8px 12px;
    border-left:1px solid var(--line); white-space:nowrap;
  }
  .routeCardBtn:first-child{ border-left:none; }
  .routeCardBtn:hover{ background:var(--paper-dim); }
  .routeCardBtn:disabled{ opacity:0.6; cursor:default; }
  .routeCardBtn:focus-visible{ outline:2px solid var(--blaze); outline-offset:-2px; }
  .routeCardMsg{ margin-top:6px; }
  /* The GPX upload at the head of My routes. Reuses .routeAction so it
     matches the buttons it used to sit beside in the route menu, with a
     rule beneath it separating "add a route" from the routes themselves —
     without it the label reads as the first card in the list. */
  .savedRoutesUpload{ margin-bottom:6px; }
  .savedRoutesUploadHint{
    margin:0 0 14px; padding-bottom:14px; border-bottom:1px solid var(--line);
  }

  /* Filter + sort, rendered only once the list is long enough to be worth
     searching (TOOLS_MIN_ROUTES in panel-lists.js). Both act on the cards
     already in the DOM. */
  .savedRoutesTools{ display:flex; gap:8px; margin-bottom:14px; }
  /* Matched to .field input[type=text] rather than styled separately, so the
     one search box in this panel looks like every other input in the app. */
  .savedRoutesFilter{
    border:1px solid var(--line); background:var(--white); border-radius:var(--radius-sm);
    padding:9px 11px; font-size:var(--fs-base); font-family:inherit; color:var(--ink); outline:none;
    flex:1; min-width:0; -webkit-appearance:none;
  }
  .savedRoutesFilter:focus{ border-color:var(--blaze); }
  /* The sort: one square icon button beside the search field — the field
     keeps the row — and a small menu of the six orders under it, the
     chosen one ticked. The button fills once the list is in anything but
     its default order, so a re-sorted list is visibly re-sorted. */
  .savedRoutesSortWrap{ position:relative; flex-shrink:0; }
  .savedRoutesSortBtn{
    width:39px; height:39px; display:inline-flex; align-items:center; justify-content:center;
    border:1px solid var(--line); background:var(--white); border-radius:var(--radius-sm);
    color:var(--ink); cursor:pointer; padding:0;
  }
  .savedRoutesSortBtn:hover{ border-color:var(--ink); }
  .savedRoutesSortBtn[aria-expanded="true"], .savedRoutesSortBtn.isActive{ background:var(--ink); color:var(--paper); border-color:var(--ink); }
  .savedRoutesSortBtn:focus-visible{ outline:2px solid var(--blaze); outline-offset:2px; }
  .savedRoutesSortMenu{
    position:absolute; top:calc(100% + 6px); right:0; z-index:5; min-width:180px; padding:6px;
    background:var(--white); border:1px solid var(--line); border-radius:var(--radius-md); box-shadow:var(--shadow);
  }
  .savedRoutesSortMenu[hidden]{ display:none; }
  .savedRoutesSortOption{
    display:flex; align-items:center; gap:8px; width:100%; text-align:left;
    padding:8px 10px 8px 30px; border:0; background:none; border-radius:var(--radius-sm);
    font-family:inherit; font-size:var(--fs-sm); font-weight:600; color:var(--ink); cursor:pointer; position:relative;
  }
  .savedRoutesSortOption:hover{ background:var(--paper-dim); }
  .savedRoutesSortOption[aria-checked="true"]::before{
    content:''; position:absolute; left:11px; top:50%; width:10px; height:6px; margin-top:-5px;
    border-left:2px solid var(--ink); border-bottom:2px solid var(--ink); transform:rotate(-45deg);
  }
  .savedRoutesSortOption:focus-visible{ outline:2px solid var(--blaze); outline-offset:-2px; }
  .savedRoutesNoMatches{ margin-top:2px; }

  /* ---- Route collections (patch 72) ------------------------------------
     A collection is a <details> section: colour bar, name, mono count, an
     edit pencil in the head; the cards under it. With no collections the
     list is the flat list it always was — none of this shows. */
  .collectionTools{ margin:-4px 0 14px; }
  .collectionTools .linkBtn{ font-weight:600; text-decoration:none; }
  .collectionTools .linkBtn:hover{ text-decoration:underline; }
  .collectionForm{
    display:flex; flex-wrap:wrap; align-items:center; gap:10px;
    margin-top:10px; padding:12px; background:var(--paper-dim);
    border:1px solid var(--line); border-radius:var(--radius-sm);
  }
  .collectionForm[hidden]{ display:none; }
  .collectionNameInput{
    flex:1 1 180px; min-width:0; border:1px solid var(--line); background:var(--white);
    border-radius:var(--radius-sm); padding:9px 11px; font-size:var(--fs-base);
    font-family:inherit; color:var(--ink); outline:none;
  }
  .collectionNameInput:focus{ border-color:var(--blaze); }
  .collectionColours{ display:flex; gap:6px; flex-shrink:0; }
  .collectionColour{ position:relative; width:28px; height:28px; cursor:pointer; }
  .collectionColour input{ position:absolute; inset:0; opacity:0; margin:0; cursor:pointer; }
  .collectionSwatch{
    display:block; width:100%; height:100%; border-radius:50%;
    background:var(--swatch, transparent); border:2px solid var(--line);
    box-sizing:border-box; transition:transform 0.12s ease, border-color 0.12s ease;
  }
  /* "No colour": an empty ring with a diagonal — the absence, drawn. */
  .collectionColour.none .collectionSwatch{
    background:linear-gradient(135deg, transparent 46%, var(--line) 46%, var(--line) 54%, transparent 54%);
  }
  .collectionColour:hover .collectionSwatch{ transform:scale(1.08); }
  .collectionColour input:checked + .collectionSwatch{ border-color:var(--ink); transform:scale(1.12); }
  .collectionColour input:focus-visible + .collectionSwatch{ outline:2px solid var(--blaze); outline-offset:2px; }
  .collectionFormActions{ display:flex; align-items:center; gap:8px; flex:1 0 100%; justify-content:flex-end; }
  .collectionFormActions .routeAction{ width:auto; margin-bottom:0; padding:8px 14px; white-space:nowrap; }
  /* Order arrows and delete: the rarer, quieter controls, on their own row
     above Cancel/Save so neither row has to wrap mid-label. */
  .collectionFormMeta{ display:flex; align-items:center; gap:12px; flex:1 0 100%; }
  .collectionFormMeta .linkBtn{ margin-left:auto; white-space:nowrap; }
  .collectionOrder{ display:inline-flex; gap:4px; }
  .collectionOrder button{
    width:32px; height:32px; border:1px solid var(--line); background:var(--white);
    border-radius:var(--radius-sm); color:var(--ink); cursor:pointer; font-size:var(--fs-base);
  }
  .collectionOrder button:hover:not(:disabled){ border-color:var(--ink); }
  .collectionOrder button:disabled{ opacity:0.35; cursor:default; }
  .collectionFormMeta .collectionDeleteBtn{ color:var(--error); }

  .routeCollection{ margin-bottom:10px; }
  .routeCollection[hidden]{ display:none; }
  .routeCollectionHead{
    display:flex; align-items:center; gap:10px; list-style:none; cursor:pointer;
    padding:8px 0; margin-bottom:6px; border-bottom:1px solid var(--line);
    -webkit-user-select:none; user-select:none;
  }
  .routeCollectionHead::-webkit-details-marker{ display:none; }
  .routeCollectionBar{
    flex-shrink:0; width:4px; height:20px; border-radius:2px; background:var(--line);
  }
  .routeCollectionName{
    font-family:'Space Grotesk', sans-serif; font-weight:600; font-size:var(--fs-base);
    color:var(--ink); min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
  }
  .routeCollectionCount{
    font-family:'IBM Plex Mono', ui-monospace, Menlo, monospace; font-size:var(--fs-xs);
    color:var(--ink-soft); font-variant-numeric:tabular-nums; white-space:nowrap;
  }
  /* The fold chevron, after the count, before the pencil. Rotates when open. */
  .routeCollectionCount::after{
    content:''; display:inline-block; width:7px; height:7px; margin-left:10px; vertical-align:2px;
    border-right:1.5px solid var(--ink-soft); border-bottom:1.5px solid var(--ink-soft);
    transform:rotate(-45deg); transition:transform 0.15s ease;
  }
  .routeCollection[open] > .routeCollectionHead .routeCollectionCount::after{ transform:rotate(45deg); vertical-align:4px; }
  .collectionEditBtn{
    margin-left:auto; width:32px; height:32px; display:inline-flex; align-items:center; justify-content:center;
    border:0; background:transparent; color:var(--ink-soft); border-radius:var(--radius-sm); cursor:pointer;
  }
  .collectionEditBtn:hover{ color:var(--ink); background:var(--paper-dim); }
  .routeCollection .collectionEditForm{ margin:0 0 10px; }
  .routeCollectionBody{ padding-top:2px; }
  .routeCollectionEmpty{ padding:6px 0 10px; }
  /* A filed card carries its collection's colour as a thin left rule, the
     way the day colour marks a day. --collection-colour is set on the card. */
  .savedRouteCard[style*="--collection-colour"]{ border-left:4px solid var(--collection-colour); }

  /* Move to… block — the shape of the share block, with one row per target. */
  .routeMoveBlock{ margin-top:10px; padding-top:4px; border-top:1px dashed var(--line); }
  .routeMoveBlock[hidden]{ display:none; }
  .routeMoveOptions{ display:flex; flex-direction:column; gap:4px; margin-bottom:8px; }
  .routeMoveOption{
    display:flex; align-items:center; gap:10px; width:100%; text-align:left;
    padding:8px 10px; border:1px solid var(--line); background:var(--white);
    border-radius:var(--radius-sm); font-family:inherit; font-size:var(--fs-sm); color:var(--ink); cursor:pointer;
  }
  .routeMoveOption:hover{ border-color:var(--ink); }
  .routeMoveOption.isCurrent{ background:var(--ink); color:var(--paper); border-color:var(--ink); cursor:default; }
  .routeMoveOption.isCurrent .routeCollectionBar{ box-shadow:0 0 0 1px var(--paper); }
  .routeMoveOption .routeCollectionBar{ height:16px; }
  .routeMoveNew{ margin-top:0; padding:8px; }
  .routeMoveNew .routeAction{ width:auto; margin-bottom:0; padding:8px 14px; }

  /* The collection select in the save form, matched to #routeNameInput. */
  #routeSaveCollectionRow[hidden]{ display:none; }
  #routeSaveCollectionRow{ margin-bottom:10px; }
  #routeSaveCollection{
    width:100%; border:1px solid var(--line); background:var(--white); border-radius:var(--radius-sm);
    padding:9px 11px; font-size:var(--fs-base); font-family:inherit; color:var(--ink); outline:none;
  }
  #routeSaveCollection:focus{ border-color:var(--blaze); }

  /* First thing a new account sees here. The sentence on its own under a
     lone upload button read as an error about the panel rather than an
     invitation, so it gets a shape and some room. */
  .savedRoutesEmpty{
    display:flex; flex-direction:column; align-items:center; gap:14px;
    padding:26px 18px; text-align:center;
  }
  .savedRoutesEmptyMark{ width:64px; height:64px; opacity:0.5; }
  .savedRoutesEmptyMark path{
    fill:none; stroke:var(--ink); stroke-width:5;
    stroke-linecap:round; stroke-linejoin:round; stroke-dasharray:11 9;
  }
  .savedRoutesEmptyText{
    font-size:var(--fs-base); line-height:1.55; color:var(--ink-soft); max-width:34ch;
  }

  /* A saved route is a card in the app's card language (see .tierCard), not
     the bordered text row it used to inherit from .deletedItem — this is the
     one screen that's nothing but a list of routes, so the routes get the
     card treatment rather than the hairline-separated-rows treatment. */
  .savedRouteCard{
    background:var(--white); border:1px solid var(--line); border-radius:var(--radius-lg);
    padding:18px; margin-bottom:14px;
    transition:border-color 0.15s ease, box-shadow 0.15s ease;
  }
  .savedRouteCard:hover{ border-color:var(--ink); box-shadow:var(--shadow-sm); }
  .savedRouteCard[hidden]{ display:none; }
  /* Name and meta on the left, the one destructive control on the right. */
  .savedRouteCardHead{ display:flex; align-items:flex-start; gap:12px; }
  .savedRouteCardHeadText{ min-width:0; flex:1; }
  /* Space Grotesk at --fs-lg to match .tierName — the card's title, where
     .deletedItemName was a row label. Wraps rather than ellipsing now that
     there's a card's width to wrap inside. */
  .savedRouteName{
    font-family:'Bricolage Grotesque', sans-serif; font-size:var(--fs-xl); font-weight:600;
    letter-spacing:-0.01em; color:var(--ink); line-height:1.2; overflow-wrap:anywhere;
  }
  .savedRouteCardHead .deletedItemMeta{
    margin-top:2px; font-family:'IBM Plex Mono', ui-monospace, Menlo, monospace; font-size:var(--fs-xs);
  }
  .savedRouteCard .restoreBtn{ flex-shrink:0; }
  /* ---------- The card's visual: the export card at list size ----------
     The same composition as the export (line, nights, figures, hachures)
     on a ground that follows the theme: chalk-dim under Ink in light mode,
     Ink under Chalk in dark — the export itself stays Ink because it
     leaves the app, but in the list a dark slab on every white card was
     the one thing on the page that ignored the setting. --routecard-bg is
     read by the outline's start ring, which is cut out of the ground. */
  .routeCardVisual{
    --routecard-bg: var(--chalk-dim);
    background:var(--routecard-bg); color:var(--ink); border-radius:var(--radius-md);
    padding:12px 14px 0; margin-top:10px; overflow:hidden;
  }
  :root[data-theme="dark"] .routeCardVisual{ --routecard-bg:#14201A; }
  .routeCardTop{ display:flex; align-items:center; gap:12px; }
  /* Figures: value in mono, label small and letter-spaced, like the export.
     Two columns rather than the export's four — a phone's sheet is 340px. */
  .routeCardStats{
    flex:1; min-width:0; display:grid; grid-template-columns:repeat(2, minmax(0,1fr)); gap:8px 12px;
  }
  .routeCardFigure{ display:flex; flex-direction:column; gap:1px; min-width:0; }
  .routeCardFigureValue{
    font-family:'IBM Plex Mono', ui-monospace, Menlo, monospace; font-variant-numeric:tabular-nums;
    font-size:var(--fs-lg); font-weight:500; color:var(--ink); line-height:1.15; white-space:nowrap;
    overflow:hidden; text-overflow:ellipsis;
  }
  .routeCardFigureLabel{
    font-family:'IBM Plex Mono', ui-monospace, Menlo, monospace; font-size:10px; letter-spacing:0.12em;
    text-transform:uppercase; color:var(--ink-soft);
  }
  /* The line, Ink on chalk (Chalk on Ink in dark), its nights in amber — routeOutlineSvg. */
  .routeCardOutline{ flex-shrink:0; width:120px; height:84px; }
  .routeCardOutline svg{ display:block; width:100%; height:100%; overflow:visible; }
  /* The climb as hachures across the foot — hachuresSvg, the export's own
     band. Bleeds to the card's edges so it reads as the card's ground. */
  .routeCardProfile{ margin:10px -14px 0; }
  .routeCardProfile svg{ display:block; width:100%; height:32px; }
  /* Below the visual, back on the panel's surface. */
  .routeCardVisual + .hiddenDaysNotice, .routeCardVisual + .expiryNotice{ margin-top:10px; }
  .routeStopFav{
    flex-shrink:0; width:44px; border:none; background:none; cursor:pointer;
    font-size:var(--fs-lg); line-height:1; color:var(--ink-soft); font-family:inherit;
    display:flex; align-items:center; justify-content:center; padding:0;
  }
  .routeStopFav:hover{ color:var(--blaze); }
  .routeStopFav.active{ color:var(--blaze); }
  .routeStopFav:focus-visible{ outline:2px solid var(--blaze); outline-offset:-2px; }
  .routeStopFav:disabled{ opacity:0.5; cursor:default; }
  /* Reorder a planned stop's position without a map gesture. Stacked
     up/down rather than the row's other controls' 44px width: a planned
     row already carries up to four full-height buttons, and two more at
     that width would crowd the exact narrow-mobile-sheet case this exists
     to help. 34px is bigger than the day-nights stepper's 26px (too small
     for a control that sits in a scrollable list of similar rows) without
     doubling this row's control real estate. */
  .routeStopReorder{
    flex-shrink:0; width:34px; display:flex; flex-direction:column;
  }
  .routeStopMoveUp, .routeStopMoveDown{
    flex:1 1 auto; width:100%; border:none; background:none; cursor:pointer;
    font-size:11px; line-height:1; color:var(--ink-soft); font-family:inherit;
    display:flex; align-items:center; justify-content:center; padding:0;
  }
  .routeStopMoveUp:hover, .routeStopMoveDown:hover{ color:var(--ink); background:var(--paper-dim); }
  .routeStopMoveUp:focus-visible, .routeStopMoveDown:focus-visible{
    outline:2px solid var(--blaze); outline-offset:-2px;
  }
  .routeStopMoveUp:disabled, .routeStopMoveDown:disabled{ opacity:0.4; cursor:default; }
  /* "End the day here" — same 44px column and borderless shape as the
     favorite star beside it, so a planned row reads as one strip of
     controls rather than a star plus a bolted-on extra. */
  /* Same 44px column and borderless shape as the favorite star beside it, so
     a planned row reads as one strip of controls.

     Off/on is carried by colour and weight, as the star carries it by
     colour and fill — not by opacity. Dimming the glyph to keep it from
     outshouting the star made it too faint to find, which is the wrong
     trade: an affordance nobody can see isn't quiet, it's missing.

     The chosen night gets a filled disc behind the crescent, the same
     shape-plus-colour treatment .routeStopPlan uses for its checkbox.
     Colour alone would be doing all the work — and font-weight does nothing
     to a symbol glyph, which has no bold cut in these fonts (measured: the
     700 and 400 crescents render at identical width). */
  .routeStopNight{
    position:relative; flex-shrink:0; width:44px; z-index:0;
    border:none; background:none; cursor:pointer; padding:0;
    font-size:var(--fs-xl); line-height:1; color:var(--ink-soft); font-family:inherit;
    display:flex; align-items:center; justify-content:center;
  }
  .routeStopNight::before{
    content:''; position:absolute; width:26px; height:26px; border-radius:50%;
    background:transparent; z-index:-1;
  }
  .routeStopNight:hover{ color:var(--ink); }
  .routeStopNight:hover::before{ background:var(--paper-dim); }
  .routeStopNight.active{ color:var(--white); }
  .routeStopNight.active::before{ background:var(--ink); }
  .routeStopNight:focus-visible{ outline:2px solid var(--blaze); outline-offset:-2px; }
  /* Sticky, so the day you're scrolled into is always named — on a week-long
     tour the header that matters is usually just above the viewport. */
  /* The sticky wrapper, so the day you're scrolled into stays named. The
     button inside it is the fold control — sticky has to live on the <li>
     rather than the button, since a position:sticky element only sticks
     within its own scroll parent. */
  /* One day: its header plus that day's rows. The wrapper is what makes the
     header stop sticking once the day has scrolled past — sticky pins to
     the nearest scrolling ancestor, so without a per-day box every header
     pinned to the list itself and they piled up at the top. Built by
     groupDaysForSticky(). */
  .routeDayGroup{ list-style:none; }
  .routeDayGroupList{ list-style:none; margin:0; padding:0; }
  /* Flex so the remove button can sit beside the fold button rather than
     inside it — a <button> cannot contain another one. The row keeps the
     header's background so the two read as one bar. */
  /* The day's colour, carried down the left edge of its header and every
     stop row beneath it — the same colour the map draws that day's line in
     (dayColor() in route-days.js is the one source for both).
     =====================================================================
     WHY A BAR AND NOT A TINT. The list already spends background colour on
     row STATE: green for a planned stop, orange for a proposed one. Tinting
     the row by day would put two meanings in one channel, and day 1's green
     sits close enough to the planned green to be read as it. The left edge
     is unclaimed, so it can carry day identity without competing.
     Colour is never the only signal either — the header still names the day
     in words, so this is reinforcement for someone matching list to map,
     not information only available to those who can distinguish the hues. */
  .routeDayHeaderRow{
    position:sticky; top:0; z-index:2;
    border-top:1px solid var(--line); border-bottom:1px solid var(--line);
    display:flex; align-items:stretch; background:var(--paper-dim);
    border-left:4px solid var(--dayColor, transparent);
  }
  .routeDayHeader{
    flex:1; min-width:0; display:flex; align-items:baseline; gap:10px;
    padding:8px 14px 8px 10px; background:var(--paper-dim);
    border:none; cursor:pointer; font-family:inherit; text-align:left;
  }
  /* Removing a day, by merging it into the one above. Quiet until the row is
     hovered: it is destructive and sits on every day but the first, so a
     always-visible red column down the list would read as the point of the
     header rather than as its least-used action.
     Always visible on a touch device, where there is no hover to reveal it —
     :hover on the ROW rather than on the button itself is what makes the
     mouse version work at all, since the button is transparent until then. */
  .routeDayRemove{
    flex-shrink:0; width:34px; border:none; background:none; cursor:pointer;
    color:var(--ink-soft); opacity:0; transition:opacity 0.12s ease, color 0.12s ease;
    display:flex; align-items:center; justify-content:center;
  }
  .routeDayHeaderRow:hover .routeDayRemove,
  .routeDayRemove:focus-visible{ opacity:1; }
  .routeDayRemove:hover{ color:var(--error); }
  .routeDayRemove:focus-visible{ outline:2px solid var(--blaze); outline-offset:-2px; }
  @media (hover: none){
    /* No hover to reveal it, so it stays visible — muted rather than bright,
       since it is still the row's secondary action. */
    .routeDayRemove{ opacity:0.55; }
  }
  .routeDayHeader:hover{ background:var(--line); }
  .routeDayHeader:focus-visible{ outline:2px solid var(--blaze); outline-offset:-2px; }
  /* Own chevron rather than the native marker, matching #routeStats and
     #routeAdvanced — the app's two other collapsible sections. Points down
     when open, right when folded. */
  .routeDayHeader::before{
    content:''; width:0; height:0; flex-shrink:0; align-self:center;
    border-left:4px solid var(--ink-soft);
    border-top:3.5px solid transparent; border-bottom:3.5px solid transparent;
    transform:rotate(90deg); transition:transform 0.15s ease;
  }
  .routeDayHeader.collapsed::before{ transform:rotate(0deg); }
  /* The name takes the slack so the stats stay hard right, where they line
     up down the list rather than drifting with each day's name length. */
  .routeDayName{
    font-size:var(--fs-sm); font-weight:700; color:var(--ink); letter-spacing:0.01em;
    flex:1; min-width:0;
  }
  .routeDayStats{ font-size:var(--fs-xs); color:var(--ink-soft); flex-shrink:0; }
  /* Only shown while folded — open, the stops are right there to be counted. */
  .routeDayCount{ font-size:var(--fs-xs); color:var(--ink-soft); flex-shrink:0; }
  /* Rudimentary per-day forecast (icon + temperature) at that day's own
     starting location — empty until wireDayHeaders() patches it in, so an
     empty span here costs nothing extra on a day with no chip (no date set,
     or too far out for the forecast window). Same size/colour as the other
     header stats so it reads as one more fact about the day, not a
     separate widget. */
  .routeDayWeather{ font-size:var(--fs-xs); color:var(--ink-soft); flex-shrink:0; display:flex; align-items:center; gap:2px; }
  .routeDayWeatherIcon{ width:16px; height:16px; }
  /* The overnight that closes a day. Indented and quieter than a stop row —
     it's a marker in the list, not something you can act on from here. */
  /* The trip at a glance, above the stop list. The block owns the divider
     and refuses to shrink; the scroll cap sits on the list inside it, so a
     two-week tour scrolls its rows rather than pushing the summary off. */
  #routeDayStripBlock{
    flex-shrink:0; border-bottom:1px solid var(--line);
  }
  #routeDayStripBlock[hidden]{ display:none; }
  /* Matches #routeStats' summary exactly — same type, same hand-rolled
     chevron, same focus ring. The two sit directly on top of each other and
     a second fold control that looked even slightly different would read as
     a different kind of control. */
  #routeDayStripBlock > summary{
    font-family:'Bricolage Grotesque', sans-serif; font-size:var(--fs-sm); font-weight:600;
    text-transform:uppercase; letter-spacing:0.04em; color:var(--ink-soft);
    cursor:pointer; list-style:none; display:flex; align-items:center; gap:6px;
    padding:10px 16px;
  }
  #routeDayStripBlock > summary::-webkit-details-marker{ display:none; }
  #routeDayStripBlock > summary::before{
    content:''; width:0; height:0; flex-shrink:0;
    border-left:4px solid currentColor; border-top:3.5px solid transparent; border-bottom:3.5px solid transparent;
    transition:transform 0.15s ease;
  }
  #routeDayStripBlock[open] > summary::before{ transform:rotate(90deg); }
  #routeDayStripBlock > summary:focus-visible{ outline:2px solid var(--blaze); outline-offset:-2px; }
  /* Scrollable rather than capped, so a two-week tour doesn't push the list
     it summarises off the screen — 4 rows is enough to see the shape and the
     rest is one flick. */
  #routeDayStrip{
    list-style:none; margin:0; padding:0;
    max-height:168px; overflow-y:auto;
  }
  /* On a phone this strip is the biggest single claim on the sheet: one row
     per day at ~29px, 168px of a 461px sheet at six days, taken straight
     from the stop list below it. It used to be hidden here outright for
     exactly that reason.

     Folding is a better answer than hiding. Closed it costs one summary row
     and still states the day count and total distance, so the trip overview
     is reachable on a phone instead of being desktop-only — and
     renderRouteSidebar() closes it on first open at this width, so that is
     the state it arrives in. */
  .routeDayStripRow button{
    width:100%; display:flex; align-items:baseline; gap:8px; text-align:left;
    background:transparent; border:none; border-bottom:1px solid var(--line);
    padding:7px 14px; cursor:pointer; font-family:inherit; font-size:var(--fs-sm);
    color:var(--ink-soft);
  }
  .routeDayStripRow:last-child button{ border-bottom:none; }
  .routeDayStripRow button:hover{ background:var(--paper); }
  .routeDayStripRow button:focus-visible{ outline:2px solid var(--blaze); outline-offset:-2px; }
  .dayStripName{ font-weight:700; color:var(--ink); flex-shrink:0; }
  .dayStripDate{ flex-shrink:0; }
  /* Distance and climbing pushed right, where the eye can run down the
     column and find the long day without reading any of the labels. */
  .dayStripStats{ margin-left:auto; flex-shrink:0; font-variant-numeric:tabular-nums; }
  .dayStripWhere{
    flex-basis:100%; color:var(--ink); font-weight:600;
    overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
  }

  .routeDayBreak{
    padding:7px 14px 7px 20px; font-size:var(--fs-sm); color:var(--ink-soft);
    background:var(--paper-dim); border-bottom:1px solid var(--line);
    display:flex; align-items:center; gap:8px;
  }
  /* The label and its nights stepper, kept on one line now that an empty
     night can add a second line beneath them. */
  .routeNightLine{ display:flex; align-items:center; gap:8px; flex:1; min-width:0; }

  /* A night with nowhere to sleep. Given the same frame as a placed one —
     it is the same kind of thing in a different state, not a divider — and
     a second line saying why it is empty and what to do, since the bare
     "Overnight stop" row read as a missing entry rather than a real one. */
  .routeDayBreak.emptyNight{
    flex-direction:column; align-items:stretch; gap:3px;
    border-left:3px solid var(--line);
    border-radius:var(--radius-md);
    margin:6px 0;
  }
  .routeNightHint{
    font-size:var(--fs-xs); font-weight:500; color:var(--ink-soft); opacity:0.85;
    line-height:1.35;
  }

  /* A night that sits on a real stop is drawn as an ENCLOSURE around that
     stop's own card, so the place being slept at is visibly inside the
     night rather than merely listed before it.
     ------------------------------------------------------------------
     Three sibling <li>s, not a wrapper element: the card between the bars
     is the same .routeStopRow the rest of the list uses, with every
     control in the same place. Nesting it inside a container would have
     meant a second card template to keep in step with the first, which is
     how the two drift apart.

     The frame is therefore drawn rather than structural — a left edge and
     a tinted backing shared by all three rows, with the corners rounded on
     the outer two. */
  .routeDayBreak.opensNight,
  .routeDayBreak.closesNight,
  .routeStopRow.isOvernight{
    border-left:3px solid var(--ink);
    /* An ink wash rather than a green one: the night is a place in the
       plan, not an open/closed state. Kept at 0.08 so it still reads
       against the planned row's chalk-dim tint it usually sits inside. */
    background:rgba(20,32,26,0.08);
  }
  .routeDayBreak.opensNight{
    border-bottom:none;
    border-radius:var(--radius-md) var(--radius-md) 0 0;
    margin-top:6px;
  }
  /* The closing bar carries no text — the opening one has already said
     which night this is. It is a 6px foot that finishes the frame. */
  .routeDayBreak.closesNight{
    padding:0; min-height:6px;
    border-bottom:1px solid var(--line);
    border-radius:0 0 var(--radius-md) var(--radius-md);
    margin-bottom:6px;
  }
  /* The enclosed card loses its own separator so the frame reads as one
     block rather than three stacked rows. */
  .routeStopRow.isOvernight{ border-bottom:none; }
  /* How many nights are spent here — pushed to the trailing edge so the row
     still reads as a sentence ("Overnight near Sterzing") with the control
     as an adjustment to it, rather than as a form field. */
  .routeNights{
    margin-left:auto; display:flex; align-items:center; gap:2px; flex-shrink:0;
  }
  .routeNightsBtn{
    background:transparent; border:1px solid var(--line); border-radius:var(--radius-sm);
    /* 34px rather than the 40px of the day stepper: this sits in a dense
       list row, and a rest day is an occasional adjustment rather than the
       control someone taps repeatedly to find their answer. Was 26px —
       raised because that fell noticeably under WCAG 2.5.5's 44x44
       guidance in exactly the dense-scrollable-list context where a mis-tap
       is likely; 34px keeps the "occasional adjustment" visual weight
       (still smaller than the day stepper and the row's reorder control)
       without being genuinely hard to hit. */
    width:34px; height:34px; font-size:var(--fs-md); line-height:1; font-weight:700;
    color:var(--ink-soft); cursor:pointer; font-family:inherit;
    display:flex; align-items:center; justify-content:center;
  }
  .routeNightsBtn:hover:not(:disabled){ background:var(--white); color:var(--ink); }
  .routeNightsBtn:disabled{ opacity:0.3; cursor:default; }
  .routeNightsBtn:focus-visible{ outline:2px solid var(--blaze); outline-offset:1px; }
  .routeNightsValue{ font-size:var(--fs-xs); font-weight:600; padding:0 4px; white-space:nowrap; }
  #routeSidebarCollapse:focus-visible{ outline:2px solid var(--blaze); outline-offset:2px; }
  /* Not on a phone. The sheet is dragged now, and peek already does what
     this chevron did — puts the list away and leaves the map readable, with
     the header still on screen as the way back. Two controls for one job,
     and the chevron was the worse of them: it hid the sheet completely and
     replaced it with a separate tab, where a drag to peek keeps the title
     and the day count in view.
     Kept on the desktop rail, where there is no drag and collapsing to the
     edge strip is the only way to give the map the full width. */
  @media (max-width:720px){
    #routeSidebarCollapse{ display:none; }
  }
  /* The lead column: the arrival time, or the stop's position when there
     is no start time yet. Fixed width so the names beside it line up down
     the list whatever it holds — the list is ordered by this column, so it
     has to read as a column. Matches the stop-list drawing on the welcome
     page, which is the first version of this screen most people see. */
  .routeStopLead{
    flex-shrink:0; width:50px; display:flex; align-items:flex-start; padding-top:2px;
  }
  .routeStopEta{
    font-family:'IBM Plex Mono', ui-monospace, Menlo, monospace; font-variant-numeric:tabular-nums;
    font-size:var(--fs-base); font-weight:500; color:var(--ink); line-height:1.35;
    white-space:normal; word-break:break-word;
  }
  .routeStopIndex{
    width:22px; height:22px; border-radius:50%; background:var(--paper-dim);
    color:var(--ink-soft); font-family:'IBM Plex Mono', ui-monospace, Menlo, monospace;
    font-size:var(--fs-xs); font-weight:500; display:flex; align-items:center;
    justify-content:center;
  }
  .routeStopInfo{ min-width:0; flex:1; display:flex; flex-direction:column; gap:3px; }
  .routeStopName{ display:block; font-size:var(--fs-md); font-weight:500; color:var(--ink); line-height:1.35; }
  .routeStopMeta{ display:block; font-size:var(--fs-sm); color:var(--ink-soft); line-height:1.4; }
  .routeStopMeta .closed{ color:var(--closed); }
  .routeStopMeta .open{ color:var(--open); }
  /* The open/closed state as a pill on its own line rather than another
     comma-separated scrap of meta text — it's the one thing on the row you
     scan for when planning around opening hours, and it has to survive being
     read at a glance. Same palette as the detail panel's .openBadge, at the
     smaller size a list row can carry (that one's margin is tuned for a
     panel, so this isn't just a modifier on it). */
  /* No flex gap: the separator between the verdict and the time is a real
     "·" character in the markup (so it survives being read as text), and a
     gap on top of it would space them twice. */
  .routeStopPill{
    display:inline-flex; align-self:flex-start; align-items:center; padding:2px 8px; border-radius:var(--radius-pill);
    border:1px solid; background:transparent;
    font-family:'IBM Plex Mono', ui-monospace, Menlo, monospace;
    font-size:var(--fs-xs); font-weight:500; letter-spacing:0.04em;
    margin-top:4px; white-space:nowrap; line-height:1.4;
  }
  /* Colour means state here and only here: open, closed, and the amber
     "closes soon" — open when you arrive, shut within the hour, which is
     the one the accent exists for (act now: ride faster or pick another).
     Outlined rather than filled so the three sit quietly in a long list and
     the row's own planned/proposed backgrounds stay legible under them. */
  .routeStopPill.open{ color:var(--open); border-color:var(--open); }
  .routeStopPill.soon{ color:var(--amber-text); border-color:var(--amber-text); background:var(--amber-tint); }
  .routeStopPill.closed{ color:var(--closed); border-color:var(--closed); }
  /* Hours simply aren't known — deliberately the quietest of the states, so
     it reads as "no information" rather than as a fourth verdict. */
  .routeStopPill.unknown{ color:var(--ink-soft); border-color:var(--line); }

  /* ---------- Bottom sheet / side panel ---------- */
  #panelOverlay{
    position:absolute; inset:0; background:rgba(20,26,20,0.35); z-index:700;
    display:none;
  }

  /* ---------- Tier comparison (panel-tiers.js) ----------
     Full-screen rather than the 400px #panel: three tiers side by side is
     the entire point, and the panel is a bottom sheet on mobile. Sits just
     below #confirmOverlay so a confirm raised from here still lands on top. */
  #tiersOverlay{
    position:fixed; inset:0; z-index:940; background:rgba(20,26,20,0.45);
    display:flex; align-items:center; justify-content:center; padding:20px;
  }
  #tiersOverlay[hidden]{ display:none; }
  #tiersDialog{
    background:var(--paper); border-radius:var(--radius-lg); box-shadow:var(--shadow);
    border-top:4px solid var(--blaze);
    width:min(920px, 100%); max-height:calc(100vh - 40px);
    display:flex; flex-direction:column; box-sizing:border-box;
  }
  #tiersHeader{
    display:flex; align-items:center; justify-content:space-between;
    padding:18px 22px 12px; flex-shrink:0;
  }
  #tiersTitle{
    font-family:'Bricolage Grotesque', sans-serif; font-weight:600; font-size:var(--fs-xxl);
    letter-spacing:0.01em; color:var(--ink); margin:0;
  }
  #tiersClose{
    background:none; border:none; cursor:pointer; font-size:var(--fs-lg);
    color:var(--ink-soft); padding:4px 8px; border-radius:var(--radius-sm); line-height:1;
  }
  #tiersClose:hover{ background:var(--paper-dim); color:var(--ink); }
  #tiersClose:focus-visible{ outline:2px solid var(--blaze); outline-offset:2px; }
  /* The scrolling region. The header stays put so the close button is always
     reachable — this page is taller than the viewport on most laptops. */
  #tiersBody{ overflow-y:auto; padding:0 22px 22px; }

  /* The head of each column: name, price, the includes-line, the action.
     What a plan costs and how to get it; what it grants is the table below,
     one row per fact, so the three columns are compared by reading across
     rather than three lists top to bottom. */
  .tierGrid{
    display:grid; grid-template-columns:repeat(3, 1fr); gap:14px;
    align-items:stretch; margin-bottom:18px;
  }
  .tierCard{
    display:flex; flex-direction:column; background:var(--white);
    border:1px solid var(--line); border-radius:var(--radius-lg); padding:16px 16px 14px;
    box-sizing:border-box; position:relative;
  }
  .tierCardTop{ display:flex; align-items:center; justify-content:space-between; gap:8px; flex-wrap:wrap; margin-bottom:2px; }
  .tierIncludes{ font-size:var(--fs-sm); color:var(--ink-soft); margin:-10px 0 12px; }
  .tierTag.isCurrent{ background:var(--ink); color:var(--paper); }
  /* The visitor's own tier, marked from live data rather than a hardcoded
     "most popular" badge — honest, and it tells them where they stand. */
  .tierCard.isCurrent{ border-color:var(--blaze); border-width:2px; padding:15px 15px 13px; }
  /* "Most popular" (panel-tiers.js) is a separate concept from isCurrent
     above and can land on a different card in the same grid — a signed-in
     Free user still sees Supporter flagged as the recommended upgrade. */
  .tierTag{
    display:inline-block; font-size:10.5px; font-weight:700;
    letter-spacing:0.08em; text-transform:uppercase;
    background:var(--blaze); color:var(--on-amber); padding:3px 9px; border-radius:999px;
  }
  .tierName{
    font-family:'Bricolage Grotesque', sans-serif; font-weight:600; font-size:var(--fs-lg);
    color:var(--ink); margin:0;
  }
  .tierPrice{
    font-family:'IBM Plex Mono', ui-monospace, Menlo, monospace; font-size:24px; font-weight:500; font-variant-numeric:tabular-nums;
    color:var(--ink); line-height:1.1; margin:8px 0 2px;
  }
  .tierInterval{ font-size:var(--fs-sm); color:var(--ink-soft); margin-bottom:14px; }
  /* The comparison table. Group rows in the mono eyebrow; the first column
     is the fact, the rest a tick, a dash or a value. */
  .tierTableWrap{ overflow-x:auto; margin-bottom:20px; }
  .tierTable{ width:100%; border-collapse:collapse; font-size:var(--fs-base); }
  .tierTable th, .tierTable td{ padding:9px 8px; border-bottom:1px solid var(--line); vertical-align:middle; }
  .tierTable thead th{ font-family:'Bricolage Grotesque', sans-serif; font-weight:600; font-size:var(--fs-sm); text-align:center; color:var(--ink); }
  .tierTable thead th.isCurrent{ color:var(--blaze-text); }
  .tierTable .tierRowLabel{ text-align:left; font-weight:600; color:var(--ink); padding-left:0; }
  .tierTable .tierRowLabel small{ display:block; font-size:var(--fs-xs); font-weight:400; color:var(--ink-soft); line-height:1.35; margin-top:2px; }
  .tierTable td{ text-align:center; width:19%; font-family:'IBM Plex Mono', ui-monospace, Menlo, monospace; font-size:var(--fs-sm); font-variant-numeric:tabular-nums; }
  .tierTable .tierCellText.isOff{ color:var(--ink-soft); font-size:var(--fs-xs); }
  .tierGroupRow th{
    padding:16px 0 6px; border-bottom:0; text-align:left;
    font-family:'IBM Plex Mono', ui-monospace, Menlo, monospace; font-size:10.5px; letter-spacing:0.12em;
    text-transform:uppercase; color:var(--ink-soft); font-weight:500;
  }
  .tierTick{
    display:inline-block; width:11px; height:6px; margin-bottom:3px;
    border-left:2px solid var(--ink); border-bottom:2px solid var(--ink); transform:rotate(-45deg);
  }
  .tierDash{ display:inline-block; width:10px; height:2px; background:var(--line); vertical-align:middle; }
  /* Loud where the columns differ: a tick in a filled amber disc, values
     in ink, and the row label bold — the reasons to move right. */
  .tierTable tr.isDiff .tierTick{
    width:22px; height:22px; margin:0; border:0; transform:none; border-radius:50%; background:var(--blaze); position:relative;
  }
  .tierTable tr.isDiff .tierTick::after{
    content:''; position:absolute; left:6px; top:6px; width:9px; height:5px;
    border-left:2px solid var(--on-amber); border-bottom:2px solid var(--on-amber); transform:rotate(-45deg);
  }
  /* Values too: where a row's numbers differ, every column past the first
     (the baseline) wears its value as an amber chip, so "30" and
     "Unlimited" carry the same weight as a tick. The first column stays
     plain — it is what the others are measured against. */
  .tierTable tr.isDiff td[data-on="true"] .tierCellText{ font-weight:600; }
  .tierTable tr.isDiff td:not(:nth-child(2)) .tierCellText:not(.isOff){
    display:inline-block; padding:3px 10px; border-radius:var(--radius-pill);
    background:var(--amber-tint); color:var(--ink); border:1px solid var(--blaze);
  }
  .tierTable tr.isDiff .tierDash{ background:var(--line); }
  /* Quiet where every plan is the same: smaller, grey, thin ticks — true,
     but not a reason to pick one column over another. */
  .tierTable tr.isMuted .tierRowLabel{ font-weight:500; color:var(--ink-soft); font-size:var(--fs-sm); }
  .tierTable tr.isMuted .tierTick{ border-color:var(--ink-soft); opacity:0.7; width:9px; height:5px; }
  .tierTable tr.isMuted th, .tierTable tr.isMuted td{ padding-top:6px; padding-bottom:6px; }
  .tierGroupRow.isMuted th{ padding-top:22px; }
  .tierFeatures{ list-style:none; margin:0 0 18px; padding:0; flex:1 1 auto; }
  .tierFeatures li{
    font-size:var(--fs-base); line-height:1.45; color:var(--ink);
    padding:0 0 10px 22px; position:relative;
  }
  /* A rotated border-corner rather than a bullet or an icon font — the same
     tick the welcome page's own plan grid uses (see welcome.html), so the
     two comparisons read as one visual language rather than two products. */
  .tierFeatures li::before{
    content:''; position:absolute; left:0; top:5px; width:11px; height:6px;
    border-left:2px solid var(--ink); border-bottom:2px solid var(--ink);
    transform:rotate(-45deg);
  }
  .tierDisclosure{
    font-size:var(--fs-xs); line-height:1.4; color:var(--ink-soft);
    margin-bottom:10px; padding-top:8px; border-top:1px solid var(--line);
  }
  /* Louder than tierDisclosure on purpose: this is telling someone their
     paid tier already ended, not disclosing fine print before a purchase. */
  .tierLapsedNotice{
    font-size:12.5px; line-height:1.45; color:var(--ink);
    background:var(--paper-dim); border-radius:8px; padding:10px 12px;
    margin-bottom:10px;
  }
  .tierLapsedNotice .tierLapsedTitle{ font-weight:700; margin-bottom:2px; }
  .tierAction{
    font-family:inherit; font-size:var(--fs-base); font-weight:700; cursor:pointer;
    border-radius:var(--radius-sm); padding:10px 12px; border:1.5px solid var(--line);
    background:var(--white); color:var(--ink); width:100%; margin-top:auto;
  }
  .tierAction:hover:not(:disabled){ background:var(--paper-dim); border-color:var(--ink-soft); }
  .tierAction:focus-visible{ outline:2px solid var(--blaze); outline-offset:2px; }
  .tierAction.primary{ background:var(--blaze); border-color:var(--blaze); color:var(--on-amber); }
  .tierAction.primary:hover:not(:disabled){ filter:brightness(0.94); }
  /* "Your plan" — present so each column has the same shape, but inert. */
  .tierAction:disabled{ opacity:0.55; cursor:default; }

  /* The code box is collapsed: most visitors are choosing a tier, not
     redeeming. <details> rather than a scripted toggle — it works before
     any JS wiring and is keyboard-accessible for free. */
  .tierCodeBox{ margin-bottom:18px; }
  .tierCodeBox > summary{
    cursor:pointer; font-size:var(--fs-base); font-weight:700; color:var(--ink);
    padding:8px 0; list-style:revert;
  }
  .tierCodeBox > summary:focus-visible{ outline:2px solid var(--blaze); outline-offset:2px; }
  .tierCodeBox .detailRow{ margin-top:8px; }

  @media (max-width:720px){
    /* Three columns cannot work here. Stacked in sort_order, so free reads
       first and patron last — the same order as the grid. */
    #tiersOverlay{ padding:0; align-items:stretch; }
    #tiersDialog{
      width:100%; max-height:100vh; border-radius:0; border-top:none;
      border-left:4px solid var(--blaze);
    }
    /* Three narrow heads across, so the table under them lines up with
       its columns; the action buttons wrap their label as needed. */
    .tierGrid{ gap:8px; }
    .tierCard{ padding:12px 10px 10px; }
    .tierCard.isCurrent{ padding:11px 9px 9px; }
    .tierName{ font-size:var(--fs-base); }
    .tierPrice{ font-size:20px; }
    .tierAction{ font-size:var(--fs-xs); padding:8px 6px; }
    .tierTag{ font-size:9.5px; padding:2px 7px; }
    .tierTable th, .tierTable td{ padding:8px 4px; }
    .tierTable td{ width:17%; font-size:var(--fs-xs); }
    #tiersBody{ padding:0 12px 16px; }
    #tiersHeader{ padding:16px 16px 10px; }
  }

  /* ---------- Confirm dialog (ui-confirm.js) ----------
     Sits above everything except the guided tour: it can be raised from the
     panel (z-index 800) and from the route menu, and must never appear
     behind whatever it's asking about. */
  #confirmOverlay{
    position:fixed; inset:0; z-index:950; background:rgba(20,26,20,0.45);
    display:flex; align-items:center; justify-content:center; padding:20px;
  }
  #confirmOverlay[hidden]{ display:none; }
  #confirmDialog{
    background:var(--paper); border-radius:var(--radius-lg); box-shadow:var(--shadow);
    border-top:4px solid var(--blaze); /* same accent the panel uses */
    width:min(400px, 100%); padding:22px 22px 18px; box-sizing:border-box;
  }
  #confirmTitle{
    font-family:'Bricolage Grotesque', sans-serif; font-weight:600; font-size:var(--fs-xl);
    letter-spacing:0.01em; color:var(--ink); margin:0 0 8px;
  }
  #confirmMessage{
    font-size:var(--fs-md); line-height:1.5; color:var(--ink-soft); margin:0 0 18px;
  }
  #confirmActions{ display:flex; gap:10px; justify-content:flex-end; flex-wrap:wrap; }
  /* A notice (noticeDialog) hides the title and the cancel button: the
     message reads in ink on its own, and the one button keeps to the right
     rather than stretching across. */
  #confirmTitle[hidden], #confirmCancelBtn[hidden]{ display:none; }
  #confirmTitle[hidden] + #confirmMessage{ color:var(--ink); font-size:var(--fs-base); }
  #confirmCancelBtn[hidden] + #confirmOkBtn{ flex:0 1 auto; }
  #confirmActions button{
    font-family:inherit; font-size:var(--fs-base); font-weight:700; cursor:pointer;
    border-radius:var(--radius-sm); padding:10px 16px; border:1.5px solid var(--line);
    background:var(--white); color:var(--ink); flex:1 1 auto; min-width:120px;
  }
  #confirmActions button:hover{ background:var(--paper-dim); border-color:var(--ink-soft); }
  #confirmActions button:focus-visible{ outline:2px solid var(--blaze); outline-offset:2px; }
  /* The destructive choice. Deliberately not the app's orange — that's the
     "go" accent on Save; a discard needs to read as a different kind of
     action, not a brighter version of the same one. */
  #confirmActions button.danger{
    background:var(--error); border-color:var(--error); color:var(--white);
  }
  #confirmActions button.danger:hover{ background:var(--error-dark); border-color:var(--error-dark); }
  @media (max-width:420px){
    /* Stack on narrow screens, destructive action last so it isn't the
       one under your thumb by default. */
    #confirmActions{ flex-direction:column-reverse; }
    #confirmActions button{ width:100%; }
  }
  #panel{
    position:absolute; z-index:800; background: var(--paper);
    box-shadow: var(--shadow); display:none; flex-direction:column;
    border-top:4px solid var(--ink);
  }
  /* A bottom sheet on a phone, with the same anatomy as the account menu
     and Route details: the pill at the top, a slide up on open, and a pull
     down to put it away (sheet.js). The ✕ is desktop furniture — on the
     sheet the pill is the close, as it is on the other two. */
  @media (max-width: 720px){
    #panel{
      left:0; right:0; bottom:0; max-height:85vh; border-radius:var(--radius-lg) var(--radius-lg) 0 0;
      animation:sheetIn 0.24s cubic-bezier(0.32, 0.72, 0, 1);
      transition:transform 0.22s cubic-bezier(0.32, 0.72, 0, 1);
    }
    #panel[data-dragging]{ transition:none; }
    #panelHeader{ padding-top:26px; touch-action:none; user-select:none; -webkit-user-select:none; }
    #panelClose{ display:none; }
  }
  @media (max-width: 720px) and (prefers-reduced-motion: reduce){
    #panel{ animation:none; transition:none; }
  }
  /* The pill every phone sheet wears at its top edge: the account menu's
     and the panel's are this class; Route details draws the same pill on
     its own grab button (#routeSheetGrab), which also resizes the sheet. */
  .sheetGrab{
    position:absolute; top:0; left:0; right:0; height:22px;
    display:flex; align-items:center; justify-content:center;
    background:none; border:none; padding:0; cursor:pointer; width:auto;
    -webkit-tap-highlight-color:transparent;
  }
  .sheetGrab::before{
    content:''; width:36px; height:4px; border-radius:var(--radius-pill); background:var(--line);
    transition:background 0.15s ease, width 0.15s ease;
  }
  .sheetGrab:hover{ background:none; }
  .sheetGrab:hover::before{ background:var(--ink-soft); }
  [data-dragging] > .sheetGrab::before{ background:var(--ink); width:44px; }
  .sheetGrab:focus-visible{ outline:2px solid var(--blaze); outline-offset:-2px; border-radius:var(--radius-sm); }
  @media (min-width:721px){
    /* The id-qualified button rules of the menus outrank a bare class, so
       the pill is named through its parents here — or the account
       dropdown kept a handle with nothing to pull. */
    .sheetGrab, #accountMenu .sheetGrab, #panel .sheetGrab{ display:none; }
  }
  #panel.tourLockClose #panelGrab{ pointer-events:none; }
  @media (min-width: 721px){
    #panel{ top:0; left:0; bottom:0; width:var(--rail-w); border-top:none; border-right:4px solid var(--ink); }
  }
  /* The same header as #routeSidebarHeader: title, then the one control
     at the trailing edge — ✕ here, the collapse chevron on the rail. */
  #panelHeader{
    display:flex; align-items:flex-start; justify-content:space-between; gap:10px;
    padding:18px 20px 10px; border-bottom:1px solid var(--line); flex-shrink:0;
  }
  #panelHeader h2{
    font-family:'Bricolage Grotesque', sans-serif; font-weight:600; letter-spacing:0.01em;
    font-size:var(--fs-xxl); margin:0; color:var(--ink); text-transform:uppercase;
  }
  #panelClose{
    background:none; border:none; cursor:pointer; color:var(--ink-soft); padding:4px; flex-shrink:0;
    border-radius:var(--radius-sm);
  }
  #panelClose:hover{ background:var(--paper-dim); color:var(--ink); }
  #panelClose:focus-visible{ outline:2px solid var(--blaze); outline-offset:2px; }
  /* Set on #panel while a guided-tour step needs the panel to stay open
     (e.g. tour-edit-stop.js's fake detail/edit views) — keeps the ✕ and
     the dimmed overlay looking exactly as normal, just unclickable, rather
     than visually disabling them and drawing attention to a state that's
     only temporary. */
  #panel.tourLockClose #panelClose{ pointer-events:none; }
  #panelOverlay.tourLockClose{ pointer-events:none; }
  #panelBody{ padding:16px 20px calc(24px + env(safe-area-inset-bottom)); overflow-y:auto; flex:1; }
  /* The mobile sheet's own bottom padding, on top of whatever padding the
     footer's inner content (.detailFooter, .routeUsageFooter) already
     has — so Report a problem / the routes usage bar don't end up flush
     against, or under, the home-indicator area. Desktop's #panel doesn't
     reach the bottom edge (see the min-width:721px rule above), so this
     is a no-op there regardless of env() value. */
  #panelFooter{ flex-shrink:0; padding-bottom:env(safe-area-inset-bottom); }
  #panelFooter[hidden]{ display:none; }

  .field{ margin-bottom:16px; }
  .field label{
    display:block; font-size:var(--fs-xs); font-weight:700; text-transform:uppercase; letter-spacing:0.06em;
    color: var(--ink-soft); margin-bottom:6px;
  }
  .field .opt{ font-weight:400; text-transform:none; letter-spacing:0; opacity:0.75; }
  .field input[type=text], .field textarea, .field select{
    width:100%; border:1px solid var(--line); background:var(--white); border-radius:var(--radius-sm);
    padding:10px 12px; font-size:var(--fs-md); font-family:inherit; color:var(--ink); outline:none;
  }
  .field input[type=text]:focus, .field textarea:focus, .field select:focus{ border-color: var(--blaze); }
  .field textarea{ resize:vertical; min-height:64px; }
  .catRow{ display:flex; gap:8px; flex-wrap:wrap; }
  .catOpt{
    flex:1; min-width:80px; border:1.5px solid var(--line); border-radius:var(--radius-sm); padding:9px 6px;
    display:flex; flex-direction:column; align-items:center; gap:4px; cursor:pointer; background:var(--white);
    font-size:var(--fs-sm); font-weight:600; text-align:center;
  }
  .catOpt.selected{ border-color: var(--ink); background: var(--paper-dim); }
  .catOpt .emoji{ font-size:var(--fs-xl); display:flex; color:var(--ink); }
  .catOpt .catGlyph{ width:20px; height:20px; }

  .formActions{ display:flex; gap:10px; margin-top:6px; }
  .formCancelBtn{
    flex:1; background:transparent; color:var(--ink); border:1.5px solid var(--line); border-radius:var(--radius-md);
    padding:13px; font-size:var(--fs-md); font-weight:700; cursor:pointer; font-family:inherit;
    letter-spacing:0.02em;
  }
  .formCancelBtn:hover{ background:var(--paper); border-color:var(--ink); }
  #saveBtn, .formSaveBtn{
    flex:2; background: var(--blaze); color:var(--on-amber); border:none; border-radius:var(--radius-md);
    padding:13px; font-size:var(--fs-md); font-weight:700; cursor:pointer;
    letter-spacing:0.02em;
  }
  #saveBtn:hover, .formSaveBtn:hover{ background: var(--blaze-dark); }
  #saveBtn:disabled, .formSaveBtn:disabled{ opacity:0.5; cursor:not-allowed; }
  /* Sits at the bottom of a form taller than the panel, so it is often
     scrolled to rather than glanced at (showFormError() in panel-form.js
     brings it into view). Given a tinted block and a leading rule so that
     when it does arrive it reads as a stopped action, not as a caption. */
  .formError{
    color:var(--error); font-size:var(--fs-base); margin-top:-4px; margin-bottom:14px; display:none;
    background:var(--error-bg); border-left:3px solid var(--error);
    border-radius:0 var(--radius-sm) var(--radius-sm) 0; padding:8px 10px; font-weight:600;
  }

  /* ---------- Schedule editor ---------- */
  .scheduleBox{ border:1px solid var(--line); border-radius:var(--radius-md); background:var(--white); padding:10px 12px; }
  .always247{ display:flex; align-items:center; gap:9px; padding-bottom:10px; margin-bottom:10px; border-bottom:1px dashed var(--line); }
  .always247 label{ font-size:var(--fs-base); font-weight:600; color:var(--ink); cursor:pointer; }
  .dayRow{ display:flex; align-items:center; gap:8px; padding:6px 0; flex-wrap:wrap; }
  .dayRow .dayLabel{ width:34px; flex-shrink:0; font-size:var(--fs-base); font-weight:700; color:var(--ink); }
  .switch{ position:relative; width:36px; height:20px; flex-shrink:0; }
  .switch input{ opacity:0; width:0; height:0; }
  .slider{
    position:absolute; cursor:pointer; inset:0; background:var(--line); border-radius:var(--radius-pill); transition:0.15s;
  }
  .slider:before{
    content:""; position:absolute; height:14px; width:14px; left:3px; top:3px; background:var(--white);
    border-radius:50%; transition:0.15s;
  }
  .switch input:checked + .slider{ background: var(--ink); }
  .switch input:checked + .slider:before{ transform: translateX(16px); }
  .switch input:disabled + .slider{ opacity:0.5; cursor:not-allowed; }
  .dayTimes{ display:flex; align-items:center; gap:6px; flex:1; min-width:170px; }
  .dayTimes input[type=time]{
    font-family:'IBM Plex Mono', ui-monospace, Menlo, monospace;
    border:1px solid var(--line); border-radius:var(--radius-sm); padding:5px 6px; font-size:var(--fs-base);
    background:var(--paper); color:var(--ink); width:92px; font-variant-numeric:tabular-nums;
  }
  .dayTimes input[type=time]:disabled{ opacity:0.5; cursor:not-allowed; }
  .dayTimes .dash{ color:var(--ink-soft); font-size:var(--fs-sm); }
  .dayClosedLabel{ font-size:var(--fs-sm); color:var(--ink-soft); font-style:italic; flex:1; }
  .copyRow{ margin-top:8px; }
  .copyRow button:disabled{ opacity:0.5; cursor:not-allowed; }
  /* Opening hours unknown: the 24h switch + every day row stay visible
     (not hidden, per the "unknown" toggle's own disable behavior above)
     but read as inert alongside them. */
  .scheduleBox.hoursUnknown #s_daysWrap, .scheduleBox.hoursUnknown .copyRow, .scheduleBox.hoursUnknown #s_247Row{ opacity:0.6; }
  .copyRow button{
    background:none; border:none; color:var(--steel); font-size:var(--fs-sm); font-weight:700; cursor:pointer; padding:0;
    text-decoration:underline;
  }
  .fieldHint{ font-size:var(--fs-sm); color:var(--ink-soft); margin-top:5px; }
  /* The live inventory shown before deleting an account — plain counts, not
     styled as a warning list, since the warning is the surrounding text and
     this is just the facts backing it up. */
  .deleteSummaryList{ margin:0; padding-left:18px; font-size:var(--fs-base); color:var(--ink); }
  .deleteSummaryList li{ margin-bottom:4px; }

  /* ---------- Recently deleted ---------- */
  .deletedItem{
    display:flex; align-items:center; gap:12px; padding:12px 0; border-bottom:1px solid var(--line);
  }
  .deletedItem:last-child{ border-bottom:none; }
  .deletedItemIcon{
    width:34px; height:34px; border-radius:50%; display:flex; align-items:center; justify-content:center;
    font-size:var(--fs-lg); flex-shrink:0; opacity:0.55; filter:grayscale(1); color:#fff;
  }
  .deletedItemIcon .catGlyph{ width:18px; height:18px; }
  .deletedItemInfo{ min-width:0; flex:1; }
  .deletedItemName{ font-size:var(--fs-md); font-weight:600; color:var(--ink); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
  .deletedItemMeta{ font-size:var(--fs-sm); color:var(--ink-soft); margin-top:2px; }
  .restoreBtn{
    flex-shrink:0; background:none; border:1.5px solid var(--line); color:var(--ink); border-radius:var(--radius-sm);
    padding:8px 13px; font-size:var(--fs-base); font-weight:700; cursor:pointer; font-family:inherit;
  }
  .restoreBtn:hover{ border-color:var(--ink); }
  .restoreBtn:disabled{ opacity:0.6; cursor:default; }

  /* Deleting a saved route (My routes). Same base as .restoreBtn but holding
     a bin icon rather than a text glyph, so it's centred on both axes and
     padded square — the 8px/13px above was sized around a character. Muted
     until hovered: it's the one destructive control on the card and
     shouldn't be what the eye lands on first, but it stays visible rather
     than hover-only, since touch has no hover to reveal it.
     Placed after .restoreBtn deliberately — same specificity, so it has to
     come later to win. */
  .deleteRouteBtn{
    display:flex; align-items:center; justify-content:center;
    padding:9px; color:var(--ink-soft); border-color:var(--line);
    transition:color 0.12s ease, border-color 0.12s ease;
  }
  .deleteRouteBtn:hover{ color:var(--error); border-color:var(--error); }
  .deleteRouteBtn svg{ display:block; }

  /* ---------- Detail view ---------- */
  .detailTopRow{ display:flex; align-items:center; justify-content:space-between; gap:10px; margin-bottom:14px; }
  .detailCatBadge{
    display:inline-flex; align-items:center; gap:6px; padding:5px 11px; border-radius:var(--radius-pill);
    font-size:var(--fs-sm); font-weight:700; color:#fff;
  }
  .detailCatBadge .catGlyph{ width:14px; height:14px; }
  .favToggleBtn{
    background:none; border:1.5px solid var(--line); border-radius:50%; width:34px; height:34px;
    display:flex; align-items:center; justify-content:center; font-size:var(--fs-xl); line-height:1; cursor:pointer;
    color:var(--ink-soft); flex-shrink:0; font-family:inherit; padding:0;
  }
  .favToggleBtn:hover{ border-color:var(--ink); }
  .favToggleBtn.active{ border-color:var(--blaze); color:var(--blaze-text); background:var(--amber-tint); }
  .openBadge{
    display:inline-flex; align-items:center; gap:6px; padding:5px 11px; border-radius:var(--radius-pill);
    font-size:var(--fs-sm); font-weight:700; margin-bottom:14px;
  }
  .openBadge.open{ background:var(--open-tint); color:var(--open); }
  .openBadge.closed{ background:var(--closed-tint); color:var(--closed); }
  .detailRow{ margin-bottom:14px; }
  .detailRow .k{ font-size:var(--fs-xs); font-weight:700; text-transform:uppercase; letter-spacing:0.06em; color:var(--ink-soft); margin-bottom:4px; }
  .detailRow .v{ font-size:var(--fs-md); color:var(--ink); line-height:1.5; }
  .scheduleList{ width:100%; border-collapse:collapse; }
  .scheduleList td{ font-size:var(--fs-base); padding:3px 0; color:var(--ink); }
  .scheduleList td.d{ font-weight:700; width:70px; color:var(--ink-soft); }
  .scheduleList tr.today td{ color:var(--blaze-text); font-weight:700; }
  .tagRow{ display:flex; flex-wrap:wrap; gap:6px; }
  .tag{ background:var(--paper-dim); border:1px solid var(--line); border-radius:var(--radius-pill); padding:4px 10px; font-size:var(--fs-base); }

  /* Was an <a> to Google Maps; now the button that builds the route in the
     app. Same accent and shape — this is still the panel's primary action —
     with the resets a <button> needs that an anchor did not: full width,
     no UA border, and the inherited font. */
  .directionsBtn{
    display:block; width:100%; box-sizing:border-box; text-align:center;
    background: var(--blaze); color:var(--on-amber); text-decoration:none;
    border:none; font-family:inherit; cursor:pointer;
    border-radius:var(--radius-md); padding:11px; font-weight:700; font-size:var(--fs-md); letter-spacing:0.02em;
  }
  .directionsBtn:hover{ background: var(--blaze-dark); }
  .directionsBtn:focus-visible{ outline:2px solid var(--ink); outline-offset:2px; }
  /* Held while the location lookup, the routing call and the corridor query
     run — seconds on a phone, and the button says so. */
  .directionsBtn:disabled{ opacity:0.6; cursor:default; }
  .directionsBtn:disabled:hover{ background: var(--blaze); }
  .utilRow{ display:flex; gap:8px; margin-top:8px; }
  .utilBtn{
    flex:1; display:flex; align-items:center; justify-content:center; gap:6px;
    border:1.5px solid var(--line); background:var(--white); border-radius:var(--radius-sm);
    padding:9px 6px; font-size:var(--fs-base); font-weight:600; color:var(--ink); cursor:pointer; font-family:inherit;
  }
  .utilBtn:hover{ background: var(--paper); }
  .utilBtn:disabled{ opacity:0.6; cursor:default; }
  .utilBtn.copied{ border-color: var(--ink); color: var(--ink); background:var(--paper-dim); }
  #deleteBtn{
    width:100%; background:transparent; border:1.5px solid var(--error); color:var(--error); border-radius:var(--radius-md);
    padding:11px; font-size:var(--fs-md); font-weight:700; cursor:pointer; margin-top:10px;
  }
  #deleteBtn:hover{ background:var(--error); color:var(--white); }
  .editStopBtn{
    width:100%; background:transparent; border:1.5px solid var(--line); color:var(--ink); border-radius:var(--radius-md);
    padding:11px; font-size:var(--fs-md); font-weight:700; cursor:pointer; margin-top:10px; font-family:inherit;
  }
  .editStopBtn:hover{ background:var(--paper); border-color:var(--ink); }

  /* ---------- Still accurate? confirmation ---------- */
  .confirmBtn{
    width:100%; background:transparent; border:1.5px solid var(--ink); color:var(--ink); border-radius:var(--radius-md);
    padding:10px; font-size:var(--fs-base); font-weight:700; cursor:pointer; font-family:inherit;
  }
  .confirmBtn:hover{ background:var(--open-tint); }
  .confirmBtn:disabled{ opacity:0.7; cursor:default; }

  /* ---------- Report a problem ---------- */
  .reportToggleBtn{
    background:none; border:none; color:var(--ink-soft); font-size:var(--fs-base); font-weight:600; cursor:pointer;
    padding:0; font-family:inherit; text-decoration:underline; margin-bottom:8px;
  }
  .reportForm{ border:1px solid var(--line); border-radius:var(--radius-md); padding:12px; background:var(--paper); }
  .reportLabel{
    display:block; font-size:var(--fs-xs); font-weight:700; text-transform:uppercase; letter-spacing:0.06em;
    color:var(--ink-soft); margin-bottom:6px;
  }
  /* Present for a screen reader, absent on screen. For a label whose text is
     already visible nearby as a heading — repeating it would be the same
     words twice in adjacent lines — but whose control still needs naming.
     The clip-rect pattern rather than display:none, which would take it out
     of the accessibility tree along with the layout. */
  .srOnly{
    position:absolute; width:1px; height:1px; margin:-1px; padding:0;
    overflow:hidden; clip:rect(0 0 0 0); clip-path:inset(50%); white-space:nowrap; border:0;
  }
  .reportForm select, .reportForm textarea{
    width:100%; border:1px solid var(--line); background:var(--white); border-radius:var(--radius-sm); padding:9px 10px;
    font-size:var(--fs-md); font-family:inherit; color:var(--ink); outline:none; margin-bottom:8px;
  }
  .reportForm textarea{ min-height:56px; resize:vertical; }
  .reportActions{ display:flex; gap:8px; justify-content:flex-end; }
  #reportSubmitBtn{
    background:var(--blaze); color:var(--on-amber); border:none; border-radius:var(--radius-sm); padding:8px 14px;
    font-size:var(--fs-base); font-weight:700; cursor:pointer; font-family:inherit;
  }
  #reportSubmitBtn:disabled{ opacity:0.6; cursor:default; }
  #reportCancelBtn{ padding:8px 14px; }

  /* ---------- Marker ---------- */
  .poi-cluster{
    display:flex; align-items:center; justify-content:center; border-radius:50%;
    background:var(--ink); color:var(--paper); font-family:'Bricolage Grotesque', sans-serif; font-weight:600;
    border:3px solid var(--white); box-shadow:var(--shadow-md);
  }
  .poi-marker{
    width:34px; height:34px; border-radius:50% 50% 50% 0; transform: rotate(-45deg);
    display:flex; align-items:center; justify-content:center; box-shadow:var(--shadow-md);
    border:2px solid var(--white); transition: filter 0.2s, opacity 0.2s;
  }
  .poi-marker span{ transform: rotate(45deg); font-size:var(--fs-lg); display:flex; color:#fff; }
  /* Category glyphs (category-glyphs.js): line icons in currentColor, sized
     by the thing they sit in. White on a coloured marker or badge, Ink on a
     plain option row. */
  .catGlyph{ width:16px; height:16px; display:block; flex-shrink:0; }
  .catGlyphText{ font-size:var(--fs-base); line-height:1; }
  .poi-marker .catGlyph{ width:17px; height:17px; }
  .poi-marker.is-closed{ filter: grayscale(1); opacity:0.45; }
  .poi-marker.is-pending{ border-style:dashed; opacity:0.88; }
  .poi-marker.is-pending-error{ border-color:var(--error); border-style:dashed; }
  /* Signal color for a stop that's in the current route plan (see
     .routeStopPlan.active in the sidebar, which uses the same --moss) —
     a ring around the pin rather than recoloring it, so the category
     color/emoji stays legible and this reads as an overlay, not a
     different kind of marker. */
  .poi-marker.is-planned{
    border-color:var(--ink); box-shadow:0 0 0 3px color-mix(in srgb, var(--ink) 35%, transparent), var(--shadow-md);
  }
  /* ---------- POI hover card ----------
     Bound to each marker with Leaflet's bindTooltip (see poiTooltipHtml in
     map-render.js), so the base .leaflet-tooltip styles come from Leaflet's
     own stylesheet and are overridden here rather than rebuilt. Only on
     devices that can hover — markers on a touchscreen never get one bound.

     Styled as the app's own small dark surface (--ink/--paper, the same
     vocabulary as the nudges) rather than Leaflet's default white box with
     a grey border, which reads as belonging to the map library instead of
     to the app. */
  .leaflet-tooltip.poiTip{
    background:var(--pill-bg); color:var(--pill-fg); border:1px solid var(--pill-border);
    border:none; border-radius:var(--radius-sm); box-shadow:var(--shadow);
    padding:7px 10px; max-width:220px; white-space:normal;
    font-family:inherit; font-size:var(--fs-sm); line-height:1.4;
  }
  .leaflet-tooltip.poiTip::before{ border-top-color:var(--pill-bg); } /* the callout arrow, which Leaflet colours to match its own default background */
  .poiTip .poiTipName{ display:block; font-weight:700; }
  /* Deliberately dimmed against the name: the category is context for the
     name, not a second heading. Not --paper-dim, which is near-white and
     would read as loud as the name on this surface. */
  .poiTip .poiTipCat{ display:block; color:var(--pill-fg); opacity:0.7; font-size:var(--fs-xs); margin-top:1px; }
  /* The same three verdicts, in the same colour language, as the sidebar's
     .routeStopPill — a stop must not read as open in one place and closed
     in another. Lightened here because these sit on --ink rather than on a
     pale pill: the pill's own var(--error) and --moss don't clear 4.5:1 against
     a dark background. "Unknown" stays the quietest of the three, matching
     the pill's reasoning that it's an absence of information rather than a
     third verdict. */
  .poiTip .poiTipOpen{ display:block; font-size:var(--fs-xs); font-weight:600; margin-top:3px; }
  .poiTip .poiTipOpen.is-open{ color:#5FC48A; }
  .poiTip .poiTipOpen.is-closed{ color:#F07A55; }
  .poiTip .poiTipOpen.is-unknown{ color:var(--pill-fg); opacity:0.65; }
  .fav-badge{
    position:absolute; top:-3px; right:-3px; width:15px; height:15px; border-radius:50%;
    background:var(--blaze); color:var(--on-amber); font-size:var(--fs-xs); line-height:15px; text-align:center;
    border:1.5px solid var(--white); box-shadow:var(--shadow-sm); font-weight:700;
  }
  .plan-badge{
    position:absolute; bottom:-2px; right:-3px; width:15px; height:15px; border-radius:50%;
    background:var(--ink); color:var(--paper); font-size:var(--fs-xs); line-height:15px; text-align:center;
    border:1.5px solid var(--white); box-shadow:var(--shadow-sm); font-weight:700;
  }
  /* Bottom-left — the one corner .fav-badge/.plan-badge don't use. Only
     ever rendered for a confirmed "open now" on a supermarket/gas station
     (see map-render.js's showsOpenBadge()), so a plain dot is enough —
     unlike the star/checkmark badges, there's no "which state" ambiguity
     to resolve with a glyph, since its mere presence already means one
     specific thing. */
  .open-badge{
    position:absolute; bottom:-2px; left:-3px; width:15px; height:15px; border-radius:50%;
    background:var(--moss); border:1.5px solid var(--white); box-shadow:var(--shadow-sm);
  }
  /* Same corner and size as the open dot — the two are mutually exclusive
     (see makeIcon), so they can share the spot. Amber rather than green or
     red: this is neither good news nor bad, it's "nobody has recorded this",
     and it should read as a caution to check rather than as a verdict.
     Deliberately not greyed into the background — a stop whose hours are
     unknown is exactly the one worth noticing while planning. */
  .unknown-badge{
    position:absolute; bottom:-2px; left:-3px; width:15px; height:15px; border-radius:50%;
    background:var(--blaze); border:1.5px solid var(--white); box-shadow:var(--shadow-sm);
    display:flex; align-items:center; justify-content:center;
    font-size:var(--fs-xs); font-weight:800; line-height:1; color:var(--on-amber);
  }

  /* ---------- "You are here" marker — distinct blue, never used by any POI category ---------- */
  .user-loc-wrap{ position:relative; width:50px; height:50px; display:flex; align-items:center; justify-content:center; }
  .user-loc-halo{
    position:absolute; width:44px; height:44px; border-radius:50%; background: color-mix(in srgb, var(--water) 28%, transparent);
    animation: userLocPulse 2.2s infinite cubic-bezier(0.2,0.6,0.4,1);
  }
  .user-loc-dot{
    position:relative; width:16px; height:16px; border-radius:50%; background:var(--water);
    border:3px solid var(--white); box-shadow:var(--shadow-sm); z-index:1;
  }
  @keyframes userLocPulse{
    0%{ transform:scale(0.25); opacity:0.9; }
    100%{ transform:scale(1); opacity:0; }
  }

  /* ---------- Loading / empty states ---------- */
  /* ---------- Boot status pill + cluster skeleton ----------
     The map-first replacement for the old full-screen loading overlay.
     Both layers sit above the map but below the header (z-index 400, the
     same band #mapLoadingIndicator and #locateBtn use) and neither takes
     pointer events, so the map stays pannable and zoomable throughout —
     which is the whole point of not covering it. */
  #bootStatus{
    /* Below the header, which is 158px tall on mobile (two rows) and 108px
       on desktop — the same offsets Leaflet's zoom control is nudged to. */
    position:absolute; top:158px; left:50%; transform:translateX(-50%);
    z-index:400; pointer-events:none;
    display:flex; align-items:baseline; gap:9px;
    background:var(--pill-bg); color:var(--pill-fg); border:1px solid var(--pill-border);
    padding:9px 15px; border-radius:var(--radius-pill);
    font-size:var(--fs-base); font-weight:600; box-shadow:var(--shadow);
    max-width:calc(100vw - 32px);
  }
  #bootStatus[hidden]{ display:none; }
  /* Smaller and paler than the overlay's: this is a background activity
     note, not the main event on the page. */
  #bootStatus .spinner{
    width:14px; height:14px; border-width:2px;
    border-color:rgba(251,251,248,0.28); border-top-color:var(--blaze);
  }
  /* The animated mark in the pill: not a ring, so none of .spinner's
     border or rotation applies to it. */
  /* Sized to its cropped viewBox and aligned on the text's baseline: a
     replaced element's baseline is its bottom edge, so with the pill laid
     out on baselines the strokes' feet sit exactly on the letters' feet,
     the same alignment the lock-up has. */
  #bootStatus .hatchMark.spinner{
    width:24px; height:14px; border:none; border-radius:0; animation:none; color:var(--pill-fg);
    align-self:baseline;
  }
  /* Failure turns the pill into an action. Re-enables pointer events on
     just the button — the pill itself stays inert so it never blocks the
     map behind it. */
  #bootStatus.failed{ background:var(--closed); color:#FFFFFF; }
  #bootRetryBtn{
    pointer-events:auto; cursor:pointer;
    background:var(--paper); color:var(--ink); border:none;
    border-radius:var(--radius-md); padding:4px 11px; font:inherit; font-size:var(--fs-sm); font-weight:700;
  }
  #bootRetryBtn[hidden]{ display:none; }
  @media (min-width:721px){ #bootStatus{ top:108px; } }

  /* Stand-ins for the cluster bubbles that are about to arrive. Not a
     guess at real positions — they are a fixed scatter, sized like the
     mid-range bubbles (44px) — so they read as "stops are coming here"
     rather than claiming a count the app doesn't know yet. Hidden from
     assistive tech entirely (aria-hidden on the container): #bootStatus
     already says what is happening, and these are pure decoration. */
  #bootSkeleton{ position:absolute; inset:0; z-index:400; pointer-events:none; }
  #bootSkeleton[hidden]{ display:none; }
  /* Opaque enough to read against OSM tiles, which are themselves busy and
     green in places — a low-alpha tint disappeared into the countryside
     entirely. These mirror the real cluster bubble (44px, white ring, soft
     shadow) so what arrives is recognisably what was promised, just
     without a number the app doesn't know yet. */
  #bootSkeleton span{
    position:absolute; width:44px; height:44px; border-radius:50%;
    border:3px solid rgba(255,255,255,0.85);
    box-shadow:var(--shadow-md);
    background:linear-gradient(100deg,
      rgba(14,143,73,0.30) 30%, rgba(14,143,73,0.55) 50%, rgba(14,143,73,0.30) 70%);
    background-size:220% 100%;
    animation: bootShimmer 1.5s ease-in-out infinite;
  }
  /* Staggered so they don't pulse in unison, which reads as one flashing
     block rather than several separate things still to come. */
  #bootSkeleton span:nth-child(2){ animation-delay:0.18s; }
  #bootSkeleton span:nth-child(3){ animation-delay:0.36s; }
  #bootSkeleton span:nth-child(4){ animation-delay:0.54s; }
  #bootSkeleton span:nth-child(5){ animation-delay:0.72s; }
  #bootSkeleton span:nth-child(6){ animation-delay:0.90s; }
  @keyframes bootShimmer{
    0%   { background-position:180% 0; }
    100% { background-position:-80% 0; }
  }
  /* Respect a reduced-motion preference: keep the placeholders (they still
     say "something is coming here") but stop them moving. */
  @media (prefers-reduced-motion: reduce){
    #bootSkeleton span{ animation:none; background:rgba(14,143,73,0.38); }
    #bootStatus .spinner{ animation:none; }
  }

  #stateOverlay{
    position:absolute; inset:0; z-index:900; background: var(--paper);
    display:flex; flex-direction:column; align-items:center; justify-content:center; gap:14px; text-align:center; padding:24px;
  }
  #stateOverlay .mark{ height:34px; width:auto; }
  #stateOverlay p{ color: var(--ink-soft); font-size:var(--fs-md); max-width:280px; margin:0; }
  #stateOverlay button{
    background: var(--ink); color:var(--paper); border:none; border-radius:var(--radius-sm); padding:10px 18px;
    font-weight:700; font-size:var(--fs-base); cursor:pointer;
  }
  .spinner{
    width:26px; height:26px; border:3px solid var(--line); border-top-color: var(--blaze); border-radius:50%;
    animation: spin 0.8s linear infinite;
  }
  @keyframes spin{ to{ transform: rotate(360deg); } }

  /* The locate control, now a map control rather than a header icon — see
     the note in index.html.
     =====================================================================
     Deliberately NOT the header icons' styling, which is what it was given
     at first and why it nearly disappeared. Those work because they sit on
     a solid dark header bar; on the map there is no such backing, and three
     things compounded:

       - a 1px #DCDAD0 border on white gave almost no edge against pale
         tiles, so the button had no outline at all over open countryside;
       - --shadow is 0 6px 20px at 0.16 alpha, a wide soft shadow tuned for
         large panels, which at 38px over a busy background is invisible;
       - the crosshair icon is thin strokes around a lot of negative space,
         which reads as map noise at 18px rather than as a control.

     So: a dark ink border instead of a hairline, a tight high-contrast
     shadow that actually separates it from the tiles, and a filled ink
     circle behind a white icon — the same inversion the header buttons use
     for their open state, which is the app's existing vocabulary for "this
     is a control, not decoration". Ink rather than blaze keeps it clearly
     secondary to the FAB. */
  /* 44px rather than the 40px it was: same reasoning as the header icons
     above, but taken on the button itself here — this one floats over the
     map with nothing beside it to stay balanced against, so growing it
     costs no layout anywhere. */
  #locateBtn{
    position:absolute; bottom:calc(30px + env(safe-area-inset-bottom)); left:14px; z-index:400;
    width:44px; height:44px; border-radius:50%;
    background:var(--ink); border:none; color:var(--paper);
    box-shadow:var(--shadow-md);
    display:flex; align-items:center; justify-content:center;
    cursor:pointer; padding:0;
    transition: background 0.12s ease, transform 0.12s ease;
  }
  #locateBtn svg{ stroke: var(--paper); }
  /* Clear of the stops list while a route is open. On a desktop the list
     is a rail down the left, so the button moves right of it (or of the
     strip it collapses to); on a phone it is a sheet along the bottom, so
     the button rides on top of the sheet's edge, following the sheet's
     height as it snaps or is dragged (--sheet-h, written by route-ui.js). */
  @media (min-width:721px){
    body.route-sidebar-open #locateBtn{ left:calc(var(--rail-w) + 18px); }
    body.route-sidebar-collapsed #locateBtn{ left:56px; }
  }
  @media (max-width:720px){
    body.route-sidebar-open #locateBtn{ bottom:calc(var(--sheet-h, 45vh) + 14px + env(safe-area-inset-bottom)); }
    #locateBtn{ transition:bottom 0.2s ease; }
    #routeSidebar[data-dragging] ~ #locateBtn, body:has(#routeSidebar[data-dragging]) #locateBtn{ transition:none; }
  }
  #locateBtn:hover{ filter:brightness(1.12); transform:scale(1.06); }
  #locateBtn:active{ transform:scale(0.96); }
  #locateBtn:focus-visible{ outline:2px solid var(--blaze); outline-offset:2px; }
  /* Your position is off screen (syncLocateButton, map-render.js): the
     button keeps its ink face and a faint amber ring breathes out of it,
     slowly — a nudge that there is a way back, not an alarm. Amber because
     it is the brand's one accent for "here is the thing to press"; blue
     stays reserved for water. */
  #locateBtn.away::after{
    content:''; position:absolute; inset:-2px; border-radius:50%; border:1.5px solid var(--amber);
    opacity:0; animation:locateAway 2.6s ease-out infinite; pointer-events:none;
  }
  @keyframes locateAway{
    0%{ transform:scale(0.96); opacity:0; }
    25%{ opacity:0.55; }
    80%{ transform:scale(1.3); opacity:0; }
    100%{ transform:scale(1.3); opacity:0; }
  }
  @media (prefers-reduced-motion: reduce){
    #locateBtn.away::after{ animation:none; opacity:0.45; transform:none; }
  }

  #offlineNote{
    position:absolute; bottom:calc(10px + env(safe-area-inset-bottom)); right:14px; z-index:400; font-size:var(--fs-xs); font-weight:700;
    background: var(--blaze); color:var(--on-amber); padding:5px 11px; border-radius:var(--radius-pill);
    pointer-events:none; display:none; align-items:flex-start; gap:5px;
    max-width: calc(38% - 20px); line-height:1.4;
  }
  #offlineNote .dot{ width:6px; height:6px; border-radius:50%; background:var(--ink); flex-shrink:0; margin-top:4px; }

  /* Transient toast, e.g. "'Weekend loop' is ready offline" once a region
     download finishes (see offline-regions.js's downloadOfflineRegion() /
     main.js's watcher). Top-center, matching #placeBanner/#drawBanner's
     existing banner styling so it reads as the same family of "active status"
     UI, but higher (top:16px, above the search bar's dropdown) since a toast
     needs to stay clear of the header controls that banner sits below. */
  /* "Draft kept on this device", appended to the unsaved-route status.
     Moss because everywhere else in this app moss means "this is fine" —
     the sentence it follows is a warning, and this is the reassurance that
     the work is not actually at risk while it sits there. */
  /* Was an inline span inside #routeSaveStatus; it is its own line under the
     Save button now, so it carries the size the status line used to give it
     and the same 8px rhythm as the buttons around it. */
  .draftNote{
    color: var(--ink-soft); font-weight:700; font-size:var(--fs-sm);
    margin:-2px 0 8px;
  }
  .draftNote[hidden]{ display:none; }

  #toast{
    position:absolute; top:16px; left:50%; transform:translateX(-50%) translateY(-8px);
    z-index:900; background:var(--pill-bg); color:var(--pill-fg); border:1px solid var(--pill-border); padding:10px 16px;
    border-radius:var(--radius-md); font-size:var(--fs-base); font-weight:600; box-shadow: var(--shadow);
    max-width:calc(100vw - 32px); text-align:center; pointer-events:none;
    opacity:0; transition: opacity 0.25s ease, transform 0.25s ease;
  }
  #toast.show{ opacity:1; transform:translateX(-50%) translateY(0); }

  /* The prominent variant: centred on screen and larger, for a message that
     answers something the person just did and would be a real loss to miss.
     Recovering a route draft is the case it exists for — the top-of-screen
     strip is deliberately easy to ignore, which is wrong when the message
     is "your work is back".

     Centred with a translate on both axes, so the two transforms (hidden
     and shown) have to carry the -50% on each — a single-axis version would
     snap the toast sideways as it appears. */
  #toast.prominent{
    top:50%; left:50%;
    transform:translate(-50%, calc(-50% + 8px));
    padding:16px 24px; font-size:var(--fs-lg); border-radius:var(--radius-md);
    box-shadow:var(--shadow-lg);
    max-width:min(420px, calc(100vw - 48px));
  }
  #toast.prominent.show{ transform:translate(-50%, -50%); }

  /* One-off celebration burst for landing back in the app after a Stripe
     checkout — see celebrateConfetti() in utils.js. Pieces fall from above
     the viewport and are removed by JS once their animation ends; z-index
     matches #toast/#stateOverlay's layer so it sits above the map and any
     panel opening underneath it (openTiers() runs in the same tick). */
  .confettiBurst{
    position:fixed; inset:0; z-index:900; pointer-events:none; overflow:hidden;
  }
  .confettiPiece{
    position:absolute; top:-16px; width:8px; height:14px; opacity:0.9;
    animation-name: confettiFall; animation-timing-function: ease-in;
    animation-fill-mode: forwards;
  }
  @keyframes confettiFall{
    0%{ transform: translate(0, 0) rotate(0deg); opacity:0.9; }
    100%{ transform: translate(var(--drift), 110vh) rotate(var(--spin)); opacity:0; }
  }
  /* celebrateConfetti() already checks prefers-reduced-motion and skips
     creating any pieces, so this is a no-op safety net rather than the
     primary control — kept in case a piece is ever created some other way. */
  @media (prefers-reduced-motion: reduce){
    .confettiPiece{ animation:none; display:none; }
  }

  /* Shown only if a viewport fetch is still pending after a short delay
     (see showMapLoadingIndicator() in map-render.js) — a cache hit never
     reaches this, since it resolves before the delay elapses. */
  #mapLoadingIndicator{
    position:absolute; top:14px; right:14px; z-index:400; pointer-events:none; display:none;
    background: rgba(250,248,244,0.9); border-radius:50%; padding:6px;
  }
  #mapLoadingIndicator .spinner{ width:16px; height:16px; border-width:2px; }
  .leaflet-top.leaflet-left{ top: 158px; }
  /* No +/− on a phone: pinch is the zoom there, and the pair sat in the
     map's top-left corner under the header for no one. */
  @media (max-width:720px){
    .leaflet-control-zoom{ display:none; }
  }
  @media (min-width:721px){
    .leaflet-top.leaflet-left{ top: 108px; }
    /* keep zoom controls clear of the route sidebar when it's open */
    body.route-sidebar-open .leaflet-top.leaflet-left{ left:calc(var(--rail-w) + 4px); }
    /* Collapsed: only the 38px strip to clear, not the full sidebar. */
    body.route-sidebar-collapsed .leaflet-top.leaflet-left{ left: 42px; }
  }

/* ---------- Accounts ---------- */
/* The sign-in form is the first place in the app with two text inputs in
   one row. Inputs are inline-block by default, so without this the email
   and password sit side by side, each too narrow to read.
   Border/background/radius/padding/focus match .field input[type=text]
   (panel-form.js's add-stop form) — every other text input in the app gets
   this treatment, and these two were the only ones left on bare browser
   default styling. */
#panelBody input[type="email"],
#panelBody input[type="password"]{
  display:block; width:100%; box-sizing:border-box; margin-bottom:8px;
  border:1px solid var(--line); background:var(--white); border-radius:var(--radius-sm);
  padding:10px 12px; font-size:var(--fs-md); font-family:inherit; color:var(--ink); outline:none;
}
#panelBody input[type="email"]:focus,
#panelBody input[type="password"]:focus{ border-color:var(--blaze); }

/* ---------- Recovery codes ---------- */
.recoveryCodeBox{
  display:flex; align-items:center; gap:10px; margin:10px 0 8px;
  background:var(--paper); border:1px solid var(--line); border-radius:var(--radius-md); padding:12px;
}
.recoveryCodeBox code{
  flex:1; font-family:'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size:var(--fs-md); font-weight:700; letter-spacing:0.06em; color:var(--ink);
  word-break:break-all;
}
.recoveryWarning{
  background:var(--amber-tint); border-left:3px solid var(--blaze); border-radius:0;
  padding:9px 11px; color:var(--blaze-text); line-height:1.5;
}
#claimCodeInput{
  width:100%; box-sizing:border-box; border:1px solid var(--line); background:var(--white);
  border-radius:var(--radius-sm); padding:10px 12px; margin-bottom:8px;
  font-family:'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size:var(--fs-md); letter-spacing:0.05em; color:var(--ink); outline:none; text-transform:uppercase;
}
#claimCodeInput:focus{ border-color:var(--blaze); }

/* ---------- Recovery nudge / welcome help nudge ----------
   Same dismissible-banner shape, same bottom position — the two are never
   shown at the same time (see main.js's maybeShowWelcomeHelp() /
   maybeShowRecoveryNudge()), so sharing one rule set keeps them visually
   identical without duplicating ~25 lines per nudge. */
#recoveryNudge, #welcomeHelpNudge{
  position:absolute; z-index:560; left:16px; right:16px; bottom:88px;
  max-width:520px; margin:0 auto;
  background:var(--pill-bg); color:var(--pill-fg); border:1px solid var(--pill-border); border-radius:var(--radius-md);
  box-shadow:var(--shadow); padding:12px 14px;
  display:flex; align-items:center; gap:10px; font-size:var(--fs-base); line-height:1.45;
}
#recoveryNudge[hidden], #welcomeHelpNudge[hidden]{ display:none; }
#recoveryNudge .nudgeIcon, #welcomeHelpNudge .nudgeIcon{ flex-shrink:0; font-size:var(--fs-lg); }
#recoveryNudge .nudgeText, #welcomeHelpNudge .nudgeText{ flex:1; min-width:0; }
#nudgeActionBtn, #welcomeHelpActionBtn{
  flex-shrink:0; background:var(--blaze); color:var(--on-amber); border:none; border-radius:var(--radius-sm);
  padding:8px 13px; font-size:var(--fs-base); font-weight:700; font-family:inherit; cursor:pointer;
}
#nudgeActionBtn:hover, #welcomeHelpActionBtn:hover{ background:var(--blaze-dark); }
#nudgeDismissBtn, #welcomeHelpDismissBtn{
  flex-shrink:0; background:none; border:none; color:var(--pill-fg); opacity:0.7; cursor:pointer;
  font-size:var(--fs-md); padding:4px 2px; font-family:inherit; line-height:1;
}
#nudgeDismissBtn:hover, #welcomeHelpDismissBtn:hover{ opacity:1; }
#nudgeActionBtn:focus-visible, #nudgeDismissBtn:focus-visible,
#welcomeHelpActionBtn:focus-visible, #welcomeHelpDismissBtn:focus-visible{ outline:2px solid var(--blaze); outline-offset:2px; }
/* Out of the way of the route sheet.

   On narrow viewports #routeSidebar is a bottom sheet (up to 55vh, anchored
   at bottom:0) while these nudges sit at bottom:88–96px with a HIGHER
   z-index (560 vs 540). So whenever the stop list is open, the nudge floats
   over the middle of it — covering whichever rows or controls happen to be
   at that height, most reliably the "Show all stops" toggle just under the
   header. The controls are still there and still focusable, but a tap lands
   on the nudge instead, which reads as a dead toggle.

   Hidden rather than repositioned: the sheet can be dragged to 55vh, so
   there is no fixed offset that clears it, and a first-run "here's how to
   get started" tip has nothing to say to someone who is already mid-route.
   It returns as soon as the sheet is closed — nothing is dismissed, and the
   nudge is still shown on the next open of the app.

   :has() rather than a class toggled from JS: the sheet's open state already
   lives in the DOM as #routeSidebar[hidden], and mirroring it onto <body>
   would be a second source of truth for the same fact. The app already
   relies on :has() for the planned-marker badges above.

   Scoped to max-width:720px to match the breakpoint that MAKES it a sheet.
   Above that the sidebar is a --rail-w left rail and the nudge sits clear of it
   in the bottom-centre, so there is nothing to get out of the way of. */
@media (max-width:720px){
  body:has(#routeSidebar:not([hidden])) #recoveryNudge,
  body:has(#routeSidebar:not([hidden])) #welcomeHelpNudge{ display:none; }
}

/* Same problem, different panel: the route menu is capped at 45vh on a
   narrow screen but can still run most of the way down it, and these nudges
   sit bottom-centre at z-index 560 against the menu's 520 — so a tip could
   take taps meant for a control inside the menu.

   Hidden rather than moved: a nudge is the lowest-priority thing on screen,
   and the moment someone has opened the route menu they are doing something
   more deliberate than reading a first-run tip. It comes back when the menu
   closes, and dismissing it is still one tap away.

   Not scoped to a breakpoint, unlike the sidebar rule above: a tall menu
   state can reach them at any width, so this is not a mobile-only accident.

   Keyed on a body class rather than on the menu's own inline display, which
   would mean matching [style*="display: none"] — a substring test against
   whatever formatting the browser happens to serialise, which breaks the
   moment anything else touches that attribute. setHeaderMenuOpen() sets the
   class as part of the same call that shows the menu. */
body.route-menu-open #recoveryNudge,
body.route-menu-open #welcomeHelpNudge{ display:none; }

@media (max-width:560px){
  #recoveryNudge, #welcomeHelpNudge{ flex-wrap:wrap; bottom:96px; }
  #recoveryNudge .nudgeText, #welcomeHelpNudge .nudgeText{ flex-basis:100%; order:1; }
  #nudgeActionBtn, #welcomeHelpActionBtn{ order:2; }
  #nudgeDismissBtn, #welcomeHelpDismissBtn{ order:3; margin-left:auto; }
  #recoveryNudge .nudgeIcon, #welcomeHelpNudge .nudgeIcon{ display:none; }
}

/* ---------- Route usage indicator (compact — in the route menu) ---------- */
#routeUsageIndicator{ display:flex; align-items:center; gap:8px; }
#routeUsageIndicator .usageBar{
  display:block; flex:1; height:5px; border-radius:3px; background:var(--paper-dim); overflow:hidden; max-width:120px;
}
/* display:block is required here — .usageBar/.usageBarFill are <span>s (see
   route-ui.js/panel-lists.js), and width/height are no-ops on an inline
   element per the CSS spec, so without this the fill silently collapses to
   0x0 regardless of the width percentage set on it. */
#routeUsageIndicator .usageBarFill{ display:block; height:100%; background:var(--ink); border-radius:3px; transition:width 0.3s ease; }
/* Shared thresholds with the fuller bar below: orange at 75% used, red at
   90%, matching the fuller bar in My routes so the signal reads the same
   wherever it appears. */
#routeUsageIndicator.warning .usageBarFill{ background:var(--blaze); }
#routeUsageIndicator.critical .usageBarFill, #routeUsageIndicator.at-limit .usageBarFill{ background:var(--error); }
.fieldHint.at-limit{ color:var(--error); font-weight:600; }

/* ---------- Route usage footer (prominent — bottom of My routes) ---------- */
/* position:sticky on the last child of #panelBody (a scrolling container)
   keeps this pinned to the bottom of the visible list regardless of scroll
   position, without needing a separate fixed-footer slot in the shared
   panel chrome — the panel is reused for many other views this shouldn't
   affect. */
.routeUsageFooter{
  padding:14px 20px; background:var(--paper); border-top:1px solid var(--line);
  box-shadow:0 -4px 10px rgba(0,0,0,0.05);
}
.routeUsageFooterLabel{ font-size:var(--fs-base); font-weight:600; color:var(--ink-soft); margin-bottom:8px; }
.routeUsageFooter .usageBar{
  display:block; height:8px; border-radius:4px; background:var(--paper-dim); overflow:hidden;
}
.routeUsageFooter .usageBarFill{ display:block; height:100%; background:var(--ink); border-radius:4px; transition:width 0.3s ease; }
.routeUsageFooter.warning .usageBarFill{ background:var(--blaze); }
.routeUsageFooter.warning .routeUsageFooterLabel{ color:var(--blaze-text); }
.routeUsageFooter.critical .usageBarFill, .routeUsageFooter.at-limit .usageBarFill{ background:var(--error); }
.routeUsageFooter.critical .routeUsageFooterLabel, .routeUsageFooter.at-limit .routeUsageFooterLabel{ color:var(--error); font-weight:700; }

/* ---------- Stop-detail footer (report a problem, OSM attribution) ---------- */
/* Same treatment as .routeUsageFooter above — both live in the shared
   #panelFooter flex slot (flex-shrink:0, outside #panelBody's scroll
   area), so this is always the last thing in the panel regardless of how
   much detail content is above it. */
.detailFooter{
  padding:14px 20px; background:var(--paper); border-top:1px solid var(--line);
  box-shadow:0 -4px 10px rgba(0,0,0,0.05);
}
.detailFooter .fieldHint{ margin-bottom:8px; }
.detailFooter .fieldHint:last-child{ margin-bottom:0; }

/* ---------- Guided tour (tour.js) ----------
   Sits above everything else in the app — #panel is the previous highest
   fixed layer at z-index 800, #stateOverlay/#toast at 900 — so the tour
   needs 1000+ to spotlight controls even while a panel or toast is
   visible underneath. Four scrim pieces frame a rectangular cutout around
   the live target rather than a single div with a CSS mask, so there's no
   mask-support fallback to worry about and the math is just four rects. */
#tourScrim{ position:fixed; inset:0; z-index:1000; pointer-events:none; }
.tourScrimPiece{ background:rgba(20,26,20,0.55); }
#tourHole{
  position:fixed; z-index:1000; pointer-events:none;
  border:2px solid var(--blaze); border-radius:var(--radius-sm);
  box-shadow:0 0 0 4px rgba(240,179,58,0.25);
  animation:tourHolePulse 1.6s ease-in-out infinite;
}
/* A slow breath on the ring, so the eye finds the thing to press. */
@keyframes tourHolePulse{
  0%, 100%{ box-shadow:0 0 0 4px rgba(240,179,58,0.25); }
  50%{ box-shadow:0 0 0 9px rgba(240,179,58,0.12); }
}
/* The card: one shape wherever it lands — head (name, count, ✕), progress
   bar, the step, then Back and the way on. On a desktop it sits beside its
   target; on a phone it docks to the bottom edge (or the top, when the
   target is low), full width, so it is always in the same place and never
   over the thing it points at. */
#tourPopover{
  position:fixed; z-index:1001; background:var(--white); border-radius:var(--radius-md);
  box-shadow:var(--shadow); padding:12px 14px 12px; width:300px; max-width:calc(100vw - 24px); display:none;
  border-top:3px solid var(--blaze);
}
#tourPopover.docked{ width:auto; max-width:none; border-radius:var(--radius-lg); }
#tourPopover:focus{ outline:none; }
#tourPopover:focus-visible{ outline:none; }
.tourHead{ display:flex; align-items:center; gap:10px; margin-bottom:8px; }
.tourTitle{
  font-family:'Bricolage Grotesque', sans-serif; font-weight:600; font-size:var(--fs-sm);
  text-transform:uppercase; letter-spacing:0.06em; color:var(--ink); min-width:0; flex:1;
  overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
}
.tourStepCount{
  font-family:'IBM Plex Mono', ui-monospace, Menlo, monospace; font-size:10.5px; letter-spacing:0.08em;
  text-transform:uppercase; color:var(--ink-soft); white-space:nowrap;
}
.tourExitBtn{
  width:28px; height:28px; margin:-4px -6px -4px 0; display:inline-flex; align-items:center; justify-content:center;
  background:none; border:none; border-radius:var(--radius-sm); color:var(--ink-soft); cursor:pointer; flex-shrink:0;
}
.tourExitBtn:hover{ background:var(--paper-dim); color:var(--ink); }
.tourExitBtn:focus-visible{ outline:2px solid var(--blaze); outline-offset:2px; }
.tourProgress{ display:flex; gap:3px; margin-bottom:10px; }
.tourProgress i{ flex:1; height:3px; border-radius:2px; background:var(--line); }
.tourProgress i.done{ background:var(--ink); }
.tourProgress i.now{ background:var(--blaze); }
.tourStepText{ font-size:var(--fs-base); color:var(--ink); line-height:1.5; margin-bottom:12px; }
.tourStepText strong{ font-weight:600; }
/* Back on the left as a quiet button; the way on at the right — a filled
   Next/Done when the step is one to read, the waiting line when it is one
   to do. Never both empty: an empty span keeps the primary slot right. */
.tourNavRow{ display:flex; align-items:center; justify-content:space-between; gap:10px; }
.tourNavBtn{
  padding:8px 12px; border:1px solid var(--line); border-radius:var(--radius-sm); background:var(--white);
  font-family:inherit; font-size:var(--fs-sm); font-weight:600; color:var(--ink); cursor:pointer;
}
.tourNavBtn:hover{ border-color:var(--ink); }
.tourNavBtn.tourNextBtn{ background:var(--ink); color:var(--paper); border-color:var(--ink); }
.tourNavBtn.tourNextBtn:hover{ opacity:0.92; }
.tourNavBtn:focus-visible{ outline:2px solid var(--blaze); outline-offset:2px; }
.tourContinueBtn{
  padding:8px 16px; border:0; border-radius:var(--radius-sm);
  background:var(--ink); color:var(--paper); font-family:inherit; font-size:var(--fs-sm); font-weight:600; cursor:pointer;
}
.tourContinueBtn:hover{ opacity:0.92; }
.tourContinueBtn:focus-visible{ outline:2px solid var(--blaze); outline-offset:2px; }
.tourDoHint{
  display:inline-flex; align-items:center; gap:7px; font-size:var(--fs-sm); font-weight:600; color:var(--blaze-text);
}
.tourDoDot{
  width:8px; height:8px; border-radius:50%; background:var(--blaze); flex-shrink:0;
  animation:tourDoDot 1.2s ease-in-out infinite;
}
@keyframes tourDoDot{ 0%, 100%{ transform:scale(1); opacity:1; } 50%{ transform:scale(0.6); opacity:0.5; } }
@media (prefers-reduced-motion: reduce){
  #tourHole, .tourDoDot{ animation:none; }
}

/* ---------- Tour launcher (the first-run "Show me around" popover) ----------
   Sits where the welcome nudge it replaces sat, and borrows that nudge's
   positioning rules (centred, inset from both edges, clear of the bottom
   controls) so one hands off to the other in place rather than the list
   appearing somewhere unrelated to the button that asked for it.

   z-index is deliberately below #tourScrim's 1000: the launcher hides itself
   for the duration of a tour (tour-launcher.js's runTour()), so it never has
   to compete with the spotlight — and if it somehow did, being underneath is
   the harmless way round. */
#tourLauncher{
  position:absolute; z-index:560; left:16px; right:16px; bottom:88px;
  max-width:380px; margin:0 auto;
  background:var(--white); border-radius:var(--radius-md); box-shadow:var(--shadow);
  padding:14px 16px;
}
#tourLauncher[hidden]{ display:none; }
.tourLauncherHead{ display:flex; align-items:center; gap:10px; margin-bottom:10px; }
.tourLauncherTitle{ flex:1; min-width:0; font-size:var(--fs-base); font-weight:700; color:var(--ink); }
.tourLauncherClose{
  flex-shrink:0; background:none; border:none; color:var(--ink-soft); cursor:pointer;
  font-size:var(--fs-md); padding:4px 2px; font-family:inherit; line-height:1;
}
.tourLauncherClose:hover{ color:var(--ink); }
.tourLauncherList{ display:flex; flex-direction:column; gap:2px; margin-bottom:10px; }
.tourLauncherItem{
  display:flex; align-items:center; gap:9px; width:100%; text-align:left;
  background:none; border:none; border-radius:var(--radius-sm); padding:9px 8px;
  font-family:inherit; font-size:var(--fs-base); color:var(--ink); cursor:pointer;
}
.tourLauncherItem:hover{ background:var(--paper); }
.tourLauncherItemLabel{ flex:1; min-width:0; }
/* Fixed width whether or not there's a tick in it, so the labels line up as
   one column instead of shifting left as tours get completed. */
.tourLauncherCheck{
  flex-shrink:0; width:14px; text-align:center;
  font-size:var(--fs-sm); font-weight:700; color:var(--ink);
}
/* Done rows stay fully legible rather than being greyed out — they're still
   worth re-running, and the tick already says which ones are finished. */
.tourLauncherItem.done .tourLauncherItemLabel{ color:var(--ink-soft); }
.tourLauncherClose:focus-visible, .tourLauncherItem:focus-visible{
  outline:2px solid var(--blaze); outline-offset:-2px; border-radius:var(--radius-sm);
}
/* The note left behind when the launcher is dismissed — says where the
   walkthroughs went, since the welcome nudge that opened it is gone for
   good by then. Borrows the dark-pill look of #recoveryNudge/#welcomeHelpNudge
   rather than the launcher's own white card: it's a passing status message,
   not another thing to interact with, and it sits in the same spot the
   launcher just vacated. */
#tourLauncherHint{
  position:absolute; z-index:560; left:16px; right:16px; bottom:88px;
  max-width:380px; margin:0 auto;
  background:var(--pill-bg); color:var(--pill-fg); border:1px solid var(--pill-border); border-radius:var(--radius-md);
  box-shadow:var(--shadow); padding:12px 14px;
  font-size:var(--fs-base); line-height:1.45;
  animation:tourHintIn 160ms ease-out;
}
@keyframes tourHintIn{ from{ opacity:0; transform:translateY(4px); } to{ opacity:1; transform:none; } }
@media (prefers-reduced-motion:reduce){
  #tourLauncherHint{ animation:none; }
}
@media (max-width:560px){
  #tourLauncher, #tourLauncherHint{ bottom:96px; }
}

/* ---------- Supporter panel ---------- */

.supporterPerks{
  margin:0 0 14px; padding:0 0 0 20px; color:var(--ink);
  font-size:var(--fs-md); line-height:1.7;
}
.supporterPerks li{ margin-bottom:2px; }

/* Same monospace/uppercase treatment as #claimCodeInput: a supporter code
   is read off a receipt email and typed by hand, so it wants the same
   character-by-character legibility a recovery code does. */
#supporterCodeInput{
  width:100%; box-sizing:border-box; border:1px solid var(--line); background:var(--white);
  border-radius:var(--radius-sm); padding:10px 12px; margin-bottom:8px;
  font-family:'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size:var(--fs-md); letter-spacing:0.05em; color:var(--ink); outline:none; text-transform:uppercase;
}
#supporterCodeInput:focus{ border-color:var(--blaze); }

/* Not uppercased or monospaced, unlike the code field — this is a person's
   chosen name, shown publicly exactly as they typed it. */
#supporterNameInput{
  width:100%; box-sizing:border-box; border:1px solid var(--line); background:var(--white);
  border-radius:var(--radius-sm); padding:10px 12px; margin-bottom:8px;
  font-size:var(--fs-md); color:var(--ink); outline:none;
}
#supporterNameInput:focus{ border-color:var(--blaze); }

.supporterList{ display:flex; flex-wrap:wrap; gap:6px; }
.supporterChip{
  background:var(--paper); border:1px solid var(--line); border-radius:var(--radius-pill);
  padding:4px 11px; font-size:var(--fs-base); color:var(--ink);
}

/* ---------- Read-only shared route ----------
   The app showing somebody else's route, opened from a share link.

   The rule is: anything that WRITES is hidden, anything that READS or
   EXPORTS stays. That distinction is what this whole change is for — the
   old shared page hid everything, including the route stats, the elevation
   profile, the day colours and the export, none of which change anything.

   Hidden here rather than in JS because it is a statement about the
   document, not about a moment: these controls are never available on this
   path, so there is no state for the app to get wrong. routeIsEditable()
   covers the map gestures, which have no markup to hide. */
body.view-only #saveRouteBtn,
body.view-only #renameRouteBtn,
body.view-only #cancelRouteChangesBtn,
body.view-only #routeClearBtn,
body.view-only #routeUndoBtn,
body.view-only #routeSaveForm,
body.view-only #routeSaveStatus,
body.view-only #collabActions,
body.view-only #routeMenuIdle,
/* Start time, pace, weight and power are the viewer's own planning inputs
   — saved to THEIR profile, not part of the route they were sent — and the
   day stepper inside the timing section writes to the route itself. With no
   timing shown there is nothing left for a rider profile to estimate. */
/* Every item in the account menu — favourites, saved routes, offline areas,
   sign in — needs the session this path deliberately never creates. A menu
   of dead ends is worse than no button. */
body.view-only #accountBtn,
/* The speed estimate is a planning input too — the viewer's own pace on
   someone else's route — and its readout was the one section left. */
body.view-only #routeSpeedEstimate,
/* Search and the category filters look for stops to add or to narrow the
   corridor by. A viewer gets neither — the list is the owner's picks — so
   the whole search row goes, leaving the brand alone in the header. */
body.view-only #headerSearchRow,
body.view-only #routeAdvanced,
body.view-only #routeRider,
body.view-only #routeProfileBlock,
body.view-only #routeDaysBlock,
body.view-only #routeRadiusBlock,
body.view-only #addStopBtn,
body.view-only #fab,
/* Ticking a stop, favouriting it, marking a night, or reordering it are all writes. */
body.view-only .routeStopPlan,
body.view-only .routeStopFav,
body.view-only .routeStopNight,
body.view-only .routeStopReorder,
body.view-only .routeDayRemove,
body.view-only .routeNights{
  display:none !important;
}

/* The visitor's way in: a small amber card at the foot of the menu on a
   share link (view or edit). The one place on those paths that says the
   app is for their routes too. */
.visitorCta{
  margin:14px 0 8px; padding:12px 12px 10px; border-radius:var(--radius-md);
  background:var(--amber-tint); border:1px solid var(--blaze);
}
.visitorCta[hidden]{ display:none; }
.visitorCtaLead{
  font-family:'Bricolage Grotesque', sans-serif; font-weight:600; font-size:var(--fs-md); color:var(--ink);
}
.visitorCtaText{ margin:4px 0 10px; font-size:var(--fs-sm); color:var(--ink-soft); line-height:1.4; }
.visitorCta .visitorCtaBtn{ display:block; text-align:center; text-decoration:none; margin-bottom:0; box-sizing:border-box; }

/* The badge that replaces the Save row: this route is someone else's. */
.viewOnlyNote{
  display:flex; align-items:center; gap:8px;
  background:var(--paper-dim); border:1px solid var(--line); border-radius:var(--radius-md);
  padding:10px 12px; font-size:var(--fs-base); color:var(--ink-soft); margin-bottom:10px;
}
.viewOnlyNote strong{ color:var(--ink); font-weight:700; }
body:not(.view-only) .viewOnlyNote{ display:none; }

/* ---------- Dark mode: Leaflet's own chrome ----------
   Leaflet ships its zoom buttons and attribution in white; the token swap
   above cannot reach them. Kept to the resolved attribute only — by the time
   the map exists, theme.js has stamped it. */
:root[data-theme="dark"] .leaflet-bar a{
  background:var(--white); color:var(--ink); border-bottom-color:var(--line);
}
:root[data-theme="dark"] .leaflet-bar a:hover{ background:var(--chalk); }
:root[data-theme="dark"] .leaflet-bar{ border-color:var(--line); }
:root[data-theme="dark"] .leaflet-control-attribution{
  background:rgba(15,24,19,0.8); color:var(--ink-soft);
}
:root[data-theme="dark"] .leaflet-control-attribution a{ color:var(--ink); }
:root[data-theme="dark"] .marker-cluster div{ color:var(--white); }
