* {
  box-sizing: border-box;
}

/* Theme: the app defaults to light, matching the light basemap (see MAP_STYLES/
   applyMapTheme in app.js). Toggling to the dark basemap also flips this chrome via
   the same body.dark-map class, so the panels/popups/legend always match the map.
   Semantic colors (mode badges, category/status/crowd/delay pills, marker rings) are
   deliberately NOT themed here -- they're self-contained bg+text pairs already chosen
   for contrast, independent of the surrounding page. */
:root {
  /* How far the screen reaches below the area iOS lays the page out in: 0, except in an iPhone
     Home Screen app (set by sizeBottomStrip in app.js -- see html.has-bottom-strip below). */
  --bottom-strip: 0px;
  /* Room at the bottom edge for the home indicator; every bottom-anchored element uses this. */
  --safe-bottom: env(safe-area-inset-bottom, 0px);
  /* --chrome-bg without the see-through, for the strip behind the status bar. */
  --chrome-solid: #ffffff;
  --chrome-bg: rgba(255, 255, 255, 0.92);
  --panel-bg: rgba(255, 255, 255, 0.96);
  --popup-bg: rgba(255, 255, 255, 0.94);
  --border-soft: rgba(0, 0, 0, 0.1);
  --border: rgba(0, 0, 0, 0.14);
  --text-primary: #111827;
  --text-secondary: #374151;
  --text-muted: #6b7280;
  --warning-text: #9a3412;
  --marker-neutral: #ffffff;

  /* Brand accent -- distinct from --accent (set inline per-vehicle in popupHtml,
     see app.js) and from the semantic delay/freshness colors (green/orange/red),
     which keep their own meaning. This one is the app's own color: buttons, active
     tab/chip states, focus rings, the sheet handle. Chosen (electric indigo) to not
     collide with any operator brand color already on screen -- NS blue, RET red,
     GVB blue, Qbuzz orange, HTM yellow, Arriva blue, EBS green. */
  --brand-rgb: 109, 94, 245;
  --brand: rgb(var(--brand-rgb));
  --brand-strong: #5643e8;
  --brand-soft: rgba(var(--brand-rgb), 0.14);
  --brand-text: #ffffff;

  /* NS's own dark navy (matches NS_BLUE in app.js and the coach-count sign on a real
     platform) for the coach-count tag (.hero-tag-coaches below) -- its own small accent,
     not --brand, since this one specifically means "this is the same shade NS itself
     uses for this exact piece of information", not the app's own brand color. Themed the
     same way --brand is (see body.dark-map) and for the same reason: a dark navy TEXT
     color sitting on a low-alpha tint of itself reads fine on a light panel but goes
     muddy-on-near-black once the panel itself turns dark, so dark mode gets a brighter
     value instead of the literal sign color. */
  --ns-blue-rgb: 0, 48, 130;
  --ns-blue: rgb(var(--ns-blue-rgb));

  /* Shared radius/shadow/spacing scale -- applied to new and newly-touched
     components; not a blanket rewrite of every untouched rule. */
  --radius-sm: 8px;
  --radius-md: 12px;
  --radius-lg: 18px;
  --radius-xl: 26px;
  --shadow-sm: 0 2px 8px rgba(17, 24, 39, 0.12);
  --shadow-md: 0 8px 24px rgba(17, 24, 39, 0.16);
  --shadow-lg: 0 16px 40px rgba(17, 24, 39, 0.22);
}

body.dark-map {
  --chrome-solid: #1e1e2e;
  --chrome-bg: rgba(30, 30, 46, 0.85);
  --panel-bg: rgba(20, 20, 30, 0.95);
  --popup-bg: rgba(24, 24, 36, 0.92);
  --border-soft: rgba(255, 255, 255, 0.08);
  --border: rgba(255, 255, 255, 0.12);
  --text-primary: #f4f4f7;
  --text-secondary: #d1d5db;
  --text-muted: #9ca3af;
  --warning-text: #fdba74;
  /* --marker-neutral deliberately NOT overridden here anymore -- vehicle
     markers/logos stay their light-mode appearance in both themes now (see
     the EBS logo selection in app.js's iconFor). The map itself no longer
     changes with theme (plain OpenStreetMap tiles, no dark variant), so a
     marker/logo that used to darken to match a darkened basemap would now
     just look inconsistent against a basemap that never actually changes. */

  /* Brighter/lighter violet on dark backgrounds so it keeps enough contrast and
     reads as "glowing" rather than muddy against near-black chrome. */
  --brand-rgb: 148, 135, 255;
  --brand: rgb(var(--brand-rgb));
  --brand-strong: #a99dff;
  --brand-soft: rgba(var(--brand-rgb), 0.22);
  --brand-text: #14103a;

  /* Brighter than the real sign's navy, same reason --brand is brighter here too --
     see --ns-blue-rgb's own comment above. --ns-blue itself has to be redeclared here
     too, not just its -rgb source, or the coach-count tag stayed the light-mode navy in
     dark mode regardless of this override: a custom property's own `var()` references
     resolve against the scope THAT property is declared in, not the element it's finally
     used on -- since --ns-blue was only ever declared in :root, its color had already
     fully resolved to :root's rgb(0, 48, 130) there, and that finished value is what
     inherited down into body.dark-map unchanged, never re-reading this override at all
     (confirmed live: exactly the unreadable dark-on-dark text this fixes). --brand right
     above already gets this right by redeclaring itself alongside --brand-rgb -- this
     just follows the same rule for its own pair. */
  --ns-blue-rgb: 130, 170, 255;
  --ns-blue: rgb(var(--ns-blue-rgb));

  --shadow-sm: 0 2px 10px rgba(0, 0, 0, 0.45);
  --shadow-md: 0 10px 28px rgba(0, 0, 0, 0.5);
  --shadow-lg: 0 18px 44px rgba(0, 0, 0, 0.55);
}

html, body {
  margin: 0;
  padding: 0;
  height: 100%;
  /* Nothing on the page scrolls as a whole (the panels scroll inside themselves), and nothing
     rubber-bands it either: on iOS a drag that runs past the end of a list otherwise pulls the
     whole page along with it. */
  overflow: hidden;
  overscroll-behavior: none;
  /* -apple-system/BlinkMacSystemFont come first so Mac/iPhone/iPad users get
     the device's real San Francisco system font at zero download cost --
     Apple doesn't license SF Pro for embedding on the web, so this system-font
     keyword is the actual correct way to use it there, not a font file of our
     own. Everyone else (Windows/Android/Linux, where SF isn't available at
     all) falls through to Inter instead -- a free, screen-optimized font
     widely used specifically as an open substitute for SF Pro, so the two
     ends of this stack read as one consistent typeface rather than Apple
     users getting SF and everyone else getting something unrelated. */
  font-family: -apple-system, BlinkMacSystemFont, 'Inter', system-ui, sans-serif;
}

/* iOS, all of it, on a page that is a map rather than a document:
   - text-size-adjust: Safari otherwise enlarges some text by its own guess when the phone turns
     sideways, so the same panel read differently in landscape.
   - tap-highlight-color: the grey box Safari flashes over anything tapped. Every button here has
     its own pressed state (button:active below).
   - touch-action: pan-x pan-y lets fingers scroll, but not pinch- or double-tap-zoom the *page*.
     The map does its own pinch zoom (Leaflet sets touch-action: none on it, which wins: the two
     are intersected), but a pinch that started on the tab bar or a panel zoomed the whole app --
     and an installed app has no browser zoom controls to get back out with.
   - The background is the page's own colour, so a launch or a rotation never flashes white in
     dark mode (html.dark-boot is set before the first paint, see index.html). */
html {
  -webkit-text-size-adjust: 100%;
  text-size-adjust: 100%;
  -webkit-tap-highlight-color: transparent;
  touch-action: pan-x pan-y;
  background: #ffffff;
}

html.dark-boot {
  background: #14141e;
}

/* The installed iPhone app, where iOS lays the page out one status-bar height short of the screen
   (see bottomStripPx in app.js). The strip below showed the page's own background (measured from
   a screenshot: exactly html.dark-boot's #14141e), so iOS does draw the page there -- what it
   wouldn't do is place *fixed* elements there: a fixed tab bar moved into it had its labels cut
   off at the strip's edge. So the page itself is made that much taller, and everything that was
   fixed is positioned inside it instead (absolute, against the body), down to the screen's real
   bottom edge. The page never scrolls (see the scroll guard in app.js).
   Painted the tab bar's colour, with the tab bar solid too, so that if iOS ever leaves part of
   the strip bare after all it reads as the tab bar running on, not as a band under it. */
html.has-bottom-strip body {
  position: relative;
  height: calc(100% + var(--bottom-strip));
}

html.has-bottom-strip .safe-area-fill,
html.has-bottom-strip #clock,
html.has-bottom-strip .utility-pill,
html.has-bottom-strip .nav-pill,
html.has-bottom-strip .settings-button,
html.has-bottom-strip .tab-bar,
html.has-bottom-strip .map-attribution,
html.has-bottom-strip .settings-backdrop {
  position: absolute;
}

html.has-bottom-strip {
  background: #ffffff;
}

html.has-bottom-strip.dark-boot {
  background: #1e1e2e;
}

html.has-bottom-strip .tab-bar {
  background: var(--chrome-solid);
  -webkit-backdrop-filter: none;
  backdrop-filter: none;
}

/* Long-pressing a train picture, an icon or a map marker brings up iOS's "Save Image / Share"
   preview, and dragging one ghosts a copy of it under the finger -- neither means anything here.
   Text in the panels stays selectable; the controls and the map don't select on a long press. */
img,
.leaflet-container,
button {
  -webkit-touch-callout: none;
  -webkit-user-drag: none;
}

.leaflet-container,
button,
.tab-bar,
.header-wrap,
.control-pill,
#clock {
  -webkit-user-select: none;
  user-select: none;
}

/* Universal tappable-button feedback -- a cheap, low-risk bit of "this app feels
   alive" polish that every existing icon button (theme toggle, locate, legend
   collapse, timetable close/refresh) picks up for free without touching their
   individual rules. */
button {
  transition: transform 0.12s ease, filter 0.15s ease, background 0.15s ease, color 0.15s ease;
}

button:active {
  transform: scale(0.92);
}

/* See the safe-area-fill divs in index.html -- a strip the size of the safe area at each edge,
   in the app's own colour. z-index above everything, including the settings modal, since this
   represents OS-level chrome space, not app content. */
.safe-area-fill {
  position: fixed;
  left: 0;
  right: 0;
  background: var(--chrome-bg);
  z-index: 2000;
  pointer-events: none;
}

