/* Hallmark · genre: atmospheric-ground / editorial-display
 * macrostructure: Map/Diagram · alt: Bento Grid
 * design-system: design.md · designed-as-app · studied: yes
 * DNA-source: image (Placify, 4 screens)
 * pre-emit critique: P5 H4 E4 S5 R4 V4
 *
 * tokens.css is the system; this file is its application. The governing rule:
 * the chrome carries no colour, the content does.
 */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

:root {
  /* Layout only. Every colour lives in tokens.css and reaches the old names
     through the mapping layer below. This block used to redeclare the entire
     pre-redesign palette — dead, but it read as the truth. */
  /* 380, not 340. A row now carries a brand mark as well as a name, an address
     and a price column, and at 340 the content budget was 297px — which
     truncated 40 of 150 station names on desktop while mobile, at 358px,
     truncated 5. The sidebar floats over the map, so this costs map width on a
     screen that has it to spare; the phone was never the constrained case. */
  --sidebar: 380px;
  --header: 0px;
  /* max(), not calc(+): the home indicator already reserves 34px. */
  --bottom-gutter: max(16px, env(safe-area-inset-bottom, 0px));
  /* Toggle height (44) plus 12px of clearance. Move one, move the other. */
  --toggle-clearance: 56px;
}

body {
  font-family: var(--font-body);
  background: var(--cream);
  color: var(--text);
  height: 100dvh;
  display: flex;
  flex-direction: column;
  overflow: hidden;
  overscroll-behavior: none;
  -webkit-user-select: none;
  user-select: none;
}
/* Allow text selection in inputs and text content */
input, textarea, .station-name, .station-address, .popup-body {
  -webkit-user-select: text;
  user-select: text;
}

/* ─── LAYOUT ─── */
.app-body { display: flex; flex: 1; overflow: hidden; position: relative; }

/* ─── SIDEBAR (transparent overlay on desktop, bottom sheet on mobile) ─── */
aside {
  position: absolute;
  top: 0;
  left: 0;
  bottom: 0;
  width: calc(var(--sidebar) + 40px);
  background: transparent;
  border-right: none;
  display: flex;
  flex-direction: column;
  padding: 24px;
  gap: 12px;
  z-index: 800;
  pointer-events: none;
}
aside > * {
  pointer-events: auto;
}

/* ─── SIDEBAR CARDS ─── */
.sidebar-card {
  background: var(--white);
  border: none;
  border-radius: var(--radius-card);
  overflow: hidden;
}
.sidebar-card-intro {
  flex-shrink: 0;
  padding: 24px;
  overflow: visible;
}
.sidebar-card-main {
  flex: 1;
  display: flex;
  flex-direction: column;
  min-height: 0;
}

/* ─── INTRO HEADER (logo + controls) ─── */
.intro-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
}
.intro-controls {
  display: flex;
  align-items: center;
  gap: 4px;
}

/* ─── CONTROL ICON BUTTONS ─── */
.ctrl-icon-btn {
  width: 36px;
  height: 36px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--color-paper-3);
  border: none;
  border-radius: var(--radius-tile);
  cursor: pointer;
  /* A <button> arrives with the UA's 13.33px Arial. Harmless while the content
     is an SVG, and a trap the day it isn't. */
  font: inherit;
  color: var(--warm-gray);
  transition: background-color .15s ease-out, color .15s ease-out, border-color .15s ease-out;
  flex-shrink: 0;
  -webkit-tap-highlight-color: transparent;
  position: relative;
}
.ctrl-icon-btn:hover { background: var(--color-rule); color: var(--text); }

/* ─── LANGUAGE ───
   Four endonyms in the preferences panel, wrapping. This was a floating
   dropdown hung off a globe icon in the header; a panel that is already open
   has no state to open, so the row is just the options. Same chip as
   .prefs-option -- one control shape for every choice in this panel. */
.lang-choice { display: flex; flex-wrap: wrap; gap: 8px; }
.lang-option {
  min-height: 40px;
  padding: 0 16px;
  border: 1px solid var(--border-muted);
  border-radius: var(--radius-pill);
  background: none;
  font: 500 var(--text-sm)/1 var(--font-body);
  color: var(--text);
  cursor: pointer;
}
.lang-option.active {
  border-color: var(--color-ink);
  background: var(--color-ink);
  color: var(--color-paper);
  font-weight: 600;
}

/* ─── INTRO LOGO ─── */
.intro-logo {
  font-family: var(--font-display);
  font-size: var(--text-2xl);
  font-weight: 600;
  color: var(--charcoal);
  margin-bottom: 8px;
  line-height: 1.1;
}

/* ─── INTRO TAGLINE ─── */
.intro-tagline {
  margin-bottom: 0;
  padding-bottom: 0;
}
.intro-title {
  font-family: var(--font-display);
  font-size: var(--text-lg);
  font-weight: 600;
  color: var(--charcoal);
  line-height: 1.3;
  margin-bottom: 4px;
}
/* Roman, and uncoloured. An italicised word inside an upright heading is one of
   the most reliable generated-design tells there is, and a green one also puts
   colour in the chrome, which this system does not do. Weight carries it. */
.intro-title em {
  font-style: normal;
  font-weight: 700;
  color: inherit;
}
.intro-desc {
  font-size: var(--text-xs);
  color: var(--warm-gray);
  line-height: 1.5;
}
.intro-desc strong {
  color: var(--text);
  font-weight: 600;
}

/* A filled track with a sliding-looking thumb, rather than three boxes divided
   by rules. Same control, an order of magnitude less furniture. */
.fuel-toggle {
  display: flex;
  gap: 4px;
  padding: 4px;
  border: none;
  border-radius: var(--radius-pill);
  background: var(--surface-sunken);
  overflow: hidden;
}
.fuel-btn {
  flex: 1;
  min-height: 44px;
  padding: 8px 12px;
  border-radius: var(--radius-pill);
  background: transparent;
  border: none;
  font-family: inherit;
  font-size: var(--text-sm);
  font-weight: 600;
  color: var(--warm-gray);
  cursor: pointer;
  transition: background-color .15s ease-out, color .15s ease-out, border-color .15s ease-out;
}
.fuel-btn.active {
  background: var(--surface-raised);
  color: var(--text);
  box-shadow: var(--shadow-sm);
}
.fuel-btn:hover:not(.active) { background: var(--stone); }

/* Data freshness (footer below station list) */
.data-freshness {
  display: block;
  padding: 8px 16px 12px;
  font-size: var(--text-sm);
  color: var(--warm-gray);
  text-align: center;
  flex-shrink: 0;
}
/* Ink, so it takes --red-text; --red is the fill. */
.data-freshness .stale { color: var(--red-text); }

/* ─── STATION LIST ─── */
.station-list {
  list-style: none;
  margin: 0;
  flex: 1;
  overflow-y: auto;
  /* 16px, matching the controls and the footer. The list used to inset at 4
     and hang wider than everything above it. */
  padding: 0 16px 16px;
  min-height: 0;
}
.station-list::-webkit-scrollbar { width: 3px; }
.station-list::-webkit-scrollbar-thumb { background: var(--stone); border-radius: var(--radius-pill); }

.station-item {
  /* It is a <button> now: reset the UA defaults it arrives with, and make it
     fill its <li> so the hit area is the whole row rather than the text. */
  width: 100%;
  text-align: left;
  font: inherit;
  color: inherit;
  appearance: none;
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 16px;
  border-radius: var(--radius-card);
  cursor: pointer;
  border: 1px solid transparent;
  background: var(--surface-raised);
  box-shadow: var(--shadow-sm);
  transition: transform var(--ease), box-shadow var(--ease), background var(--ease);
  margin-bottom: 8px;
}
.station-item:hover { background: var(--surface-raised); box-shadow: var(--shadow-md); }
.station-item:active { transform: scale(.985); }
/* Selection is a surface, set by the design-system block below. This used to
   read `background: var(--surface-raised); border-color: var(--green)` and
   neither declaration had rendered since that block landed: `border: none` and
   `background: var(--color-paper-3)` win there at equal specificity, later in
   the file. It also borrowed the tier set for something that is not a price
   tercile, which design.md forbids outright. */

/* The one place colour enters the chrome, and it is earned: these are the
   station's own identity, not our decoration — the same argument that lets the
   map carry every hue on screen. 32px, aligned to the name's cap-height rather
   than the row's top, so it reads as part of the name and not as a bullet. */
.station-brand-mark {
  /* 28, not 32. On a 340px sidebar the row budget is 297px and every pixel here
     comes out of the name and address, which are the two things that identify
     the station. The marks stay legible at 28. */
  width: 28px;
  height: 28px;
  flex: none;
  object-fit: contain;
  margin-top: 1px;
}

