    /* Self-hosted IBM Plex (SIL OFL, see glyphs/OFL.txt). */
    @font-face { font-family: "IBM Plex Sans"; font-weight: 400; font-display: swap;
      src: url("fonts/IBMPlexSans-Regular.woff2") format("woff2"); }
    @font-face { font-family: "IBM Plex Sans"; font-weight: 600; font-display: swap;
      src: url("fonts/IBMPlexSans-SemiBold.woff2") format("woff2"); }
    @font-face { font-family: "IBM Plex Mono"; font-weight: 500; font-display: swap;
      src: url("fonts/IBMPlexMono-Medium.woff2") format("woff2"); }
    @font-face { font-family: "IBM Plex Mono"; font-weight: 600; font-display: swap;
      src: url("fonts/IBMPlexMono-SemiBold.woff2") format("woff2"); }

    :root {
      /* Base type bump for desktop: every font size is rem-based, so this
         scales all text at once (1rem = 18px). Spacing is px and stays put.
         Mobile drops back to 16px below (the 18px bump crowded the small
         viewport). Map labels read this back live via LABEL_SCALE, so they
         track whichever size is in effect — no constant to keep in sync. */
      font-size: 112.5%;
      --bg:          #F6F4EF;
      --bg-elev:     #FFFFFF;
      --bg-sunken:   #EDE9E0;
      --ink:         #111210;
      --ink-3:       #6B6B66;
      --ink-4:       #9A9994;
      --rule:        #DCD7CB;
      --rule-soft:   #E7E2D6;
      /* Muted-fill colour for solid affordances that sit ON the elevated
         panel and must stay visible — the progress track and the sheet grab
         handle. --rule reads fine on the light surface but collapses into
         --bg-elev in dark (it's a hairline-divider value), so these are
         tokenised separately with a value that reads as a fill per scheme. */
      --track:       #D5CFC1;
      /* Status-pill chip fill. In light it's the sunken tone; in dark
         --bg-sunken is *darker* than the panel so the chip recedes into the
         background (the near-invisible DOCKED pills) — dark gets a raised tone
         instead so the chip reads as a chip. */
      --pill-bg:     #EDE9E0;
      /* --water doubles as the deepest depth tint: the bathy layer ships only
         the four shallower bands (depth-0..3), so the water background shows
         through as the deepest shade wherever the sea is deeper than the last
         band — and any uncovered/data-gap sea reads as deep too, which is the
         right default. See scripts/build_bathymetry.py. */
      --water:       #A8C3D9;
      --land:        #F2EFE6;
      --coast:       #C8C2B2;
      /* Gentle bathymetric tint, shallow→deep; sits between the water fill and
         the land. depth-1 is the shallowest-of-deep, --water the deepest. */
      --depth-0:     #CFDDE9;
      --depth-1:     #BCD0E1;
      --pax:         #1F5BD6;
      --frt:         #D43D2B;
      --hsr:         #7C3AED;
      --warn:        #C77A1F;

      --ok:          #2E7D4E;
      --sheet-radius: 1.125rem;
      /* Peek height scales with the type (rem), sized to clear four vessel
         rows under the header; the 44dvh term is the cap so a large zoom/font
         can't bury the map — min() takes the smaller, leaving >=56dvh of map.
         First line is the fallback for engines without min()/dvh; JS reads the
         resolved px off #peek-probe (see peekHeight). */
      --sheet-peek-h: 19.75rem;
      --sheet-peek-h: min(19.75rem, 44dvh);
      --tab-h: env(safe-area-inset-bottom, 0px);
      /* Live-set from JS; declared here as defaults so they resolve
         statically. --bearing-rev: rotation of the compass arrow (see
         .compass .arrow). --sheet-top: bottom-sheet snap offset (see .sheet). */
      --bearing-rev: 0deg;
      --sheet-top: 60vh;
    }
    @media (prefers-color-scheme: dark) {
      :root {
        --bg:          #0F0F0D;
        --bg-elev:     #181815;
        --bg-sunken:   #0A0A09;
        --ink:         #F2EFE6;
        --ink-3:       #8F8B7E;
        --ink-4:       #5A574F;
        --rule:        #2A2925;
        --rule-soft:   #1F1E1A;
        --track:       #3D3B34;
        --pill-bg:     #34332C;
        /* Deepest tint = the water background (see light mode). */
        --water:       #0B1018;
        --land:        #1A1A17;
        --coast:       #2A2925;
        /* Deep = darker; depth-3 is the shallowest-of-deep, --water deepest. */
        --depth-0:     #1B2430;
        --depth-1:     #171F2A;
        --depth-2:     #131A24;
        --depth-3:     #0F151E;
        --pax:         #6FA0FF;
        --frt:         #F26757;
        --hsr:         #A78BFA;
        --warn:        #E5A85A;
        --ok:          #5BB87E;
      }
    }
    /* Mobile: revert to 16px (complements the desktop .sheet @900px rule).
       The 112.5% bump reads fine on a wide viewport but crowds a phone. */
    @media (max-width: 899.98px) {
      :root { font-size: 100%; }
    }

    * { box-sizing: border-box; }
    html, body {
      margin: 0;
      padding: 0;
      /* 100dvh tracks the visible viewport so map and sheet stay
         flush when Safari's URL bar shows; 100% is the fallback. */
      height: 100%;
      height: 100dvh;
      background: var(--bg);
      color: var(--ink);
      font-family: "IBM Plex Sans", system-ui, sans-serif;
      -webkit-text-size-adjust: 100%;
      text-size-adjust: 100%;
      /* Stop iOS rubber-band from dragging the map when overscrolling
         inside the sheet. */
      overscroll-behavior: none;
      overflow: hidden;
    }

    @media (prefers-reduced-motion: reduce) {
      .sheet { transition: none !important; }
    }

    :focus-visible {
      outline: 2px solid var(--frt);
      outline-offset: 2px;
    }

    /* Full-screen; the peek sheet floats over the bottom of it. */
    #map {
      position: absolute;
      inset: 0;
    }
    /* Off-screen probe carrying the peek height so JS can read the
       dvh/min() expression resolved to px (see peekHeight) now that
       #map no longer encodes it via its bottom. */
    #peek-probe {
      position: absolute;
      left: 0;
      bottom: 0;
      width: 0;
      height: var(--sheet-peek-h);
      visibility: hidden;
      pointer-events: none;
    }

    .maplibregl-ctrl-attrib,
    .maplibregl-ctrl-bottom-right,
    .maplibregl-ctrl-bottom-left { display: none !important; }

    /* North arrow — the font's ↑ glyph over N. .arrow rotates by -BEARING
       so it keeps pointing true north if the map is ever rotated. */
    .compass {
      position: fixed;
      top: 0.75rem;
      right: 0.75rem;
      z-index: 5;
      pointer-events: none;
      display: flex;
      flex-direction: column;
      align-items: center;
      font-family: "IBM Plex Mono", monospace;
      font-weight: 600;
      color: var(--ink);
      /* --bg halo keeps the mark legible over the map without a backing box. */
      text-shadow: 0 0 2px var(--bg), 0 0 2px var(--bg);
    }
    .compass .arrow {
      font-size: 1.5rem;
      line-height: 0.8;
      transform: rotate(var(--bearing-rev, 0deg));
    }
    .compass .n {
      font-size: 0.85rem;
      letter-spacing: 0.04em;
    }
    /* Reset-view button, nested in the compass column so it centres below the
       north arrow (built in dashboard.js → buildHomeButton). Bare like the
       compass — no box, just the same --bg halo — so the top-right stays light.
       The compass is pointer-events:none, so re-enable them on the button. */
    .compass .map-home {
      margin-top: 0.45rem;
      width: 1.15rem;
      height: 1.1rem;            /* STIX house aspect ≈ 1.06 */
      padding: 0;
      border: 0;
      background: transparent;
      color: inherit;
      cursor: pointer;
      pointer-events: auto;
      opacity: 0.82;
      transition: opacity .12s ease;
      -webkit-tap-highlight-color: transparent;
    }
    .compass .map-home::before {
      content: "";
      display: block;
      width: 100%;
      height: 100%;
      background-color: currentColor;
      -webkit-mask: url("home.svg") center / contain no-repeat;
      mask: url("home.svg") center / contain no-repeat;
      /* Match the compass text halo so the mark reads over any basemap. */
      filter: drop-shadow(0 0 2px var(--bg)) drop-shadow(0 0 2px var(--bg));
    }
    .compass .map-home:hover,
    .compass .map-home:focus-visible { opacity: 1; }

    /* Cross-route / home nav — a frosted pill floated top-left over the water.
       The compass owns top-right and the sheet owns the bottom, so the map's
       top-left is clear at every breakpoint. Built in dashboard.js
       (buildSiteNav). Tokens flip in dark mode, so the glass adapts for free. */
    .mapnav {
      position: fixed;
      z-index: 5;
      top:  calc(0.75rem + env(safe-area-inset-top,  0px));
      left: calc(0.75rem + env(safe-area-inset-left, 0px));
      display: flex;
      align-items: center;
      gap: 0.5rem;
      padding: 0.3125rem 0.625rem;
      border: 1px solid var(--rule);
      border-radius: 999px;
      /* Solid fallback first, then frosted glass where color-mix is supported. */
      background: var(--bg-elev);
      background: color-mix(in srgb, var(--bg-elev) 82%, transparent);
      backdrop-filter: blur(8px) saturate(1.1);
      -webkit-backdrop-filter: blur(8px) saturate(1.1);
      box-shadow: 0 1px 2px rgba(0,0,0,.06), 0 6px 16px rgba(0,0,0,.08);
      font-family: "IBM Plex Mono", ui-monospace, monospace;
      font-weight: 500;
      font-size: 0.78rem;
      line-height: 1;
      -webkit-user-select: none;
      user-select: none;
    }
    .mapnav a { text-decoration: none; color: inherit; }
    .mapnav-home {
      display: flex;
      align-items: center;
      color: var(--ink-3);
      font-size: 0.95rem;     /* the ⛴ glyph a touch larger than the labels */
      opacity: 0.92;
      transition: opacity .12s ease;
    }
    /* Monochrome ferry glyph (STIX U+26F4) as a currentColor mask, so it tracks
       the theme. Single source of truth: web/ferry.svg (build_og_image.py). */
    .mapnav-ferry {
      display: block;
      width: 1.28em; height: 0.84em;   /* glyph aspect ≈ 1.52 */
      background-color: currentColor;
      -webkit-mask: url("ferry.svg") center / contain no-repeat;
      mask: url("ferry.svg") center / contain no-repeat;
    }
    .mapnav-home:hover, .mapnav-home:focus-visible { opacity: 1; }
    .mapnav-div { width: 1px; height: 0.9rem; background: var(--rule); }
    .mapnav-routes { display: flex; align-items: center; gap: 0.375rem; }
    .mapnav-mid { color: var(--ink-4); }
    .mapnav-route { color: var(--ink-3); transition: color .12s ease; }
    .mapnav-route:hover, .mapnav-route:focus-visible { color: var(--ink); }
    .mapnav-route.is-active { color: var(--ink); font-weight: 600; }
    /* Full names by default; collapse to short codes on the narrowest phones. */
    .mapnav-route .abbr { display: none; }
    @media (max-width: 359.98px) {
      .mapnav-route .full { display: none; }
      .mapnav-route .abbr { display: inline; }
    }

    .mono {
      font-family: "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, monospace;
      font-weight: 500;
      font-variant-ligatures: none;
      font-feature-settings: "zero";
    }
    .eyebrow {
      font-family: "IBM Plex Mono", monospace;
      font-weight: 500;
      font-size: 0.625rem;
      letter-spacing: 0.12em;
      color: var(--ink-3);
      text-transform: uppercase;
    }
    .h2 {
      font-size: 1.125rem;
      font-weight: 600;
      letter-spacing: -0.01em;
      margin: 0;
      line-height: 1.2;
    }

    .dot {
      display: inline-block;
      width: 0.5rem;
      height: 0.5rem;
      border-radius: 50%;
      flex: none;
    }
    .dot.pax { background: var(--pax); }
    .dot.frt { background: var(--frt); }
    .dot.hsr { background: var(--hsr); }
    /* Shape-code the role so the cue survives colour-vision deficiency:
       pax=circle, frt=square, hsr=up-pointing triangle (clip-path). The square
       fills the dot box (x/y = the circle's diameter). */
    .dot.frt { border-radius: 0; }
    .dot.hsr { border-radius: 0; clip-path: polygon(50% 0, 100% 100%, 0 100%); }

    .status-pill {
      font-family: "IBM Plex Mono", monospace;
      font-size: 0.625rem;
      font-weight: 500;
      letter-spacing: 0.07em;
      text-transform: uppercase;
      padding: 0.125rem 0.375rem;
      background: var(--pill-bg);
    }
    .status-underway  { color: var(--pax); }
    .status-scheduled { color: var(--ink-3); }
    .status-docking   { color: var(--warn); }
    .status-unloading { color: var(--warn); }
    .status-arrived   { color: var(--ok); }
    .status-cancelled { color: var(--frt); }
    .status-docked    { color: var(--ink-3); }
    .status-late      { color: var(--warn); }

    /* "LATE +Nm" on an underway hull that left its slot past the grace. */
    .late-pill {
      font-family: "IBM Plex Mono", monospace;
      font-size: 0.625rem;
      font-weight: 600;
      letter-spacing: 0.07em;
      text-transform: uppercase;
      white-space: nowrap;
      color: var(--warn);
    }
    .late-pill[hidden] { display: none; }

    /* Bottom sheet — two snap points; JS sets --sheet-top live. */
    .sheet {
      position: fixed;
      left: 0; right: 0;
      top: var(--sheet-top, 60vh);
      /* Anchor to the screen bottom and absorb the safe-area inset as padding,
         so in standalone PWA mode the sheet covers the home-indicator strip
         instead of leaving the map showing below it. The peek/snap math still
         reserves --tab-h (peekTop, map fit), so the content area is unchanged. */
      bottom: 0;
      padding-bottom: var(--tab-h);
      background: var(--bg-elev);
      border-top: 1px solid var(--rule);
      border-top-left-radius: var(--sheet-radius);
      border-top-right-radius: var(--sheet-radius);
      box-shadow: 0 -8px 30px rgba(0,0,0,0.10);
      transition: top 280ms cubic-bezier(0.2, 0, 0, 1);
      display: flex;
      flex-direction: column;
      overflow: hidden;
      touch-action: pan-y;
      z-index: 6;
    }
    /* Desktop: pin the sheet to the lower-left as a narrow panel instead
       of a full-width bottom sheet, leaving the map clear on the right. */
    @media (min-width: 900px) {
      /* 35vw, but never below 475px — under that the table columns squash
         (the route/type/status can't coexist). Between 900px and ~1357px
         viewport the sheet holds at 475px and the map takes the rest; the
         Nantucket fit reserves the live sheet width as left padding (see
         fitSlice), so it tracks this automatically. */
      .sheet { right: auto; width: 35vw; min-width: 475px; }
    }
    /* touch-action:none stops iOS from stealing the gesture. */
    .sheet-drag {
      flex: none;
      cursor: grab;
      touch-action: none;
      user-select: none;
      -webkit-user-select: none;
    }
    .sheet-drag.dragging { cursor: grabbing; }
    .grab {
      position: relative;
      padding: 0.5625rem 0 0.3125rem;
      display: flex;
      justify-content: center;
      color: var(--ink-4);
    }
    /* Chevron hints the drag direction: points up in the peek (drag to
       expand), flips to point down once expanded (drag to collapse). */
    .grab-chevron {
      width: 2.5rem;
      height: 0.5rem;
      transition: transform 220ms cubic-bezier(0.2, 0, 0, 1);
    }
    .sheet[data-snap="full"] .grab-chevron { transform: rotate(180deg); }
    .sheet-header {
      flex: none;
      padding: 0.375rem 1.125rem 0.625rem;
    }
    /* 12h/24h clock toggle, pinned top-right of the home header (built in JS).
       The title reserves room on its right so it can't run under the toggle. */
    .sheet-header.sheet-home { position: relative; }
    .sheet-header.sheet-home .h2 { padding-right: 6rem; }
    .clock-toggle {
      position: absolute;
      top: 0.4375rem;
      right: 1.125rem;
      display: inline-flex;
      border: 1px solid var(--rule);
      border-radius: 999px;
      overflow: hidden;
      font-family: "IBM Plex Mono", monospace;
      font-size: 0.75rem;
      font-weight: 600;
    }
    .clock-toggle button {
      appearance: none;
      border: 0;
      background: transparent;
      color: var(--ink-4);
      padding: 0.375rem 0.6875rem;
      font: inherit;
      letter-spacing: 0.02em;
      cursor: pointer;
    }
    .clock-toggle button[aria-pressed="true"] {
      background: var(--bg-sunken);
      color: var(--ink);
    }
    /* Home header: title on top, then one muted sub-line carrying both the
       "unofficial" disclaimer and the live-feed freshness. Full width, so it
       wraps instead of overflowing on a narrow phone — no breakpoint needed. */
    .updated {
      margin-top: 0.125rem;
      font-size: 0.6875rem;
      color: var(--ink-3);
    }
    /* Only the freshness clause flips amber when the feed goes stale; the
       disclaimer stays muted. */
    .updated.stale .age { color: var(--frt); }

    .scroll {
      overflow: auto;
      flex: 1;
      -webkit-overflow-scrolling: touch;
      /* Stop iOS chaining overscroll up into the body (drags the map). */
      overscroll-behavior: contain;
    }
    /* Collapsed: lock the list so a flick can't fling it — the first
       downward intent expands instead (see the wheel/touch intercept in
       JS). This is what removes the "expand throws you to the bottom" jank:
       there's no live scroll surface to leak momentum during the expand. */
    .sheet[data-snap="peek"] .scroll { overflow: hidden; }
    /* Peek: fade the bottom edge of the locked list to hint it continues
       below the fold (drag the handle or scroll to expand). Sticky so it
       rides the bottom of the scrollport; negative margin keeps it from
       adding height. Dropped once expanded so the true list end shows. */
    body:not([data-view="detail"]) .sheet[data-snap="peek"] .scroll::after {
      content: "";
      position: sticky;
      bottom: 0;
      display: block;
      height: 2.5rem;
      margin-top: -2.5rem;
      pointer-events: none;
      background: linear-gradient(to top, var(--bg-elev), transparent);
    }

    /* minmax(0,1fr) keeps long names from pushing the pill out. */
    .vrow {
      display: grid;
      grid-template-columns: 1rem minmax(0, 1fr) auto;
      gap: 0.625rem;
      align-items: center;
      padding: 0.625rem 1.125rem;
      border-top: 1px solid var(--rule-soft);
      cursor: pointer;
      -webkit-tap-highlight-color: rgba(0,0,0,0.05);
    }
    .vrow:first-of-type { border-top: 1px solid var(--rule); }
    .vrow > div { min-width: 0; }
    .vrow .vessel-name {
      font-weight: 600;
      font-size: 0.875rem;
      letter-spacing: -0.005em;
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
    }
    .vrow .vessel-sub {
      font-family: "IBM Plex Mono", monospace;
      font-weight: 500;
      font-size: 0.6875rem;
      color: var(--ink-3);
      margin-top: 0.125rem;
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
    }

    .empty {
      padding: 0.875rem 1.125rem;
      color: var(--ink-3);
      font-size: 0.8125rem;
    }

    .sect-head {
      padding: 1.125rem 1.125rem 0.5rem;
      display: flex;
      align-items: baseline;
      justify-content: space-between;
    }
    /* Sticky picker: keeps the terminal toggle + column headers pinned to
       the top of the scroll area while the departures list scrolls under it.
       Opaque background so rows don't show through the side margins. */
    .sched-sticky {
      position: sticky;
      top: 0;
      z-index: 3;
      background: var(--bg-elev);
      padding-top: 0.25rem;
    }
    .segmented {
      margin: 0 1.125rem;
      display: grid;
      /* Equal columns for however many terminals the route has (3 for MV,
         2 for Nantucket) — so the buttons always fill the full width. */
      grid-auto-flow: column;
      grid-auto-columns: 1fr;
      gap: 0.125rem;
      padding: 0.1875rem;
      border: 1px solid var(--rule);
      background: var(--bg-sunken);
      font-size: 0.75rem;
    }
    .seg-btn {
      appearance: none;
      border: 1px solid transparent;
      background: transparent;
      color: var(--ink-3);
      padding: 0.4375rem 0;
      font-family: inherit;
      font-size: 0.75rem;
      font-weight: 600;
      cursor: pointer;
    }
    .seg-btn[aria-selected="true"] {
      background: var(--bg-elev);
      border-color: var(--frt);
      color: var(--frt);
      box-shadow: 0 1px 2px rgba(0,0,0,0.06);
    }
    /* Departures — first column is rem so time text doesn't clip
       when the root font is scaled up. */
    /* Shared column template for the header and every row. Definite tracks
       (not `auto`) so the two independent grids resolve to identical widths and
       the columns line up: depart | route (fills) | arrive | status. */
    .dept-header, .drow {
      grid-template-columns: 4rem 1fr 3.25rem 1.5rem 5.5rem;
      gap: 0.75rem;
    }
    .dept-header span:last-child,
    .drow .status-pill { justify-self: end; }
    .dept-header {
      margin: 0.5rem 1.125rem 0;
      display: grid;
      padding: 0.375rem 0 0.25rem;
      border-top: 2px solid var(--ink);
      border-bottom: 1px solid var(--rule);
      font-family: "IBM Plex Mono", monospace;
      font-weight: 500;
      font-size: 0.5625rem;
      letter-spacing: 0.1em;
      text-transform: uppercase;
      color: var(--ink-3);
    }
    /* Query container so rows can switch between full terminal names and
       abbreviations based on the list's own width, not the viewport's. */
    #schedule {
      container-type: inline-size;
      container-name: schedule;
    }
    .drow {
      position: relative;
      margin: 0 1.125rem;
      display: grid;
      /* columns + gap come from the shared `.dept-header, .drow` rule above */
      align-items: center;
      padding: 0.6875rem 0;
      border-bottom: 1px solid var(--rule-soft);
    }
    .drow .when {
      font-family: "IBM Plex Mono", monospace;
      font-size: 0.875rem;
      font-weight: 600;
      letter-spacing: 0.02em;
      color: var(--ink);
    }
    .drow .route {
      font-family: "IBM Plex Mono", monospace;
      font-weight: 500;
      font-size: 0.75rem;
      color: var(--ink-3);  /* the "to" label */
    }
    .drow .route .full,
    .drow .route .abbr { color: var(--ink); }  /* the destination itself */
    /* Arrival time — its own column (mono, muted) so it stays vertically aligned. */
    .drow .arrive {
      font-family: "IBM Plex Mono", monospace;
      font-weight: 500;
      font-size: 0.75rem;
      color: var(--ink-3);
    }
    /* Route endpoints carry both a full name and an abbreviation. Default to
       the abbreviation; swap to full names once the list is wide enough to
       hold them (container query on #schedule, set below). */
    .drow .route .full {
      display: none;
      /* Full place names read better — and fit in less room — set in the
         proportional sans rather than the row's tabular monospace. */
      font-family: "IBM Plex Sans", system-ui, sans-serif;
      font-weight: 600;
    }
    .drow .route .abbr { display: inline; }
    /* ~26rem is the narrowest the route column can show full names without
       crowding the arrival time; the 35vw desktop panel clears it, mobile
       (~24rem) doesn't and keeps the abbreviations. */
    @container schedule (min-width: 26rem) {
      .drow .route .full { display: inline; }
      .drow .route .abbr { display: none; }
    }

    /* Boat class — each row carries its class colour (matching the map/detail),
       shown as a shape marker in its own "Type" column after the arrival time
       (pax=circle, frt=square, hsr=up-pointing triangle) so the role decodes by shape, not
       colour alone. The route's headline class (fast ferry on Nantucket,
       passenger backbone on the Vineyard) gets a solid marker; the rest sit at
       0.4 so it jumps out. */
    .drow--hsr { --role-c: var(--hsr); }
    .drow--pax { --role-c: var(--pax); }
    .drow--frt { --role-c: var(--frt); }
    .drow .type-mark {
      display: inline-block;
      width: 0.5rem;
      height: 0.5rem;
      border-radius: 50%;
      background: var(--role-c);
      opacity: 0.4;
    }
    .drow--frt .type-mark { border-radius: 0; }
    /* Fast ferry = an up-pointing triangle (clip-path). */
    .drow--hsr .type-mark { border-radius: 0; clip-path: polygon(50% 0, 100% 100%, 0 100%); }
    .drow--headline .type-mark { opacity: 1; }

    /* Legend: decodes the row colours once, in the sticky header. */
    .sched-legend {
      display: flex;
      flex-wrap: wrap;
      justify-content: flex-end;
      gap: 0.875rem;
      margin: 0 1.125rem;
      padding: 0.4375rem 0 0.125rem;
      font-family: "IBM Plex Mono", monospace;
      font-size: 0.5625rem;
      font-weight: 500;
      letter-spacing: 0.08em;
      text-transform: uppercase;
      color: var(--ink-3);
    }
    .sched-legend:empty { display: none; }
    .lg-item { display: inline-flex; align-items: center; gap: 0.375rem; }
    .lg-item.hsr { --role-c: var(--hsr); }
    .lg-item.pax { --role-c: var(--pax); }
    .lg-item.frt { --role-c: var(--frt); }
    .lg-dot {
      width: 0.5rem;
      height: 0.5rem;
      border-radius: 999px;
      background: var(--role-c);
      opacity: 0.4;
    }
    /* Same role shapes as the list: square freighter, up-pointing triangle
       fast ferry (clip-path). */
    .lg-item.frt .lg-dot { border-radius: 0; }
    .lg-item.hsr .lg-dot { border-radius: 0; clip-path: polygon(50% 0, 100% 100%, 0 100%); }
    .lg--headline { color: var(--role-c); }
    .lg--headline .lg-dot { opacity: 1; }

    /* Day divider — marks where the list rolls over to the next day
       ("TOMORROW" once today's sailings are done). Reads as an eyebrow. */
    .drow-day {
      margin: 0 1.125rem;
      padding: 0.625rem 0 0.375rem;
      font-family: "IBM Plex Mono", monospace;
      font-size: 0.625rem;
      font-weight: 600;
      letter-spacing: 0.08em;
      text-transform: uppercase;
      color: var(--ink-3);
      border-bottom: 1px solid var(--rule);
    }

    /* Vessel detail — swap sheet content; map doesn't move. */
    .sheet-home   { display: block; }
    .sheet-detail { display: none; }
    body[data-view="detail"] .sheet-home   { display: none; }
    body[data-view="detail"] .sheet-detail { display: block; }

    /* Detail is a self-contained, fixed panel — there's nothing to scroll or
       drag to, so size it to its content (anchored to the bottom, capped so a
       tall card still leaves the map visible) and drop the grab strip. */
    body[data-view="detail"] .sheet {
      top: auto;
      height: auto;
      max-height: calc(100% - var(--tab-h) - 1rem);
    }
    body[data-view="detail"] .grab { display: none; }
    /* Grab strip gone — give the header back its top breathing room. */
    body[data-view="detail"] .detail-header { padding-top: 0.875rem; }
    /* Breathing room below the last card so it clears the rounded bottom. */
    body[data-view="detail"] .scroll { padding-bottom: 1rem; }
    /* Installed/standalone has no Safari toolbar but does sit above the home
       indicator — nudge the detail's stat cards up a touch there. Safari keeps
       the tighter spacing. (On top of the sheet's --tab-h safe-area padding.) */
    @media (display-mode: standalone) {
      body[data-view="detail"] .scroll { padding-bottom: 1.75rem; }
    }

    .detail-header {
      flex: none;
      padding: 0.5rem 0.375rem 0.5rem 0.375rem;
      display: flex;
      align-items: center;
      gap: 0.625rem;
    }
    .detail-iconbtn {
      flex: none;
      width: 2.125rem;
      height: 2.125rem;
      padding: 0;
      border: 1px solid var(--rule);
      background: var(--bg-elev);
      display: flex;
      align-items: center;
      justify-content: center;
      color: var(--ink);
      cursor: pointer;
    }
    .detail-iconbtn svg { width: 1rem; height: 1rem; }
    .detail-header .detail-name-stack {
      flex: 1;
      min-width: 0;
    }
    .detail-header .name-row {
      display: flex;
      align-items: center;
      gap: 0.5rem;
      min-width: 0;
    }
    .detail-header .name-row .dot { width: 0.625rem; height: 0.625rem; flex: none; }
    .detail-header h1 {
      font-family: "IBM Plex Sans", system-ui, sans-serif;
      font-size: 1.125rem;
      font-weight: 600;
      letter-spacing: -0.01em;
      margin: 0;
      flex: 1;
      min-width: 0;
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
    }
    .detail-header .role {
      display: block;
      font-family: "IBM Plex Mono", monospace;
      font-weight: 500;
      font-size: 0.625rem;
      letter-spacing: 0.08em;
      text-transform: uppercase;
      color: var(--ink-3);
      margin-top: 0.0625rem;
      margin-left: 1.125rem;  /* align under the name, past the dot+gap */
    }
    /* Role-coloured when fresh, --warn when stale; distinct from the
       home stamp (grey/red) so it's clear this is AIS age not page age. */
    /* Right-aligned stack: status pill above the AIS-age text. */
    .detail-header .detail-meta {
      flex: none;
      display: flex;
      flex-direction: column;
      align-items: flex-end;
      gap: 0.25rem;
    }
    .detail-header .last-seen {
      flex: none;
      font-size: 0.6875rem;
      color: var(--ink-3);
    }
    .detail-header .last-seen.pax   { color: var(--pax); }
    .detail-header .last-seen.frt   { color: var(--frt); }
    .detail-header .last-seen.hsr   { color: var(--hsr); }
    .detail-header .last-seen.stale { color: var(--warn); }

    .detail-progress {
      flex: none;
      padding: 0.375rem 1.125rem 0.375rem;
    }
    .progress-bar {
      position: relative;
      height: 1.375rem;
      margin: 0 0.875rem;
    }
    .progress-bar .track {
      position: absolute;
      left: 0.4375rem; right: 0.4375rem; top: 0.5625rem;
      height: 0.25rem;
      background: var(--track);
      border-radius: 0.125rem;
    }
    .progress-bar .covered {
      position: absolute;
      left: 0.4375rem; top: 0.5625rem;
      height: 0.25rem;
      background: var(--ok);
      border-radius: 0.125rem;
    }
    .progress-bar .bullet-end {
      position: absolute;
      top: 0.25rem;
      width: 0.875rem;
      height: 0.875rem;
      border-radius: 50%;
      background: var(--bg-elev);
      border: 2px solid var(--ink);
      box-sizing: border-box;
    }
    .progress-bar .bullet-end.left  { left: 0; }
    .progress-bar .bullet-end.right { right: 0; }
    .progress-bar .bullet-vessel {
      position: absolute;
      top: 0.125rem;
      width: 1.125rem;
      height: 1.125rem;
      border-radius: 50%;
      background: var(--ok);
      border: 3px solid var(--bg-elev);
      box-sizing: border-box;
      box-shadow: 0 0 0 2px var(--ok);
      transform: translateX(-50%);
      pointer-events: none;
    }
    /* Late: the progress bar (fill + vessel bullet) goes amber. */
    .progress-bar.late .covered { background: var(--warn); }
    .progress-bar.late .bullet-vessel {
      background: var(--warn);
      box-shadow: 0 0 0 2px var(--warn);
    }
    .progress-labels {
      display: grid;
      grid-template-columns: 1fr 1fr 1fr;
      gap: 0.5rem;
      margin-top: 0.625rem;
      align-items: flex-start;
    }
    .eyebrow-mini {
      font-family: "IBM Plex Mono", monospace;
      font-weight: 500;
      font-size: 0.625rem;
      letter-spacing: 0.12em;
      text-transform: uppercase;
      color: var(--ink-3);
    }
    .progress-labels .eyebrow-mini { letter-spacing: 0.06em; }
    .progress-labels .eyebrow-mini.eta { color: var(--ok); }
    .progress-labels .time {
      font-family: "IBM Plex Mono", monospace;
      font-size: 1.125rem;
      font-weight: 600;
      margin-top: 0.125rem;
    }
    .progress-labels .time.eta { color: var(--ok); }
    /* Behind schedule: the ETA eyebrow + time go amber, like the badge/bar. */
    .progress-labels .eyebrow-mini.eta.late,
    .progress-labels .time.eta.late { color: var(--warn); }
    /* How late the hull left, pinned to the departure time (stays put even as
       the ETA recovers). Smaller, amber, trailing the departure clock. */
    .progress-labels .time .dep-late {
      color: var(--warn);
      font-size: 0.7em;
      font-weight: 600;
      margin-left: 0.1em;
    }
    .progress-labels .col.center { text-align: center; }
    .progress-labels .col.right  { text-align: right; }

    .detail-grid {
      flex: none;
      margin: 1rem 1.125rem 0;
      display: grid;
      grid-template-columns: 1fr 1fr 1fr;
      border: 1px solid var(--rule);
    }
    .detail-grid > .cell {
      padding: 0.375rem 0.75rem 0.5rem;
      border-left: 1px solid var(--rule);
    }
    .detail-grid > .cell:first-child { border-left: 0; }
    .detail-grid .value {
      font-family: "IBM Plex Mono", monospace;
      font-size: 1.125rem;
      font-weight: 600;
      margin-top: 0.125rem;
    }
    .detail-grid .value-suffix {
      font-family: "IBM Plex Mono", monospace;
      font-size: 0.6875rem;
      font-weight: 500;
      color: var(--ink-3);
      margin-left: 0.375rem;
    }