.safe-area-top {
  top: 0;
  height: env(safe-area-inset-top, 0px);
  /* Nothing drawn: the map runs on under the status bar, the way a native app's content does,
     with iOS's own soft edge behind the clock. A fill here -- translucent, then solid -- only
     ever showed as a band across the top with iOS's fade as a lip below it. Still in the page,
     because its height is how app.js reads the top safe area (safeAreaTopPx). */
  background: none;
}

.safe-area-bottom {
  bottom: 0;
  height: var(--safe-bottom);
}

/* Title card + Admin Panel button, side by side in the top-left corner. */
.header-wrap {
  position: absolute;
  /* Installed to the Home Screen (see .safe-area-fill above), the status bar strip is
     drawn as opaque page content sitting ABOVE this at z-index 2000 -- a flat top:12px
     put this whole pill directly underneath it, completely hidden, rather than below
     it. env() is 0px in a plain browser tab, where this is simply top:12px same as
     always. */
  top: calc(12px + env(safe-area-inset-top, 0px));
  /* + the side insets: an iPhone on its side puts the notch/Dynamic Island on the left or
     right, and these corner controls would sit underneath it. 0 everywhere else. */
  left: calc(12px + env(safe-area-inset-left, 0px));
  z-index: 1000;
  display: flex;
  align-items: stretch;
  gap: 8px;
}

header {
  padding: 0.6rem 0.9rem;
  background: var(--chrome-bg);
  color: var(--text-primary);
  border-radius: var(--radius-lg);
  border: 1px solid var(--border-soft);
  box-shadow: var(--shadow-md);
  -webkit-backdrop-filter: blur(10px);
  backdrop-filter: blur(10px);
}

#clock {
  /* Bottom-left, not top-center -- centered at the top was the one spot guaranteed to
     collide with the status bar / Dynamic Island on a phone regardless of any
     safe-area fix, since the OS draws its own clock in almost exactly that same
     position. Bottom-left is otherwise unused (every other floating control anchors
     right), and needs the same mobile tab-bar clearance as .utility-pill below --
     see its own mobile override for why 96px specifically. */
  position: fixed;
  left: calc(12px + env(safe-area-inset-left, 0px));
  /* + the home-indicator inset (0 everywhere but a notched iPhone): see .safe-area-bottom. */
  bottom: calc(28px + var(--safe-bottom));
  z-index: 1000;
  padding: 0.6rem 0.9rem;
  background: var(--chrome-bg);
  color: var(--text-primary);
  border-radius: var(--radius-lg);
  border: 1px solid var(--border-soft);
  box-shadow: var(--shadow-md);
  -webkit-backdrop-filter: blur(10px);
  backdrop-filter: blur(10px);
  font-variant-numeric: tabular-nums;
  font-weight: 700;
  letter-spacing: 0.02em;
}

/* Shared look for the floating icon-button pills -- see buildNavPill and
   buildUtilityPill in app.js, which each build one of these. */
.control-pill {
  display: flex;
  flex-direction: column;
  border: 1px solid var(--border-soft);
  border-radius: var(--radius-lg);
  background: var(--chrome-bg);
  box-shadow: var(--shadow-md);
  -webkit-backdrop-filter: blur(10px);
  backdrop-filter: blur(10px);
  overflow: hidden;
}

/* Both plain fixed elements (see buildNavPill/buildUtilityPill in app.js),
   not Leaflet controls sharing a corner -- explicit positions here instead
   of relying on Leaflet's own same-corner stacking order, which proved
   unreliable in practice (see buildSettingsControl's own comment on this).
   Stacked bottom-up with gaps -- like iOS groups related controls into their
   own separate clusters rather than one long list -- above .map-attribution,
   which sits right at the true corner (see buildAttributionLabel). */
.utility-pill {
  position: fixed;
  right: calc(12px + env(safe-area-inset-right, 0px));
  bottom: calc(28px + var(--safe-bottom));
  z-index: 1000; /* matches Leaflet's own .leaflet-top/.leaflet-bottom stacking level */
}

.nav-pill {
  position: fixed;
  right: calc(12px + env(safe-area-inset-right, 0px));
  bottom: calc(122px + var(--safe-bottom));
  z-index: 1000;
}

/* Settings button -- a plain fixed element (see buildSettingsControl in
   app.js), not a Leaflet control, so it needs its own explicit position here
   rather than relying on a corner-anchored Leaflet container. Placed above
   the zoom control on purpose; .leaflet-top.leaflet-right below is pushed
   down to make room instead of the two overlapping. */
.settings-button {
  position: fixed;
  /* Same reasoning as header's top above -- a flat 12px hid this completely behind the
     opaque status-bar fill on an installed Home Screen app. */
  top: calc(12px + env(safe-area-inset-top, 0px));
  right: calc(12px + env(safe-area-inset-right, 0px));
  z-index: 1000; /* matches Leaflet's own .leaflet-top/.leaflet-bottom stacking level */
}

.leaflet-top.leaflet-right {
  /* This one happened to still clear the status-bar fill (56px > the largest real
     safe-area-inset-top out there), so it never visibly broke -- but that was luck,
     not correctness: a fixed value can't know it'll always be more than whatever inset
     an env() query actually reports. Adding it properly instead of leaning on that. */
  margin-top: calc(56px + env(safe-area-inset-top, 0px));
  margin-right: env(safe-area-inset-right, 0px);
}

.control-pill button {
  width: 40px;
  height: 40px;
  border: none;
  background: transparent;
  color: var(--text-primary);
  font-size: 1.05rem;
  line-height: 1;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
}

.control-pill button:not(:last-child) {
  border-bottom: 1px solid var(--border-soft);
}

/* Hover styles only where something can really hover. On a touch screen iOS applies :hover on
   tap and keeps it until the next tap somewhere else, so a tapped button stayed highlighted as
   if it were still selected. Every hover rule in this file is wrapped like this. */
@media (hover: hover) {
  .control-pill button:hover {
    background: var(--brand-soft);
  }
}

.control-pill button:disabled {
  cursor: wait;
  opacity: 0.7;
}

/* Highlights whichever of Nearby/Favorites (see .nav-pill) the panel is
   currently showing, if either. Mobile uses the dedicated bottom tab bar
   instead (see .tab-bar below) rather than this pill. */
.control-pill button.active {
  background: var(--brand);
  color: var(--brand-text);
}

/* Bottom tab bar -- mobile only (hidden by default, shown in the mobile media
   query below). Desktop's equivalent is the control pill's Nearby/Favorites
   buttons instead of a full nav bar, since desktop has no need to ever "leave"
   the map view the way a phone's limited screen does. */
.tab-bar {
  display: none;
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  /* Above .timetable-panel's 1100: on a phone the sheet sits on top of the tab bar, like a native
     iOS sheet, instead of covering it -- covered, Nearby/Favorites couldn't be switched (or the
     open one tapped again to close it) without first closing the sheet. Below the modals' 1300. */
  z-index: 1150;
  padding-bottom: var(--safe-bottom);
  /* Phone on its side: keeps the two buttons out from under the notch / Dynamic Island. */
  padding-left: env(safe-area-inset-left, 0px);
  padding-right: env(safe-area-inset-right, 0px);
  background: var(--chrome-bg);
  border-top: 1px solid var(--border-soft);
  box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.15);
  -webkit-backdrop-filter: blur(10px);
  backdrop-filter: blur(10px);
}

.tab-bar-btn {
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.15rem;
  padding: 0.5rem 0 0.4rem;
  border: none;
  background: none;
  color: var(--text-muted);
  font-family: inherit;
  font-weight: 600;
  font-size: 0.68rem;
  cursor: pointer;
}

.tab-bar-btn.active {
  color: var(--brand);
}

.tab-bar-icon {
  font-size: 1.25rem;
  line-height: 1;
}

/* Leaflet's zoom control, drawn as one more .control-pill: Leaflet's own 30px square with its
   grey 2px border sat between round 40px pills (Settings above, locate/theme below) and 2px out
   of line with Settings' right edge (Leaflet's 10px control margin vs the pills' 12px). The
   theme tokens carry it through dark mode like every other pill. */
.leaflet-touch .leaflet-bar.leaflet-control-zoom,
.leaflet-bar.leaflet-control-zoom {
  margin-right: 12px;
  border: 1px solid var(--border-soft);
  border-radius: var(--radius-lg);
  background: var(--chrome-bg);
  box-shadow: var(--shadow-md);
  -webkit-backdrop-filter: blur(10px);
  backdrop-filter: blur(10px);
  overflow: hidden;
}

.leaflet-touch .leaflet-control-zoom a,
.leaflet-control-zoom a {
  width: 40px;
  height: 40px;
  line-height: 40px;
  font-size: 1.35rem;
  font-weight: 500;
  background: transparent;
  color: var(--text-primary);
  border-radius: 0 !important;
  border-bottom: 1px solid var(--border-soft);
}

.leaflet-control-zoom a:last-child {
  border-bottom: none;
}

.leaflet-control-zoom a.leaflet-disabled {
  background: transparent;
  color: var(--text-muted);
  opacity: 0.5;
}

@media (hover: hover) {
  .leaflet-control-zoom a:hover {
    background: var(--brand-soft);
    color: var(--text-primary);
  }
}

/* OpenStreetMap has no dark style, so in dark mode its tiles are inverted and turned back round
   the colour wheel: dark land, dark-blue water, parks still green, roads still their colours --
   instead of a bright white map under dark controls. On the layer's one pane-sized container
   (osmLayer's className in app.js), so it's one GPU filter rather than one per tile, and
   Mapbox (its own canvas, with real dark styles) is never touched. */
body.dark-map .osm-tiles {
  filter: invert(1) hue-rotate(180deg) brightness(0.92) contrast(0.88) saturate(0.7);
}

/* Map attribution -- its own plain fixed element (see buildAttributionLabel
   in app.js), not Leaflet's own attribution control and not a child of the
   control pill (sharing that flex box stretched the whole pill to the
   text's width instead of staying a slim column of square buttons). Styled
   to match the standard OSM/Leaflet attribution look (small translucent
   white box, flush in the corner) rather than our own dark-mode-aware chrome
   tokens -- it sits directly on top of busy, colorful map tiles that never
   change with the app's theme, so it needs guaranteed contrast against
   *that*, not a look that matches the surrounding UI chrome. .utility-pill
   above it is positioned to leave room, not stacked via Leaflet. */
.map-attribution {
  position: fixed;
  right: env(safe-area-inset-right, 0px);
  bottom: var(--safe-bottom);
  z-index: 999; /* just under the control pill/zoom's 1000, since it sits visually behind/below them */
  padding: 2px 6px;
  background: rgba(255, 255, 255, 0.8);
  color: #333;
  font-size: 0.68rem;
  line-height: 1.4;
}

.map-attribution a {
  color: #0078a8;
  text-decoration: none;
}

@media (hover: hover) {
  .map-attribution a:hover {
    text-decoration: underline;
  }
}