.station-info { flex: 1; min-width: 0; }
.station-name {
  font-size: var(--text-md);
  font-weight: 600;
  letter-spacing: -0.01em;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.station-address {
  font-size: var(--text-sm);
  color: var(--warm-gray);
  margin-top: 1px;
  /* Two lines, not one with an ellipsis. The price column is three lines tall
     (price, card price, age) and the name plus a one-line address is two, so a
     truncated address was losing characters into 43px of empty space directly
     below it. Measured on a 358px row: "Talstrasse 88, 7563 Samnaun Dorf" fits
     in two lines with the row height unchanged. */
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}
/* Unconfirmed prices are hidden, not dimmed. Dimming the whole row dropped the
   price to ~3.6:1 and the address to ~2.1:1 contrast in light mode — it made a
   station harder to READ without making it harder to PICK, and it could still
   rank #1. Shown only when the user has explicitly opted in via "Show anyway",
   where a left border marks it without hurting legibility. */
.station-item.unconfirmed {
  /* No left stripe. It was a thick asymmetric accent border -- a named AI tell --
     and it duplicated the amber freshness label, which says it in words. */
  opacity: .82;
}

.station-prices {
  text-align: right;
  flex-shrink: 0;
}
/* The number the whole app exists to deliver. It used to be 14px/700 — the same
   weight as the street address beside it — so nothing on the row said which
   part was the answer. Tabular figures so the column of prices lines up on the
   decimal, and negative tracking because large numerals set loose. */
.station-effective-price {
  font-family: var(--font-body);
  font-size: var(--text-price);
  font-weight: 600;
  line-height: 1;
  letter-spacing: -0.025em;
  color: var(--color-ink);
  font-variant-numeric: tabular-nums;
  font-feature-settings: 'tnum' 1;
}
/* "CHF" is a unit, not the answer. It rides small and muted beside the number. */
.station-effective-price .cur {
  display: block;
  font-size: var(--text-2xs);
  font-weight: 600;
  letter-spacing: .1em;
  text-transform: uppercase;
  color: var(--color-ink-2);
  margin-bottom: 3px;
}

/* The third thing in the price stack, and it was sitting 1px below the second.
   2px, not 6: this margin and --text-price together decide whether the right
   column matches the left. At --text-xl, 6px overshoots the left column by 4px
   and 2px lands them level. Solved by measurement, so move one and re-check the
   other. */
.price-freshness {
  font-size: var(--text-xs);
  font-weight: 500;
  margin-top: 2px;
}
/* This used to run the marker scale — green/amber/red — and that made one colour
   set mean two different things on one screen: a price tercile on the map, an
   age in the list. The tier set is the semantic set; freshness cannot borrow it.

   It also fired constantly. Stale rows are excluded before the list is built, so
   everything visible is fresh or ageing, and amber landed on nearly every row —
   loud enough to compete with the price it was annotating.

   So the age is metadata, and reads like the distance and the address next to
   it. The text already says "1d ago"; the number carries it. Only `stale` keeps
   a warning colour, and it is only reachable through "Show anyway", where being
   loud is the entire point. */
.price-freshness.fresh  { color: var(--color-ink-2); }
.price-freshness.ageing { color: var(--color-ink-2); }
.price-freshness.stale  { color: var(--red-text); }

/* "N hidden — price not confirmed · Show anyway" */
/* Sits above the list now, so the padding runs the other way: it needs air
   BELOW it, separating it from the first row, rather than the 4px that used to
   tuck it against the footer.

   Full width, state on the left and the action pushed to the right — the same
   split every station row uses, and the reason a plain left-aligned version
   failed: with the button sitting immediately after the text the pair read as a
   stray column header, where a state/action split reads as a status bar. */
.hidden-notice {
  display: flex;
  align-items: center;
  gap: 8px;
  flex-wrap: wrap;
  padding: 0 16px 12px;
  font-size: var(--text-xs);
  color: var(--warm-gray);
}
/* margin-left rather than justify-content: space-between, because this wraps.
   space-between leaves a lone second-line item at flex-START, so at 320 with a
   long brand list the action would jump back to the left edge; an auto margin
   pushes it right on its own line too. */
.hidden-notice-btn {
  margin-left: auto;
  background: none;
  border: none;
  padding: 4px 4px;
  font: inherit;
  color: var(--text);
  text-decoration: underline;
  cursor: pointer;
}
.hidden-notice-btn:hover { opacity: 0.7; }

.empty-state {
  padding: 32px 16px;
  text-align: center;
  color: var(--warm-gray);
  font-size: var(--text-md);
  line-height: 1.6;
}

/* ─── LOADING SKELETON ─── */
.loading-skeleton {
  padding: 32px 16px;
  text-align: center;
}
.skeleton-pulse {
  width: 60px;
  height: 60px;
  border-radius: 50%;
  margin: 0 auto 12px;
  background: var(--stone);
  animation: skeletonPulse 1.5s ease-in-out infinite;
}
.skeleton-text {
  font-size: var(--text-sm);
  color: var(--warm-gray);
  animation: skeletonPulse 1.5s ease-in-out infinite;
}
@keyframes skeletonPulse {
  0%, 100% { opacity: 0.4; }
  50% { opacity: 1; }
}

/* ─── MAP ─── */
#map { flex: 1; width: 100%; z-index: 100; }

/* Map Legend */
.map-legend {
  position: absolute;
  bottom: 28px;
  left: calc(var(--sidebar) + 50px);
  z-index: 500;
  background: color-mix(in srgb, var(--color-paper-2) 95%, transparent);
  border: 1px solid var(--stone);
  border-radius: var(--radius-card);
  padding: 8px 12px;
  font-size: var(--text-sm);
  box-shadow: var(--shadow-float);
}
.legend-title {
  font-weight: 700;
  font-size: var(--text-2xs);
  text-transform: uppercase;
  letter-spacing: 0.8px;
  color: var(--warm-gray);
  margin-bottom: 4px;
}
.legend-row {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 0px 0;
  color: var(--text);
}
.legend-dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  flex-shrink: 0;
}

/* ─── LEAFLET POPUP ─── */
.leaflet-popup-content-wrapper {
  border-radius: var(--radius-card) !important;
  box-shadow: var(--shadow-float) !important;
  padding: 0 !important;
  /* paper-2, the CARD step. It was paper-3, which is also what .popup-maps-btn
     uses — so in light theme the Google/Apple/Waze buttons were the same colour
     as the card behind them and simply were not there. Dark theme overrode this
     to paper-2 already, which is the only reason it looked right there. */
  background: var(--color-paper-2) !important;
  overflow: visible;
  font-family: var(--font-body) !important;
}
/* 280, not 220. The price column carries "CHF 1.80 with AVIA", which is nowrap at
   ~112px, so at 220 it took half the popup and squeezed the name and address into
   68px — a five-line address next to a two-line price. Mobile already set 260 for
   the same reason; desktop never did. */
/* 320, up from 280, because the top row now carries a brand mark AND reserves
   28px for the close button's corner. Both floors yield on a narrow screen:
   at 320px the popup would otherwise run 5px off the right edge, taking the
   close button with it. 48px is 24px of map either side. */
.leaflet-popup-content {
  margin: 0 !important;
  width: auto !important;
  min-width: min(320px, calc(100vw - 48px));
  max-width: min(340px, calc(100vw - 48px));
  overflow: visible;
}
.leaflet-popup-tip { background: var(--white) !important; }
/* 20px is the right visual weight; 44px is the right target. Expand the hit area,
   not the glyph — the same trick the header icons and sort buttons use. */

/* The popup carries the same anatomy as a list row — name and meta left, price
   right — because from the map it IS the first detail view (markers show only a
   price), and from the list it must not read as a different kind of object.
   One containment layer. Colour appears only on the freshness label. */
/* 20 and 16, not 16 and 12: the popup carries a list row's content in a narrower
   card, so it needs MORE air than the row, not the same rhythm.

   Even padding, because there is no close button to make room for. It went
   through a reserved 28px gutter, then 18px, then a 32px band of its own before
   being deleted outright: the map dismisses the popup on tap and Escape does it
   from the keyboard, so the glyph was buying nothing with that space. */
.popup-body { padding: 20px; display: flex; flex-direction: column; gap: 16px; }
/* The right padding reserves the close button's corner. Leaflet parks it at
   top:18/right:10 and it is 20px wide, so it sat ON the price column's "CHF"
   label — overlapping by 14x8px, and its 44px hit area covered the label
   completely. Tapping directly above the price, which is where a thumb goes,
   closed the popup. Only this row is inset; the nav buttons below still run the
   full width. */