/* The one deliberate exception to markers/logos staying frozen to their
   light-mode look (see body.dark-map's comment on --marker-neutral) --
   attribution is real UI chrome with its own opaque box, not something
   blended onto the map itself, and the real openstreetmap.org site also
   inverts its attribution for dark mode. */
body.dark-map .map-attribution {
  background: rgba(20, 20, 30, 0.85);
  color: #cbd5e1;
}

body.dark-map .map-attribution a {
  color: #93c5fd;
}

/* Vehicle markers: colored badge with a ring, instead of a flat dot. Plain modes get
   a white ring; Intercity/Sprinter trains get a swapped-color ring (see markerColors). */
.vehicle-marker-wrapper {
  background: transparent;
  border: none;
}

.vehicle-marker-box {
  position: relative;
}

.vehicle-marker {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  border-radius: 50%;
  background: var(--fill);
  border: 3px solid var(--ring);
  box-shadow: 0 1px 5px rgba(0, 0, 0, 0.55);
  display: flex;
  align-items: center;
  justify-content: center;
  line-height: 1;
  color: var(--text-primary); /* only affects the plain-text "?" fallback icon -- emoji/logo ignore this; needs to track the theme now that fill is a neutral instead of always-colored */
}

/* A vehicle the server has moved along its route because its own fix went stale (see
   vehicleMotion.js). The dashed ring is the marker-sized version of the panel's
   "Estimated position" tag: close enough to a normal marker to read as the same vehicle,
   different enough that a rider can tell at a glance which markers are reporting and
   which are being inferred. */
.vehicle-marker-estimated {
  border-style: dashed;
}

/* Route number chip, overlapping the marker's bottom-right edge like Transit
   App's route badges. Base position (centered on the marker) plus the actual
   corner offset both come from app.js's inline transform, scaled to icon size. */
.line-badge {
  position: absolute;
  top: 50%;
  left: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  background: #111827;
  color: #fff;
  font-weight: 700;
  line-height: 1;
  border: 2px solid #fff;
  border-radius: 999px;
  padding: 0 0.35em;
  white-space: nowrap;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
  pointer-events: none;
}

/* Heading arrow: only rendered for live-GPS trains moving above ~10km/h (see
   server.js/app.js). Points up (north/0deg) by default; rotated per-vehicle inline. */
.heading-arrow {
  position: absolute;
  top: 50%;
  left: 50%;
  width: 0;
  height: 0;
  border-left: 5px solid transparent;
  border-right: 5px solid transparent;
  border-bottom: 9px solid #fff;
  filter: drop-shadow(0 0 1.5px rgba(0, 0, 0, 0.7));
}

/* Fills the fixed-size icon box Leaflet manages, and is the element that actually
   gets scaled on zoom (see rescaleIcon in app.js) -- a plain style.transform update
   instead of rebuilding the icon, since Leaflet already applies its own position
   transform directly to the wrapper div one level up. */
.icon-scale-wrap {
  width: 100%;
  height: 100%;
}

/* Station markers: small and green (the one mode color not otherwise in use). */
.station-marker-wrapper {
  background: transparent;
  border: none;
}

/* Rounded-square NS logo, like a Google Maps POI pin. !important beats Leaflet's
   own ".leaflet-marker-pane img" reset (forces width:auto otherwise). */
.station-marker {
  width: 100% !important;
  height: 100% !important;
  border-radius: 25%;
  border: 1.5px solid #fff;
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
  object-fit: cover;
  display: block;
}

/* Plain placeholder dot -- see buildBusStopIcon's comment on why this exists
   at all (the icon file it used to show has been removed, and the only
   remaining use is a brief pre-numbering moment, not a standalone marker). */
.bus-stop-marker-dot {
  width: 100%;
  height: 100%;
  border-radius: 50%;
  background: #6b7280;
  border: 1.5px solid #fff;
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
  box-sizing: border-box;
  display: block;
}

/* Numbered route-stop dot -- replaces a stop's usual mode logo while its route is
   focused, so "1, 2, 3..." reads as this trip's own sequence, not a generic mark. */
.route-stop-marker-wrapper {
  background: transparent;
  border: none;
}

.route-stop-marker {
  border-radius: 50%;
  border: 2px solid #fff;
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
  display: flex;
  align-items: center;
  justify-content: center;
  color: #fff;
  font-weight: 700;
  line-height: 1;
}

header h1 {
  margin: 0;
  font-size: 1.2rem;
  font-weight: 800;
  letter-spacing: -0.01em;
  display: flex;
  align-items: center;
  gap: 0.45rem;
}

.live-dot {
  width: 9px;
  height: 9px;
  border-radius: 50%;
  background: #22c55e;
  box-shadow: 0 0 0 rgba(34, 197, 94, 0.6);
  animation: live-pulse 2s infinite;
}

/* The last /api/vehicles poll failed (see refresh()'s catch) -- red instead of the
   pulsing green, no pulse of its own (a pulsing red reads as more alarming than this
   is meant to; the map still shows the last good positions, it's just not current). */
/* Backend still warming up after a restart -- same pulse as live, in orange, so it reads as
   "working, not finished" rather than as a problem (that's what red is for). */
.live-dot.live-dot-loading {
  background: #f97316;
  animation-name: live-pulse-loading;
}

.live-dot.live-dot-error {
  background: #ef4444;
  box-shadow: none;
  animation: none;
}

@keyframes live-pulse {
  0% {
    box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.6);
  }
  70% {
    box-shadow: 0 0 0 6px rgba(34, 197, 94, 0);
  }
  100% {
    box-shadow: 0 0 0 0 rgba(34, 197, 94, 0);
  }
}

@keyframes live-pulse-loading {
  0% {
    box-shadow: 0 0 0 0 rgba(249, 115, 22, 0.6);
  }
  70% {
    box-shadow: 0 0 0 6px rgba(249, 115, 22, 0);
  }
  100% {
    box-shadow: 0 0 0 0 rgba(249, 115, 22, 0);
  }
}

.vehicle-count {
  /* No margin of its own -- the h1's flex gap already spaces every child evenly, so an
     extra margin here just stacked on top of that gap and pulled the dot off-center,
     leaning it toward "OV Live" instead of sitting evenly between the two. */
  font-size: 0.8rem;
  font-weight: 600;
  opacity: 0.8;
}

/* Vehicle popup: dark glass card matching the header/legend, replacing Leaflet's default white box. */
.vp-popup .leaflet-popup-content-wrapper {
  background: var(--popup-bg);
  color: var(--text-primary);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-lg);
  -webkit-backdrop-filter: blur(10px);
  backdrop-filter: blur(10px);
}

.vp-popup .leaflet-popup-tip {
  background: var(--popup-bg);
}

.vp-popup .leaflet-popup-content {
  margin: 0.7rem 0.9rem;
}

.vp-popup .leaflet-popup-close-button {
  color: var(--text-secondary) !important;
}

.vp {
  min-width: 160px;
  font-size: 0.85rem;
}

/* Shared "hero" header -- icon badge + line/route + one status chip + a row of small
   secondary tags -- built by heroHtml() in app.js and used by both the vehicle
   quick-card (.vp) and the full timetable panel. Wraps at narrow widths instead of a
   fixed grid, so it needs no separate mobile treatment. */
.hero {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  gap: 0.5rem 0.6rem;
}

.hero-icon-badge {
  flex: 0 0 auto;
  width: 42px;
  height: 42px;
  border-radius: 50%;
  background: #fff;
  border: 3px solid var(--ring, var(--text-muted));
  display: flex;
  align-items: center;
  justify-content: center;
  box-shadow: var(--shadow-sm);
}

.hero-title {
  flex: 1 1 140px;
  min-width: 0;
  padding-top: 0.1rem;
}

.hero-line {
  font-weight: 700;
  font-size: 1rem;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.hero-route {
  font-size: 0.78rem;
  color: var(--text-muted);
  margin-top: 0.1rem;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.hero-right {
  flex: 0 0 auto;
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 0.25rem;
  padding-top: 0.1rem;
  /* Room for the absolutely-positioned refresh/close corner buttons (see
     .timetable-header-actions) -- this is the one flex sibling on the title row that
     needs to reserve space for them now that they're out of flow; .hero-title (which
     grows to fill whatever's left before this) inherits the same clearance for free. */
  margin-right: 4.5rem;
}

.hero-status {
  display: inline-block;
  padding: 0.2rem 0.6rem;
  border-radius: 999px;
  font-size: 0.76rem;
  font-weight: 700;
  white-space: nowrap;
}

.hero-tags {
  flex: 1 1 100%;
  min-width: 0;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.35rem;
  margin-top: 0.1rem;
}

.hero-tags:empty {
  display: none;
}

/* The composition units' own numbers (see unitNumbersText in app.js), between the pictures and
   the coaches/speed/platform tags. flex-basis: 100% inside .hero-tags' flex-wrap is what
   actually puts it on its own line there, rather than plain text that would just wrap in
   wherever the previous row ran out of width. Small and muted -- useful to know, not something
   that should compete with the tags around it for attention. */
.hero-unit-numbers {
  flex-basis: 100%;
  font-size: 0.7rem;
  color: var(--text-muted);
}

.hero-tag {
  display: inline-block;
  padding: 0.15rem 0.5rem;
  border-radius: 999px;
  background: var(--border-soft);
  color: var(--text-secondary);
  font-size: 0.72rem;
  font-weight: 600;
  white-space: nowrap;
}

/* The one tag that isn't reporting something the vehicle said -- it's saying the app
   worked this position out itself. Given its own (brand-tinted) treatment rather than the
   neutral grey of the rest so it can't be skimmed past as just another readout. */
.hero-tag-estimated {
  background: rgba(var(--brand-rgb), 0.15);
  color: var(--brand);
}

/* Same tinted-badge treatment as .hero-tag-estimated, in NS's own navy (--ns-blue, see
   :root) -- for the coach-count tag next to the composition image (see trainUnitHtml/
   currentPanelCompositionTag), so it reads as its own distinct piece of information
   rather than blending into the neutral grey delay/platform tags around it, in the same
   shade the platform's own coach-count sign uses since this is literally the same
   information. */
.hero-tag-coaches {
  background: rgba(var(--ns-blue-rgb), 0.15);
  color: var(--ns-blue);
}

.vp-icon {
  font-size: 1.4rem;
  line-height: 1;
}

/* Lets the NS logo (an <img>, unlike the emoji icons) scale with whichever
   font-size is active in its context -- marker badge, popup icon, or panel header.
   !important is needed because Leaflet's own CSS (.leaflet-marker-pane img) forces
   width:auto with higher specificity than a single class selector can beat. */
.mode-icon-img {
  width: 1em !important;
  height: 1em !important;
  object-fit: contain;
  vertical-align: middle;
}

/* Bold filled mode icons (see MODE_ICON_SVG in app.js) replacing the old
   emoji. Color is context-dependent, not one fixed value -- these render in
   three different kinds of background, each needing its own rule:
     1. Default: follows the normal theme-aware text color, for chrome
        contexts (a popup/panel header's .vp-icon, an inactive mode-filter
        chip) that still theme normally.
     2. On a vehicle marker specifically: forced to a fixed dark color
        regardless of theme, because the marker's own fill is frozen white in
        both themes now (see body.dark-map's --marker-neutral comment) --
        the theme-aware default above would turn near-white in dark mode and
        vanish against that still-white fill.
     3. On an active/filled mode-filter chip: matches that chip's own
        already-computed contrast color (--chip-text) instead of either of
        the above, since the chip's background is that mode's own color, not
        white and not the app's chrome background. */
.mode-icon-svg {
  width: 1em;
  height: 1em;
  display: block;
  color: var(--text-primary);
  vertical-align: middle;
}

.vehicle-marker .mode-icon-svg {
  color: #374151;
}

.mode-chip.active .mode-icon-svg {
  color: var(--chip-text);
}

/* Train's minimalist icon (public/icons/train.png) -- a solid black-on-
   transparent raster image, not one of the hand-drawn SVGs above, so it
   can't take a color via currentColor and needs filter:invert() instead
   wherever it needs to flip. On a vehicle marker the fill is always the
   same neutral white as every other mode now (see markerColors in app.js),
   so the icon's own default dark just reads fine there, same as bus/tram/
   metro/ferry below -- no per-vehicle class needed any more. The panel
   header (.hero-icon-badge, the only other place this appears) is a fixed
   white circle in both themes too -- same reasoning as --marker-neutral
   below -- so no dark-mode override is needed there either; inverting it to
   white used to leave a white icon sitting on that still-white badge,
   invisible in dark mode. */
.train-icon-img {
  width: 1em;
  height: 1em;
  display: block;
  vertical-align: middle;
}

/* Bus/tram/metro's minimalist icons (public/icons/bus-alt.png, train-tram.png,
   subway.png) -- same solid-black-raster situation as train.png, and the
   same fixed-white .hero-icon-badge reasoning above: this icon never
   appears anywhere its background actually changes with theme, so it
   never needs a dark-mode invert either. */
.transit-icon-img {
  width: 1em;
  height: 1em;
  display: block;
  vertical-align: middle;
}

/* Settings' mode-filter chips: every icon here always sits inside its own
   .mode-chip-icon color bubble now (see below), active or not, light or
   dark mode -- so the icon itself just stays white unconditionally, the
   same as it already was on an active chip. Train's bubble happens to be NS
   yellow (dark-contrast) rather than the white-contrast hue every other mode
   gets, but it's inverted the same as the rest anyway -- a white icon reads
   fine on yellow too, and having every chip's icon rendered identically
   regardless of its own bubble color reads as more consistent than one
   dark icon standing out among four white ones. */
.mode-chip .transit-icon-img,
.mode-chip .train-icon-img {
  filter: invert(1);
}

/* General UI control icons (settings/locate/theme/nearby/favorites) -- solid
   black rasters like the ones above, but simpler still: these never render
   anywhere but plain theme-following chrome (a control-pill button, a tab
   bar button, a panel title's .vp-icon) -- never a marker, never a colored
   chip fill -- so one blanket dark-mode rule covers every place this class
   is used, with no scoping needed. */
.ui-icon-img {
  width: 1.1em;
  height: 1.1em;
  display: block;
  vertical-align: middle;
}

body.dark-map .ui-icon-img {
  filter: invert(1);
}

/* The logo is white-on-transparent, so it disappears against a light-mode chrome
   background (header/popup/panel -- always wrapped in .vp-icon there). Give it a small
   NS-blue badge; the marker version doesn't need this since it already sits on the
   vehicle's own colored circle. */
.vp-icon .mode-icon-img {
  background: #003082;
  border-radius: 4px;
  padding: 2px;
  box-sizing: content-box;
}

/* min-width:0 overrides flexbox's default min-width:auto, which otherwise refuses
   to shrink a text child below its own content width -- without this, a long bus
   destination fights the timestamp on the right for space, and as the timestamp's
   own width flickers with its digit count (9s -> 10s), the destination visibly
   jumps between one and two lines every refresh instead of just sitting still. */
/* Kept for the small bus-stop name popup only now (see the bindPopup call in app.js's
   renderBusStops) -- the vehicle/timetable hero header uses .hero-line/.hero-route
   instead (see above). */
.vp-line {
  font-weight: 700;
  font-size: 1rem;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.vp-updated {
  font-size: 0.68rem;
  color: var(--text-muted);
  white-space: nowrap;
}

.vp-composition-pill {
  display: inline-block;
  padding: 0.1rem 0.4rem;
  border-radius: 999px;
  font-size: 0.68rem;
  font-weight: 700;
  white-space: nowrap;
}

/* Real train-profile images (see trainUnitHtml) replacing the old composition text --
   these are wide side-view banners (400-900px for a coupled formation), so this row
   deliberately does NOT wrap or shrink them to fit: it scrolls horizontally instead,
   same as swiping through photos (or the NS app's own train-formation view), rather
   than squeezing a train illustration down to the point it's unreadable or bumping the
   rest of the hero's tags onto new lines. min-width: 0 is load-bearing here, not
   decorative -- without it this flex item (like .hero-tags below, see its own
   min-width: 0) grows to its content's full intrinsic width instead of clipping to the
   row, which is what let a 900px-wide image blow the whole panel out past the edge of
   the screen instead of scrolling inside it.

   .filter-chip-row (the Nearby/Favorites station-type filter chips -- see modeFilterRowHtml
   in app.js) uses this same "one row, swipe/drag to see the rest" treatment too, but only
   on mobile -- see the @media (max-width: 600px) block below. On a wide enough screen the
   chips fit without needing it (there are only 5), so there it's just .mode-filter-grid's
   ordinary wrap, same as Settings' own copy of these chips uses. */
.vp-composition-images {
  display: flex;
  flex-wrap: nowrap;
  align-items: center;
  gap: 0.4rem;
  max-width: 100%;
  min-width: 0;
  overflow-x: auto;
  overflow-y: hidden;
  cursor: grab;
  touch-action: pan-x;
  /* No visible scrollbar -- app.js's own click-and-drag handler is the one sanctioned
     way to scroll this with a mouse (touch/trackpad still pan it natively). A visible
     native scrollbar sits in the very same box and hit-tests as part of it, so dragging
     ITS thumb and app.js's pointermove handler both fought to set scrollLeft on the same
     gesture at once -- which is what made the scrollbar itself feel broken/unusable
     rather than just invisible. */
  scrollbar-width: none; /* Firefox */
  -ms-overflow-style: none; /* legacy Edge */
}

.vp-composition-images::-webkit-scrollbar {
  display: none; /* Chrome/Safari */
}

.vp-composition-images-dragging,
.filter-chip-row-dragging {
  cursor: grabbing;
  user-select: none;
}

.vp-composition-image {
  height: 44px;
  width: auto;
  flex: 0 0 auto;
  pointer-events: none; /* drag-to-scroll grabs the row, not the image (see app.js) */
}

.vp-pill {
  display: inline-block;
  padding: 0.15rem 0.55rem;
  border-radius: 999px;
  font-size: 0.72rem;
  font-weight: 600;
  color: #111;
}

.vp-meta {
  margin-top: 0.5rem;
  font-size: 0.75rem;
  color: var(--text-muted);
}

.vp-sep {
  margin: 0 0.35rem;
}

#map {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
}

/* Settings: a plain centered modal, not part of the map/sheet/sidebar system --
   see #settings-backdrop in index.html and buildSettingsControl in app.js. */
.settings-backdrop {
  position: fixed;
  inset: 0;
  z-index: 1300; /* above the sheet (1100) and tab bar (1050) -- a modal covers everything */
  display: flex;
  align-items: center;
  justify-content: center;
  /* Kept clear of the status bar / Dynamic Island and the home indicator on an installed
     iPhone app -- the tall admin modal otherwise reached up underneath the status bar. */
  padding: calc(16px + env(safe-area-inset-top, 0px)) calc(16px + env(safe-area-inset-right, 0px)) calc(16px + var(--safe-bottom))
    calc(16px + env(safe-area-inset-left, 0px));
  background: rgba(0, 0, 0, 0.45);
}

.settings-backdrop[hidden] {
  display: none;
}

.settings-modal {
  overscroll-behavior: contain; /* see .timetable-body */
  width: min(380px, 100%);
  max-height: min(80vh, 100%);
  overflow-y: auto;
  background: var(--panel-bg);
  color: var(--text-primary);
  border-radius: var(--radius-lg);
  border: 1px solid var(--border-soft);
  box-shadow: var(--shadow-lg);
}

.settings-modal-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 0.9rem 1rem;
  border-bottom: 1px solid var(--border);
  font-weight: 700;
}

.settings-close {
  border: none;
  background: none;
  color: var(--text-secondary);
  font-size: 1.4rem;
  line-height: 1;
  cursor: pointer;
}

.settings-modal-body {
  padding: 1rem;
}

.settings-section-title {
  font-size: 0.75rem;
  text-transform: uppercase;
  letter-spacing: 0.03em;
  font-weight: 700;
  color: var(--text-muted);
  margin-bottom: 0.6rem;
}

.settings-section-title:not(:first-child) {
  margin-top: 1.1rem;
}

/* Marks a setting that's still being proven out, so it reads as "try this" rather than
   as a finished feature that's misbehaving. Inline in the section title so it can't be
   mistaken for applying to the whole modal. */
.settings-experimental {
  display: inline-block;
  margin-left: 0.4rem;
  padding: 0.1rem 0.4rem;
  border-radius: 999px;
  background: rgba(var(--brand-rgb), 0.15);
  color: var(--brand);
  font-size: 0.62rem;
  letter-spacing: 0.04em;
}

/* Chips wrap naturally here (the modal has room), unlike their old floating
   strip which needed horizontal scroll to fit the same set compactly. */
.mode-filter-grid {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
}

.mode-chip {
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  gap: 0.35rem;
  padding: 0.4rem 0.65rem;
  border-radius: 999px;
  border: 1.5px solid var(--chip-color);
  background: transparent;
  color: var(--text-primary);
  font-family: inherit;
  font-weight: 700;
  font-size: 0.78rem;
  white-space: nowrap;
  cursor: pointer;
}

/* Filled with the mode's own color when active -- matches that mode's markers
   and route lines elsewhere, rather than a generic on/off indicator. */
.mode-chip.active {
  background: var(--chip-color);
  border-color: var(--chip-color);
  color: var(--chip-text);
}

.mode-chip:not(.active) {
  opacity: 0.6;
}

/* A filled color badge behind the icon, independent of the chip's own
   active/inactive fill -- echoes the vehicle marker's own colored-ring
   language, and means the (always-white, see .mode-chip .transit-icon-img)
   icon stays legible even on an inactive chip's transparent background,
   which would otherwise go white-on-near-white in light mode. */
.mode-chip-icon {
  font-size: 1rem;
  line-height: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 1.5em;
  height: 1.5em;
  border-radius: 50%;
  background: var(--chip-color);
  flex: 0 0 auto;
}

.mode-chip-icon .transit-icon-img,
.mode-chip-icon .train-icon-img {
  width: 0.95em;
  height: 0.95em;
}

.settings-switch-row {
  display: flex;
  align-items: center;
  gap: 0.75rem;
}

.settings-switch-desc {
  flex: 1 1 auto;
  font-size: 0.82rem;
  color: var(--text-secondary);
}

/* Plain on/off switch -- a settings row with exactly two states doesn't need the
   Vehicle-types chips' multi-select treatment, just a standard track+knob toggle. */
.settings-switch {
  flex: 0 0 auto;
  position: relative;
  width: 2.6rem;
  height: 1.5rem;
  padding: 0.15rem;
  border: none;
  border-radius: 999px;
  background: var(--border-soft);
  cursor: pointer;
  transition: background-color 0.15s ease;
}

.settings-switch.active {
  background: var(--brand);
}

.settings-switch-knob {
  display: block;
  width: 1.2rem;
  height: 1.2rem;
  border-radius: 50%;
  background: #fff;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
  transition: transform 0.15s ease;
}

.settings-switch.active .settings-switch-knob {
  transform: translateX(1.1rem);
}

/* Timetable slide-in panel */
.timetable-panel {
  position: absolute;
  top: 0;
  right: 0;
  bottom: 0;
  /* An iPhone on its side with the notch on the right, or an installed iPad app with its status
     bar on top: the content stays clear of them. 0 elsewhere. */
  padding-top: env(safe-area-inset-top, 0px);
  padding-right: env(safe-area-inset-right, 0px);
  width: 500px;
  max-width: 90vw;
  z-index: 1100;
  background: var(--panel-bg);
  color: var(--text-primary);
  border-left: 1px solid var(--border-soft);
  box-shadow: var(--shadow-lg);
  display: flex;
  flex-direction: column;
  -webkit-backdrop-filter: blur(10px);
  backdrop-filter: blur(10px);
  /* transform (translateX on desktop, translateY on mobile) is JS-driven --
     see setSheetSnap/setSheetVisible in app.js -- this only supplies the easing. */
  transition: transform 300ms cubic-bezier(0.22, 1, 0.36, 1);
}

.timetable-panel[hidden] {
  display: none;
}

/* Applied while closed/closing so the (still-in-the-DOM, mid-animation) panel
   can't intercept clicks meant for the map underneath it. */
.timetable-panel.sheet-closed {
  pointer-events: none;
}

/* Drag affordance for the mobile bottom sheet -- hidden on desktop, where the
   panel doesn't resize, just slides in/out (see the mobile media query below). */
.sheet-drag-handle {
  display: none;
}

.timetable-header {
  display: flex;
  flex-direction: column;
  padding: 0.9rem 1rem;
  border-bottom: 1px solid var(--border);
  flex-shrink: 0;
}

/* refresh/close used to sit as a normal flex sibling of .timetable-header-content, which
   meant the WHOLE hero -- icon badge, title, status chip, and every tag row below it --
   stayed narrowed for its entire height just to leave room for two small corner icons at
   the very top. Harmless for text tags (they just wrap), but a real train image (see
   .vp-composition-images) has a hard right edge, so that same narrowing read as the
   picture being awkwardly cut off well short of the panel's actual edge, with a much
   bigger gap on the right than the left. Taking the buttons out of flow (absolute,
   positioned in the corner -- see .timetable-header-actions below) fixes that: only
   .hero-right (the status chip, see heroHtml) still reserves room for them via its own
   margin, and everything else, including the tags row, is free to use the full width. */
.timetable-header-row {
  position: relative;
}

/* Holds either the full vehicle hero (see heroHtml in app.js, for a vehicle's quick-card
   or full timetable) or the smaller simpleHeaderHtml (station board, Nearby, Favorites)
   -- one container, so there's never a duplicate identity block between here and the
   scrollable body below. */
.timetable-header-content {
  min-width: 0;
}

.simple-header {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  min-width: 0;
  padding-top: 0.1rem;
  /* Same reservation as .hero-right, and for the same reason -- this is a single-line
     header with nothing else to reserve the corner buttons' space for it. */
  margin-right: 4.5rem;
}

.simple-header-text {
  min-width: 0;
}

.simple-header-title {
  font-weight: 700;
  font-size: 1rem;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.simple-header-subtitle {
  font-size: 0.75rem;
  color: var(--text-muted);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* Next to .hero-line (see heroHtml) when a trip's first-stop date is known -- e.g. a
   train that hasn't started yet, shown so a wrong-day journey is obvious at a glance. */
.hero-date {
  font-size: 0.7rem;
  font-weight: 500;
  color: var(--text-muted);
  white-space: nowrap;
  margin-left: 0.4rem;
}

.timetable-header-actions {
  position: absolute;
  top: 0.1rem;
  right: 0;
  display: flex;
  align-items: center;
  gap: 0.4rem;
}

.timetable-close,
.timetable-refresh {
  background: none;
  border: none;
  font-size: 0.85rem;
  line-height: 1;
  cursor: pointer;
  padding: 0 0.25rem;
  opacity: 0.75;
}

@media (hover: hover) {
  .timetable-close:hover,
  .timetable-refresh:hover {
    opacity: 1;
  }
}

.timetable-body {
  padding: 0.9rem 1rem;
  /* The last row clears the home indicator, and on a phone the part of the sheet slid off the
     bottom of the screen (--sheet-hidden, set by setSheetSnap in app.js) -- without that room
     the end of a long list could never be scrolled into view. */
  padding-bottom: calc(0.9rem + var(--safe-bottom) + var(--sheet-hidden, 0px));
  overflow-y: auto;
  /* A flick that reaches the end of the list stops there, rather than carrying on into the
     sheet or the page behind it (iOS bounces whatever is next in line). */
  overscroll-behavior: contain;
}

.timetable-loading {
  color: var(--text-muted);
  font-size: 0.85rem;
}

.timetable-updated {
  display: flex;
  align-items: center;
  gap: 0.35rem;
  font-size: 0.68rem;
  color: var(--text-muted);
  margin-bottom: 0.5rem;
}

/* Reuses the countdown's own live-pulse glyph (see .countdown-live-icon) -- same
   signal, "this figure updates itself", just at this row's smaller font-size, which
   the icon's em-based sizing already follows automatically. */
.timetable-freshness-item {
  display: inline-flex;
  align-items: center;
  gap: 0.25rem;
}

.timetable-freshness-sep {
  opacity: 0.5;
}

.crowd-pill {
  display: inline-block;
  padding: 0.05rem 0.4rem;
  border-radius: 999px;
  font-size: 0.62rem;
  font-weight: 700;
}

.timetable-notice {
  font-size: 0.78rem;
  color: var(--warning-text);
  background: rgba(249, 115, 22, 0.12);
  border: 1px solid rgba(249, 115, 22, 0.3);
  border-radius: var(--radius-md);
  padding: 0.5rem 0.6rem;
  margin-bottom: 0.6rem;
}

.timetable-notice-disruption {
  font-weight: 600;
}

/* Informational rather than a warning -- a planned split is normal operation, so it
   reads in the brand colour instead of the disruption amber. */
.timetable-notice-split {
  color: var(--text-primary);
  background: var(--brand-soft);
  border-color: var(--border-soft);
  font-weight: 600;
}

.stop-row-tag-split {
  background: var(--brand-soft);
  font-weight: 700;
}

.timetable-notice-sub {
  font-weight: 400;
  opacity: 0.85;
}

.stop-list {
  display: flex;
  flex-direction: column;
}

/* Each row draws its own short vertical line segment above/below its dot (see
   .timeline-rail below) instead of one absolutely-positioned line spanning the whole
   list -- consecutive segments visually read as one continuous line since rows sit
   flush against each other, but this way nothing needs pixel-math against the list's
   total height, and it keeps working correctly for variable-height rows (a wrapped
   long station name, a row with secondary tags, etc). --route-color is set inline on
   .stop-list itself (see openTimetable in app.js) to the same color the drawn map
   route line uses, so the panel's timeline visually matches the route on the map. */
.stop-row {
  display: flex;
  align-items: stretch;
  gap: 0.6rem;
  padding: 0.5rem 0.4rem;
}

.timeline-rail {
  flex: 0 0 auto;
  width: 16px;
  display: flex;
  flex-direction: column;
  align-items: center;
}

.timeline-line {
  flex: 1 1 auto;
  width: 2px;
  min-height: 6px;
  background: var(--route-color, var(--border));
}

.timeline-line-hidden {
  visibility: hidden;
}

.timeline-dot {
  flex: 0 0 auto;
  width: 9px;
  height: 9px;
  border-radius: 50%;
  margin: 3px 0;
  background: var(--route-color, var(--brand));
}

.timeline-dot-passed {
  width: 7px;
  height: 7px;
  background: var(--text-muted);
  opacity: 0.7;
}

.timeline-dot-next {
  width: 13px;
  height: 13px;
  box-shadow: 0 0 0 4px var(--brand-soft);
}

.timeline-dot-skipped {
  width: 7px;
  height: 7px;
  background: transparent;
  border: 2px solid var(--text-muted);
}

.stop-row-main {
  flex: 1 1 auto;
  min-width: 0;
  padding-top: 0.05rem;
}

.stop-row-name {
  display: flex;
  align-items: center;
  gap: 0.35rem;
  font-size: 0.92rem;
  font-weight: 600;
  min-width: 0;
}

/* Truncate rather than wrap -- a long station/destination name (e.g. "Den Haag
   Hollands Spoor") would otherwise wrap across several lines and blow out the row. */
.stop-row-name span:last-child {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.stop-row-tags {
  display: flex;
  flex-wrap: wrap;
  gap: 0.3rem;
  margin-top: 0.25rem;
}

.stop-row-tag {
  display: inline-block;
  padding: 0.05rem 0.4rem;
  border-radius: 999px;
  background: var(--border-soft);
  color: var(--text-muted);
  font-size: 0.68rem;
  font-weight: 600;
}

/* Same tinted-badge treatment as .stop-row-tag, in the app's one red (#dc2626 -- already
   used for a severe delay and a cancelled stop, so this reads as the same "pay attention"
   color, not a new one). Declared AFTER .stop-row-tag, not just alongside .stop-row-tag-split
   above -- both have equal selector specificity to .stop-row-tag and this one, unlike that
   one, overrides `color`, so it has to come later in the cascade to actually win the tie
   rather than losing to .stop-row-tag's own color coming after it in source order. */
.stop-row-tag-alert {
  background: color-mix(in srgb, #dc2626 15%, transparent);
  color: #dc2626;
  font-weight: 700;
}

.stop-row-passed {
  opacity: 0.45;
}

.stop-row-skipped .stop-row-name {
  text-decoration: line-through;
  opacity: 0.5;
}

.stop-row-next {
  background: var(--brand-soft);
  border-radius: var(--radius-sm);
}

.stop-row-next .stop-row-name {
  font-weight: 700;
}

/* Passed-stop collapse (see openTimetable in app.js): a single tappable row standing
   in for a run of already-passed stops, expanding them in place on click -- no
   re-fetch, they're already in the DOM. */
.stop-row-summary-btn {
  flex: 1 1 auto;
  text-align: left;
  background: none;
  border: none;
  padding: 0.5rem 0.4rem;
  font-size: 0.8rem;
  font-weight: 600;
  color: var(--text-muted);
  cursor: pointer;
}

@media (hover: hover) {
  .stop-row-summary-btn:hover {
    color: var(--text-primary);
  }
}

/* Station departure board: no timeline (a flat list of separate future departures,
   not one trip's stop sequence) -- otherwise the same row anatomy as .stop-row, with
   a leading category badge instead of a rail/dot. Rows still get their own divider,
   since there's no connecting line to read as the separator between them. */
.departure-row {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  padding: 0.5rem 0.4rem;
  border-bottom: 1px solid var(--border-soft);
}

.departure-row-badge {
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  justify-content: center;
  min-width: 34px;
  padding: 0.2rem 0.45rem;
  border-radius: var(--radius-sm);
  font-size: 0.72rem;
  font-weight: 800;
  text-align: center;
}

/* The one time shown per row (see timeInfoHtml in app.js) -- a primary tabular-nums
   value, an inline delay badge when late enough to matter, and the original scheduled
   time as a smaller struck-through caption underneath only when it actually differs. */
.row-time {
  flex: 0 0 auto;
  min-width: 54px;
  text-align: right;
  padding-left: 0.3rem;
}

.row-time-empty {
  color: var(--text-muted);
  font-size: 0.85rem;
}

.row-time-value {
  font-variant-numeric: tabular-nums;
  font-weight: 700;
  font-size: 0.95rem;
  color: var(--text-primary);
}

.row-time-delay {
  margin-left: 0.2rem;
  font-weight: 700;
  font-size: 0.72rem;
  color: #f59e0b;
}

.row-time-delay.delay-ontime {
  color: #16a34a;
}

.row-time-delay.delay-slight {
  color: #d97706;
}

.row-time-delay.delay-severe {
  color: #dc2626;
}

.row-time-was {
  display: block;
  color: var(--text-muted);
  font-size: 0.65rem;
  text-decoration: line-through;
  margin-top: 0.1rem;
}

/* A stop that genuinely waits shows both times, stacked. The label is what makes it
   readable at a glance without a header row -- deliberately quiet, since on most rows
   there's only one time and no label at all. */
.row-time-line {
  display: block;
}

.row-time-split .row-time-line + .row-time-line {
  margin-top: 0.15rem;
}

.row-time-label {
  margin-right: 0.25rem;
  font-size: 0.6rem;
  font-weight: 700;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--text-muted);
}

.row-time-split .row-time-value {
  font-size: 0.85rem;
}

.row-event-cancelled {
  color: #ef4444;
  font-size: 0.8rem;
  font-weight: 700;
  text-decoration: line-through;
}

.row-event-skip {
  color: var(--text-muted);
  font-size: 0.8rem;
  font-weight: 600;
}

.timetable-next-badge {
  display: inline-block;
  flex-shrink: 0;
  padding: 0.05rem 0.4rem;
  border-radius: 999px;
  background: var(--brand);
  color: var(--brand-text);
  font-size: 0.62rem;
  line-height: 1.4;
  font-weight: 700;
  letter-spacing: 0.03em;
}

/* Traffic-light coloring for every "last updated"/"fetched" timestamp -- same
   palette as the delay chips above, just applied as text color instead of a
   filled pill (these sit inline in otherwise-plain status text). */
.freshness-good {
  color: #16a34a;
}

.freshness-warn {
  color: #f97316;
}

.freshness-bad {
  color: #dc2626;
}

/* Stop card -- the shared component for the Nearby and Favorites views (see
   stopCardHtml/showNearby/showFavorites in app.js). One card per stop: an icon,
   name + distance, a favorite-star toggle, and (for bus/tram/metro/ferry stops)
   a row of live upcoming-departure pills fetched inline. NS stations show a
   plain chevron instead of eager departure pills -- tapping opens the existing
   full station panel rather than pre-fetching NS's budget-limited API for
   every station in the list. */
.stop-list-view {
  display: flex;
  flex-direction: column;
  gap: 0.6rem;
}

/* Station-type filter chips (see modeFilterRowHtml in app.js) -- shared shape for both
   Nearby's and Favorites' copies. */
.stop-type-filter-control {
  padding: 0.2rem 0.1rem 0.9rem;
  margin-bottom: 0.6rem;
  border-bottom: 1px solid var(--border-soft);
}

.stop-type-filter-control label {
  display: block;
  margin-bottom: 0.4rem;
  font-size: 0.8rem;
  font-weight: 600;
  color: var(--text-secondary);
}

/* Favorites' own search box (see renderFavorites/searchStopsAndStations in app.js) -- same
   "control row, bottom border, margin below" shape as .nearby-radius-control/.nearby-mode-
   filter-control just below, so the top of Favorites reads as one family with the top of
   Nearby rather than a one-off. */
.favorites-search {
  padding: 0.2rem 0.1rem 0.9rem;
  margin-bottom: 0.6rem;
  border-bottom: 1px solid var(--border-soft);
}

.favorites-search-input {
  display: block;
  width: 100%;
  padding: 0.55rem 0.8rem;
  border-radius: var(--radius-md);
  border: 1px solid var(--border-soft);
  background: var(--popup-bg);
  color: var(--text-primary);
  font-family: inherit;
  /* 16px, not this app's usual smaller rem sizes -- iOS Safari auto-zooms the whole page in
     on focusing any input/textarea/select whose computed font-size is under 16px, on the
     (reasonable in general, wrong here) assumption that smaller text means the person needs
     help reading what they're typing. 16px is exactly the cutoff, not just "bigger": anything
     under it triggers the zoom, 16px and up never does. */
  font-size: 16px;
}

.favorites-search-input::placeholder {
  color: var(--text-muted);
}

.favorites-search-input:focus {
  outline: none;
  border-color: var(--brand);
  box-shadow: 0 0 0 3px var(--brand-soft);
}

/* Safari/Chrome's own "clear" (x) button inside a type=search input -- left as native
   rather than reimplemented, just recolored on a dark panel so it doesn't render as a
   stray dark-on-dark glyph. */
body.dark-map .favorites-search-input::-webkit-search-cancel-button {
  filter: invert(1) brightness(1.6);
}

.nearby-radius-control {
  padding: 0.2rem 0.1rem 0.9rem;
  margin-bottom: 0.6rem;
  border-bottom: 1px solid var(--border-soft);
}

.nearby-radius-control label {
  display: block;
  margin-bottom: 0.4rem;
  font-size: 0.8rem;
  font-weight: 600;
  color: var(--text-secondary);
}

.nearby-radius-control label span {
  font-weight: 700;
  color: var(--text-primary);
}

.nearby-radius-control input[type='range'] {
  width: 100%;
  accent-color: var(--brand);
}

.stop-card {
  padding: 0.6rem 0.7rem;
  border-radius: var(--radius-md);
  background: var(--popup-bg);
  border: 1px solid var(--border-soft);
  box-shadow: var(--shadow-sm);
  cursor: pointer;
}

/* Reachable by keyboard (see the stop-card keydown handler in app.js), so it shows where it is. */
/* Read by screen readers, not shown (the live-status announcement next to the title). */
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
  border: 0;
}

.stop-card:focus-visible {
  outline: 2px solid currentColor;
  outline-offset: 2px;
}

.stop-card-main {
  display: flex;
  align-items: center;
  gap: 0.55rem;
}

.stop-card-icon {
  flex-shrink: 0;
  width: 30px;
  height: 30px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 1rem;
  line-height: 1;
  background: var(--chip-color, #6b7280);
  color: var(--chip-text, #fff);
}

/* public/icons/train-station.png -- solid black raster, always inverted to
   white here: both backgrounds this renders on (NS blue for a station, the
   neutral gray for a plain stop) are dark/mid enough that white is correct
   for either, unlike train's marker case which genuinely needed to differ by
   color. Not theme-dependent either -- .stop-card-icon's own background
   doesn't change with light/dark mode. */
.stop-icon-img {
  width: 60%;
  height: 60%;
  display: block;
  filter: invert(1);
}

.stop-card-info {
  flex: 1 1 auto;
  min-width: 0;
}

.stop-card-name {
  font-weight: 700;
  font-size: 0.85rem;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.stop-card-meta {
  font-size: 0.72rem;
  color: var(--text-muted);
}

.stop-card-fav {
  flex-shrink: 0;
  width: 40px;
  height: 40px;
  border: none;
  background: none;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
}

/* One star shape for both states (previously outline used public/icons/star.png
   via a mask and "solid" was a separately hand-drawn SVG -- their different
   internal padding made the icon visibly change size the moment it toggled,
   which is exactly the bug this replaced). Same path, same size, always --
   only the fill/stroke toggles between hollow (not favorited) and solid gold
   (favorited). */
.stop-card-fav-icon {
  display: block;
  width: 70%;
  height: 70%;
}

.stop-card-fav-icon path {
  fill: none;
  stroke: var(--text-muted);
  stroke-width: 1.6;
  stroke-linejoin: round;
  transition: fill 0.15s ease, stroke 0.15s ease;
}

.stop-card-fav.active .stop-card-fav-icon path {
  fill: #f59e0b;
  stroke: #f59e0b;
}

/* A small standard tap "pop" the moment a stop gets favorited -- see
   toggleFavoriteById, which adds this class only on the way to favorited
   (not in reverse) and removes it once it finishes so it can play again
   next time. Deliberately subtle (a single small overshoot, no undershoot
   below 1) rather than a big bounce -- this is on top of the button's own
   universal :active press feedback (see button:active above), not a
   replacement for it. */
@keyframes fav-pop {
  0% {
    transform: scale(1);
  }
  50% {
    transform: scale(1.18);
  }
  100% {
    transform: scale(1);
  }
}

.stop-card-fav.fav-pop {
  animation: fav-pop 0.22s ease-out;
}

.stop-card-chevron {
  flex-shrink: 0;
  color: var(--text-muted);
  font-size: 1.1rem;
  line-height: 1;
}

.stop-card-departures {
  display: flex;
  flex-wrap: wrap;
  gap: 0.35rem;
  margin-top: 0.5rem;
  padding-left: calc(30px + 0.55rem); /* lines up under the name, past the icon */
}

.stop-card-dep-row {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  width: 100%;
  padding: 0.3rem 0.1rem;
  background: none;
  border: 0;
  border-top: 1px solid var(--border-soft);
  font: inherit;
  color: var(--text-primary);
  text-align: left;
  cursor: pointer;
}

.stop-card-dep-row:first-child {
  border-top: 0;
}

.stop-card-dep-row:active {
  opacity: 0.6;
}

.stop-card-dep-line {
  flex: 0 0 auto;
  min-width: 30px;
  padding: 0.1rem 0.35rem;
  border-radius: 6px;
  background: var(--chip-color, var(--brand));
  color: var(--chip-text, var(--brand-text));
  font-size: 0.72rem;
  font-weight: 700;
  text-align: center;
}

.stop-card-dep-dest {
  flex: 1 1 auto;
  min-width: 0;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
  font-size: 0.78rem;
}

/* Shared countdown chip -- same colours wherever a time is counting down. Same size as
   .row-time-value, since it sits right next to that exact time on a departure row and
   reads as a matched pair, not a smaller caption underneath it. */
.countdown {
  display: inline-flex;
  align-items: center;
  gap: 0.28rem;
  flex: 0 0 auto;
  font-size: 0.95rem;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

.countdown-live-icon {
  width: 1.35em;
  height: 1.35em;
  flex: 0 0 auto;
  transform: rotate(-45deg); /* fans the waves diagonally instead of straight up */
  fill: currentColor;
  stroke: currentColor;
  stroke-width: 1.6;
  stroke-linecap: round;
  overflow: visible;
}

.countdown-live-icon path {
  fill: none;
}

/* Each arc brightens in turn, inner then outer, reading as a signal pulsing outward on
   loop -- only while the countdown is actually live (see isLive in app.js): a grey,
   not-live icon stays static, since animating it would claim a liveness the figure
   behind it doesn't have. Opacity only, never transform, so it never fights the fixed
   -45deg rotation set above. */
@keyframes countdown-live-pulse {
  0%,
  100% {
    opacity: 0.3;
  }
  50% {
    opacity: 1;
  }
}

.countdown:not(.delay-unknown) .countdown-live-icon path,
/* Unlike the countdown, a freshness row has no "not really live" state to gate on --
   it's always a real fetch or GPS-ping timestamp -- so it just always pulses. */
.timetable-freshness-item .countdown-live-icon path {
  animation: countdown-live-pulse 1.6s ease-in-out infinite;
}

.countdown:not(.delay-unknown) .countdown-live-icon path:nth-of-type(2),
.timetable-freshness-item .countdown-live-icon path:nth-of-type(2) {
  animation-delay: 0.35s;
}

.countdown.delay-ontime {
  color: #16a34a;
}

.countdown.delay-slight {
  color: #d97706;
}

.countdown.delay-severe {
  color: #dc2626;
}

.countdown.delay-unknown {
  color: var(--text-muted);
}

.departure-row-tappable {
  width: 100%;
  background: none;
  font: inherit;
  color: inherit;
  text-align: left;
  border-left: 0;
  border-right: 0;
  border-top: 0;
  cursor: pointer;
}

.departure-row-tappable:active {
  background: var(--brand-soft);
}

/* "All departures" for one stop (see openStopPanel) -- one box per line+direction,
   each listing its own next few times, rather than the Nearby card's single flat
   time-ordered list. */
.stop-departure-groups {
  display: flex;
  flex-direction: column;
  gap: 0.6rem;
}

.stop-departure-group {
  display: flex;
  align-items: center;
  gap: 0.7rem;
  padding: 0.7rem 0.8rem;
  border-radius: var(--radius-md);
  border: 1px solid var(--border-soft);
  background: var(--popup-bg);
}

.stop-departure-group-info {
  flex: 1 1 auto;
  min-width: 0;
}

.stop-departure-group-dest {
  font-size: 0.85rem;
  font-weight: 600;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}

.stop-departure-group-times {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.6rem;
  margin-top: 0.3rem;
}

.stop-departure-time {
  padding: 0;
  border: 0;
  background: none;
  font: inherit;
  cursor: pointer;
}

.stop-departure-time:active {
  opacity: 0.6;
}

.stop-card-empty,
.stop-card-loading {
  padding: 1.5rem 0.5rem;
  text-align: center;
  color: var(--text-muted);
  font-size: 0.85rem;
}

.stop-card-retry {
  display: block;
  margin: 0.6rem auto 0;
  padding: 0.4rem 0.9rem;
  border: none;
  border-radius: var(--radius-sm);
  background: var(--brand);
  color: var(--brand-text);
  font-family: inherit;
  font-weight: 700;
  font-size: 0.8rem;
  cursor: pointer;
}

/* Mobile overrides -- kept at the end of the file so they win the cascade
   against the desktop rules above at equal selector specificity. */
@media (max-width: 600px) {
  .tab-bar {
    display: flex;
  }

  /* Nearby's and Favorites' station-type filter chips (see modeFilterRowHtml in app.js):
     one row that swipes/drags to see the rest, rather than .mode-filter-grid's ordinary
     wrap (which is what this still is above 600px -- a desktop-width panel has room for
     all 5 chips on one line without needing this at all). On a phone the panel is only as
     wide as the screen, so wrapping would burn a second (or third) line of vertical space
     in a view that's already stacking a mode filter, a radius slider/search box and the
     stop list itself; one swipeable row keeps more of that list on screen at once. Same
     drag mechanics as .vp-composition-images (see the delegated pointer handlers in
     app.js), which is why the properties below mirror that rule instead of introducing a
     new pattern. */
  .filter-chip-row {
    flex-wrap: nowrap;
    overflow-x: auto;
    overflow-y: hidden;
    cursor: grab;
    touch-action: pan-x;
    scrollbar-width: none; /* Firefox */
    -ms-overflow-style: none; /* legacy Edge */
  }

  .filter-chip-row::-webkit-scrollbar {
    display: none; /* Chrome/Safari */
  }

  /* Desktop's Nearby/Favorites pill duplicates the dedicated bottom tab bar's
     job on mobile, so it's hidden there entirely rather than repositioned. */
  .nav-pill {
    display: none;
  }

  /* The fixed tab bar sits on top of the map's bottom edge -- without this,
     the utility pill (locate/theme) would end up underneath it, impossible
     to tap. Bigger than the base (non-mobile) offset for the same element
     since it now needs to clear both the tab bar itself and .map-attribution
     sitting just above it. */
  /* The tab bar grows by the home-indicator inset (see .tab-bar's padding-bottom), so
     everything stacked above it has to move up by the same amount -- on a notched iPhone the
     flat offsets left the attribution line underneath the tab bar. */
  .utility-pill {
    bottom: calc(96px + var(--safe-bottom));
  }

  /* Same reason as .utility-pill just above -- the fixed tab bar would otherwise sit
     on top of it. No .map-attribution-style extra stacking needed here since that one
     is right-anchored and this is bottom-LEFT -- they never share a row to clear. */
  #clock {
    bottom: calc(96px + var(--safe-bottom));
  }

  /* .map-attribution moves up to sit just above the tab bar instead of at
     the screen's true bottom edge, which the tab bar now occupies. */
  .map-attribution {
    bottom: calc(62px + var(--safe-bottom));
  }

  /* .stop-row/.departure-row no longer need a mobile-specific override here -- they're
     flex rows now (name/tags flexing, one fixed-ish time column on the right), not a
     grid with px column widths sized for a 500px desktop panel, so they already reflow
     correctly at any width on their own. */

  /* Bottom sheet: the map stays full-screen underneath at all times (the sheet
     floats over it, rather than the old fixed 50/50 split) -- JS drives the open
     amount as `transform: translateY(...)` in px, snapping between peek/half/full
     (see setSheetSnap/setSheetVisiblePx and the pointer handlers on #timetable-header
     in app.js). This block only supplies the shape, the closed resting position,
     and the drag-handle affordance.

     height: 100dvh, not 100vh -- iOS Safari's `vh` is sized to the LARGEST viewport
     (bottom toolbar collapsed), not the one actually on screen. With the toolbar
     showing (the normal state, not just a scroll-triggered transient), a 100vh-tall
     sheet is taller than the real visible area, so at a shallow snap like "peek" its
     lowest content row rendered right at the toolbar's edge instead of clear above
     it -- looking clipped even though nothing was actually cut by an overflow rule.
     100dvh tracks the real visible viewport as the toolbar shows/hides. The 100vh
     line stays first as a fallback for the handful of engines without dvh support,
     which the second declaration then overrides wherever it's understood. */
  .timetable-panel {
    left: 0;
    right: 0;
    top: auto;
    /* Ends at the tab bar's top edge (--tab-bar-h, measured by syncTabBarHeight in app.js), so
       every snap point, the drag, and the scrollable list all work in the space above it. */
    bottom: var(--tab-bar-h, 0px);
    width: 100%;
    max-width: 100%;
    /* A phone's sheet runs edge to edge (the base rule's padding is for the side panel); its
       top is kept clear of the status bar by how far it may open instead (see
       sheetMaxVisiblePx in app.js). */
    padding-top: 0;
    padding-right: 0;
    height: calc(100vh - var(--tab-bar-h, 0px));
    height: calc(100dvh - var(--tab-bar-h, 0px));
    border-left: none;
    border-radius: var(--radius-xl) var(--radius-xl) 0 0;
  }

  /* In the installed app the sheet simply spans the page, top to bottom: there is no browser
     toolbar for dvh to track there. */
  .standalone-app .timetable-panel {
    top: 0;
    height: auto;
  }

  #timetable-header {
    /* Owns its own touch handling (see the pointerdown/move/up drag gesture in
       app.js) -- without this, the browser's native scroll/pull-to-refresh
       gesture fights the drag, making it feel laggy or triggering a refresh.
       pan-x, not none: touch-action is computed as the INTERSECTION of an
       element and all its ancestors, so a plain `none` here silently overrode
       .vp-composition-images' own touch-action: pan-x too, leaving the train
       image strip (see trainUnitHtml) completely unscrollable by touch -- stuck
       showing whatever slice happened to render, with no way to drag it into
       view. This still blocks native handling of the vertical axis the sheet
       drag itself needs (see the pointerdown handler below, which now also
       explicitly steps aside for that element), while letting a horizontal
       swipe pass through to whatever wants to scroll horizontally beneath it. */
    touch-action: pan-x;
    user-select: none;
    cursor: grab;
  }

  /* The tab bar under the sheet already clears the home indicator. */
  .timetable-body {
    padding-bottom: calc(0.9rem + var(--sheet-hidden, 0px));
  }

  .sheet-drag-handle {
    display: block;
    width: 36px;
    height: 4px;
    border-radius: 999px;
    background: var(--border);
    margin: 0.5rem auto 0.4rem;
  }
}

/* Admin Panel: the button next to the title card, and the modal it opens (a wider version of the
   Settings modal -- see #admin-backdrop in index.html and the admin panel section of app.js).
   Traffic-light colours are the panel's own semantic set, not themed: green = fine, orange =
   worth a look, red = needs fixing, grey = nothing to judge yet. */
.admin-open {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 44px;
  padding: 0;
  border: 1px solid var(--border-soft);
  border-radius: var(--radius-lg);
  background: var(--chrome-bg);
  color: var(--text-primary);
  box-shadow: var(--shadow-md);
  -webkit-backdrop-filter: blur(10px);
  backdrop-filter: blur(10px);
  cursor: pointer;
}

@media (hover: hover) {
  .admin-open:hover {
    background: var(--brand-soft);
  }
}

.admin-modal {
  width: min(680px, 100%);
  max-height: min(88vh, 100%);
}

.admin-body {
  --st-ok: #22c55e;
  --st-warn: #f59e0b;
  --st-bad: #ef4444;
  --st-unknown: #9ca3af;
  display: flex;
  flex-direction: column;
  gap: 0.9rem;
}

.admin-banner {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  padding: 0.8rem 0.95rem;
  border-radius: var(--radius-md);
  border: 1px solid color-mix(in srgb, var(--st) 45%, transparent);
  background: color-mix(in srgb, var(--st) 13%, transparent);
}

.admin-banner-title {
  font-weight: 800;
  font-size: 0.98rem;
}

.admin-banner-sub {
  font-size: 0.76rem;
  color: var(--text-muted);
  margin-top: 0.1rem;
}

.st-ok { --st: var(--st-ok); }
.st-warn { --st: var(--st-warn); }
.st-bad { --st: var(--st-bad); }
.st-unknown { --st: var(--st-unknown); }

/* The light itself. A soft glow on green/orange/red, none on grey. */
.admin-light {
  flex: 0 0 auto;
  width: 12px;
  height: 12px;
  border-radius: 50%;
  background: var(--st);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--st) 25%, transparent);
}

.st-unknown .admin-light {
  box-shadow: none;
}

.st-bad .admin-light {
  animation: admin-pulse 1.6s infinite;
}

@keyframes admin-pulse {
  0%, 100% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--st) 25%, transparent); }
  50% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--st) 10%, transparent); }
}