.popup-top {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}
.popup-info { min-width: 0; }
.popup-name {
  font: 600 var(--text-md)/1.25 var(--font-body);
  color: var(--color-ink);
  letter-spacing: -0.01em;
}
.popup-address {
  font-size: var(--text-sm);
  color: var(--color-ink-2);
  margin-top: 2px;
  overflow: hidden;
  text-overflow: ellipsis;
}
/* Mirrors .station-prices in the list: price, card price, age, right-aligned and
   stacked. The two surfaces answer the same question and now say it the same way. */
.popup-prices { text-align: right; flex-shrink: 0; }
.popup-price {
  font-size: var(--text-price);
  font-weight: 600;
  line-height: 1;
  letter-spacing: -0.025em;
  color: var(--color-ink);
  font-variant-numeric: tabular-nums;
  text-align: right;
  white-space: nowrap;
}
.popup-price .cur {
  display: block;
  font-size: var(--text-2xs);
  font-weight: 600;
  letter-spacing: .1em;
  text-transform: uppercase;
  color: var(--color-ink-2);
  margin-bottom: 3px;
}

.popup-nav-btns { display: flex; gap: 10px; }
.popup-maps-btn {
  flex: 1;
  min-height: 40px;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 4px;
  border: none;
  border-radius: var(--radius-tile);
  background: var(--color-paper-3);
  color: var(--color-ink);
  font: 600 var(--text-sm)/1 var(--font-body);
  cursor: pointer;
}
.popup-maps-btn:hover { background: var(--stone); border-color: var(--warm-gray); }

/* Leaflet sets `font-family: "Helvetica Neue"` on .leaflet-container, and that
   is the font every control inside #map inherits. `font: inherit` does not
   rescue them — it inherits Leaflet's value. So the fuel buttons rendered in
   Inter in the sidebar and Helvetica Neue on the map: the same control, two
   typefaces, depending only on which view you were in.

   One rule at the container fixes all of them, including Leaflet's own zoom and
   attribution controls. Same specificity as Leaflet's, and this file loads
   after, so order decides it. */
.leaflet-container {
  font-family: var(--font-body);
  /* Leaflet ships `background: #ddd` on this element and it flips with nothing,
     so in dark mode a light-grey slab sat behind every tile that had not landed
     yet and past the loaded edge while panning. --color-map-land is the tile
     colour itself (measured off the basemap, not chosen), so the gap now reads
     as map rather than as a hole in it. Ours wins on order: styles.css loads
     after leaflet.css and both selectors are (0,1,0). */
  background: var(--color-map-land);
}

/* Leaflet's own controls, brought onto the system. Left alone they ship a 2px
   rgba(0,0,0,.2) outline, a 4px radius, Lucida Console, and a hard-coded white
   that does not flip — so in dark mode the zoom pair sat on the map as a white
   block with a black border. Desktop only, which is how it survived a phone.

   .leaflet-touch .leaflet-bar is (0,2,0), so these need the container in front
   to win; the extra element on the link beats .leaflet-bar a outright. */
.leaflet-container .leaflet-bar {
  border: none;
  border-radius: var(--radius-tile);
  box-shadow: var(--shadow-float);
  overflow: hidden;
}
.leaflet-container .leaflet-bar a {
  width: 36px;
  height: 36px;
  border: none;
  border-radius: 0;
  background: var(--color-paper-3);
  color: var(--color-ink);
  font: 500 var(--text-lg)/36px var(--font-body);
}
/* The ring has to be drawn INSIDE the link. The bar clips (that is what rounds
   these two square children against its own radius — Leaflet's
   .leaflet-touch .leaflet-bar a:first-child is (0,3,1) and outranks setting
   border-radius on the child), and the global focus ring sits at
   outline-offset: 2px, i.e. outside the border box. Clipped, all that survived
   was a 2px stub at the seam between the buttons, which reads as a divider and
   is identical whichever of the two has focus.

   Same fix, and the same reason, as .loyalty-rappen inside .loyalty-chip.
   :where() contributes no specificity, so the global rule is only (0,1,0). */
.leaflet-container .leaflet-bar a:focus-visible { outline-offset: -2px; }
.leaflet-container .leaflet-bar a:hover { background: var(--color-rule); }
.leaflet-container .leaflet-bar a.leaflet-disabled {
  background: var(--color-paper-3);
  color: var(--color-ink-2);
  opacity: .45;
}

/* Same story: a fixed translucent white that reads as a bright strip on the dark
   map. Mobile swaps this out for the "i" button; desktop shows it. */
.leaflet-container .leaflet-control-attribution {
  background: var(--color-paper-2);
  color: var(--color-ink-2);
}
/* The LINKS inside it, which the rule above does not reach. `.leaflet-container a`
   is (0,2,0) and paints Leaflet's own #0078A8 — a colour that is not ours, does
   not flip, and measured 3.54:1 on dark paper. The chrome carries no colour
   (design.md); underline carries the affordance instead. Needs (0,3,0) to win,
   which is the same trap .popup-tcs-link already documents. */
.leaflet-container .leaflet-control-attribution a {
  color: var(--color-ink-2);
  text-decoration: underline;
}
.leaflet-container .leaflet-control-attribution a:hover { color: var(--color-ink); }

/* .leaflet-container a wins on specificity (0,2,0) and paints every link in
   Leaflet's default #0078A8, so this has to match it rather than assume our
   own class is enough. */
.leaflet-container .popup-tcs-link {
  display: inline-flex;
  align-self: center;
  align-items: center;
  gap: 4px;
  font-size: var(--text-xs);
  color: var(--color-ink-2);
  text-decoration: underline;
  text-underline-offset: 2px;
}
.leaflet-container .popup-tcs-link:hover { color: var(--color-ink); }

/* ─── PRICE CLUSTERS (TCS-style: price tag + count badge) ─── */
.cluster-tag {
  position: relative;
  display: inline-flex;
  align-items: center;
  padding: 4px 8px;
  background: var(--tag-color, var(--tier-none));
  color: var(--on-accent);
  font-size: var(--text-sm);
  font-weight: 600;
  font-family: var(--font-body);
  white-space: nowrap;
  box-shadow: var(--shadow-pin);
  border: 1.5px solid var(--color-map-land);
  cursor: pointer;
  margin-bottom: 4px;
}
/* arrow pointing down */
.cluster-tag::after {
  content: '';
  position: absolute;
  bottom: -5px;
  left: 50%;
  transform: translateX(-50%);
  width: 0; height: 0;
  border-left: 4px solid transparent;
  border-right: 4px solid transparent;
  border-top: 5px solid var(--tag-color, var(--tier-none));
}
/* station count badge — top-right corner */
.cluster-badge {
  position: absolute;
  top: -8px;
  right: -8px;
  min-width: 16px;
  height: 16px;
  padding: 0 4px;
  border-radius: var(--radius-pill);
  background: var(--color-ink);
  /* Paper on ink. This read --on-accent and went invisible the moment the tiers
     inverted, because the badge is not on a tier -- it is on its own dark chip. */
  color: var(--color-paper);
  font-size: var(--text-2xs);
  font-weight: 700;
  display: flex;
  align-items: center;
  justify-content: center;
  border: 1.5px solid var(--color-map-land);
  box-shadow: var(--shadow-pin);
  line-height: 1;
}

/* ─── INDIVIDUAL PRICE TAGS ─── */
.price-tag-icon {
  background: none !important;
  border: none !important;
  display: flex !important;
  align-items: flex-end !important;
  justify-content: center !important;
}
.station-price-tag {
  position: relative;
  display: inline-flex;
  align-items: center;
  padding: 4px 8px;
  background: var(--tag-color, var(--tier-none));
  color: var(--on-accent);
  font-size: var(--text-sm);
  font-weight: 600;
  font-family: var(--font-body);
  white-space: nowrap;
  box-shadow: var(--shadow-pin);
  border: 1.5px solid var(--color-map-land);
  cursor: pointer;
  margin-bottom: 4px; /* space for arrow */
}
/* small arrow pointing down to the geo location */
.station-price-tag.selected {
  font-size: var(--text-md);
  padding: 4px 8px;
  border-width: 2px;
  z-index: 1;
}
.station-price-tag::after {
  content: '';
  position: absolute;
  bottom: -5px;
  left: 50%;
  transform: translateX(-50%);
  width: 0; height: 0;
  border-left: 4px solid transparent;
  border-right: 4px solid transparent;
  border-top: 5px solid var(--tag-color, var(--tier-none));
}

/* ─── LOADING STATE ─── */
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }

/* ─── MARKER FADE-IN (triggered on clear → browse transition) ─── */
@keyframes markerFadeIn {
  from { opacity: 0; }
  to { opacity: 1; }
}