.admin-group-title {
  font-size: 0.72rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  font-weight: 700;
  color: var(--text-muted);
  margin: 0.2rem 0 0.4rem;
}

.admin-rows {
  display: flex;
  flex-direction: column;
  border: 1px solid var(--border-soft);
  border-radius: var(--radius-md);
  overflow: hidden;
}

.admin-row:not(:last-child) {
  border-bottom: 1px solid var(--border-soft);
}

.admin-row-head {
  display: flex;
  align-items: center;
  gap: 0.65rem;
  width: 100%;
  padding: 0.65rem 0.8rem;
  border: none;
  background: transparent;
  color: inherit;
  font: inherit;
  text-align: left;
  cursor: pointer;
}

@media (hover: hover) {
  .admin-row-head:hover {
    background: var(--brand-soft);
  }
}

.admin-row-text {
  flex: 1 1 auto;
  min-width: 0;
}

.admin-row-label {
  font-weight: 700;
  font-size: 0.86rem;
}

.admin-row-summary {
  font-size: 0.76rem;
  color: var(--text-muted);
  overflow-wrap: anywhere;
}

.admin-row.st-warn .admin-row-summary,
.admin-row.st-bad .admin-row-summary {
  color: var(--text-secondary);
}

.admin-chip {
  flex: 0 0 auto;
  font-size: 0.7rem;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  padding: 0.12rem 0.45rem;
  border-radius: 999px;
  background: var(--brand-soft);
  color: var(--text-secondary);
  white-space: nowrap;
}

.admin-chevron {
  flex: 0 0 auto;
  width: 8px;
  height: 8px;
  border-right: 2px solid var(--text-muted);
  border-bottom: 2px solid var(--text-muted);
  transform: rotate(45deg);
  transition: transform 0.15s;
  margin: 0 0.2rem 0.2rem 0;
}

.admin-row.open .admin-chevron {
  transform: rotate(-135deg);
  margin: 0.25rem 0.2rem 0 0;
}

.admin-row-detail {
  display: none;
  padding: 0.55rem 0.8rem 0.8rem 2.1rem;
  font-size: 0.78rem;
  color: var(--text-secondary);
}

.admin-row.open .admin-row-detail,
.admin-row.always-open .admin-row-detail {
  display: block;
}

.admin-row.always-open .admin-chevron {
  display: none;
}

.admin-desc {
  color: var(--text-muted);
  margin-bottom: 0.5rem;
}

.admin-hint {
  margin: 0 0 0.6rem;
  padding: 0.5rem 0.65rem;
  border-radius: var(--radius-sm);
  border-left: 3px solid var(--st);
  background: color-mix(in srgb, var(--st) 12%, transparent);
  color: var(--text-primary);
}

.admin-metrics {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  gap: 0.2rem 1rem;
  margin: 0;
}