[data-theme="dark"] .station-item:hover {
  background: var(--surface-hover);
}
[data-theme="dark"] .station-item.active {
  background: var(--surface-hover);
}
[data-theme="dark"] .leaflet-popup-tip {
  background: var(--white) !important;
}
[data-theme="dark"] .popup-maps-btn {
  color: var(--charcoal);
  border-color: var(--border-muted);
}
[data-theme="dark"] .popup-maps-btn:hover {
  background: var(--surface-hover);
}

[data-theme="dark"] .fuel-btn.active {
  background: var(--surface-raised);
  color: var(--text);
}
[data-theme="dark"] .fuel-btn:hover:not(.active) {
  background: var(--surface-hover);
}

/* The ring stays the cut-out from the base rule. A --border-muted hairline here
   put a second visible edge on a 16px chip, which at that size is most of it. */

/* ─── RESPONSIVE / MOBILE ─── */
@media (max-width: 700px) {
  :root { --sidebar: 100%; }

  /* Locked by default to stop rubber-banding, which is right for the MAP.
     It is wrong for the list, and expensively so: iOS Safari only collapses its
     bottom toolbar when the DOCUMENT scrolls, and a locked document can never
     do that. Measured on an iPhone, the page got 775px of an 896px screen and
     held the other 121px of browser chrome open permanently — the gap. Every
     app that does not have this problem scrolls its document.
     html.doc-scroll below unlocks it for the list view; overscroll-behavior on
     body still handles the bounce this was guarding against. */
  html, body {
    position: fixed;
    width: 100%;
    height: 100%;
    overflow: hidden;
  }
  /* On body, NOT on html. An opaque ROOT background is the one that propagates
     to the canvas, and Safari backfills the strip below the page from there --
     so while html carried a colour of its own, body's could never reach the
     surface we are trying to control. Leaving html transparent makes body the
     single source, in every view and both themes. */
  body { background: var(--ground); }
  html.doc-scroll,
  html.doc-scroll body {
    position: static;
    height: auto;
    min-height: 100dvh;
    overflow-x: clip;
    overflow-y: visible;
  }
  /* The root half of this selector used a descendant combinator, so it needed
     an html element inside an html element and matched nothing, ever. */
  [data-theme="dark"] body {
    background: var(--ground);
  }

  /* Disable tap highlight globally on mobile */
  * { -webkit-tap-highlight-color: transparent; }

  /* Slim top navbar on mobile */
  .sidebar-card-intro {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    z-index: 1000;
    padding: 8px 16px;
    padding-top: calc(8px + env(safe-area-inset-top, 0px));
    background: color-mix(in srgb, var(--white) 90%, transparent);
    backdrop-filter: blur(12px);
    -webkit-backdrop-filter: blur(12px);
    border-radius: 0;
  }
  [data-theme="dark"] .sidebar-card-intro {
    background: color-mix(in srgb, var(--white) 90%, transparent);
  }
  .sidebar-card-intro .intro-logo {
    font-size: var(--text-xl);
    margin-bottom: 0;
  }
  .sidebar-card-intro .intro-tagline { display: none; }
  .sidebar-card-intro .ctrl-icon-btn {
    width: 32px;
    height: 32px;
  }
  .sidebar-card-intro .ctrl-icon-btn svg {
    width: 14px;
    height: 14px;
  }

  /* Main card becomes transparent wrapper on mobile */
  .sidebar-card-main {
    display: contents;
  }

  /* 100lvh — the LARGE viewport — so the map paints the full screen and Safari's
     bottom toolbar overlays it, rather than the map stopping at the toolbar and
     leaving 121px of bare chrome below. The list view gets that space back by
     scrolling the document, which makes Safari collapse the toolbar; the map
     view has nothing to scroll and never can, so it has to paint underneath
     instead. visibleMapRect() clips the QUERY back to the visible area, so
     nothing is ranked from behind the toolbar.
     Fixed, not absolute inside .app-body: the list scrolls the document and an
     absolute map would grow with it. */
  #map {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    width: 100%;
    height: 100dvh;   /* fallback where lvh is unsupported */
    /* 100lvh, not more. An earlier build asked for calc(100lvh + 140px) to try
       to paint into the band below lvh where Safari's floating toolbar sits.
       That was never going to work -- a position:fixed element is never
       composited there, which is why body.view-map carries a tile-matched
       background instead -- and it actively broke every path that uses
       Leaflet's own geometry rather than centreOnVisible(): a tapped station
       landed ~70px below the visible centre, half under the view-toggle pill,
       and the national fit framed the country 70px low. It also fetched an
       extra row of tiles into a band that is never painted. */
    height: 100lvh;
    z-index: 100;
  }

  /* In normal flow, not fixed. The list is the document now, so scrolling it
     scrolls the page — which is what lets iOS Safari collapse its bottom
     toolbar and hand back the ~121px it otherwise holds open forever. Every app
     that "doesn't have this problem" scrolls its document; ours scrolled an
     inner div, which Safari does not count. */
  aside {
    position: relative;
    width: 100%;
    min-height: 100dvh;
    background: var(--color-paper);
    border: none;
    border-radius: 0;
    box-shadow: none;
    z-index: 900;
    overflow: hidden;
    padding: 0;
    gap: 0;
    pointer-events: auto;
    display: flex;
    flex-direction: column;
  }
  /* Exactly one of the two is on screen at a time.
     The two views want opposite scroll behaviour: the list IS the document and
     must scroll it; the map is a fixed surface and must not. */
  body.view-map aside { display: none; }
  body.view-list,
  body.view-list .app-body {
    height: auto;
    min-height: 100dvh;
    overflow: visible;
    display: block;
  }
  /* No lock here. The sheet is display:none in this view, so the page is
     exactly one viewport tall and cannot scroll — which is all the "lock" ever
     achieved, minus the part where it reset Safari's toolbar to expanded. */
  body.view-map {
    min-height: 100dvh;
    /* Safari cannot show #map through its bottom bar: a position:fixed element
       is never composited into that strip, so WebKit backfills it with a flat
       colour read off the page background. Matching whatever sits above it is
       the only lever that exists -- which is exactly what body.view-list does
       with the sheet below, and the reason list view reads as "transparent".
       Both values sampled from the tiles rather than picked: 65.7% of CARTO
       dark_all's pixels are #090909, and standard OSM's land is #f2efe8. */
    background: var(--map-land);
  }
  [data-theme="dark"] body.view-map { background: var(--map-land); }
  /* MUST equal the sheet exactly: Safari fills the strip below the page from
     the page background, so any drift shows as a band under the last row. */
  body.view-list { background: var(--color-paper); }
  body.view-list .map-locate,
  /* Leaflet's own attribution bar goes; the (i) button below replaces it.
     Leaflet's self-credit is a courtesy -- BSD-2-Clause wants the notice in the
     source, not in the UI -- but OpenStreetMap's and CARTO's are licence
     conditions and simply move behind the button. */
  .leaflet-control-attribution { display: none; }

  /* Zoom buttons are a desktop affordance. On a phone pinch already does it,
     and zoom is now the only thing that decides how much you get — so a control
     that changes the answer in fixed jumps is worse than the gesture that does
     it continuously. */
  .leaflet-control-zoom { display: none; }
  [data-theme="dark"] aside {
    background: var(--color-paper);
  }

  /* No inner scroller: the rows flow into the document and the page scrolls. */
  .station-list {
    flex: none;
    overflow: visible;
  }
  /* Clears the fixed navbar. It goes on the flex container, not on
     .sidebar-card-main, which is display:contents on mobile and therefore has no
     box to pad — that rule computed correctly and did nothing. */
  aside {
    padding-top: calc(52px + env(safe-area-inset-top, 0px));
    /* Clears the floating view toggle, which otherwise sits on top of the
       "N hidden" disclosure and the attribution. */
    padding-bottom: calc(var(--bottom-gutter) + var(--toggle-clearance));
  }

  /* Map legend repositioned below top navbar */
  .map-legend {
    bottom: auto;
    top: calc(44px + env(safe-area-inset-top, 0px));
    left: 10px;
  }

  /* Station items slightly more padded for touch */
  .station-item { padding: 12px 12px; gap: 12px; }

  /* Same floor as desktop — the price column is the same width on both, so the
     name and address need the same room. 280 fits a 375px screen with margin. */
  .leaflet-popup-content { min-width: min(320px, calc(100vw - 48px)); max-width: min(340px, calc(100vw - 48px)); }
}

/* Controls: fuel and radius, the only two things above the list now. The search
   button, the origin row and the settings collapse all went with the search. */
.controls {
  display: flex;
  flex-direction: column;
  gap: 8px;
  padding: 12px 16px 8px;
  flex-shrink: 0;
}