.admin-metrics dt {
  color: var(--text-muted);
  white-space: pre;
}

.admin-metrics dd {
  margin: 0;
  text-align: right;
  font-variant-numeric: tabular-nums;
  font-weight: 600;
  overflow-wrap: anywhere;
}

.admin-reqs {
  margin-top: 0.6rem;
  padding-top: 0.5rem;
  border-top: 1px dashed var(--border);
}

.admin-reqs-title {
  font-size: 0.68rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  font-weight: 700;
  color: var(--text-muted);
  margin-bottom: 0.25rem;
}

.admin-reqs-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 0.4rem;
}

.admin-reqs-cell b {
  display: block;
  font-size: 0.95rem;
  font-variant-numeric: tabular-nums;
}

.admin-reqs-cell span {
  font-size: 0.68rem;
  color: var(--text-muted);
}

/* NS budget gauge: a bar out of 300 with the orange/red thresholds marked on it. */
.admin-gauge {
  margin: 0.2rem 0 0.7rem;
}

.admin-gauge-figure {
  display: flex;
  align-items: baseline;
  gap: 0.35rem;
  margin-bottom: 0.35rem;
}

.admin-gauge-figure b {
  font-size: 1.5rem;
  font-variant-numeric: tabular-nums;
}

.admin-gauge-figure span {
  color: var(--text-muted);
}