/* Locate button, where every map puts it.
   The `[data-theme="dark"]` that used to sit on the line above had no block of
   its own, and comments are stripped before selectors are parsed -- so this
   compiled as `[data-theme="dark"] .map-locate` and the button was unstyled
   (no position, no background, no shadow) in light theme. It needs no dark
   variant: --white and --text are already per-theme. */
.map-locate {
  position: absolute;
  right: 14px;
  top: calc(14px + env(safe-area-inset-top, 0px));
  z-index: 500;
  width: 44px;
  height: 44px;
  display: flex;
  align-items: center;
  justify-content: center;
  border: 1px solid var(--border-muted);
  /* Round, not the 12px tile every other icon button takes. A control that
     floats free on a map is a FAB and reads as one; the tile radius made it look
     like a chip that had come loose from a row. */
  border-radius: 50%;
  background: var(--white);
  font: inherit;
  color: var(--text);
  cursor: pointer;
  box-shadow: var(--shadow-pin);
}
.map-locate:hover { background: var(--cream); }
.map-locate.active { color: var(--green-dark); border-color: var(--green); }

/* Must come after the base rule above, not in the earlier mobile block: same
   specificity, so declaration order decides, and the earlier one always lost.
   The mobile navbar is fixed at z-index 1000 against this button's 500, so at
   top:14px the button sat underneath it — present, invisible and untappable. */
@media (max-width: 700px) {
  .map-locate {
    /* Bottom right: the one corner a thumb reaches on a phone. It used to sit
       top right, which is the desktop convention and is the far corner from a
       hand holding the device. It swaps with the attribution button, which is
       read once and never in a hurry. Fixed, not absolute, so it pins to the
       layout viewport like the view-toggle beside it. */
    position: fixed;
    top: auto;
    bottom: var(--bottom-gutter);
    right: 12px;
  }
}

@media (max-width: 700px) {
}

.retry-btn {
  display: block;
  margin: 12px auto 0;
  min-height: 40px;
  padding: 0 16px;
  border: 1px solid var(--border-muted);
  border-radius: var(--radius-pill);
  background: var(--white);
  font: 600 var(--text-md)/1 var(--font-body);
  color: var(--text);
  cursor: pointer;
}
.retry-btn:hover { background: var(--cream); }

/* ─── View toggle ───
   The only way between the two full-screen views, so it is always reachable and
   never overlaps the list's last row (which is why the list pads 96px). */
.view-toggle { display: none; }

@media (max-width: 700px) {
  .view-toggle {
    display: flex;
    position: fixed;
    bottom: var(--bottom-gutter);
    left: 50%;
    transform: translateX(-50%);
    z-index: 1150;
    align-items: center;
    gap: 8px;
    /* 44, like every other touch target in the app. It was 46 — the only
       control at that height — and next to the 44px locate button in the same
       bottom row the two-pixel difference read as a mistake rather than as
       hierarchy. --toggle-clearance above is derived from this. */
    min-height: 44px;
    padding: 0 24px;
    border: none;
    border-radius: var(--radius-pill);
    background: var(--text);
    color: var(--white);
    font: 600 var(--text-md)/1 var(--font-body);
    box-shadow: var(--shadow-float);
    cursor: pointer;
  }
  .view-toggle[hidden] { display: none; }
}

/* ─── Fuel selector on the map ───
   The one control that changes every number on screen, and until now it was
   reachable only from the list: to switch to Diesel you went List -> tap ->
   Map, on the surface you use while driving. Chargemap keeps its primary
   filter as chips on the map for the same reason.
   Mobile only. On desktop the sidebar is beside the map, so it is never
   more than a glance away. */
.map-controls { display: none; }

/* Collapsed attribution. Desktop keeps Leaflet's own bar, which has room. */
.map-attrib-btn { display: none; }
@media (max-width: 700px) {
  /* fixed, not absolute: these are positioned from the BOTTOM of #map, and #map
     now deliberately overshoots the viewport. Fixed pins them to the layout
     viewport, which is where the view-toggle already sits correctly. */
  .map-attrib-btn {
    display: flex;
    position: fixed;
    /* One bottom row: attribution left, view toggle centre, locate right. The
       map used to carry four controls in four corners at three sizes, with
       nothing relating any of them — the eye had no group to land on. This is
       the only one that moved; it was alone in the top-right.

       gutter + 7, not gutter: the toggle is 44px tall sitting on the gutter, so
       its centre line is gutter+22, and a 30px button has to be lifted 7px to
       share it. Aligning the BOXES would leave this one visibly low. */
    left: 12px;
    bottom: calc(var(--bottom-gutter) + 7px);
    z-index: 500;
    width: 30px;
    height: 30px;
    align-items: center;
    justify-content: center;
    border: 1px solid var(--border-muted);
    border-radius: var(--radius-tile);
    background: var(--white);
    color: var(--warm-gray);
    /* The one serif in the chrome, and deliberate: an italic roman "i" is what
       an info mark is. It was hard-coded to Georgia, which meant it echoed
       nothing — on the display token it echoes the wordmark. */
    font: 600 var(--text-md)/1 var(--font-display);
    font-style: italic;
    cursor: pointer;
    box-shadow: var(--shadow-pin);
  }

  /* 30px is the right visual weight for something read once; 44px is the right
     target. Expand the hit area, not the button — the same trick the header
     icons and the sort buttons use. */
  .map-attrib-btn::before {
    content: '';
    position: absolute;
    inset: -7px;
  }
  .map-attrib-panel {
    position: fixed;
    left: 12px;
    bottom: calc(var(--bottom-gutter) + 44px);   /* above its button */
    z-index: 500;
    max-width: calc(100vw - 24px);
    padding: 8px 12px;
    border-radius: var(--radius-card);
    background: var(--white);
    color: var(--text);
    font-size: var(--text-xs);
    line-height: 1.5;
    box-shadow: var(--shadow-float);
  }
  .map-attrib-panel[hidden] { display: none; }
  /* .leaflet-container in front, because this panel lives inside #map and
     `.leaflet-container a` is (0,1,1) — exactly the same weight as
     `.map-attrib-panel a`, so the tie went to whichever came later and Leaflet's
     #0078A8 was still painting here at 4.03:1. (0,2,1) settles it. Same trap as
     .popup-tcs-link and the attribution links; third time this selector has
     won something it should not. */
  .leaflet-container .map-attrib-panel a { color: inherit; text-decoration: underline; }
}

/* Toasts. Above the map and the sheet, below the preferences panel (z 1200). */
.toast-stack {
  position: fixed;
  left: 50%;
  transform: translateX(-50%);
  top: calc(12px + env(safe-area-inset-top, 0px));
  z-index: 1100;
  display: flex;
  flex-direction: column;
  gap: 8px;
  width: min(420px, calc(100vw - 24px));
  pointer-events: none;
}
.toast {
  display: flex;
  align-items: flex-start;
  gap: 12px;
  padding: 12px 12px;
  border-radius: var(--radius-tile);
  background: var(--text);
  /* --color-paper, not --on-accent: this sits on INK, not on a tier. It only
     ever worked because light mode's on-tier happened to equal paper, and it
     stopped when the tiers inverted. */
  color: var(--color-paper);
  font: 500 var(--text-sm)/1.4 var(--font-body);
  box-shadow: var(--shadow-float);
  opacity: 0;
  transform: translateY(-6px);
  transition: opacity .2s ease, transform .2s ease;
  pointer-events: auto;
}
.toast.in { opacity: 1; transform: none; }
.toast.error { background: var(--color-tier-dear); color: var(--color-on-tier); }
.toast-close {
  margin-left: auto;
  flex-shrink: 0;
  width: 24px;
  height: 24px;
  border: none;
  background: none;
  color: inherit;
  font-size: var(--text-lg);
  line-height: 1;
  opacity: .7;
  cursor: pointer;
}
.toast-close:hover { opacity: 1; }

/* Preferences gear carries a dot when anything is set, because cards and price
   confidence are otherwise invisible from outside the panel. */
.ctrl-icon-btn.has-prefs { position: relative; }
.ctrl-icon-btn.has-prefs::after {
  content: '';
  position: absolute;
  top: 4px;
  right: 4px;
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--green);
  box-shadow: 0 0 0 2px var(--white);
}

/* Preferences panel. Covers the sheet rather than sitting inside it — these are
   set-once controls and must not compete with results for vertical space. */