.admin-gauge-bar {
  position: relative;
  height: 10px;
  border-radius: 999px;
  background: var(--border-soft);
  overflow: hidden;
}

.admin-gauge-fill {
  height: 100%;
  border-radius: 999px;
  background: var(--st);
  transition: width 0.4s;
}

.admin-gauge-tick {
  position: absolute;
  top: 0;
  bottom: 0;
  width: 2px;
  background: var(--panel-bg);
  opacity: 0.9;
}

.admin-spark {
  display: block;
  width: 100%;
  height: 64px;
  margin-top: 0.6rem;
}

.admin-spark-note {
  display: flex;
  justify-content: space-between;
  font-size: 0.68rem;
  color: var(--text-muted);
}

.admin-events {
  display: flex;
  flex-direction: column;
  gap: 0.35rem;
  font-size: 0.78rem;
}

.admin-event {
  display: flex;
  gap: 0.55rem;
  align-items: baseline;
}

.admin-event time {
  flex: 0 0 auto;
  color: var(--text-muted);
  font-variant-numeric: tabular-nums;
}

.admin-event .admin-light {
  align-self: center;
  width: 9px;
  height: 9px;
  box-shadow: none;
  animation: none;
}

.admin-empty {
  color: var(--text-muted);
  font-size: 0.78rem;
}

.admin-footer {
  font-size: 0.7rem;
  color: var(--text-muted);
  text-align: center;
}

@media (max-width: 600px) {
  .admin-row-detail {
    padding-left: 0.8rem;
  }
  .admin-reqs-grid {
    gap: 0.2rem;
  }
}

/* Map layer setting: a two-way segmented control at the top of Settings (OpenStreetMap | Mapbox). */
.settings-segmented {
  display: flex;
  padding: 3px;
  border-radius: 999px;
  background: var(--border-soft);
  gap: 3px;
}

.settings-segmented button {
  flex: 1 1 0;
  border: none;
  border-radius: 999px;
  padding: 0.45rem 0.6rem;
  background: transparent;
  color: var(--text-secondary);
  font-family: inherit;
  font-weight: 700;
  font-size: 0.8rem;
  cursor: pointer;
}

.settings-segmented button.active {
  background: var(--brand);
  color: var(--brand-text);
}

.settings-segmented button:disabled {
  opacity: 0.45;
  cursor: not-allowed;
}

.settings-basemap-note {
  margin-top: 0.5rem;
  font-size: 0.72rem;
  line-height: 1.35;
  color: var(--text-muted);
}

/* mapbox-gl-leaflet draws the GL map 10% larger than the screen on every side (so panning doesn't
   show blank edges), which pushes Mapbox's own corner logo off-screen. Pulled back in to the visible
   corner, and lifted above the clock that sits bottom-left. Mapbox's terms require the logo to show. */
.leaflet-gl-layer .mapboxgl-ctrl-bottom-left {
  left: calc(8.3333% + 6px);
  bottom: calc(8.3333% + 64px);
}

/* "Mapbox design" drop-down under the Map layer toggle (only shown while Mapbox is active). */
.settings-select-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.75rem;
  margin-top: 0.7rem;
}

.settings-select-row[hidden] {
  display: none;
}

.settings-select-row label {
  font-size: 0.8rem;
  font-weight: 700;
  color: var(--text-secondary);
}

.settings-select-row select {
  flex: 0 1 190px;
  padding: 0.4rem 0.6rem;
  border-radius: var(--radius-sm);
  border: 1px solid var(--border);
  background: var(--panel-bg);
  color: var(--text-primary);
  font-family: inherit;
  font-size: 0.82rem;
}

/* 16px on a touch screen, for the same reason as .favorites-search-input: iOS zooms the whole
   page in on focusing a field whose text is under 16px -- and with page zoom otherwise switched
   off (see html's touch-action), nothing would zoom it back out. */
@media (pointer: coarse) {
  .settings-select-row select {
    font-size: 16px;
  }
}

/* iOS Settings > Accessibility > Motion > Reduce Motion: what a native app honours too. Slides and
   pulses become near-instant changes; nothing is hidden, only not animated. !important because
   the sheet's slide is set inline by setSheetVisiblePx in app.js. */
@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}