.prefs-panel {
  position: fixed;
  /* On desktop the sidebar is a transparent overlay holding a floating card
     (20px padding inside a 380px column), so the panel matches that card rather
     than sitting flush against the window edge. */
  top: 20px;
  bottom: 20px;
  left: 20px;
  width: var(--sidebar);
  z-index: 1200;
  display: flex;
  flex-direction: column;
  background: var(--white);
  border: 1px solid var(--stone);
  border-radius: var(--radius-card);
  overflow: hidden;
  opacity: 0;
  transform: translateY(8px);
  transition: opacity .18s ease, transform .18s ease;
}
/* MUST come with the display rule above. `display: flex` is an author style and
   beats the UA stylesheet's `[hidden] { display: none }`, so without this the
   panel stays laid out while "hidden" — invisible at opacity 0, but still a
   340px-wide, full-height box at z-index 1200 over the sidebar, swallowing
   every click behind it: the gear that opens it, the fuel toggle, Fill, the
   results list. On a phone it covers the whole screen. */
.prefs-panel[hidden] { display: none; }
.prefs-panel.open { opacity: 1; transform: none; }

/* Full screen on a phone rather than a floating card. MUST come after the base
   rule above: same specificity, so declaration order decides. While this lived
   in the earlier mobile block the desktop `top/bottom/left: 20px; width: 340px`
   won, and the panel opened as a card floating in the middle of the phone. */
@media (max-width: 700px) {
  .prefs-panel {
    inset: 0;
    width: 100%;
    border: none;
    border-radius: 0;
    padding-top: env(safe-area-inset-top, 0px);
    padding-bottom: env(safe-area-inset-bottom, 0px);
  }
}
.prefs-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding: 16px 16px 12px;
  flex-shrink: 0;
}
.prefs-title {
  font: 400 var(--text-lg)/1.2 var(--font-display);
  color: var(--text);
}
.prefs-close {
  width: 44px;
  height: 44px;
  display: flex;
  align-items: center;
  justify-content: center;
  border: none;
  background: none;
  color: var(--warm-gray);
  cursor: pointer;
  border-radius: var(--radius-tile);
  margin: -8px -8px -8px 0;
}
.prefs-close:hover { background: var(--cream); color: var(--text); }
.prefs-body {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  padding: 4px 16px 32px;
}
/* Sections are separated by air and nothing else — no rules, no tinted blocks.
   The :last-child { border-bottom: none } that used to sit here was the residue
   of a divider-based version: nothing has set a border on .prefs-section since,
   so it had been cancelling a rule that no longer existed. */
.prefs-section { padding: 24px 0 0; }
.prefs-section-title {
  font-size: var(--text-sm);
  font-weight: 600;
  color: var(--text);
  margin-bottom: 4px;
}
.prefs-hint {
  font-size: var(--text-xs);
  line-height: 1.45;
  color: var(--warm-gray);
  margin-bottom: 10px;
}
.prefs-section .prefs-hint:last-child { margin: 12px 0 0; }
.prefs-reset {
  margin-top: 10px;
  padding: 8px 12px;
  min-height: 38px;
  border: 1px solid var(--border-muted);
  border-radius: var(--radius-pill);
  background: none;
  font: 500 var(--text-sm)/1 var(--font-body);
  color: var(--text);
  cursor: pointer;
}
.prefs-reset:hover { background: var(--cream); }

/* Two mutually exclusive states, rendered as buttons rather than radios so the
   touch target is the whole thing. */
.prefs-choice { display: flex; flex-wrap: wrap; gap: 8px; }
/* Side by side these clipped at 375 — "Include unconfirmed" ran off the panel
   edge. Clickable labels are one-line objects (gate 49). */
@media (max-width: 420px) {
  .prefs-choice:not(#themeChoice) { flex-direction: column; }
  .prefs-choice:not(#themeChoice) .prefs-option { flex: 1 1 auto; width: 100%; }
}
.prefs-option {
  flex: 1 1 auto;
  min-height: 40px;
  padding: 0 16px;
  border: 1px solid var(--border-muted);
  border-radius: var(--radius-pill);
  background: none;
  font: 500 var(--text-sm)/1 var(--font-body);
  color: var(--text);
  cursor: pointer;
}
.prefs-option.on {
  border-color: var(--color-ink);
  background: var(--color-ink);
  color: var(--color-paper);
  font-weight: 600;
}

/* Brands, with their share of the network so the choice is informed. */
.brand-grid { display: flex; flex-wrap: wrap; gap: 8px; }
.brand-more {
  min-height: 40px;
  padding: 0 12px;
  border: 1px dashed var(--color-rule);
  border-radius: var(--radius-pill);
  background: none;
  font: 500 var(--text-sm)/1 var(--font-body);
  color: var(--color-ink-2);
  cursor: pointer;
}
.brand-chip {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  /* 40px, the same as .prefs-option and .brand-more. They are the same kind of
     control and sat two pixels apart for no reason anyone could have named. */
  min-height: 40px;
  padding: 0 12px;
  border: 1px solid var(--border-muted);
  border-radius: var(--radius-pill);
  background: none;
  font: 500 var(--text-sm)/1 var(--font-body);
  color: var(--text);
  cursor: pointer;
}
.brand-chip.on {
  background: var(--color-ink);
  border-color: var(--color-ink);
  color: var(--color-paper);
}
.brand-count { font-size: var(--text-xs); color: var(--warm-gray); }
.brand-chip.on .brand-count { color: var(--color-paper); }

/* Loyalty cards. A chip is a brand name until you tap it, then it grows the
   rappen field — so the row stays quiet for the majority who hold no card, and
   the number is present and editable for those who do. */
.loyalty-row {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}
.loyalty-chip {
  display: inline-flex;
  align-items: center;
  border: 1px solid var(--border-muted);
  border-radius: var(--radius-pill);
  background: var(--white);
  overflow: hidden;
}
.loyalty-chip.on {
  border-color: var(--green);
  background: var(--green-bg);
}
.loyalty-toggle {
  border: none;
  background: none;
  font: 500 var(--text-sm)/1 var(--font-body);
  color: var(--text);
  /* Tapped one-handed in a car: keep the target comfortably past 44px once the
     chip's own padding is counted. 40px matches every other chip. */
  min-height: 40px;
  padding: 0 12px;
  cursor: pointer;
}
.loyalty-chip.on .loyalty-toggle {
  color: var(--green-dark);
  font-weight: 600;
  padding-right: 6px;
}
.loyalty-rappen {
  width: 44px;
  border: none;
  border-left: 1px solid var(--green);
  background: transparent;
  color: var(--green-dark);
  font: 600 var(--text-sm)/1 var(--font-body);
  text-align: right;
  padding: 0 4px 0 8px;
  min-height: 38px;
}
/* --color-focus, not --green: the ring sits on the chip's own --green-bg fill,
   where --green measured 1.80:1 against the 3:1 a non-text indicator needs
   (light; dark was fine at 8.57, which is why it survived). --color-focus gives
   4.07 / 6.10. And :focus-visible, not :focus, so it stops ringing on a mouse
   click. Offset stays inset — .loyalty-chip clips, so an outset ring is eaten. */
.loyalty-rappen:focus-visible { outline: 2px solid var(--color-focus); outline-offset: -2px; }
.loyalty-unit {
  font-size: var(--text-xs);
  color: var(--green-dark);
  padding: 0 12px 0 4px;
}

/* "1.85 with AVIA" — sits directly under the pump price, in both the list row and
   the map popup. Green because it is the figure the row is ranked on.

   --green-dark, NOT --green: this is ink, and in light mode the two are now
   different colours. It read 2.10:1 for as long as --green-dark aliased the
   marker fill — on every discounted row, which is the second most important
   number the app prints. 5.84:1 now.

   --text-sm, not --text-xs. At 12px directly beneath a 28px number it read as
   fine print crammed against the price rather than the second figure in a pair,
   which is what it is: the one the row is actually ordered by. */
.station-card-price {
  font-size: var(--text-sm);
  font-weight: 600;
  line-height: 18px;
  margin-top: 4px;
  color: var(--green-dark);
  white-space: nowrap;
}

@media (max-width: 700px) {
  /* A column of two lines: the toggle-and-gear row, then the disclosure. The
     column itself does not take pointer events -- it spans the full width and
     would otherwise swallow drags either side of the controls -- so each line
     takes them back. */
  .map-controls {
    display: flex;
    flex-direction: column;
    align-items: stretch;
    gap: 8px;
    pointer-events: none;
    position: fixed;
    /* Full bleed between the map's own 12px gutters — the same edge the bottom
       row sits on, so the two groups line up vertically. It used to be
       content-width (193px) hanging off the left, which left a ragged right
       edge against a bottom row that reached both sides. */
    left: 12px;
    right: 12px;
    top: calc(12px + env(safe-area-inset-top, 0px));
    z-index: 500;
  }
  .map-controls-row {
    display: flex;
    align-items: center;
    gap: 8px;
    pointer-events: auto;
  }
  /* On the tiles it needs a surface of its own: --warm-gray text over a live
     basemap is unreadable at 12px. Same glass as every other floating control,
     from the § Map chrome block below. Full-bleed between the map's gutters like
     the row above it — it shrink-wrapped at first, which left a short pill
     floating under a full-width row and read as a tooltip rather than as part of
     the group. */
  .map-controls .hidden-notice {
    padding: 6px 14px;
    border: 1px solid var(--hairline);
    border-radius: var(--radius-pill);
    color: var(--text);
    pointer-events: auto;
  }
  /* The fuel toggle takes what the icon buttons leave, rather than a fixed
     content width: at 320px the two together would be 297px against 296px of
     room, and one pixel of overflow is not a layout. */
  .map-controls .fuel-toggle { flex: 1; min-width: 0; }
  .map-controls .intro-controls { flex: none; }
  /* On the tiles it is a map control, not a header icon, so it takes the map's
     sizing rather than the navbar's. It was 36 and square against a 52px pill,
     which is what "the button seems off" was.

     52, NOT the app's usual 44, and this is the one place that is right:
     THE GEAR MATCHES THE ROW IT IS IN. Its neighbour is the fuel toggle's
     shell, sitting 8px away — not the 44px buttons inside that shell, which the
     eye never compares it to. Two adjacent objects are read by their outer
     edges, so 44 left a visible notch in the row's silhouette while matching
     something nothing was measuring it against. At 52 the top row is one band.

     That is also why it does not match the 44px locate button: they are in
     different rows, at opposite corners, never seen side by side. The bottom
     row aligns on a centre line and spans 30–44; this row aligns on edges.

     Round for the reason .map-locate is — a control floating free on a map is a
     FAB, and the 12px tile read as a chip that had come loose from a row.

     The glass surface comes from the § Map chrome block below, which this used
     to duplicate and get subtly wrong: it carried --shadow-pin while every other
     floating control carried --shadow-md, and --shadow-pin is `none` in dark, so
     the gear alone sat flat on the map. Only the border WIDTH is set here — the
     base is `border: none` and the shared block sets colour alone. */
  .map-controls .ctrl-icon-btn {
    width: 52px;
    height: 52px;
    border: 1px solid var(--hairline);
    border-radius: 50%;
  }
  /* Scaled off the locate button's 20-in-44, so the glyph carries the same
     weight inside the larger disc rather than swimming in it. */
  .map-controls .ctrl-icon-btn svg { width: 22px; height: 22px; }
  /* Reads over tiles rather than over the sheet, so it needs its own surface. */
  .map-controls .fuel-toggle {
    border-color: var(--border-muted);
    border-radius: var(--radius-pill);
    box-shadow: var(--shadow-pin);
    background: var(--white);
  }
  .map-controls .fuel-btn {
    padding: 8px 12px;
    font-size: var(--text-sm);
    background: var(--white);
  }
  .map-controls .fuel-btn.active { background: var(--charcoal); color: var(--color-paper); }
}

/* ─── Map chrome ───
   Floating controls over a live map is the one context where translucency is
   doing a job rather than decorating: it keeps the map legible underneath while
   the control stays readable on top. Restrained on purpose — 2026 guidance runs
   against heavy glassmorphism, and a control read while driving has to survive
   whatever tiles are behind it, so the surface stays mostly opaque and the text
   contrast is not negotiable. */
@media (max-width: 700px) {
  .map-controls .fuel-toggle,
  .map-controls .ctrl-icon-btn,
  .map-controls .hidden-notice,
  .map-locate,
  .map-attrib-btn,
  .map-attrib-panel {
    background: color-mix(in srgb, var(--white) 82%, transparent);
    -webkit-backdrop-filter: blur(20px) saturate(1.6);
    backdrop-filter: blur(20px) saturate(1.6);
    border-color: var(--hairline);
    box-shadow: var(--shadow-md);
  }
  .map-controls .fuel-btn { background: transparent; }
  .map-controls .fuel-btn.active { background: var(--charcoal); color: var(--white); }

  /* Bigger, and it lifts. The only way between the two views. */
  .view-toggle {
    box-shadow: var(--shadow-float);
    transition: transform var(--ease);
  }
  .view-toggle:active { transform: translateX(-50%) scale(.94); }
}

/* Markers carry the price, so they are type first and pin second. */
.station-price-tag {
  font-size: var(--text-md);
  font-weight: 700;
  letter-spacing: -0.01em;
  font-variant-numeric: tabular-nums;
  padding: 4px 8px;
  border-width: 2px;
}
.station-price-tag.selected { font-size: var(--text-lg); padding: 4px 12px; }
.cluster-tag { font-weight: 700; font-variant-numeric: tabular-nums; }

/* Motion is for orientation, not decoration — and it is optional. */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: .001ms !important;
    transition-duration: .001ms !important;
  }
}

/* ══════════════════════════════════════════════════════════════════════════
   THE SYSTEM, MAPPED
   ══════════════════════════════════════════════════════════════════════════
   tokens.css is the source of truth. This block aliases the names the existing
   rules already use onto it, so the whole stylesheet inherits the system
   without a blind rename — design.md forbids that, because app.js reads the
   --tier-* names at runtime through getComputedStyle.

   The governing rule: THE CHROME CARRIES NO COLOUR. Every green, brass and
   cream that used to live here is gone; --green-bg and friends now resolve to
   paper, because a tinted panel competes with the map instead of framing it. */
:root {
  --cream:          var(--color-paper-3);
  --stone:          var(--color-rule);
  --warm-gray:      var(--color-ink-2);
  --charcoal:       var(--color-ink);
  --text:           var(--color-ink);
  --white:          var(--color-paper-2);
  --border-muted:   var(--color-rule);
  --hairline:       var(--color-rule);

  /* Semantic, and only ever a small mark. The plain names are the FILL, the
     -text/-dark names are the same colour as INK on paper — see tokens.css. The
     migration collapsed both onto the fill, which was harmless until the light
     tiers inverted and the ink roles went with them. Checked before repointing:
     every var(--green-dark) is a `color:`, every var(--green) is a fill, border
     or outline. The names were already right; only what they pointed at was. */
  --green:          var(--color-tier-cheap);
  --green-dark:     var(--color-tier-cheap-text);
  --green-text:     var(--color-tier-cheap-text);
  --amber:          var(--color-tier-mid);
  --amber-text:     var(--color-tier-mid-text);
  --red:            var(--color-tier-dear);
  --red-text:       var(--color-tier-dear-text);
  --red-on-chip:    var(--color-tier-dear-text);
  /* The tinted panel backgrounds resolve to paper. This is the single biggest
     visual consequence of the studied DNA. */
  --green-bg:       var(--color-paper-3);
  --amber-bg:       var(--color-paper-3);
  --red-bg:         var(--color-paper-3);

  --surface:        var(--color-paper-2);
  --surface-raised: var(--color-paper-2);
  --surface-sunken: var(--color-paper-3);
  --surface-hover:  var(--color-paper-3);
  --ground:         var(--color-paper);
  --map-land:       var(--color-map-land);
  --on-accent:      var(--color-on-tier);
  --link:           var(--color-focus);
  --link-hover:     var(--color-focus);
  --gps:            var(--color-gps);
  --tier-cheap:     var(--color-tier-cheap);
  --tier-mid:       var(--color-tier-mid);
  --tier-dear:      var(--color-tier-dear);
  --tier-none:      var(--color-tier-none);

  --shadow-sm:      var(--shadow-card);
  --shadow-md:      var(--shadow-float);
  --radius:         var(--radius-card);
  --radius-lg:      var(--radius-card);
  --ease:           var(--dur-base) var(--ease-out);
}

body { background: var(--color-paper); }

/* The price is data, not a headline. The studied source sets its large numerals
   in the grotesque and keeps the serif for titles and moments — so the serif
   experiment that was here comes out. Tabular, so a column of prices aligns on
   the decimal, and negative tracking because large figures set loose. */
.station-effective-price {
  font-family: var(--font-body);
  font-size: var(--text-price);
  font-weight: 600;
  line-height: 1;
  letter-spacing: -0.025em;
  font-variant-numeric: tabular-nums;
}
.station-effective-price .cur {
  display: block;
  font-family: var(--font-body);
  font-size: var(--text-2xs);
  font-weight: 600;
  letter-spacing: .1em;
  text-transform: uppercase;
  color: var(--color-ink-2);
  margin-bottom: 3px;
}

/* Elevation by lightness. No outline, no lit edge, no shadow on dark: a card is
   a lighter surface, which is the one depth cue that survives both themes. */
.station-item {
  background: var(--color-paper-2);
  border: none;
  border-radius: var(--radius-card);
  box-shadow: var(--shadow-card);
}
.station-item:hover { background: var(--color-paper-3); box-shadow: var(--shadow-card); }
/* The selected row. The surface step alone is 1.16:1 against its neighbours and
   is the SAME value :hover uses, so before this the selected station and the one
   under the pointer were indistinguishable — and the green border that used to
   tell them apart had been overridden to `border: none` here since this block
   landed.

   An inset ring, for the reasons in design.md § An outline that carries state:
   ink rather than colour so it borrows nothing from the tier set, inset so it
   costs no layout and cannot be clipped, and 2px to match the focus ring's
   weight while staying a different colour from it. The keyboard ring is defined
   later in this file and so still wins outright on a focused row, which is the
   right precedence. */
.station-item.active {
  background: var(--color-paper-3);
  border: none;
  outline: 2px solid var(--color-ink);
  outline-offset: -2px;
}

/* Desktop only: the rows sit INSIDE .sidebar-card-main, so they must not also be
   cards. Both were --color-paper-2, and dark has no --shadow-card to tell them
   apart, so the list rendered as one slab. Light escaped it only because the
   shadow is real there.

   The surface belongs to the OUTERMOST layer present, and that is what makes
   this a breakpoint rule rather than a global one: on desktop the container
   exists and owns it; on mobile .sidebar-card-main is `display: contents` and
   there is no container, so the row IS the outermost layer and keeps its card.
   Same principle, opposite outcome, which is why mobile is untouched.

   Fixing the container instead was tried twice and is wrong both ways: `aside`
   is transparent over the live map, so dropping the container's surface leaves
   the list floating on tiles, and stepping it down to --color-paper makes it a
   different colour from .sidebar-card-intro directly above it -- two sibling
   cards, two colours, which is a worse tell than the bug.

   Only the resting row is neutralised. :hover and .active are (0,2,0) and keep
   --color-paper-3, measured 1.163 against the card, so both states still read;
   .active also carries its ink ring. Placed after the base rules it must beat,
   per the equal-specificity ordering rule this file records four times. */
@media (min-width: 701px) {
  .station-item {
    background: transparent;
    box-shadow: none;
  }
}

.station-price-tag, .cluster-tag {
  font-family: var(--font-body);
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  border-radius: var(--radius-tile);
  border-width: 2px;
  color: var(--color-on-tier);
}
.cluster-badge { font-variant-numeric: tabular-nums; }

/* The wordmark is the one place the display serif appears on the list screen. */
.intro-logo { font-family: var(--font-display); font-weight: 400; letter-spacing: -0.01em; }

@media (max-width: 700px) {
}

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: .001ms !important;
    transition-duration: .001ms !important;
  }
}

/* ══════════════════════════════════════════════════════════════════════════
   THE SYSTEM, MAPPED
   ══════════════════════════════════════════════════════════════════════════
   tokens.css is the source of truth. This block aliases the names the existing
   rules already use onto it, so the whole stylesheet inherits the system
   without a blind rename — design.md forbids that, because app.js reads the
   --tier-* names at runtime through getComputedStyle.

   The governing rule: THE CHROME CARRIES NO COLOUR. Every green, brass and
   cream that used to live here is gone; --green-bg and friends now resolve to
   paper, because a tinted panel competes with the map instead of framing it. */
:root {
  --cream:          var(--color-paper-3);
  --stone:          var(--color-rule);
  --warm-gray:      var(--color-ink-2);
  --charcoal:       var(--color-ink);
  --text:           var(--color-ink);
  --white:          var(--color-paper-2);
  --border-muted:   var(--color-rule);
  --hairline:       var(--color-rule);

  /* Semantic, and only ever a small mark. The plain names are the FILL, the
     -text/-dark names are the same colour as INK on paper — see tokens.css. The
     migration collapsed both onto the fill, which was harmless until the light
     tiers inverted and the ink roles went with them. Checked before repointing:
     every var(--green-dark) is a `color:`, every var(--green) is a fill, border
     or outline. The names were already right; only what they pointed at was. */
  --green:          var(--color-tier-cheap);
  --green-dark:     var(--color-tier-cheap-text);
  --green-text:     var(--color-tier-cheap-text);
  --amber:          var(--color-tier-mid);
  --amber-text:     var(--color-tier-mid-text);
  --red:            var(--color-tier-dear);
  --red-text:       var(--color-tier-dear-text);
  --red-on-chip:    var(--color-tier-dear-text);
  /* The tinted panel backgrounds resolve to paper. This is the single biggest
     visual consequence of the studied DNA. */
  --green-bg:       var(--color-paper-3);
  --amber-bg:       var(--color-paper-3);
  --red-bg:         var(--color-paper-3);

  --surface:        var(--color-paper-2);
  --surface-raised: var(--color-paper-2);
  --surface-sunken: var(--color-paper-3);
  --surface-hover:  var(--color-paper-3);
  --ground:         var(--color-paper);
  --map-land:       var(--color-map-land);
  --on-accent:      var(--color-on-tier);
  --link:           var(--color-focus);
  --link-hover:     var(--color-focus);
  --gps:            var(--color-gps);
  --tier-cheap:     var(--color-tier-cheap);
  --tier-mid:       var(--color-tier-mid);
  --tier-dear:      var(--color-tier-dear);
  --tier-none:      var(--color-tier-none);

  --shadow-sm:      var(--shadow-card);
  --shadow-md:      var(--shadow-float);
  --radius:         var(--radius-card);
  --radius-lg:      var(--radius-card);
  --ease:           var(--dur-base) var(--ease-out);
}

body { background: var(--color-paper); }

/* ── Per-view ground, restated last ──────────────────────────────────────────
   These must beat the generic `body` rules above: Safari fills the strip below
   the page from the page background, so list view has to equal the sheet and
   map view has to equal the tiles. Same specificity, so order is what decides,
   and the generic rules are declared earlier on purpose. */
@media (max-width: 700px) {
  body.view-list,
  [data-theme="dark"] body.view-list { background: var(--color-paper); }
  body.view-map { background: var(--map-land); }              /* OSM land */
  [data-theme="dark"] body.view-map { background: var(--map-land); }  /* CARTO dark_all land */
}

/* ── Focus · gate 26 ──────────────────────────────────────────────────────
   The stylesheet shipped with no :focus-visible at all, so a keyboard user had
   no indicator on any control. The ring appears instantly and never animates —
   a ring that fades in is a ring that arrives after the user needed it. */
:where(a, button, input, select, textarea, [tabindex]):focus-visible {
  outline: 2px solid var(--color-focus);
  outline-offset: 2px;
  border-radius: var(--radius-tile);
  transition: none;
}
:where(button, a):disabled,
:where(button, a)[aria-disabled="true"] { opacity: .45; cursor: not-allowed; }

/* ── Touch targets on the map ────────────────────────────────────────────────
   Price markers measure 62x32 — under the 44px minimum, on the one surface
   that gets tapped at a junction. Growing the tag would clutter the map, so the
   HIT AREA grows instead: a transparent inset overlay, which leaves the marker
   anchor and the visual size exactly as they were. */
.station-price-tag::before,
.cluster-tag::before {
  content: '';
  position: absolute;
  inset: -8px -6px;
}

/* Rows are buttons, so they get the focus ring the rest of the app has. */
.station-item:focus-visible {
  outline: 2px solid var(--color-focus);
  outline-offset: 2px;
}

.ctrl-icon-btn,
.hidden-notice-btn { position: relative; }
.ctrl-icon-btn::before,
.hidden-notice-btn::before {
  content: '';
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: max(100%, 44px);
  height: max(100%, 44px);
}

/* ── First run ───────────────────────────────────────────────────────────────
   Orientation and the action that resolves it, on one line, in the view that
   has no locate button. It is not chrome — it disappears permanently the first
   time you locate or move the map — so it can afford to be a sentence. */
/* One control, nothing beside it. This was a sentence plus a button: the
   sentence described the viewport and the button named the mechanism, which
   meant two halves competing for one line and, in the worst version, the text
   asserting where the reader was NOT — the one fact unavailable while this is on
   screen, since it only renders when there is no GPS fix.

   The button now carries both what it does and what it gets you, which is the
   only place an action belongs. Longest locale is German at 264px against 288px
   of inner width on a 320px screen. */
.first-run {
  display: flex;
  margin: 0;
  padding: 0 16px 12px;
}
.first-run[hidden] { display: none; }
.first-run-locate {
  flex: 1;
  min-height: 44px;
  padding: 0 12px;
  border: none;
  border-radius: var(--radius-pill);
  background: var(--color-ink);
  color: var(--color-paper);
  font: 600 var(--text-sm)/1 var(--font-body);
  cursor: pointer;
}
