/* ═══════════════════════════════════════════════════════════════════════════
   MegaNet — the design system (#109, foundation of EPIC #107)
   ═══════════════════════════════════════════════════════════════════════════

   Six per-tab issues (#136–#141) are about to restyle nineteen tabs in
   parallel. What keeps that from producing six products in one window is that
   the decisions they share are made once, here, and are named — so a U-issue
   applies a token rather than picking a number.

   The full prose is docs/design-system.md. What follows is the contract, in
   the order the tokens are declared below:

     1. Tokens.       Colour, spacing, type, radius, elevation, motion, focus.
                      A U-issue uses these. If one is missing, it is added
                      here and documented, not invented locally.
     2. Breakpoints.  Six named steps, and no seventh. `npm run shell` fails
                      on any `@media (max-width:)` whose value is not one of
                      them, and the list it checks against is BREAKPOINTS in
                      core.js — the same object isPhoneNav() and
                      NAV_AUTO_COLLAPSE_PX read. One source of truth, in the
                      file the app actually runs.
     3. Patterns.     Panel, panel header, button, form field, and *one* table
                      pattern (see "Tables" below). Six issues styling tables
                      independently is the single most likely way this epic
                      produces six products; there is one answer and it is
                      already what the app does.
     4. Primitives.   Skip link, landmarks, focus ring, `.sr-only`, the shared
                      live region, and one reduced-motion policy.

   Contrast is a property of the palette rather than of any tab, so it is
   checked here too: `npm run shell` computes WCAG contrast for every pair
   below that produces text-on-background, in *both* themes, off the values
   the browser actually resolves. A token added without a dark-theme value
   fails that check rather than shipping as a light-theme colour on a dark
   panel.

   ⚠ ~~One palette change does not reach one chart.~~ Closed by #141. ArroData
   still writes literal colour values into its SVG — the PNG export needs
   something to resolve — but it resolves them off --ad-series-1…12 below at
   draw time rather than out of an array in arro-data.js, the same way it has
   always resolved --text and --panel. A palette change reaches the chart on
   the next repaint(), which toggleTheme() already calls.
   ═══════════════════════════════════════════════════════════════════════════ */

* { box-sizing: border-box; }

:root {
  --bg: #f4f7fb;
  --panel: #ffffff;
  --panel-sub: #fbfdff;
  --text: #16202a;
  --muted: #4f6478;
  --border: #dde5ee;
  --subtle: #f7fafc;
  --header: #0b5cab;
  --thead: #f8fafc;
  --shadow: 0 1px 2px rgba(0,0,0,.04);
  --rx-bg: #eef4fb;
  --tx-bg: #eef8f1;
  --map-line: #ff6f00;
  /* The sky behind the terrain in the 3-D view (Map3D). Tokens rather than
     literals in map-3d.js for the reason every other map colour here is one:
     the theme owns colour. They are NOT flipped for the dark theme — see the
     note beside the dark block. */
  --map3d-sky: #7fb3e8;
  --map3d-horizon: #dfeaf5;
  --map3d-fog: #e8eef5;
  /* A link whose terrain profile says the path is obstructed (MapLos).
     Crimson rather than the blast red above it: a blast is something an
     operator armed and will disarm, an obstruction is a property of the
     ground, and the two can be on screen at once. */
  --map-line-blocked: #d81b60;
  /* The three fade-margin bands (MapFade). Green is the one that had to be
     chosen rather than picked: half the ground these links cross is forest,
     cane or irrigated pasture, and a natural green line over a green base map
     is a line nobody can find. This is a signal green — the most saturated one
     that still reads as green rather than as cyan — and it is drawn a pixel
     heavier than the other two over the same white casing, which is what
     finally lifts it off a satellite tile. Yellow and red are ordinary: they
     have no camouflage to escape. */
  --map-fade-good: #00e676;
  --map-fade-ok:   #ffab00;
  --map-fade-bad:  #e53935;
  /* Repeater backbone paths. Black by request — the white casing the map draws
     under it is what carries it on dark imagery, in both themes. Since #186 the
     black is a dashed line laid *over* the coloured core rather than the core
     itself, so a backbone path still reads as a backbone path while its colour
     goes on saying whatever the link colouring says. */
  --map-backbone: #000000;
  /* The link-by-frequency ramp (MapFreq, #186). Eight hues assigned in
     ascending frequency order — this network uses four (151.5, 151.525, 151.95,
     152.4) and the rest are headroom, so a file with more channels colours all
     of them rather than wrapping onto a colour already in use.

     **Every one of them is a cool hue, and that is the constraint rather than a
     preference.** A channel number carries no judgement: 151.525 MHz is not
     worse than 151.5. Orange, amber and red on this map mean something — a fade
     margin under the threshold, an obstructed path, a repeater whose loss
     strands a station — and a ramp that spent them on channel identity would
     have a third of the network drawn in the colour of a problem. So the range
     is blue through violet to magenta with a pale pink at the end, and the hot
     half of the wheel is left to the layers that are actually saying *bad*.
     (The first version of this ramp used #ff9100 for 151.525 and 65 links
     around Gatton came out amber, which is how the rule got written down.)

     Chosen against everything else that can be on the map at once: none is the
     crimson of an obstructed path, the red of a blast, the deep blue of a
     highlighted river or the teal of a survey mark. Saturated on purpose —
     these lines are read over satellite imagery and topo shading, under the
     white casing every link carries — and, like --map-backbone, they are the
     same in both themes: the base maps do not go dark with the app, so a colour
     that works over a tile works over it in either theme, and all eight are
     bright enough to hold up as legend swatches on a dark panel. */
  --map-freq-1: #2979ff;
  --map-freq-2: #d500f9;
  --map-freq-3: #7c4dff;
  --map-freq-4: #00e5ff;
  --map-freq-5: #b388ff;
  --map-freq-6: #00b0ff;
  --map-freq-7: #64ffda;
  --map-freq-8: #f48fb1;
  /* Blast radius (#161): the pass-range links that die with a repeater, and
     the ring on a station with no other way in. Red as asked for, and a red
     none of the role fills or ACMA mechanisms use; the ring is the darker of
     the pair. Colour is not the only channel — the ring is also heavier,
     dashed longer and grown, and the dying links are the only ones the focus
     dim leaves at full strength. Resolved at draw time in map-blast.js
     (Leaflet options are SVG attributes and do not take var()). */
  --map-blast: #c62828;
  --map-blast-ring: #b71c1c;
  /* Watercourses matching the filter box. Deliberately not the orange of a
     pass-range link nor any station-role fill, and a deeper blue than the water
     the topo tiles already draw, so a highlighted river reads as highlighted. */
  --map-river: #0d47a1;
  /* Survey marks & CORS sites (#120). Teal — none of the four role fills, none
     of the ACMA mechanism colours, and distinct from the river's blue and the
     draw tool's magenta, so the layer reads as its own thing at a glance. */
  --map-survey: #00695c;
  --map-repeater: #0b5cab;
  --map-field: #107c10;
  /* Station-role colours (mirror ROLE_COLOR in app.js and the map markers). */
  --role-field: #107c10;
  --role-repeater: #0b5cab;
  --role-base: #c7401a;
  --role-satcom: #7c35a3;
  /* Bit Flipper's station-of-interest hue (#139): an identity colour, not a
     status — it ties the pinned table row (.bf-row-base) to the halo and pin
     on the map below, so it is one value in both themes and deliberately out
     of the contrast contract like the other categorical hues. bit-flipper.js
     resolves it with cssVar() for the Leaflet options, which cannot take
     var(). */
  --bf-base: #ff8c00;
  --accent: #0b5cab;
  --ok:   #107c10;
  --bad:  #c7401a;
  /* Darkened from #a86400 by #109's contrast pass. It cleared AA on a --panel
     (4.68:1) and missed it on the page background (4.36:1) — and --bg is where
     a warning line outside a panel actually sits. 4.82:1 on --bg now, 5.18:1
     on --panel. The other three status colours already cleared both. */
  --warn: #9e5e00;
  /* Side nav: expanded, and collapsed to an icon rail. The expanded width is
     set by the longest label, "Interference Workbench" — a nav that ellipsises
     its own tab names is worse than a wider one. */
  --mn-nav: 236px;
  --mn-nav-rail: 56px;
  /* The help panel opposite it. Narrower than the nav expanded — it holds prose
     at a readable measure, not a column of buttons — and narrower collapsed,
     because its strip carries one control where the nav's carries fifteen. */
  --mn-help: 300px;
  --mn-help-rail: 44px;
  /* Search hits in the station table. Amber, to match the map's "matches
     filter" pin ring, so the same match reads the same way in both halves of
     the Stations tab. */
  --hit-bg:   #ffe08a;
  --hit-text: #3a2600;
  --hit-ring: #e8a000;
  --hit-glow: rgba(255, 183, 0, .85);
  /* Draw & measure overlay — magenta, which is not a station role, not an ACMA
     mechanism and not the orange of a pass-range link. */
  --draw: #c2185b;

  /* ── Radio Path Maps: the eight region fills (#137) ────────────────────────
     A categorical palette, and the one kind of colour set that does not reduce
     to the status tokens above: these say "a different region", not "better or
     worse", so hue is the whole signal and there is no ordering to preserve.
     They were eight literals inside network-maps.js, written into
     `polygon.style.fill` and into a chip's inline `background` — which is why a
     drawing that is 90% of the tab's surface stayed at light-theme saturation
     on a #0f1720 page.

     They are not in the contrast contract and should not be: a basin fill is
     drawn at 0.32 fill-opacity behind no text, and the chip dot is a 9 px
     square beside a label that already says the region's name. What carries the
     meaning is the name; the colour is the join between the drawing and the
     chip. Both are decorative under 1.4.1, and a categorical set forced to 4.5:1
     against two themes is a set of eight near-blacks that no longer tell each
     other apart. */
  --maps-region-far-north: #2a9d8f;
  --maps-region-mackay:    #e76f51;
  --maps-region-burdekin:  #457b9d;
  --maps-region-central:   #e9a92c;
  --maps-region-widebay:   #6aa84f;
  --maps-region-seqld:     #9b5de5;
  --maps-region-west:      #d98b3a;
  --maps-region-nsw:       #8d99ae;

  /* ── ARRO Data / Field Data: the twelve series colours (#141) ──────────────
     The warning at the top of this file said a palette change does not reach
     the ARRO chart, because that chart writes literal colour values into its
     SVG so the PNG export has something to resolve. Both halves of that were
     true and only the second was necessary: the *literals* have to be in the
     SVG, but they did not have to be typed into arro-data.js. They are these
     tokens now, and the chart resolves them off the document at draw time,
     exactly as it already resolved --text, --muted, --border and --panel. So
     the SVG still carries hex, the PNG still renders, and a palette change now
     reaches the chart on the next repaint() rather than waiting for somebody to
     retype twelve values in a second file.

     Categorical, like the region fills above, and out of the contrast contract
     for the same reason — these say "a different sensor", not "better or
     worse". What tells two series apart without colour is the legend, the dash
     pattern on the line (arro-data.js's seriesDash) and the readings table
     under the chart; the hue is the join between them.

     An operator who picks a colour by hand owns it: that series stops tracking
     the token, in both themes, because a chosen colour is a decision and the
     theme is not entitled to overrule it. */
  --ad-series-1:  #0b5cab;
  --ad-series-2:  #c7401a;
  --ad-series-3:  #107c10;
  --ad-series-4:  #7c35a3;
  --ad-series-5:  #b8860b;
  --ad-series-6:  #00838f;
  --ad-series-7:  #ad1457;
  --ad-series-8:  #5d4037;
  --ad-series-9:  #3949ab;
  --ad-series-10: #ef6c00;
  --ad-series-11: #2e7d32;
  --ad-series-12: #6a1b9a;

  /* ── ACMA interference mechanisms, and the RF Changes series (#138) ────────
     The third categorical set, and it arrives here for the reason the first two
     did: it was seven literals inside ACMA_MECH in core.js, drawn as a legend
     swatch, a lane label, a chart mark and a map pin, and none of the four
     could follow the theme. --acma-mech-cosite-desense was #6d4c41 in both —
     a dark brown on a #18222d panel, which is the mechanism with the most
     candidates in the current extract and the one nobody could see.

     Categorical, so out of the contrast contract like the region fills and the
     series colours: these say "a different mechanism", not "worse". What tells
     two mechanisms apart without hue is the label beside the swatch — which is
     why #138 draws the timeline's lane labels in --text with a coloured key
     square in front, rather than colouring the words. Colour is never the only
     channel and it is never the text.

     `color` in core.js stays the light literal on purpose: five consumers are
     Leaflet options, which become SVG presentation attributes, and those do not
     take var(). Those five are the Stations map and are #136's — see
     docs/design-system.md §3, "the ACMA boundary". */
  --acma-mech-co-channel:     #d32f2f;
  --acma-mech-adjacent:       #f57c00;
  --acma-mech-imd3:           #7b1fa2;
  --acma-mech-imd5:           #ce93d8;
  --acma-mech-imd3-triple:    #9575cd;
  --acma-mech-harmonic:       #0288d1;
  --acma-mech-cosite-desense: #6d4c41;

  /* …and the ink each one takes when it is a filled pill rather than a swatch
     (#136, the contrast question #138 handed over). The map popup wrote
     `color:#fff` over all seven, and four of the seven failed AA doing it —
     adjacent at 2.70:1, imd5 at 2.39:1, imd3-triple at 3.68:1, harmonic at
     3.86:1. There is no single ink that works: white fails four of them and
     near-black fails three. So the ink is paired with the hue, the way
     --c-*-t is paired with --c-* for the packet bit cells, and picked per
     colour as whichever of the two clears by the most. Worst pair here is
     4.67:1. On the dark theme every hue is a light one, so all seven take the
     dark ink — which is why only the exceptions are restated there. */
  --acma-mech-ink:                #12171d;
  --acma-mech-co-channel-ink:     #ffffff;   /* 4.98:1 */
  --acma-mech-adjacent-ink:       #12171d;   /* 6.66:1 */
  --acma-mech-imd3-ink:           #ffffff;   /* 8.20:1 */
  --acma-mech-imd5-ink:           #12171d;   /* 7.54:1 */
  --acma-mech-imd3-triple-ink:    #12171d;   /* 4.89:1 */
  --acma-mech-harmonic-ink:       #12171d;   /* 4.67:1 */
  --acma-mech-cosite-desense-ink: #ffffff;   /* 7.61:1 */

  /* The RF Changes timeline's data-quality series — the pasted corruption
     counts, up to eight at once. Same argument as --ad-series-*, one tab over:
     they were RFC_SERIES_COLORS, an array in rf-changes.js, drawn as a
     `stroke=` attribute the theme could not reach. rf-changes.js resolves these
     off the document at draw time and pairs each with a dash pattern, so two
     series are told apart in greyscale and by a dichromat. */
  --rfc-series-1: #0b5cab;
  --rfc-series-2: #c7401a;
  --rfc-series-3: #107c10;
  --rfc-series-4: #7c35a3;
  --rfc-series-5: #b8860b;
  --rfc-series-6: #00838f;
  --rfc-series-7: #ad1457;
  --rfc-series-8: #5d4037;

  /* The eight change classes in the RF Changes snapshot-diff panel. The fourth
     categorical set and the smallest — one 10 px swatch each, beside a heading
     that names the class in words. Tokens rather than literals in
     rf-changes.js's RFC_CLASS because the swatch is a CSS context, so the token
     reaches it with no repaint and nothing has to resolve anything. */
  --rfc-class-cotenant: #d32f2f;
  --rfc-class-added:    #c62828;
  --rfc-class-removed:  #607d8b;
  --rfc-class-freq:     #f57c00;
  --rfc-class-power:    #7b1fa2;
  --rfc-class-antenna:  #0288d1;
  --rfc-class-site:     #6d4c41;
  --rfc-class-status:   #455a64;

  /* The onset marker on the RF Changes timeline and the RX channel line on the
     RF Environment strip plot: the one annotation on either chart that is not
     categorical. It says "this is the moment/frequency everything else is
     measured against", which is --bad's job everywhere else in the app. It is a
     token of its own rather than --bad directly because it is drawn *on* a
     chart, over a shaded band, and the dark theme wants it brighter than the
     text colour --bad is tuned for. */
  --chart-marker: #c62828;
  --chart-marker-band: rgba(198, 40, 40, .09);

  /* ALERT packet bit-field colours (self-contained: mid-tone bg + light text,
     theme-independent — the cell paints its own ground and its own ink, so one
     pair serves both themes and the contrast is whatever the pair is).
     Three of the nine were measured under AA in #140 and did not clear it:
     --c-data at 3.94:1, --c-hd at 3.84:1 and --c-batt at 4.47:1 against their
     own ink, on bit cells that are 11-13 px type. Each ground is darkened just
     far enough to clear 4.5 with headroom, hue and saturation untouched, so a
     packet reads the same and the labels on it are legible. The white ink on
     the .r-pack gradient was under too (4.25:1 over the green half) and comes
     out at 5.01:1 on the darker ground. */
  --c-frame: #4a5568; --c-frame-t: #eef1f5;   /* 6.64:1 */
  --c-addr:  #2f6fb8; --c-addr-t:  #eaf3ff;   /* 4.59:1 */
  --c-data:  #2a7e4f; --c-data-t:  #e8fbf1;   /* 4.65:1 (was #2e8b57, 3.94:1) */
  --c-ident: #8a6300; --c-ident-t: #fff3d2;   /* 4.92:1 */
  --c-crc:   #6d51ad; --c-crc-t:   #efe8ff;   /* 5.15:1 */
  --c-batt:  #ac4858; --c-batt-t:  #ffe6ea;   /* 4.65:1 (was #b04a5a, 4.47:1) */
  --c-hd:    #0e7c7c; --c-hd-t:    #e2fafa;   /* 4.60:1 (was #0f8a8a, 3.84:1) */
  --c-time:   #a8562a; --c-time-t:   #fff0e6;  /* 4.68:1 */
  --c-status: #5a6b7d; --c-status-t: #eef3f8;  /* 4.91:1 */

  /* RSSI bands — the ERT-A2 decoder's signal scale (#140). Tokens rather than
     the literals alert2.js used to carry, because unlike the bit cells above
     these are drawn *on the page's own ground*: a 3 px bar beside a number, a
     legend dot, a map pin. WCAG 1.4.11 asks 3:1 of a graphic that carries
     meaning, and measured on the dark theme the darkest two did not have it —
     "weak" at 2.46:1 and "strong" at 2.97:1 against the dark panel — while in
     the light theme "fair" sat at 2.99:1. So the scale is stated twice: the
     same five steps, each tuned to the ground it is actually drawn on. */
  --rssi-strong:   #137a3b;   /* 5.32:1 on light */
  --rssi-good:     #5a9e18;   /* 3.25:1 */
  --rssi-fair:     #b98511;   /* 3.21:1 (was #c08a12, 2.99:1) */
  --rssi-marginal: #d4691f;   /* 3.53:1 */
  --rssi-weak:     #b3261e;   /* 6.41:1 */

  /* ── Control surfaces (#109) ───────────────────────────────────────────────
     Buttons, inputs, selects and textareas had their border written out three
     times as a literal — #b9c7d6 on buttons, #bfd0e0 on fields, #b9c7d6 again
     on .btn-link — and none of the three had a dark-theme value, so every
     control in the app was outlined in pale blue-grey on a #18222d panel.
     They are one token now, and it is the token the contrast check measures.

     It is darker than what it replaces, and that is the point rather than a
     side effect: WCAG 1.4.11 wants 3:1 between a control's boundary and what
     is behind it, and #b9c7d6 on white is 1.70:1 — the edge of a button was
     not reliably visible to anyone who needed it to be. Both values clear 3:1
     against --panel *and* against --bg, because a control sits on both — the
     first candidate cleared the panel at 3.20:1 and missed the page at 2.98:1,
     which is exactly the kind of near-miss a check catches and an eye does
     not. */
  --ctl-border: #798c9f;
  /* The affirmative action — Save, Apply, Sign in. Was the literal #107c10 in
     both themes with white on top (5.44:1, so the colour was fine); it is a
     token now so the check can see it and so a U-issue has something to name. */
  --primary: #107c10;
  --primary-text: #ffffff;

  /* ── Focus (#109) ──────────────────────────────────────────────────────────
     One ring, defined once, so that U1–U6 inherit it rather than each deciding
     what focus looks like on their own backgrounds. It is the accent, which is
     already checked for contrast against both --panel and --bg, plus an outer
     halo in the page colour so the ring stays visible where it lands on a
     control that is itself accent-coloured.

     The rule: never `outline: none` without a replacement that is at least as
     visible. There is one allowlisted exception in this file (.nv-node) and it
     replaces the outline with a shape change on the thing being focused;
     `npm run shell` holds the allowlist, so a second one has to be argued for
     rather than added. (There were two until #165 deleted the Stations
     divider, and the check is what remembered that its exception had to go
     with it — a stale entry fails it exactly as an unlisted removal does.) */
  --focus: var(--accent);
  --focus-width: 2px;
  --focus-offset: 2px;
  --focus-halo: var(--bg);

  /* ── Spacing (#109) ────────────────────────────────────────────────────────
     A 4 px base. The app was already writing .3/.35/.4/.45/.5/.55/.6/.65rem
     for what are three distinct gaps; these are those three, plus the two
     above and the one below them. Existing rules are not being rewritten
     wholesale — a U-issue converts its own tab as it goes. */
  --sp-1: .25rem;   /*  4px — inside a chip, between an icon and its label */
  --sp-2: .5rem;    /*  8px — between controls in a row */
  --sp-3: .75rem;   /* 12px — between the parts of a panel */
  --sp-4: 1rem;     /* 16px — between panels, and the page gutter */
  --sp-5: 1.5rem;   /* 24px — between sections of a long page */
  --sp-6: 2rem;     /* 32px — above a page-level heading */

  /* ── Type (#109) ───────────────────────────────────────────────────────────
     The scale the app already uses, named. .72/.74 became one step, .84/.85
     became one, .9/.92/.93 became one — those were not three decisions. */
  --fs-xs: .74rem;  /* counts, captions, attribution */
  --fs-sm: .85rem;  /* dense tables, secondary lines */
  --fs-md: .92rem;  /* body */
  --fs-lg: 1.05rem; /* a panel's own heading on a phone */
  --fs-xl: 1.3rem;  /* a page heading */
  --lh-tight: 1.25; /* headings */
  --lh-body: 1.5;   /* prose */
  --font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;

  /* ── Radius & elevation (#109) ─────────────────────────────────────────────
     --shadow keeps its name: it is used in ~20 places and is the panel's
     elevation. --shadow-2 is the raised one (drawers, modals, popovers), which
     was previously written out per-component. */
  --r-sm: 4px;      /* a chip, a kbd, a swatch */
  --r-md: 8px;      /* a button, a field, a note */
  --r-lg: 10px;     /* a panel */
  --r-pill: 999px;
  --shadow: 0 1px 2px rgba(0,0,0,.04);
  --shadow-2: 0 6px 24px rgba(8,15,24,.18);

  /* ── Motion (#109) ─────────────────────────────────────────────────────────
     --motion-rail is the one that is load-bearing: it must stay in step with
     NAV_TRANSITION_MS / HELP_TRANSITION_MS in core.js, because app.js waits
     that long before re-measuring every Leaflet map. The reduced-motion policy
     is one block at the foot of this file. */
  --motion-fast: .12s;
  --motion-rail: .16s;
  --motion-slow: .24s;
  --ease: ease;
}

[data-theme="dark"] {
  /* The two ends of the RSSI scale, lifted off the dark panel — see the light
     theme's block for what each number is and why the scale is stated twice.
     The middle three already clear 3:1 here and are left where they are, so
     the scale reads the same on both themes. */
  --rssi-strong: #14803e;   /* 3.21:1 on dark (light theme's #137a3b was 2.97:1) */
  --rssi-fair:   #c08a12;   /* 5.27:1 — the light theme's own value is the one that had to move */
  --rssi-weak:   #d42d24;   /* 3.22:1 (light theme's #b3261e was 2.46:1) */

  --bg: #0f1720;
  --panel: #18222d;
  --panel-sub: #111923;
  --text: #e7eff7;
  --muted: #9fb1c3;
  --border: #2a3948;
  --subtle: #111c26;
  --header: #133f6d;
  --thead: #1d2a36;
  --shadow: 0 1px 2px rgba(0,0,0,.18);
  --rx-bg: #1d3242;
  --tx-bg: #19362a;
  --map-line: #ff9f43;
  --map-line-blocked: #ff6090;
  /* The 3-D sky, darkened — but only a little, and not to night. The ground in
     that view is lit by real base-map tiles which do not go dark with the app,
     so a black sky over a daylit landscape reads as a rendering fault rather
     than as a theme. This is dusk over the same ground: enough to stop a bright
     band glaring out of a dark page, not enough to claim the sun has set. */
  --map3d-sky: #2c4a6b;
  --map3d-horizon: #5d7793;
  --map3d-fog: #44586e;
  /* Lifted for the dark theme like every other map colour here — the base maps
     do not go dark with the app, so these keep the same job over the same
     tiles and only need to hold up against a dark panel in the legend. */
  --map-fade-good: #2bff9e;
  --map-fade-ok:   #ffc233;
  --map-fade-bad:  #ff5f52;
  --map-backbone: #000000;
  --map-blast: #ff6659;
  --map-blast-ring: #ff8a80;
  --map-river: #4fc3f7;
  --map-survey: #4dd0c8;
  --map-repeater: #6bb6ff;
  --map-field: #57d657;
  /* Lighter role tints so coloured station names stay legible on dark panels. */
  --role-field: #57d657;
  --role-repeater: #6bb6ff;
  --role-base: #ff8a63;
  --role-satcom: #c79ae8;
  --accent: #6bb6ff;
  --ok:   #3ecf8e;
  --bad:  #ff6b6b;
  --warn: #ffb347;
  /* A pale wash disappears on a dark panel, so the hit goes the other way:
     deep amber block, light text, and the glow doing most of the work. */
  --hit-bg:   #7a5200;
  --hit-text: #ffeab4;
  --hit-ring: #ffc400;
  --hit-glow: rgba(255, 196, 0, .55);
  --draw: #ff6fb0;

  /* The eight region fills, lifted for a dark page. Same eight hues, raised in
     lightness and dropped in saturation so a 0.32-opacity fill over #111923 is
     still visibly its own colour rather than eight shades of grey-blue. */
  --maps-region-far-north: #5fd0c2;
  --maps-region-mackay:    #ff9c7f;
  --maps-region-burdekin:  #7fb3d5;
  --maps-region-central:   #f7c95c;
  --maps-region-widebay:   #98d47b;
  --maps-region-seqld:     #c396f5;
  --maps-region-west:      #f0b26b;
  --maps-region-nsw:       #b7c1d4;

  /* The twelve series colours, lifted for a dark page. Same twelve hues, raised
     in lightness and dropped in saturation: a 1.7 px #5d4037 line on a #18222d
     panel was a brown that could not be seen at all, and #0b5cab was reading as
     the panel's own border. This is the set the chart has never had — before
     #141 it drew the light values on both themes, because the palette lived in
     a JavaScript array that toggleTheme() had no way to reach. */
  --ad-series-1:  #6bb6ff;
  --ad-series-2:  #ff8a63;
  --ad-series-3:  #57d657;
  --ad-series-4:  #c79ae8;
  --ad-series-5:  #e3c05c;
  --ad-series-6:  #4fd3dd;
  --ad-series-7:  #ff8ab5;
  --ad-series-8:  #c9a894;
  --ad-series-9:  #9aa9ff;
  --ad-series-10: #ffb066;
  --ad-series-11: #8fdc9e;
  --ad-series-12: #d9a6ff;

  /* The seven mechanism colours, lifted for a dark page — same seven hues,
     raised in lightness and dropped in saturation, on the same reasoning as the
     region fills and the series colours above. #6d4c41 is the one that was
     actually invisible; #7b1fa2 and #9575cd were close behind. */
  --acma-mech-co-channel:     #ff6b6b;
  --acma-mech-adjacent:       #ffa94d;
  --acma-mech-imd3:           #c79ae8;
  --acma-mech-imd5:           #e6c4f2;
  --acma-mech-imd3-triple:    #b3a4e8;
  --acma-mech-harmonic:       #6bb6ff;
  --acma-mech-cosite-desense: #c9a894;

  /* Every dark-theme hue above is a light one, so every pill takes the dark
     ink here — 6.49:1 at worst (co-channel). Only the two the light theme set
     to white need restating. */
  --acma-mech-co-channel-ink:     #12171d;   /* 6.49:1 */
  --acma-mech-imd3-ink:           #12171d;   /* 7.90:1 */
  --acma-mech-cosite-desense-ink: #12171d;   /* 8.16:1 */

  /* The eight RF Changes series, lifted for a dark page. Same eight values the
     twelve ARRO series use for their first eight, and deliberately so: the two
     charts sit two tabs apart and a reader moving between them should not have
     to learn a second palette. */
  --rfc-series-1: #6bb6ff;
  --rfc-series-2: #ff8a63;
  --rfc-series-3: #57d657;
  --rfc-series-4: #c79ae8;
  --rfc-series-5: #e3c05c;
  --rfc-series-6: #4fd3dd;
  --rfc-series-7: #ff8ab5;
  --rfc-series-8: #c9a894;

  /* The eight change classes, lifted for a dark page. #455a64 and #607d8b were
     both slate-on-slate against a #18222d panel. */
  --rfc-class-cotenant: #ff6b6b;
  --rfc-class-added:    #ff8a63;
  --rfc-class-removed:  #9fb1c3;
  --rfc-class-antenna:  #6bb6ff;
  --rfc-class-freq:     #ffa94d;
  --rfc-class-power:    #c79ae8;
  --rfc-class-site:     #c9a894;
  --rfc-class-status:   #b0bfcc;

  /* Brighter than the light value: this line is drawn over a shaded band on a
     dark panel, where #c62828 reads as a smudge. */
  --chart-marker: #ff6b6b;
  --chart-marker-band: rgba(255, 107, 107, .12);

  /* Control surfaces, dark. --border (#2a3948) is the right colour for a
     divider between two dark surfaces and the wrong one for the edge of a
     control: it is 1.36:1 against --panel, so a button would have had no
     visible boundary at all. This is 3.81:1. */
  --ctl-border: #6a7d92;
  /* The green holds in the dark theme — white on #107c10 is 5.44:1 either way,
     and the affirmative action reading the same colour in both themes is worth
     more than matching --ok, which lightens so it can be *text* on a panel. */
  --primary: #107c10;
  --primary-text: #ffffff;

  /* --shadow is already redefined above with the rest of the surfaces; only the
     raised elevation needs a darker value here. */
  --shadow-2: 0 6px 24px rgba(0,0,0,.5);
}

body {
  margin: 0;
  font-family: Segoe UI, Arial, sans-serif;
  background: var(--bg);
  color: var(--text);
}

/* Links had no rule at all, so they fell back to the browser's #0000EE and, once
   followed, #551A8B — two shades of near-black against a dark panel. Visited
   keeps the accent: which ARRO page you have already opened is not worth a
   colour, and losing the link entirely is. Leaflet scopes its own
   `.leaflet-container a`, so map controls and popups are unaffected. */
a,
a:visited {
  color: var(--accent);
}

/* ── Accessibility primitives (#109) ─────────────────────────────────────────
   The parts that are the app's rather than any tab's. U1–U6 inherit these;
   they do not restate them, and they do not opt out of them. */

/* Text for a screen reader and for nothing else. Not `display: none` and not
   `visibility: hidden` — both remove it from the accessibility tree, which is
   the one place it is meant to be. The 1px/clip-path form is the one that
   survives a forced-colours mode. */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  border: 0;
  overflow: hidden;
  white-space: nowrap;
  clip-path: inset(50%);
}

/* The skip link. First focusable thing on the page, invisible until it has
   focus, and then a real target over the banner. It exists because the nav is
   nineteen buttons plus a find box: a keyboard user arriving at the page had
   twenty-one stops before the first thing on the tab they came for. */
.skip-link {
  position: absolute;
  top: 0;
  left: 0;
  z-index: 1300;              /* over the drawers (1200) and their backdrops */
  padding: var(--sp-2) var(--sp-3);
  background: var(--panel);
  color: var(--accent);
  border: 1px solid var(--ctl-border);
  border-radius: 0 0 var(--r-md) 0;
  font-size: var(--fs-md);
  font-weight: 600;
  text-decoration: none;
  transform: translateY(-120%);
}

.skip-link:focus-visible {
  transform: translateY(0);
}

/* One focus indicator, on everything, defined against the token rather than
   per component. The halo is what keeps it visible when the ring lands on an
   accent-coloured control — the accent ring alone would disappear into it. */
:where(a, button, input, select, textarea, summary, [tabindex]):focus-visible {
  outline: var(--focus-width) solid var(--focus);
  outline-offset: var(--focus-offset);
  box-shadow: 0 0 0 calc(var(--focus-width) + var(--focus-offset)) var(--focus-halo);
}

/* :where() is 0-specificity on purpose — a component that needs a different
   ring (the modal card, the memory strip) overrides it with one selector and
   no !important. What a component may *not* do is remove it. */

/* The main region is a focus target for the skip link and for a tab switch on
   a phone; it must not paint a ring when the page is merely clicked into. */
#main-content:focus { outline: none; }
#main-content:focus-visible {
  outline: var(--focus-width) solid var(--focus);
  outline-offset: calc(var(--focus-offset) * -1);
  box-shadow: none;
}

/* ── One page wrapper (#109) ─────────────────────────────────────────────────
   Eight tabs open with the same inline style — `max-width:Npx;margin:auto;
   padding:1rem;display:grid;gap:1rem` — written out eight times with four
   different maximums. A tab that wants a centred column uses this and sets
   --page-max if the default is wrong for it. Inline styles are what U1–U6
   are replacing; this is the thing to replace them with. */
.page {
  max-width: var(--page-max, 1100px);
  margin: 0 auto;
  padding: var(--sp-4);
  display: grid;
  gap: var(--sp-4);
  align-content: start;
}

/* ── Page header ─────────────────────────────────────────────────────────────
   The banner is the same three things at every width — who this is, what is
   loaded, and the four global actions — but on a phone it has to say them in
   one row rather than four. What gives, in order: the button labels (the icons
   carry them), then the "Network Tool" tagline, then the title's size. The
   station count stays: it is the one line that changes, and the one that tells
   you the file actually loaded. */

header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 1rem;
  padding: .8rem 1.25rem;
  background: var(--header);
  color: white;
}

.header-id {
  min-width: 0;                 /* the title may ellipsise before the buttons wrap */
}

header h1 {
  margin: 0 0 .15rem 0;
  font-size: 1.45rem;
  line-height: 1.15;
}

header p {
  margin: 0;
  opacity: .95;
  font-size: .95rem;
  max-width: 70ch;
}

/* Tagline and station count share one line — two stacked lines of 0.85 rem
   text under the title bought nothing that one line does not. */
.header-sub {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: .1rem .5rem;
  font-size: .85rem;
  line-height: 1.3;
  color: rgba(255, 255, 255, .82);
}

.header-stats:not(:empty)::before {
  content: '·';
  margin-right: .5rem;
  opacity: .6;
}

/* The shared .panel-header rule spaces its two ends apart, which is wrong here:
   these are one cluster pinned to the right of the title, and the title is what
   gives when the row runs short. */
.header-actions {
  justify-content: flex-end;
  flex-shrink: 0;
}

.hdr-btn {
  display: inline-flex;
  align-items: center;
  gap: .4rem;
  white-space: nowrap;
}

/* Signed in. The header collapses to bare icons on a narrow window, and at that
   width a key icon that means "sign in" and one that means "you are signed in as
   Cameron" are the same glyph — so the state is carried by the button, not by
   its label, and survives the label being hidden. */
.hdr-btn.is-in {
  background: rgba(255, 255, 255, .22);
  border-color: rgba(255, 255, 255, .5);
}

/* The icon is decoration while the label is showing, and the whole button once
   the label is gone — it grows to match at the breakpoint below. */
.hdr-ico {
  font-size: .95rem;
  line-height: 1;
}

/* Opens the nav drawer. Only ever visible on a phone, where the icon rail has
   been taken out of the layout and this is the one way back to the tabs. */
.hdr-nav {
  display: none;
  flex: 0 0 auto;
  padding: 0;
  width: 2.4rem;
  height: 2.4rem;
  font-size: 1.15rem;
  line-height: 1;
  background: rgba(255, 255, 255, .14);
  border-color: rgba(255, 255, 255, .35);
  color: #fff;
}

.hdr-nav.on {
  background: rgba(255, 255, 255, .3);
}

#nav-backdrop,
#help-backdrop {
  position: fixed;
  inset: 0;
  z-index: 1150;               /* under the drawer (1200), over everything else */
  background: rgba(8, 15, 24, .45);
}

#nav-backdrop[hidden],
#help-backdrop[hidden] {
  display: none;
}

/* ── Memory meter ────────────────────────────────────────────────────────────
   A thin segmented strip directly under the header — see issue #79. Neutral
   (thin, low-contrast segments) below the warn threshold; a subtle glow past
   it, a firmer one past the bad threshold. It must not become another thing
   that nags, so there is deliberately no motion here beyond the hover grow. */
.mem-bar {
  display: flex;
  width: 100%;
  height: 3px;
  background: var(--border);
  cursor: pointer;
  transition: height .15s ease, box-shadow .15s ease;
}
.mem-bar[hidden] { display: none; }
.mem-bar:hover, .mem-bar:focus-visible { height: 5px; }
.mem-bar:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.mem-bar.mem-warn { box-shadow: inset 0 -2px 0 rgba(168, 100, 0, .35); }
.mem-bar.mem-bad  { box-shadow: inset 0 -2px 0 rgba(198, 64, 26, .55); }

.mem-seg {
  height: 100%;
  min-width: 2px;
  transition: width .3s ease;
}

.mem-card { max-width: 640px; }

.mem-table {
  width: 100%;
  border-collapse: collapse;
  margin-top: .8rem;
  font-size: .92rem;
}
.mem-table th, .mem-table td {
  text-align: left;
  padding: .4rem .5rem;
  border-bottom: 1px solid var(--border);
}
.mem-table tfoot td {
  border-top: 1px solid var(--border);
  border-bottom: none;
  font-weight: 600;
}
.mem-bytes { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
.mem-swatch {
  display: inline-block;
  width: .7rem;
  height: .7rem;
  border-radius: 2px;
  margin-right: .5rem;
  vertical-align: middle;
}
.mem-release { font-size: .82rem; padding: .25rem .55rem; }
.mem-heap { color: var(--muted); font-size: .85rem; margin: .9rem 0 0; line-height: 1.5; }

.layout {
  display: grid;
  grid-template-columns: 320px 1fr;
  gap: 1rem;
  padding: 1rem;
}

.panel {
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: 10px;
  padding: .9rem;
  box-shadow: var(--shadow);
}

.panel-sub {
  background: var(--panel-sub);
  border: 1px solid var(--border);
  border-radius: 10px;
  padding: .8rem;
}

.sidebar {
  position: sticky;
  top: 1rem;
  height: fit-content;
}

/* ── Stations tab: one column ──────────────────────────────────────────────
   Two, from #136 until #165: a filter rail, a draggable divider, and a
   scroller inside each column so that reaching a filter at the bottom of the
   rail did not drag the map off the top. Every part of that served a rail
   taller than the screen that had to stay beside the map.

   #164 moved Map display, Draw & measure and the legend onto the map itself
   and #165 moved the filters under it, so there is no rail, no second column
   to size, and nothing for a divider to divide. The page scrolls as one piece
   at every width now, which is what it already did below `lg`. What went with
   the split: --mn-split, .map-split and its `outline: none` exception, and
   both inner scrollers.

   `.map-layout` still overrides `.layout` because `.layout` is a 320 px rail
   plus a column, and the one child left here would otherwise be laid out in
   the rail. minmax(0, 1fr) rather than 1fr: `1fr` is `minmax(auto, 1fr)`, and
   a station table's min-content would set the column's width and take the
   page sideways with it. */

.map-layout {
  grid-template-columns: minmax(0, 1fr);
}

.stack {
  display: grid;
  gap: 1rem;
}

/* A grid item is min-width:auto by default, which means "never narrower than
   my content". So a panel holding a min-width:880px table (.bf-table, the
   RF tables) grew its column to 880 px, the column grew the page, and the
   whole document — header, nav and all — scrolled sideways on a phone while
   the .table-wrap that was supposed to scroll sat there with nothing to do.
   A panel is never the thing that should decide the page is wider than the
   screen; whatever is too wide inside it has its own scroller. Stated on the
   panels rather than on each grid that holds them, because several tabs lay
   themselves out with an inline `display:grid` and no class to hang this on. */
.panel,
.panel-sub,
.stack > *,
.layout > *,
.crud-layout > * {
  min-width: 0;
}

.panel-header,
.header-actions,
.button-row {
  display: flex;
  gap: .5rem;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
}

/* …and its counterpart, which the app was missing (#138). The three above are
   "a row spread across the panel" — `justify-content: space-between` is the
   whole point of them, a heading at one end and its actions at the other.
   `.button-group` is "these controls belong together": same gap, same wrap, no
   spreading. Used with `.button-row` it does not fight it — the group is one
   child of the row and travels as one.

   Worth naming rather than leaving to each tab, because reaching for
   `.button-row` when you meant this is a mistake that *looks* deliberate: two
   buttons at opposite ends of a panel read as "confirm" and "cancel". */
.button-group {
  display: flex;
  gap: var(--sp-2);
  flex-wrap: wrap;
  align-items: center;
}

.panel-header-wrap {
  align-items: flex-start;
}

.panel-header h2,
.panel-header h3 {
  margin: 0;
}

button {
  border: 1px solid var(--ctl-border);
  background: var(--panel);
  color: var(--text);
  padding: .55rem .8rem;
  border-radius: var(--r-md);
  cursor: pointer;
}

button[disabled] {
  opacity: .55;
  cursor: not-allowed;
}

button.primary {
  background: var(--primary);
  color: var(--primary-text);
  border-color: var(--primary);
}

/* A real <a> that reads as a button. Used where the action is "leave for another
   site" — an anchor keeps middle-click, ctrl-click and "copy link address",
   which a button with a window.open handler throws away. */
.btn-link {
  display: inline-block;
  border: 1px solid var(--ctl-border);
  background: var(--panel);
  color: var(--accent);
  padding: .5rem .8rem;
  border-radius: var(--r-md);
  font-size: .9rem;
  font-weight: 600;
  text-decoration: none;
  cursor: pointer;
}

.btn-link:hover {
  border-color: var(--accent);
  text-decoration: underline;
}

.btn-link.disabled {
  color: var(--muted);
  opacity: .55;
  pointer-events: none;
}

/* …and the small one. `.pill` is `.btn-link` at a footnote's size: the shape a
   row of *equal* things takes when there are four or five of them and they are
   all the same kind of move — the links out of a station callout, the two that
   move the map under it, the two document libraries. #170 made that row pills
   because a stack of underlined text read as a menu, and because half of it was
   already `<a href="#" onclick=…>` — the thing #138 named. A pill looks like a
   button, so the ones whose action is in-page are buttons now.

   Both elements take it: an <a> leaving the site and a <button> doing something
   here look identical on purpose, because to the hand they are the same target.
   What they announce themselves as still differs, which is the half that
   matters. `border-radius` is --r-pill rather than --r-md — the one visual
   difference from .btn-link, and what makes five of them read as a set. */
.pill,
button.pill {
  display: inline-flex;
  align-items: center;
  gap: .25rem;
  border: 1px solid var(--ctl-border);
  border-radius: var(--r-pill);
  background: var(--panel);
  color: var(--accent);
  padding: .2rem .6rem;
  font: inherit;
  font-size: .8rem;
  font-weight: 600;
  line-height: 1.5;
  text-align: left;
  text-decoration: none;
  white-space: nowrap;
  cursor: pointer;
}

.pill:hover {
  border-color: var(--accent);
  background: var(--subtle);
}

/* A pill that is currently *on* — the blast styling armed, the pin being moved.
   Filled rather than outlined, which is the same distinction .si-pill makes
   between its resting and hover states, so "this one is live" reads without
   needing the label to say so twice. */
.pill.is-on {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--panel);
}

.pill[disabled] {
  color: var(--muted);
  opacity: .55;
  pointer-events: none;
}

/* The row they sit in. Wraps, because the widest of these rows is inside a
   Leaflet popup ~300 px across and the narrowest phone is narrower than that. */
.pill-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: .3rem;
}

/* …and its opposite, which the app needed just as often and had never named
   (#138). `.btn-link` is an <a> that reads as a button, for leaving the site.
   `.link-btn` is a <button> that reads as a link, for the thing the app writes
   forty times as `<a href="#" onclick="doSomething();return false">` — which is
   announced as a link, offers a middle-click that opens a copy of the page, and
   navigates to the top of the document if the handler ever throws before
   `return false`. The action is in-page, so the element is a button and this is
   what makes it look like what it replaced.

   Not `.row-open`: that one is deliberately styled as the *text* it replaces,
   because forty bordered controls down a name column read as a form. This one
   is styled as the link it replaces, because that is what it is. */
.link-btn {
  border: 0;
  background: none;
  padding: 0;
  margin: 0;
  font: inherit;
  color: var(--accent);
  text-align: left;
  cursor: pointer;
}

.link-btn:hover {
  text-decoration: underline;
}

kbd {
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: .85em;
  border: 1px solid var(--border);
  border-bottom-width: 2px;
  border-radius: 4px;
  padding: 0 .25em;
  background: var(--subtle);
}

input[type="search"],
input[type="file"],
input[type="text"],
input[type="number"],
textarea,
select {
  width: 100%;
  /* …and no wider than the column it is in (#136). `width: 100%` sets the
     *used* width and leaves the intrinsic minimum alone: a <textarea> still
     reports its `cols` default — about 426 px here — as min-content, and a
     grid track written `1fr` is `minmax(auto, 1fr)`, so one textarea in a
     one-column form grid made the whole column 426 px wide inside a 375 px
     phone. Every control in this rule is already `width: 100%`; none of them
     has any business setting a floor under its container. */
  min-width: 0;
  padding: .55rem .65rem;
  border: 1px solid var(--ctl-border);
  border-radius: var(--r-md);
  background: var(--panel);
  color: var(--text);
}

textarea {
  min-height: 90px;
}

/* Read-only fields are still fields — they hold a value worth copying — but
   they must not look like somewhere to type. */
input[readonly] {
  background: var(--subtle);
  color: var(--muted);
  cursor: default;
}

.upload-grid {
  display: grid;
  gap: .55rem;
  margin-top: .75rem;
}

.upload-grid label,
.form-grid label {
  display: grid;
  gap: .3rem;
  font-size: .9rem;
  color: var(--muted);
}

.button-column {
  display: grid;
  gap: .5rem;
  margin-top: .75rem;
}

.checklist {
  max-height: 38vh;
  overflow: auto;
  margin-top: .75rem;
  display: grid;
  gap: .3rem;
}

.checklist label {
  display: flex;
  gap: .5rem;
  align-items: start;
  padding: .2rem 0;
  font-size: .92rem;
}

/* ── Stations tab filter panel ────────────────────────────────────────────────
   One bordered block per question the operator is asking (what kind of site,
   what does it measure, whose network, where). The long groups are <details>
   that collapse to a title plus a live "All / None / 3 of 15" state, so a dozen
   networks and twenty sensor types still fit in a sidebar. */

.filter-block {
  border-top: 1px solid var(--border);
  margin-top: .7rem;
  padding-top: .6rem;
}

/* ── The filter card's own layout (#165) ──────────────────────────────────
   These filters spent their life in a 320 px rail, where a single column was
   the only arrangement there was. They are a card the width of the page now,
   and six full-width blocks stacked down a 1400 px screen would waste exactly
   the room #165 set out to reclaim. So: the search leads and spans, and the
   grouped filters flow into as many columns as the width allows.

   `auto-fill` with a 15rem floor rather than a fixed count — the card is as
   wide as the window minus the nav and the help rail, both of which slide, so
   the number of columns is a consequence rather than a decision. */

/* Flex rather than two grid columns, and the basis is the whole trick: the
   search box asks for 24rem and the two reset buttons are unshrinkable, so on
   any card too narrow to hold both the buttons wrap to their own row instead of
   squeezing the search box to a slot too small to read a pasted address in.
   That is a phone, and it needs no breakpoint to say so. */
.filter-lead {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-2) var(--sp-4);
  align-items: flex-start;
}

.filter-lead > .filter-block--lead {
  flex: 1 1 24rem;
  min-width: 0;
}

.filter-lead > .filter-resets {
  flex: 0 0 auto;
  margin-left: auto;
}

/* First thing in the card: the rule the block pattern draws above itself would
   be a line under the summary's own, for nothing. */
.filter-block--lead {
  border-top: 0;
  margin-top: 0;
  padding-top: 0;
}

.filter-groups {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
  gap: var(--sp-3) var(--sp-4);
  align-items: start;
  margin-top: var(--sp-2);
}

/* The blocks carry their own top margin for the stacked case; inside the grid
   the gap does that job and the margin would only make the rows uneven. The
   rule above each stays — in columns it reads as the block's own header rule
   rather than as a separator between two stacked things, which is the same
   thing it was doing before. */
.filter-groups > .filter-block {
  margin-top: 0;
}

.filter-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: .5rem;
  cursor: pointer;
  list-style: none;
}

.filter-head::-webkit-details-marker { display: none; }

/* Twisty on the collapsible groups only — the always-open ones get no arrow. */
details.filter-block > .filter-head .filter-title::before {
  content: '▸';
  display: inline-block;
  width: .8em;
  margin-right: .15rem;
  color: var(--muted);
  transition: transform .12s ease;
}

details.filter-block[open] > .filter-head .filter-title::before {
  transform: rotate(90deg);
}

.filter-title {
  font-weight: 600;
  font-size: .88rem;
}

.filter-state {
  font-size: .76rem;
  color: var(--muted);
  white-space: nowrap;
}

.filter-hint {
  margin: .4rem 0 .2rem;
  font-size: .76rem;
  line-height: 1.4;
  color: var(--muted);
}

.filter-note {
  margin: .45rem 0 0;
  font-size: .8rem;
  color: var(--muted);
}

.filter-field {
  display: grid;
  gap: .25rem;
  font-size: .82rem;
  color: var(--muted);
}

.filter-field + .filter-field {
  margin-top: .5rem;
}

/* Search box. A textarea rather than an input so a pasted column of ALERT
   addresses keeps its line breaks; it opens one line tall and app.js grows it
   to fit the paste, up to a cap, after which it scrolls. */
.filter-search {
  /* autoGrowSearch measures the pasted content and hands the answer over as
     --grow (#136). `auto` while it measures, then the pixel height. */
  height: var(--grow, auto);
  min-height: 2.4rem;
  max-height: 170px;
  padding: .5rem .6rem;
  font: inherit;
  font-size: .88rem;
  line-height: 1.35;
  resize: vertical;
  overflow-y: auto;
  white-space: pre-wrap;
}

.filter-clear {
  padding: .05rem .4rem;
  font-size: .74rem;
  border-color: transparent;
  background: transparent;
  color: var(--accent);
}

/* ── The search stack ────────────────────────────────────────────────────────
   One entry is a box, the fields it is pointed at, and what it found. A stack
   of one looks like the single box that was here before it — the tick boxes are
   the only addition, and they read as a caption under the box rather than as a
   second filter block, because they are not narrowing anything: they say how to
   read the text above them. */

.search-row + .search-row {
  margin-top: .55rem;
  padding-top: .55rem;
  border-top: 1px dashed var(--border);
}

/* Dashed rather than solid, and only *between* entries: the filter card is
   already full of solid rules separating one question from the next, and these
   are the same question asked twice. */

.search-scope {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: .15rem .55rem;
  margin-top: .3rem;
}

.search-scope-lead {
  font-size: .74rem;
  color: var(--muted);
}

.search-field {
  display: inline-flex;
  align-items: center;
  gap: .25rem;
  font-size: .78rem;
  cursor: pointer;
  white-space: nowrap;
}

/* Pushed to the far end so it is never mistaken for a fourth field, and only
   drawn once there is more than one entry to remove. */
.search-remove {
  margin-left: auto;
  padding: 0 .45rem;
  font-size: .9rem;
  line-height: 1.5;
  color: var(--muted);
}

.search-foot {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: .3rem .7rem;
  margin-top: .5rem;
}

.search-add {
  padding: .15rem .5rem;
  font-size: .75rem;
  border-radius: 6px;
}

.search-mode {
  display: inline-flex;
  flex-wrap: wrap;
  align-items: center;
  gap: .15rem .5rem;
  font-size: .78rem;
  color: var(--muted);
}

.search-mode-lead {
  font-size: .74rem;
}

.search-mode-opt {
  display: inline-flex;
  align-items: center;
  gap: .25rem;
  cursor: pointer;
  white-space: nowrap;
}

.filter-actions {
  display: flex;
  gap: .35rem;
  margin: .45rem 0 .1rem;
}

.filter-actions button {
  padding: .15rem .5rem;
  font-size: .75rem;
  border-radius: 6px;
}

/* Two ways to clear: hold the current view (the default) or zoom back out.
   They wrap onto their own line rather than squeezing the "Filters" heading. */
.filter-resets {
  display: flex;
  flex-wrap: wrap;
  gap: .3rem;
  justify-content: flex-end;
}

.filter-reset {
  padding: .25rem .6rem;
  font-size: .78rem;
  white-space: nowrap;
}

.filter-list {
  max-height: 15rem;
  overflow: auto;
  margin-top: .3rem;
}

.filter-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: .4rem;
  padding: .12rem .15rem;
  border-radius: 6px;
}

.filter-row:hover {
  background: var(--subtle);
}

.filter-row-label {
  display: flex;
  align-items: center;
  gap: .4rem;
  min-width: 0;
  font-size: .87rem;
  cursor: pointer;
}

.filter-row-label > span {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* The "not recorded yet" bucket — a data gap, not a category of station. */
.filter-row-none > span {
  font-style: italic;
  color: var(--muted);
}

.filter-row-side {
  display: flex;
  align-items: center;
  gap: .3rem;
  flex-shrink: 0;
}

.filter-count {
  font-size: .74rem;
  color: var(--muted);
  font-variant-numeric: tabular-nums;
}

.filter-only {
  padding: 0 .3rem;
  font-size: .7rem;
  line-height: 1.6;
  border-color: transparent;
  background: transparent;
  color: var(--accent);
}

/* Where there is a pointer the "only" shortcut stays out of the way until the
   row is hovered; touch screens get it permanently, since they never hover. */
/* The "only" shortcut is revealed on hover where there is a pointer, and is
   always there where there is not. It used to hide with `visibility: hidden`,
   and the `:focus` rule underneath it could never fire (#136): a
   visibility-hidden element is not focusable, so on every hover-capable
   device — which is every desktop — the shortcut was unreachable by keyboard
   entirely. `opacity: 0` keeps it in the focus order and in the accessibility
   tree, so tabbing into it reveals it, which is what the rule always meant.
   Kept out of the flow's hit-testing while invisible so a stray click on the
   row does not land on it. */
@media (hover: hover) {
  .filter-only {
    opacity: 0;
    pointer-events: none;
  }
  .filter-row:hover .filter-only,
  .filter-only:focus-visible,
  .filter-only:focus {
    opacity: 1;
    pointer-events: auto;
  }
}

/* The table under the map is listing stations picked off the map, not the
   filter result. The bar says so, and carries the only way back. */
.sel-bar {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: .4rem .7rem;
  padding: .45rem .6rem;
  border-bottom: 1px solid var(--border);
  background: var(--subtle);
}

.sel-bar-count {
  font-size: .87rem;
}

.sel-bar-note {
  font-size: .76rem;
  color: var(--muted);
}

.sel-bar-actions {
  display: flex;
  gap: .35rem;
  margin-left: auto;
}

.sel-bar-actions button {
  padding: .25rem .6rem;
  font-size: .78rem;
  white-space: nowrap;
}

.filter-check {
  display: flex;
  align-items: center;
  gap: .45rem;
  margin: .3rem 0;
  font-size: .87rem;
}

.filter-check input {
  flex-shrink: 0;
}

/* Slider with its value in the label above it (max TX distance, and anything
   else that wants the same shape). */
.filter-range {
  display: block;
  margin: .45rem 0 .2rem;
  font-size: .87rem;
}

.filter-range > span {
  display: block;
  margin-bottom: .15rem;
}

.filter-range input[type="range"] {
  width: 100%;
  accent-color: var(--accent);
}

.filter-range.is-off {
  opacity: .5;
}

/* ── Where the filter text was found ──────────────────────────────────────
   Knowing that a row matched is half an answer — "491" can land in a station
   number, an ALERT address or a name, and the row alone doesn't say which.
   The marked run is tinted and given an amber glow, which is what carries it
   in dark mode where a pale highlight would vanish into the panel. */
mark.hit {
  background: var(--hit-bg);
  color: var(--hit-text);
  border-radius: 3px;
  padding: 0 .14em;
  font-weight: 700;
  box-shadow: 0 0 0 1px var(--hit-ring), 0 0 6px 1px var(--hit-glow);
}

/* Inside a selected (tinted) row the glow would smear, so it tightens up. */
tr.selected mark.hit {
  box-shadow: 0 0 0 1px var(--hit-ring);
}

/* ── The table pattern — there is one (#109) ─────────────────────────────────
   Six per-tab issues are about to style tables. This is the answer all six
   inherit, and it is mostly a description of what the app already does rather
   than a new invention — which is the reason to write it down rather than let
   each of them re-derive a different half of it.

     1. Every table is wrapped in `.table-wrap`. The *wrapper* scrolls; the
        page never does. A panel is not allowed to decide the document is
        wider than the screen (see the min-width:0 rule above) — whatever is
        too wide has its own scroller.
     2. Wide by columns, not by page. Below --bp-md the global
        `table-layout: fixed` is dropped inside a wrapper so each column takes
        what it needs and the total scrolls sideways; the wrapper paints a
        shadow on whichever edge has more table behind it, because a sideways
        scroll is otherwise invisible until you try it. That block is at the
        foot of this file with the rest of the responsive rules.
     3. Column priority is opt-in, one class, and it is the *only* sanctioned
        way to drop a column: `.col-optional` (below). It hides the cell from
        sight and from the accessibility tree together, so a screen reader is
        not read a header with no data under it. A column carrying anything a
        field crew needs does not get this class — it scrolls instead.
     4. Stacked cards are not the house answer. They were considered and are
        not used: the app's tables are read by comparing rows down a column
        (repeater counts, RSSI, addresses), and stacking destroys exactly that.
     5. `.bf-table` is the sanctioned exception to `table-layout: fixed` and
        stays one — eight columns including a 16-character binary field, at a
        fixed 880 px, scrolling inside its wrapper. It is pattern 1 taken to
        its limit rather than a departure from it.
     6. Every table gets a caption. `.sr-only` on it if the panel heading
        already says the same thing out loud — a table with no accessible name
        is the single most common finding across the thirteen tabs #111
        measured.
     7. A wrapper that can scroll is a labelled region, and a row that opens
        something carries a button. Both added by #137, the first U-issue to
        apply this pattern; both are below, with the reasoning. */

.table-wrap {
  overflow: auto;
  border: 1px solid var(--border);
  border-radius: var(--r-md);
  margin-top: var(--sp-3);
  /* Not cosmetic, and found the hard way (#138). `overflow` clips a descendant
     only when the scroll container is that descendant's containing block, and
     an absolutely positioned box's containing block is the nearest *positioned*
     ancestor — which this was not. So a `.sr-only` inside a cell (the word
     behind a ✓, the caption of a table whose panel heading already says its
     name) was positioned against the page rather than the wrapper, escaped the
     clip, and put its static x — 490 px into an 880 px table — into the
     document's scrollWidth. The page scrolled sideways on a phone because a
     screen-reader-only span was doing it, which is a bug no screenshot shows
     and no eye finds.

     Every table in the app is in one of these, so stating it here is what makes
     `.sr-only` safe to use in a cell at all. */
  position: relative;
}

/* Pattern 7a — a scrolling wrapper is a region, and it is named (#137).
   `overflow: auto` on a div makes it scrollable with a mouse and a trackpad and
   with nothing else: Firefox gives such a box a tab stop of its own, Chrome and
   Safari do not, so a table taller or wider than its wrapper had content no
   keyboard could reach. The fix is three attributes on the wrapper and they go
   together —

     role="region" tabindex="0" aria-labelledby="<the panel heading's id>"

   — because `tabindex` alone buys a stop that a screen reader announces as
   nothing at all, and `role="region"` without a name is not exposed as a
   landmark. The label points at the heading already sitting above the table
   rather than repeating its words: two names for one thing drift.

   Only wrappers that can actually scroll. `.tall` and `.medium` cap their
   height and always can; a short table in a plain wrapper is not a region and
   must not become a tab stop for nothing. */
.table-wrap[tabindex]:focus-visible {
  outline: var(--focus-width) solid var(--focus);
  outline-offset: calc(var(--focus-offset) * -1);
}

/* Pattern 7b — a clickable row carries a real button (#137).
   `<tr onclick>` is a mouse affordance and nothing more: it is not in the tab
   order, it has no role, and a screen reader is given no hint that the row does
   anything. Rather than make the row itself focusable — a row is not a control,
   and `role="button"` on a `<tr>` destroys the table semantics around it — the
   row's *name* cell holds a real button, and the row keeps its own handler so
   the whole thing stays a target for a pointer. The button stops the event so
   the two never both fire.

   The button is styled as the text it replaces, not as a button: this is a
   name that happens to be operable, and forty bordered buttons down a column
   would read as a form. Hover and focus are what say it is a control. */
.row-link {
  cursor: pointer;
}

.row-open {
  border: 0;
  background: none;
  padding: 0;
  margin: 0;
  font: inherit;
  color: inherit;
  text-align: left;
  cursor: pointer;
}

.row-open:hover {
  color: var(--accent);
  text-decoration: underline;
}

/* Pattern 10 — a sortable column header is a button, and the <th> says which
   way it sorted (#138).
   Two tables on the RF tabs carried nineteen `<th style="cursor:pointer"
   onclick="…sort('score')">` between them. That is the row-open problem one
   level up: a `<th>` is not in the tab order and has no role, so *every* column
   on both tables could only be re-sorted with a mouse — and a screen reader was
   never told the table was sorted at all, let alone by which column.

   The shape mirrors pattern 7b exactly, and for the same reason: the cell is
   not a control, so the control goes inside it.

     <th scope="col" aria-sort="descending">
       <button type="button" class="th-sort" onclick="…">Score<span
             class="th-arrow" aria-hidden="true">▼</span></button>
     </th>

   `aria-sort` goes on the <th>, which is where the spec puts it, and exactly
   one <th> in a table may carry it. The arrow is `aria-hidden` because
   `aria-sort` already says it — a name reading "Score ▼" makes a screen reader
   pronounce a glyph, and this is the one place the visible text and the
   accessible name are allowed to differ. Full width so the whole cell is the
   target, and left-aligned so the column heading has not moved. */
.th-sort {
  border: 0;
  background: none;
  padding: 0;
  margin: 0;
  font: inherit;
  color: inherit;
  text-align: left;
  width: 100%;
  display: flex;
  align-items: center;
  gap: var(--sp-1);
  cursor: pointer;
}

.th-sort:hover {
  color: var(--accent);
}

.th-arrow {
  font-size: var(--fs-xs);
  color: var(--accent);
}

/* Pattern 11 — a graphic wider than its column scrolls in a named region
   (#138).
   Pattern 7a, applied to something that is not a table. Both RF charts sit in a
   `div` with `overflow-x: auto` and a `min-width` on the SVG inside — 640 px on
   the strip plot, 720 px on the timeline — which on a phone is a box holding
   two thirds of a picture and no way to reach the rest without a mouse. It is
   the same three attributes and the same reasoning, plus the split #141 found:

     the scroller is the *control*  — tab stop, role="region", a name saying
                                      what operating it does;
     the <svg> is the *picture*     — role="img", a name carrying the numbers.

   One name each. Putting both jobs on one element is what made the ARRO chart's
   label say "Sensor readings over time" whether it held twelve readings or
   twelve thousand. */
.chart-scroll {
  overflow-x: auto;
  overflow-y: hidden;
}

.chart-scroll:focus-visible {
  outline: var(--focus-width) solid var(--focus);
  outline-offset: calc(var(--focus-offset) * -1);
}

/* Text drawn inside an SVG cannot take a class from the design system by
   accident — `fill` is not inherited from a stylesheet unless something says
   so. These are that something, and they exist because the alternative is
   `style="fill:var(--muted)"` on every label, which is an inline style the
   check cannot see past and the tab has to justify one by one. */
.chart-text  { fill: var(--text); }
.chart-muted { fill: var(--muted); }
.chart-line  { stroke: var(--muted); }
.chart-grid  { stroke: var(--border); }
.chart-mark  { stroke: var(--chart-marker); fill: var(--chart-marker); }
.chart-band  { fill: var(--chart-marker-band); }

/* A row of labelled controls above a table or a chart — a filter bar, in the
   two shapes the app draws it: `.control-row` wraps at whatever width it runs
   out of, `.control-row > label` stacks its text over its control the way
   `.form-grid` does. Six tabs write this out inline with four different gaps;
   this is the one to use. The 44 px floor under a coarse pointer is EPIC #107's
   per-tab Definition of Done rather than the app-wide 2 rem, because a filter
   bar is exactly the thing being tapped in a vehicle. */
.control-row {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-2) var(--sp-4);
  align-items: flex-end;
  margin: var(--sp-3) 0;
}

.control-row > label {
  display: grid;
  gap: var(--sp-1);
  font-size: var(--fs-sm);
  color: var(--muted);
}

.control-row > label > input,
.control-row > label > select {
  color: var(--text);
}

/* A field holding a number of two or three digits. `input` is `width: 100%` by
   default, which in a wrapping row means "as wide as the row", and every tab
   that needed a small one wrote `style="width:4.5rem"`. */
.field-num {
  width: 5rem;
}

/* A checkbox with its caption beside it — the one label shape .control-row's
   stack-text-over-control grid gets wrong. Third tab to need it (#139, after
   .check-inline in the modal form and .ml-check on the Message Log), which is
   the sign it is the system's: one inline-flex row, the caption in a <span>
   so the input does not stretch, and the whole label is the pointer target. */
.check-label {
  display: inline-flex;
  gap: var(--sp-1);
  align-items: center;
  font-size: var(--fs-sm);
  color: var(--muted);
  cursor: pointer;
}
.check-label input { width: auto; }

@media (pointer: coarse) {
  .control-row > label > input,
  .control-row > label > select,
  .control-row > button,
  .check-label {
    min-height: 2.75rem;
  }
}

/* The empty state under a filtered-away table. `.small` already carries the
   colour; this is the padding that keeps it off the wrapper's border. */
.table-empty {
  padding: var(--sp-3);
  margin: 0;
}

/* A column that carries context rather than an answer. Hidden below --bp-sm
   from sight and from the accessibility tree at once — `display: none` rather
   than a visual-only hide, so nothing reads out a header whose cells are gone.
   Goes on the <th> *and* on every <td> beneath it; miss one and the header row
   and the body rows disagree about how many columns there are. Not on the
   <col>: `display:none` on a column element is not honoured, and it does not
   need to be — below --bp-md the wrapper's tables are `table-layout: auto`, so
   the remaining columns re-share the width on their own. */
@media (max-width: 700px) {
  .col-optional { display: none; }
}

.table-wrap.tall {
  max-height: 42vh;
}

.table-wrap.medium {
  max-height: 34vh;
}

table {
  width: 100%;
  border-collapse: collapse;
  font-size: .9rem;
  table-layout: fixed;
}

th,
td {
  border-bottom: 1px solid var(--border);
  padding: .45rem .55rem;
  text-align: left;
  vertical-align: top;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* Bit Flipper results — override the global fixed layout so all eight columns
   (including the 16-char binary and sensor IDs) stay fully visible; the wrapper
   scrolls horizontally when the viewport is too narrow. */
.bf-table {
  table-layout: auto;
  min-width: 880px;
}
.bf-table th,
.bf-table td {
  overflow: visible;
  text-overflow: clip;
}
.bf-table .mono {
  white-space: nowrap;
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}

/* Station-of-interest row (the entered ALERT address) pinned at the top of the
   Bit Flipper table, tinted to match the map's orange "exact match" marker —
   the same --bf-base token the halo and pin resolve (#139). */
.bf-table tr.bf-row-base td {
  background: color-mix(in srgb, var(--bf-base) 12%, transparent);
}
.bf-table tr.bf-row-base:hover td {
  background: color-mix(in srgb, var(--bf-base) 18%, transparent);
}

/* The rest of the Bit Flipper tab's own classes (#139), replacing the inline
   styles the conversion removed. */

/* #bf-results is the re-render boundary — a bare div between .page and the
   panel, so keystrokes replace the results without touching the inputs above
   them. As a grid item it needs the same min-width: 0 the .panel rule gives
   panels sitting directly under .page; without it, its min-content (the
   unwrapped control row inside) becomes the page's width and the whole
   document scrolls sideways at 375 px. */
#bf-results { min-width: 0; }

.bf-addr { width: 9rem; }

.bf-arro-label {
  flex: 1;
  min-width: 15rem;
}

.bf-map {
  height: 420px;
  border-radius: var(--r-md);
  margin-top: var(--sp-2);
}

.bf-td-match { text-align: center; }

/* Role and station-of-interest pills inside the map popups. The hue arrives as
   --pill, and it is ROLE_COLOR's / --bf-base's stable literal on purpose: the
   pill is its own ground in both themes, and var(--role-*) here would put
   white text on the dark theme's light hues (design-system §1, the ACMA
   boundary's reasoning applied one tab over). */
.bf-pill {
  background: var(--pill, var(--muted));
  color: #fff;
  padding: 1px 5px;
  border-radius: var(--r-pill);
  font-size: .76rem;
  margin-right: 2px;
}
.bf-pill--base { margin-left: 4px; margin-right: 0; }

.bf-pop-sub  { font-size: .82rem; }
.bf-pop-block { margin-top: 4px; display: block; }

th {
  position: sticky;
  top: 0;
  background: var(--thead);
  z-index: 1;
}

tr:hover td {
  background: color-mix(in srgb, var(--subtle) 92%, transparent);
}

tr.selected td {
  background: color-mix(in srgb, #d9ebff 60%, var(--subtle));
}

[data-theme="dark"] tr.selected td {
  background: color-mix(in srgb, #28527f 55%, var(--subtle));
}

/* "Repeaters listening" (Stations tab): the row whose repeater the map is
   currently focused on. Tinted in the repeater's own colour rather than the
   selected-row blue — this is a view focus, not a selection, and the two are
   on screen together. */
tr.rpt-focused td {
  background: color-mix(in srgb, var(--role-repeater) 16%, transparent);
}

tr.rpt-focused:hover td {
  background: color-mix(in srgb, var(--role-repeater) 22%, transparent);
}

tr.rpt-focused td:first-child {
  box-shadow: inset 3px 0 0 var(--role-repeater);
}

.note,
.summary-box {
  margin-top: .85rem;
  padding: .75rem;
  background: var(--subtle);
  border-radius: 8px;
  border: 1px solid var(--border);
  font-size: .92rem;
}

.note.compact {
  margin-top: 0;
}

.stats {
  display: grid;
  gap: .35rem;
  font-size: .93rem;
  margin-top: .75rem;
}

/* The same figures laid out across the top of a tab rather than down the side
   of a panel — Pass Ranges' four headline counts (#137). Wraps rather than
   scrolls, so a phone gets them stacked without a media query. */
.stats.stats-row {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-2) var(--sp-5);
}

/* A count that is a problem when it is not zero. The colour is the whole of the
   emphasis, so the label beside it always names what it is counting — a colour
   on its own is not information (WCAG 1.4.1). */
.stat-bad,
h2.h-bad {
  color: var(--bad);
}

.badge {
  display: inline-block;
  padding: .15rem .4rem;
  border-radius: 999px;
  background: #e8f3ff;
  color: #0b5cab;
  font-size: .8rem;
}

[data-theme="dark"] .badge {
  background: #183350;
  color: #9fd0ff;
}

/* Station-typed badges. The Stations tab is the source of truth for what a
   role looks like — field is green, repeater is blue (ROLE_COLOR in core.js,
   mirrored by --role-field/--role-repeater above). These two classes carry
   that meaning into table chips; the pairs stay literal, tint-plus-ink chosen
   for AA on panel backgrounds, but each family is the same family as its
   role token. The generic .badge above is NOT part of this contract — it is a
   neutral count chip used across half the tabs and happens to be blue. */
.badge--repeater {
  background: #e8f3ff;
  color: #0b5cab;
}

[data-theme="dark"] .badge--repeater {
  background: #183350;
  color: #9fd0ff;
}

.badge--field {
  background: #e8f7ee;
  color: #1a6b35;
}

[data-theme="dark"] .badge--field {
  background: #0e3320;
  color: #7dd6a0;
}

/* "via pass range" — a row that is in the table by relation, not because the
   filter named it. Cyan to match the dashed ring its pin gets on the map. */
.badge--rel {
  background: #e0f7fa;
  color: #00646f;
  font-size: .72rem;
}

[data-theme="dark"] .badge--rel {
  background: #06333a;
  color: #6fe3f2;
}

.small {
  font-size: .82rem;
  color: var(--muted);
}

/* ── A status line's colour is a system decision (#141) ──────────────────────
   Four surfaces had each invented their own way of saying "this line is good /
   worth a look / wrong": `style="color:var(--ok)"` inline in datastore.js,
   .ad-bad-txt and .ad-warn-txt in the ARRO Data tab, .hist-bad-text in the
   history tab. They are the same three statements and they are these three
   classes, which is what stops the fifth surface inventing a fourth spelling.
   The first three are converted; .hist-bad-text is left, because it also
   carries an :empty rule and the Inspection History tab is not #141's.

   The colours are the status tokens, so they are already in the contrast
   contract `npm run shell` checks — which is the actual argument for a class
   over an inline colour: an inline `color:` is a decision no check can see.
   `.txt-muted` is here for the same reason, as the thing to name when a line
   needs the muted colour without `.small`'s type step. */
.txt-ok    { color: var(--ok); }
.txt-warn  { color: var(--warn); }
.txt-bad   { color: var(--bad); }
.txt-muted { color: var(--muted); }

/* Station name coloured by primary role, matching the map's colour coding. */
.stn-name {
  font-weight: 600;
  color: var(--role-field);
}
.stn-name.role-repeater { color: var(--role-repeater); }
.stn-name.role-base     { color: var(--role-base); }
.stn-name.role-satcom   { color: var(--role-satcom); }

/* The station table's ARRO column — one link per row, out to that station's
   telemetry admin page. `nowrap` because the cell is 7 % wide and "ARRO ↗"
   broken over two lines is both ugly and a wider row: the arrow is part of the
   label (it is what says the link leaves the app), not punctuation after it. */
.stn-arro { white-space: nowrap; }

/* The station table when it is the column beside the map rather than the page
   (#186 — the column set itself is decided in stationsTable, app.js).

   Five columns in about 420 px, and the 8.8 px of side padding every cell
   carries is 88 px of that — a fifth of the table spent on gutters, at the
   width where a name has the least room to spare. Tightened rather than
   removed: the columns still have to read as columns, and .4rem still separates
   them by more than the space between two words. It buys back roughly 30 px,
   which is a quarter of the name column.

   The station number is the one cell held to one line. It is an identifier: six
   digits broken over two lines read as two numbers, and it is the field a
   person copies out of this table into a work order. */
.stn-table--narrow th,
.stn-table--narrow td { padding: .4rem .35rem; }

.stn-table--narrow .stn-num { white-space: nowrap; }

.rx-cell {
  background: var(--rx-bg);
}

.tx-cell {
  background: var(--tx-bg);
}

.freq-pill {
  display: inline-block;
  padding: .12rem .42rem;
  border-radius: 999px;
  font-size: .82rem;
  font-weight: 600;
}

.crud-layout {
  display: grid;
  grid-template-columns: minmax(320px, 1fr) minmax(360px, 1fr);
  gap: 1rem;
}

.form-grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: .7rem;
  margin-top: .75rem;
}

.form-grid .full {
  grid-column: 1 / -1;
}

/* ── Station editor (#136) ───────────────────────────────────────────────────
   Thirty-four inline styles, all of them one of five things: a hint under a
   field, a sub-heading inside a section, a flex row, a destructive button, or
   the sensor row's column widths. Stated once here. */
.ef-head    { margin-bottom: var(--sp-1); }
.ef-status  { margin-bottom: var(--sp-2); }
.ef-lead    { margin: var(--sp-1) 0 var(--sp-2); }
.ef-block   { margin: 0 0 var(--sp-2); }
.ef-flush   { margin: 0; }
.ef-section { margin-top: var(--sp-2); }
.ef-actions { margin-top: var(--sp-2); }
.ef-h       { margin: var(--sp-2) 0 var(--sp-3); }
.ef-note    { margin-top: var(--sp-1); }
.ef-plain   { font-weight: 400; }
.ef-sub     { font-weight: 600; margin-bottom: var(--sp-1); }

/* A hint that belongs to the field above it, so it rides up into the grid gap
   rather than reading as a paragraph of its own. */
.ef-hint { margin-top: -.35rem; }

/* The links out of the station in the editor card: the same row of pills the
   map callout carries, from the same mapLinksHtml(). Wraps — five pills do not
   fit across the form's left column on a phone. */
.ef-links {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: .3rem;
  margin-top: -.35rem;
}

.ef-section-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-2);
  margin-bottom: var(--sp-2);
}

.ef-roles {
  display: flex;
  gap: var(--sp-4);
  flex-wrap: wrap;
  margin-top: var(--sp-1);
}

/* The role checkboxes sit inside a <label> that is already bold; these are the
   values under it, not headings of their own. */
.ef-role    { font-weight: normal; color: inherit; }
.ef-enabled { grid-column: 1; }

/* The carried-stations list caps its height, so it scrolls — which is why it
   is a named region in the markup (pattern 7a, for something that is not a
   table). Its rows are .link-btn buttons: each one opens a station. */
.ef-carried {
  max-height: 11rem;
  overflow: auto;
  border: 1px solid var(--border);
  border-radius: var(--r-sm);
  padding: var(--sp-1) var(--sp-2);
}

.ef-carried-row {
  display: block;
  width: 100%;
  padding: .1rem 0;
  color: inherit;
}
.ef-carried-row:hover { color: var(--accent); }

/* One sensor: address, slot, type, the Bit Flipper link, the ARRO link, remove. */
.sensor-row {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-1);
  align-items: center;
  /* Deliberately more than the gap above rather than equal to it: on a narrow
     screen the row wraps, and a row separated from the next one by exactly the
     distance that separates its own two lines reads as twice as many rows. */
  margin-bottom: var(--sp-2);
}

/* Wrapping, because the fixed-width half of this row is wider than a phone once
   the Bit Flipper link is in it. The two address boxes, the two links and the
   remove button come to ~350 px between them before the type box has any width
   at all, so unwrapped the row pushed the whole document sideways by ten of them
   at 375 px — `npm run tabs` measures exactly that. Wrapped, the type box takes
   the second line with the links beside it and nothing overflows; on any screen
   wide enough the row is still one line, exactly as it was. */
.sensor-row .sensor-aid  { flex: 0 0 7.5rem; width: 7.5rem; }
/* Narrower than the ALERT box on purpose: a slot is 0-254 and three digits is
   the whole of it, so the row still reads as one wide field (the type) between
   two short ones rather than as three equal columns competing for the eye. */
.sensor-row .sensor-a2id { flex: 0 0 5.5rem; width: 5.5rem; }
.sensor-row .sensor-type { flex: 1 1 auto; width: auto; min-width: 0; }
.sensor-row .sensor-arro { flex: 0 0 4.2rem; text-align: right; }
.sensor-row .sensor-del  { flex: 0 0 auto; padding: .2rem .55rem; line-height: 1; }

/* The row's own deep link: this address, in the Bit Flipper. A .link-btn and
   not a .btn-link because it goes to another tab of this app rather than off
   the site — and deliberately not also `.small`, which would repaint it in
   --muted and take the one thing that says it is a link. */
.sensor-row .sensor-flip { flex: 0 0 auto; font-size: .82rem; }

/* Disabled is the row's honest state until there is an address to flip: an
   empty box on a new row, or a half-typed one. Says so rather than sitting
   there looking pressable. */
.sensor-row .sensor-flip[disabled] {
  color: var(--muted);
  cursor: default;
}

.sensor-row .sensor-flip[disabled]:hover { text-decoration: none; }

/* The slots a relayed ALERT2 station has been heard on but nobody has named.
   A plain list inside the editor's own note text — it is a hint about live data,
   not a control surface, so it borrows the card's ground like everything else in
   here and the only affordance is the button on each unnamed row. */
.ef-a2-list {
  list-style: none;
  margin: var(--sp-1) 0 0;
  padding: 0;
}

.ef-a2-list li {
  display: flex;
  gap: var(--sp-1);
  align-items: baseline;
  flex-wrap: wrap;
  margin-bottom: .15rem;
}

.ef-a2-list button {
  padding: .1rem .45rem;
  font-size: inherit;
  line-height: 1.3;
}

/* Delete, and the sensor row's ×. The destructive action reads as destructive
   without being red-filled — an outline in the base-station orange, which is
   the app's existing "this one is different" hue and clears AA as text on both
   themes (it is --role-base, checked by shell.mjs's contrast contract). */
.btn-danger {
  border-color: var(--role-base);
  color: var(--role-base);
}

/* ── The Inspections section at the foot of the editor card ─────────────────
   Everything here is inside the station editor's own panel, so it borrows that
   card's ground rather than drawing a second one — no background of its own on
   anything but the picker, which needs to read as a control surface.

   No dark-theme block. Every colour below is a token, and the tokens are the
   ones the dark block at the top of this file already redefines. The chart is
   the one exception and it cannot be solved here: an SVG presentation attribute
   does not resolve a var(), so station-inspections.js reads the palette off the
   document with cssVar() at draw time — the arrangement #141 arrived at, and
   the reason a theme switch reaches the picture on its next repaint. */
.si-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--sp-2);
  flex-wrap: wrap;
}

.si-h    { margin-bottom: 0; }
.si-lead { margin: var(--sp-1) 0 var(--sp-3); }

/* The door into the Inspection History tab. A pill rather than a plain button
   because it leaves this card for another tab, which is a different kind of
   action from Save and Delete beside it — and an outline rather than a fill,
   because a filled pill next to the card's one primary button reads as a second
   primary. */
.si-pill {
  border: 1px solid var(--accent);
  border-radius: var(--r-pill);
  background: transparent;
  color: var(--accent);
  padding: .3rem .85rem;
  font-size: .85rem;
  font-weight: 600;
  white-space: nowrap;
}
.si-pill:hover { background: var(--accent); color: var(--panel); }

.si-muted { color: var(--muted); }
.si-bad   { color: var(--bad); }
.si-flush { margin: var(--sp-2) 0 0; }
.si-gate  { margin: 0 0 var(--sp-2); }

/* ── The parameter picker ──────────────────────────────────────────────────── */
.si-picker {
  border: 1px solid var(--border);
  border-radius: var(--r-md);
  background: var(--subtle);
  margin-bottom: var(--sp-3);
}

.si-picker > summary {
  cursor: pointer;
  padding: var(--sp-2) var(--sp-3);
  font-weight: 600;
}
.si-picker > summary .small { font-weight: 400; }

.si-picker-body {
  padding: 0 var(--sp-3) var(--sp-3);
  border-top: 1px solid var(--border);
}

.si-picker-actions {
  display: flex;
  gap: var(--sp-3);
  flex-wrap: wrap;
  padding: var(--sp-2) 0;
}

/* One <fieldset> per section of the paper sheet, so the legend is a real group
   name rather than a bold line above a list of unrelated checkboxes. */
.si-group {
  border: 0;
  border-top: 1px solid var(--border);
  margin: 0;
  padding: var(--sp-2) 0 0;
}

.si-group > legend {
  font-size: .8rem;
  font-weight: 600;
  color: var(--muted);
  text-transform: uppercase;
  letter-spacing: .04em;
  padding: 0;
}

.si-opt {
  display: inline-flex;
  align-items: center;
  gap: var(--sp-1);
  margin: .15rem var(--sp-4) .15rem 0;
  font-size: .85rem;
  font-weight: 400;
}
.si-opt input { margin: 0; }

.si-picker-note { margin: var(--sp-3) 0 0; }

/* ── The chart ─────────────────────────────────────────────────────────────── */
/* Scales with the card, and keeps its aspect: the viewBox is 760x250 and the
   SVG is the picture, not the control — the operable version is the checkbox
   list above it and the table below, which is what lets this be role="img"
   (docs/design-system.md §3, pattern 8's conditions). */
.si-svg {
  display: block;
  width: 100%;
  height: auto;
  max-width: 760px;
  margin: 0 auto;
  overflow: visible;
}

/* ── The legend ────────────────────────────────────────────────────────────── */
/* Real DOM rather than text inside the SVG: it wraps on a phone, it is
   selectable, and each line's own range is what replaces "which line is
   higher" once there are two axes. */
.si-legend {
  list-style: none;
  margin: var(--sp-2) 0 0;
  padding: 0;
  display: grid;
  gap: .2rem;
}

.si-legend > li {
  display: flex;
  align-items: baseline;
  gap: var(--sp-2);
  flex-wrap: wrap;
  font-size: .85rem;
}

.si-key      { flex: 0 0 auto; align-self: center; }
.si-key-svg  { display: block; width: 26px; height: 10px; }
.si-legend-name { font-weight: 600; }

.si-notdrawn { margin: var(--sp-2) 0 0; }
.si-notdrawn p { margin: 0 0 var(--sp-1); }
.si-notdrawn p:last-child { margin-bottom: 0; }

/* ── The readings table (part 3 of the chart pattern) ──────────────────────── */
.si-table {
  margin-top: var(--sp-3);
  border-top: 1px solid var(--border);
  padding-top: var(--sp-2);
}

.si-table > summary { cursor: pointer; font-weight: 600; }
.si-table > summary .small { font-weight: 400; }

.si-table-grid {
  border-collapse: collapse;
  width: 100%;
  font-size: .85rem;
  margin-top: var(--sp-2);
}

.si-table-grid th,
.si-table-grid td {
  border: 1px solid var(--border);
  padding: .25rem .45rem;
  text-align: right;
  white-space: nowrap;
}

/* The numbers are right-aligned so a column of them lines up on the decimal
   point; the two columns that say which visit a row is are not numbers. */
.si-table-grid thead th { background: var(--subtle); text-align: right; }
.si-table-grid tbody th { text-align: left; font-weight: 600; }
.si-table-grid tr > :nth-child(-n+2) { text-align: left; }

@media (pointer: coarse) {
  /* EPIC #107's 44 px floor for the editor's own controls (#136). A form
     filled in on a phone at a station site is the case this is for. */
  .sensor-row .sensor-del,
  .sensor-row .sensor-flip,
  .ef-carried-row,
  .ef-section-head button {
    min-height: 2.75rem;
    display: inline-flex;
    align-items: center;
  }
  .ef-carried-row { justify-content: flex-start; }

  /* …and the same floor for this section's own controls. The parameter
     checkboxes are the case that matters: forty of them at .85rem is a target
     nobody hits with a thumb. */
  .si-pill,
  .si-picker > summary,
  .si-table > summary,
  .si-opt {
    min-height: 2.75rem;
    display: inline-flex;
    align-items: center;
  }
  .si-picker > summary,
  .si-table > summary { display: flex; }
}

hr {
  border: 0;
  border-top: 1px solid var(--border);
  margin: 1rem 0;
}

.map-canvas {
  height: 420px;
  border: 1px solid var(--border);
  border-radius: 8px;
  overflow: hidden;
  margin-top: .75rem;
}

.map-legend {
  display: flex;
  flex-wrap: wrap;
  gap: .85rem;
  align-items: center;
}

.legend-item {
  display: inline-flex;
  align-items: center;
  gap: .35rem;
}

/* Colour arrives as --dot, the .legend-sq convention (#138), applied to the
   shared dot at #136 — the Stations map's legend and the filter rows' role
   swatches were the last callers writing a literal `background:` here, so the
   scoped `.wb-page .legend-dot` override #139 needed is gone with them.
   The two role classes below it went with them too: nothing rendered them. */
.legend-dot {
  width: 10px;
  height: 10px;
  border-radius: 999px;
  display: inline-block;
  background: var(--dot, var(--muted));
}

.legend-line {
  width: 16px;
  height: 0;
  border-top: 3px solid var(--map-line);
  display: inline-block;
}

.legend-line-river { border-top-color: var(--map-river); }

/* The direction arrows (MapArrows, #186). A glyph rather than a drawn chevron:
   the marks on the map are canvas strokes with no CSS to borrow, and one
   character at the legend's size is a closer likeness than a box with two
   borders on it. */
.legend-arrow {
  display: inline-block;
  color: var(--map-line);
  font-size: .8rem;
  line-height: 1;
}
/* Heavier than the pass-range swatch, as the path itself is; the outline is
   the legend's stand-in for the white casing that separates black from a dark
   basemap. */
.legend-line-backbone {
  border-top: 4px solid var(--map-backbone);
  outline: 1px solid var(--border);
}

/* The blast-radius card's inline swatches (#161) — the same two tokens the map
   resolves at draw time, drawn on the panel, which is the surface the contract
   pair in test/shell.mjs checks them against. The ring swatch repeats the real
   ring's second channels: dashed and heavier than any base pin ring. */
.blast-swatch-line {
  width: 1.4em;
  height: 0;
  border-top: 3px solid var(--map-blast);
  display: inline-block;
  vertical-align: middle;
}
.blast-swatch-ring {
  width: .8em;
  height: .8em;
  border-radius: 999px;
  border: 3px dashed var(--map-blast-ring);
  display: inline-block;
  vertical-align: middle;
}
.legend-dot-survey { background: var(--map-survey); }
/* SoRT's warmed burnt orange for LiDAR contour lines — a hard-coded colour on
   purpose: the swatch matches the server-rendered image (MapContours draws the
   MapServer's own symbology), which no theme token can restyle. */
.legend-line-contour { border-top-color: rgb(214, 104, 26); }
/* The line-of-sight verdict in the legend (#175): the obstructed-link colour,
   through the same token the drawn links take, so the two agree in both
   themes. */
.legend-line-los { border-top-color: var(--map-line-blocked); }
/* The three fade-margin bands, through the same tokens the drawn links take so
   the key and the map cannot drift apart. The good band is drawn at the weight
   the map draws it, because that extra pixel is part of how it is recognised. */
.legend-line-fade-good { border-top-color: var(--map-fade-good); border-top-width: 3px; }
.legend-line-fade-ok   { border-top-color: var(--map-fade-ok); }
.legend-line-fade-bad  { border-top-color: var(--map-fade-bad); }

/* The wind-region key (#176). Ten regions, each with a sentence about what it
   costs, is a nested list rather than ten more chips in the legend's wrapped
   row — so it claims the legend's full width and stacks, in the panel and in
   the 👁️ flyout alike. The swatch stays top-aligned because the text beside it
   is two or three lines, not one. */
.legend-wind {
  /* `width` rather than a flex-basis, so it reads the same in both containers
     this legend has: the panel's wrapping row (where 100% width puts it on a
     line of its own) and the 👁️ flyout's column (where the basis would be a
     *height* and 100% of one is not what any of this means). */
  flex: 0 0 auto;
  width: 100%;
  display: flex;
  flex-direction: column;
  gap: .3rem;
}

.legend-wind-h { margin-bottom: .1rem; }

.legend-wind-row {
  align-items: flex-start;
  gap: .45rem;
  line-height: 1.35;
}

.legend-wind-row .legend-sq {
  margin-top: .3em;
  flex: 0 0 auto;
}

/* The one word in the key that changes what a structure has to be, so it is
   the one word that is marked. Colour plus weight, never colour alone. */
.legend-wind-cyc {
  color: var(--bad);
  font-weight: 600;
  text-transform: uppercase;
  font-size: .85em;
  letter-spacing: .02em;
}

.legend-wind-foot { margin-top: .15rem; }

/* The legend's last line, naming the layers that are off. Text on its own
   line rather than another swatch row: it is a pointer, not a key. */
.legend-off { margin-top: var(--sp-1); }

/* River name on hover. Follows the cursor along the line (sticky tooltip), so it
   stays small and out of the way of the pins it sits under. */
.mn-river-label {
  font-size: .72rem;
  font-weight: 600;
  padding: 1px 5px;
  color: var(--map-river);
}

/* Basin and hub names (#178). Both are drawn with L.divIcon in a pane whose
   pointer-events are off, so these are labels in the plainest sense — no
   target, no hover, nothing to click. They also sit over satellite imagery as
   often as over a street map, which is why they carry their own halo rather
   than a theme colour: a 12 px name in var(--text) is unreadable over half the
   basemaps this app offers, and a filled tooltip ground the size of "Balonne-
   Condamine" would hide the basin it is naming. The halo is four offset
   shadows rather than -webkit-text-stroke, which thins the glyph from the
   inside and is not in every engine.

   iconSize is null on the marker, so Leaflet leaves the box to the content;
   these translate themselves back over their own anchor point. */
/* The SLS section on the station card (#180). Set off from the rows above it
   with a rule rather than a heading block, because it is the same grammar of
   label/value rows saying where they came from — a different document, not a
   different kind of card. */
.stn-card-sls { border-top: 1px solid var(--border); padding-top: .5rem; }
.stn-card-sls > .small { display: block; margin-bottom: .25rem; }
.mn-sls-note { margin: .35rem 0 0; }

/* Manual, as a pill. The one fact in that block that changes what a person
   does: a gauge read by hand reports nothing over the radio, so silence from it
   is not a telemetry fault to go chasing. Amber rather than red — it is a
   property of the site, not a problem with it — and it carries a word as well
   as a colour, since colour is never the only channel. */
.mn-sls-manual {
  display: inline-block;
  padding: 0 .4em;
  border: 1px solid rgba(217, 138, 20, .55);
  border-radius: 3px;
  background: rgba(217, 138, 20, .12);
  font-weight: 600;
}

.mn-catchment-label,
.mn-hub-label {
  font-weight: 700;
  white-space: nowrap;
  pointer-events: none;
  width: auto !important;
  height: auto !important;
  margin: 0 !important;
}
.mn-catchment-label > span,
.mn-hub-label > span {
  display: inline-block;
  transform: translate(-50%, -50%);
  text-shadow:
     1px  1px 0 rgba(255, 255, 255, .92),
    -1px  1px 0 rgba(255, 255, 255, .92),
     1px -1px 0 rgba(255, 255, 255, .92),
    -1px -1px 0 rgba(255, 255, 255, .92),
     0    0   4px rgba(255, 255, 255, .75);
}
.mn-catchment-label > span {
  font-size: .72rem;
  color: #1c3c52;
}
/* A hub covers a state, so its name is read at a zoom where a basin's would be
   off screen — bigger, and spaced, so it reads as the region label it is
   rather than as another feature name. */
.mn-hub-label > span {
  font-size: .8rem;
  letter-spacing: .06em;
  text-transform: uppercase;
  color: #33404d;
}
@media (prefers-color-scheme: dark) {
  .mn-catchment-label > span { color: #123044; }
  .mn-hub-label > span { color: #2a3742; }
}

/* The road parcel's name on hover (#176). Sticky to the cursor like the river
   label above, and dressed the way the fixed river name is — its own near-white
   ground with fixed dark ink — because unlike the river label this one sits over
   satellite imagery as often as over a street map, and a tooltip that borrows
   the theme's colours is unreadable over half of them.

   The ink is a warm dark brown rather than the layer's own yellow: it ties the
   label to the line it names while staying a colour that can be read at 11 px,
   which #f9a825 on near-white (1.9:1) is not. The border carries the tint
   instead — that is a boundary, not text. */
.leaflet-tooltip.mn-road-label {
  background: rgba(255, 255, 255, .94);
  color: #4e342e;
  font-size: .72rem;
  font-weight: 600;
  border: 1px solid rgba(249, 168, 37, .55);
  border-radius: 3px;
  box-shadow: 0 1px 3px rgba(0, 0, 0, .35);
  padding: 0 5px;
  line-height: 1.6;
  white-space: nowrap;
  pointer-events: none;
}
.leaflet-tooltip.mn-road-label::before { display: none; }

/* #150: the permanent name a named river carries on the map. Unlike the sticky
   hover tooltip above, this sits on the line over both basemaps, so it takes
   the pin-label deal: its own near-white ground with fixed dark-blue ink —
   AA against its own box in either theme, the same argument #151 measured for
   station names. Italic is the cartographic convention for water. */
.leaflet-tooltip.mn-river-label-fixed {
  background: rgba(255, 255, 255, .93);
  color: #0d47a1;
  font-style: italic;
  border: 1px solid rgba(13, 71, 161, .35);
  border-radius: 3px;
  box-shadow: 0 1px 3px rgba(0, 0, 0, .35);
  padding: 0 5px;
  line-height: 1.5;
  white-space: nowrap;
  pointer-events: none;
}
.leaflet-tooltip.mn-river-label-fixed::before {
  display: none;               /* no callout arrow — the name sits on its line */
}

/* #150 A4: the river list in #map-river-note — one real button per named
   river drawn, which is the keyboard and touch path to the callout. App-wide
   2 rem floor; 2.75 rem under a coarse pointer, the same floor #137 set for
   map-adjacent controls tapped in a vehicle (docs/design-system.md §Touch). */
.mn-river-note-btns {
  display: flex;
  flex-wrap: wrap;
  gap: .25rem;
  margin-top: .25rem;
}
.mn-river-note-btn {
  display: inline-flex;
  align-items: center;
  min-height: 2rem;
  padding: 0 .55rem;
  font-size: .78rem;
  color: var(--map-river);
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: 4px;
  cursor: pointer;
}
.mn-river-note-btn:hover { border-color: var(--map-river); }
@media (pointer: coarse) {
  .mn-river-note-btn { min-height: 2.75rem; }
}

/* ── The Dark base map ───────────────────────────────────────────────────── */

/* Esri's Dark Gray Canvas arrives as a mid-grey around #444 — dark for a map,
   but nowhere near the near-black the Dark base is *for*. The brief was a base
   the operator can ignore entirely so the pins and links are the only thing
   left to look at, and #444 still reads as a surface. This takes it the rest of
   the way.

   It is a filter on the tile layer rather than a different tile host because
   there is no keyless host that ships it this dark (CARTO's Dark Matter, the
   obvious candidate, now stamps "API KEY REQUIRED" across every keyless tile),
   and because the filter has to miss the labels: Dark_Gray_Reference rides in
   the mnBaseLabels pane above this one, is not inside this container, and so
   keeps its full brightness while the ground under it goes black. Darkening
   both together is what would make the names unreadable.

   The class is set by makeBaseLayers (map-controls.js) via Leaflet's
   `className` option, which lands on the layer's own tile container — so this
   never touches the other three bases, the label pane, or any overlay.

   brightness before contrast, and both mild: harder numbers crush Esri's
   coastlines and hairline roads out of the tile altogether, which loses the
   little orientation the base still owes the operator. */
.leaflet-layer.mn-base-dark {
  filter: brightness(.55) contrast(1.08);
}

/* ── Map pins ────────────────────────────────────────────────────────────── */

/* Every station pin is drawn with a white ring (set in app.js) so it separates
   from the base map; pins matching the filter box swap it for amber and get a
   name label. Keep this rule cheap — it applies to ~1300 SVG circles. */
.mn-pin {
  cursor: pointer;
}

/* name labels — filter-box matches always, plus whatever is in view once the
   zoom is tight enough (auto/on modes in mapLabelIds) */
.leaflet-tooltip.mn-pin-label {
  background: rgba(255, 255, 255, .93);
  color: #1a1a1a;
  border: 1px solid rgba(0, 0, 0, .25);
  box-shadow: 0 1px 3px rgba(0, 0, 0, .35);
  border-radius: 3px;
  padding: 0 5px;
  font-size: .72rem;
  font-weight: 600;
  line-height: 1.5;
  white-space: nowrap;
  pointer-events: none;
}

/* A filtered-out or focus-dimmed pin's name recedes with it. The box keeps its
   own white ground (fading the whole tooltip would put grey text straight on
   satellite tiles); the ink drops weight instead. #616161 on the label's
   rgba(255,255,255,.93) ground is 5.3:1 over a black tile, 6.2:1 over white —
   AA either way, in both themes, because the ground is the label's own. */
.leaflet-tooltip.mn-pin-label-dim {
  color: #616161;
  font-weight: 400;
  border-color: rgba(0, 0, 0, .12);
  box-shadow: none;
}

.leaflet-tooltip.mn-pin-label::before {
  display: none;               /* no callout arrow — the pin is right above it */
}

/* legend swatch for "matches filter" */
.legend-dot-hit {
  box-shadow: 0 0 0 2px #ffc400;
}

/* legend swatch for "related by pass range" — a dashed ring, matching the one
   the map draws on pins pulled in by the relation rather than by the filter. */
.legend-dot-rel {
  outline: 2px dashed #00b8d4;
  outline-offset: 1px;
}

/* transient message over the map (locating, GPS errors) */
.map-note {
  position: absolute;
  top: 14px;
  left: 50%;
  transform: translateX(-50%);
  z-index: 1050;               /* above leaflet controls, below the ACMA card */
  max-width: 80%;
  padding: .3rem .8rem;
  border: 1px solid var(--border);
  border-radius: 999px;
  background: var(--panel);
  color: var(--text);
  font-size: .82rem;
  box-shadow: 0 2px 8px rgba(0, 0, 0, .25);
  pointer-events: none;
}

/* ── On-map control panels (#164) ────────────────────────────────────────────
   The Stations map's own furniture: base map, map display, draw & measure and
   the legend, as four icons stacked in the map's top-right corner. Built by
   MapChrome (map-controls.js) and available to the app's other six Leaflet
   maps on the same terms.

   The rule the whole thing turns on: **an icon is all there is until you ask
   for more.** A map is the one surface in this app where the content is the
   whole panel, so a control that permanently covers a corner of it is a
   control that is permanently in the way. Everything below is in service of
   that — the panel opens on hover, and on a click, a tap or Enter, and it
   shuts again on its own.

   Two open states, because they answer two different questions:

     hovered / clicked   a flyout. `position: absolute`, opening leftward out
                         of its own icon, so it covers the map and *nothing
                         moves*. That last part is the reason it is not simply
                         expanded in place the way Leaflet's own layers control
                         is: with four controls in one corner, expanding in
                         place shoves the three icons below it down the screen
                         the instant the pointer crosses the first — which puts
                         the icon you were reaching for somewhere else.

     pinned              docked. `position: static`, so the panel takes its
                         space in the corner's own column and two pinned panels
                         stack instead of overlapping. Pinning is the operator
                         saying "I am working in this one", and a tool in use
                         should not be sharing pixels with the map under it.
                         Remembered between visits — see state.mapPanelsPinned.

   Neither state is a modal and neither traps focus: this is a disclosure, the
   button says so with aria-expanded, and Shift+Tab off the first control in
   the panel lands back on the icon that opened it. */

.mn-mapctl {
  /* Narrow enough to leave the map readable beside it, wide enough for the
     "Max TX distance" slider and its label on one line. Falls back to the
     viewport on a phone, where 19rem plus the icon column is the whole screen. */
  --mn-mapctl-w: min(19rem, calc(100vw - 7rem));
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: var(--sp-1);
}

/* The icon, and — while the panel is shut — the entire control. */
.mn-mapctl-btn {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 2.25rem;
  height: 2.25rem;
  padding: 0;
  font-size: 1.05rem;
  line-height: 1;
  box-shadow: var(--shadow-2);
}

.mn-mapctl-ico {
  /* Emoji ride high in most families; this is what centres them in the box. */
  display: block;
  transform: translateY(.5px);
}

/* A corner button that is a *mode* rather than a disclosure — What is here —
   says so while it is armed, the way a draw tool does. Colour is not the only
   channel: the button also carries aria-pressed and its label changes to
   "Click the map to say where". */
.mn-mapctl-btn.is-on {
  border-color: var(--accent);
  background: var(--subtle);
  box-shadow: var(--shadow-2), inset 0 0 0 2px var(--accent);
}

/* Reset (#191) is the one corner button that takes something away, so it says
   so on hover rather than only in its tooltip — the same warning colour the
   rest of the app uses for a destructive control. Resting state is the same as
   its neighbours: it is a recovery button, not a hazard sitting in the corner
   colouring the map red. */
.mn-map-reset:hover,
.mn-map-reset:focus-visible {
  border-color: var(--bad);
  color: var(--bad);
}

.mn-mapctl-body {
  display: none;
  position: absolute;
  top: 0;
  right: calc(100% + var(--sp-2));
  width: var(--mn-mapctl-w);
  padding: var(--sp-3);
  text-align: left;
  background: var(--panel);
  color: var(--text);
  border: 1px solid var(--border);
  border-radius: var(--r-lg);
  box-shadow: var(--shadow-2);
}

/* The gap between the icon and the flyout is a hole the pointer falls through
   on the way across. This bridges it — invisible, and only as wide as the gap
   it covers. It is on the body rather than on the wrapper because the wrapper
   is what the *unopened* icon is, and a control that eats clicks 8 px to its
   left while showing nothing is a control that swallows map clicks. */
.mn-mapctl-body::after {
  content: '';
  position: absolute;
  top: 0;
  bottom: 0;
  left: 100%;
  width: var(--sp-2);
}

/* Open. Three ways in, and they are three different users.

     .is-open   clicked, tapped, or opened with Enter or Space on the icon —
                a plain disclosure, the same contract <summary> has, and the
                only way in without a pointer that hovers. It is the state the
                button's aria-expanded reports, and map-controls.js is what
                sets it, so the two cannot disagree.
     :hover     the headline behaviour, on pointers that have one. Not
                reported by aria-expanded, and that is correct rather than an
                omission — nobody reading this page through a screen reader is
                hovering it.
     :has(:focus-visible) on the body — the safety net, and the reason it is
                not optional: while a panel is open on hover alone, its
                controls are in the tab order. Tab into one, move the mouse,
                and without this the panel goes display:none *with focus inside
                it* — focus lands back on <body> and the keyboard user is at
                the top of the document. #136 met this exact defect in a hover
                gate elsewhere in the app. map-controls.js also promotes such a
                panel to .is-open when keyboard focus arrives, so the state and
                what is on screen stay in step; this rule is what holds it open
                in the frame before that happens.

                `:focus-visible` and not the `:focus-within` this was until the
                panels stopped going away, and the difference is the whole of
                that defect: a *click* on a checkbox inside the panel focuses it
                as surely as Tab does, so `:focus-within` held every flyout open
                for as long as that click's focus lasted — and the promotion in
                map-controls.js turned those few hundred milliseconds into open
                for good. One tick of "Snap to stations" and the panel would not
                leave the map. Keyboard focus is what needs a net here; a
                pointer that focused something is a pointer that can leave. */
.mn-mapctl.is-open > .mn-mapctl-body,
.mn-mapctl.is-pinned > .mn-mapctl-body,
.mn-mapctl-body:has(:focus-visible) {
  display: block;
}

@media (hover: hover) {
  .mn-mapctl:hover > .mn-mapctl-body { display: block; }

  /* Clicking the icon of a panel that is already on screen shuts it — and on a
     mouse the pointer is necessarily still on that icon, so the rule above
     would put it straight back and the click would read as having done
     nothing. map-controls.js drops .is-shut the moment the pointer leaves, so
     the next hover opens the panel again; nothing here is remembered. */
  .mn-mapctl.is-shut:hover > .mn-mapctl-body { display: none; }
}

/* An armed draw tool turns the whole map into a crosshair, this panel
   included — and the panel is the one part of it you are not about to draw
   on. */
.leaflet-container.mn-drawing .mn-mapctl {
  cursor: auto;
}

/* Pinned: back into the flow of the corner's column, so panels stack rather
   than overlap. The icon goes while it is docked — it said "there is a Legend
   behind here", and the panel now says that itself, in words, with its own pin
   in the corner to put it back. Vertical room in that column is the scarce
   thing the moment two panels are pinned, and an icon that repeats the heading
   two lines above it is the first thing that should give it up. */
.mn-mapctl.is-pinned > .mn-mapctl-btn {
  display: none;
}

.mn-mapctl.is-pinned > .mn-mapctl-body {
  position: static;
  width: var(--mn-mapctl-w);
}

.mn-mapctl.is-pinned > .mn-mapctl-body::after { content: none; }

.mn-mapctl-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-2);
  margin-bottom: var(--sp-2);
  padding-bottom: var(--sp-1);
  border-bottom: 1px solid var(--border);
}

.mn-mapctl-title {
  margin: 0;
  font-size: var(--fs-md);
  line-height: var(--lh-tight);
}

/* Pressed is not carried by opacity alone: the border and the fill change with
   it, and aria-pressed carries it to anything not reading pixels. */
.mn-mapctl-pin {
  flex: 0 0 auto;
  padding: .2rem .4rem;
  font-size: .85rem;
  line-height: 1;
  border-radius: var(--r-sm);
  opacity: .6;
}

.mn-mapctl-pin[aria-pressed="true"] {
  opacity: 1;
  border-color: var(--accent);
  background: var(--subtle);
}

/* The panel scrolls inside itself rather than growing off the map. Two caps,
   because a docked panel is sharing the corner with the others and a flyout is
   not. Scroll here rather than on .mn-mapctl-body so the title and the pin stay
   put, and so the hover bridge above is not clipped by an overflow context. */
.mn-mapctl-content {
  /* Capped against the map rather than against the viewport: --mn-map-h is the
     map container's own height, published by trackMapHeight() in
     map-controls.js. A vh figure was only ever right for one of the shapes this
     map takes — 62vh in the page, 52dvh on a phone, the whole window in full
     screen, a column beside the station list in the split — and 42vh of a
     full-screen map is a flyout with two thirds of the map still under it,
     while 42vh of a phone map is a flyout hanging past its bottom edge.

     The subtraction is what is above and below a flyout inside the map: the
     10 px Leaflet corner margin at each end, the panel's own heading, and the
     icon column the panel opens beside. 24rem is the cap for a *short* panel —
     a flyout is a glance, and Map display asks for and gets its own taller one
     below. The vh figure behind it is the fallback for the frame before the
     first measurement. */
  max-height: min(24rem, var(--mn-map-h, 62vh) - 7rem);
  overflow-y: auto;
}

/* …except Map display, which is not a glance. It holds a dozen switches, three
   sliders, four selects, a radio group and the whole ACMA block behind one
   icon, and the honest answer to "this menu is getting massive" is a find box
   *and* the height to show what the find box leaves. So it takes the map, less
   the same furniture — and it still shrinks to its content, so filtering the
   panel down to two rows leaves a two-row panel rather than a tall empty box. */
.mn-mapctl[data-panel="display"] .mn-mapctl-content {
  max-height: calc(var(--mn-map-h, 62vh) - 7rem);
}

/* Tighter docked than floating, and deliberately: a flyout has the map to
   itself for as long as the pointer is on it, while two docked panels are
   dividing one column between them and the map underneath. Past two the column
   runs out of map and Leaflet clips it — which is the honest failure, and the
   reason the cap is here rather than left to the content. */
.mn-mapctl.is-pinned .mn-mapctl-content {
  max-height: min(11rem, 18vh);
}

/* The legend reads as a list in a column this narrow, not as a wrapped row. */
.mn-mapctl .map-legend {
  flex-direction: column;
  align-items: flex-start;
  gap: var(--sp-1);
}

/* Below `md` the map itself shrinks to 52dvh (see the breakpoint block at the
   foot of this file), so the flyout's share of the viewport has to come down
   with it or the panel is taller than the map it hangs off. */
/* Below `md` the map itself shrinks, and --mn-map-h shrinks with it — so the
   flyout cap needs no breakpoint of its own any more. What still does is the
   *docked* cap: two pinned panels divide one column between them, and that is a
   share of the map rather than a measurement of it. */
@media (max-width: 900px) {
  .mn-mapctl.is-pinned .mn-mapctl-content {
    max-height: min(11rem, 15vh);
  }
}

/* A finger is not a mouse pointer, and on a touch screen the tap target *is*
   the control: EPIC #107's 44 px floor, the same one #137's map chips take. */
@media (pointer: coarse) {
  .mn-mapctl-btn {
    width: 2.75rem;
    height: 2.75rem;
  }

  .mn-mapctl-pin {
    min-width: 2.75rem;
    min-height: 2.75rem;
  }
}

/* ── The 3-D view (map-3d.js) ─────────────────────────────────────────────
   The MapLibre canvas, inside the Leaflet container and covering it.

   The z-index is the whole design and is worth stating plainly, because the
   obvious reading of Leaflet's stylesheet is the wrong one. Its panes run 200
   (tiles) → 400 (overlays) → 500 (shadows) → 600 (markers) → 650 (tooltips) →
   700 (popups). `.leaflet-control` is 800 — but a control is never stacked at
   800, because it sits inside `.leaflet-top` / `.leaflet-bottom`, which are
   positioned at **1000** and so open a stacking context of their own. The
   corners are the ceiling, not the controls.

   So the window is 700 < z < 1000, and 750 sits in it: this covers
   *everything the 2-D map draws* and covers *nothing an operator can press*.
   The base-map picker, the four panels, the corner buttons and the ⛰️ toggle
   itself all stay on screen and keep working while 3-D is on.

   That is what makes this a mode rather than a second map. There is one set of
   controls, and it drives both — so a filter changed in 3-D is the same filter,
   and leaving 3-D leaves the map exactly as the controls say it should be.

   The cards drawn over the map (#stn-card and the other three) are children of
   .mn-map-stage, outside the Leaflet container entirely, so they are above this
   already and a pin clicked in 3-D still paints one. */
#map3d {
  position: absolute;
  inset: 0;
  z-index: 750;
  border-radius: inherit;
  /* Nothing to see until the renderer has been fetched and the map built; the
     class is added by Map3D.open() and removed by close(). A div with no
     dimensions still covers the controls it is behind, which is why this is
     display rather than opacity. */
  display: none;
}

#map3d.is-on { display: block; }

/* The megabyte of renderer is being fetched. A spinner rather than nothing,
   because on a slow connection this is a second or two of a button that looks
   like it did not work. */
#map3d.is-loading {
  display: block;
  background: var(--bg, #f4f7fb);
  opacity: 0.6;
}

#map3d.is-loading::after {
  content: "Loading the 3-D renderer…";
  position: absolute;
  inset-inline: 0;
  top: 45%;
  text-align: center;
  font: inherit;
  color: var(--muted, #4f6478);
}

/* MapLibre's own furniture, brought into this app's type. Its stylesheet is
   fetched with the library and sets its own sizes; these are the two places
   that would otherwise read as somebody else's map inside ours. */
#map3d .maplibregl-ctrl-attrib-inner { font-size: 0.72rem; }
#map3d .maplibregl-ctrl-group { border-radius: 6px; }

/* The ⛰️ corner button while the mode is on — the same "this is armed" dress
   the ℹ️ what-is-here button uses, so the two modes read the same way. */
.mn-mapctl-btn.mn-map-3d.is-on {
  background: var(--accent, #0b5cab);
  color: #fff;
}

/* The 3-D panel: the two buttons on one row, everything else stacked the way
   every other on-map panel stacks. */
.map3d-panel { display: grid; gap: var(--sp-1, 0.5rem); }

.map3d-actions {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-1, 0.5rem);
}

/* ── Elevation base map (map-elevation.js) ──────────────────────────────── */
/* The extras that only mean anything while the Elevation base is the chosen
   one: the relief switch and the colour file's own ramp, shown as a key. Hidden
   with [hidden] from the picker's change handler rather than removed, so the
   panel does not change height as a side effect of building it. */

.mn-base-elev-extra {
  margin-top: var(--sp-2);
  padding-top: var(--sp-2);
  border-top: 1px solid var(--line);
}

/* A vertical strip, highest band at the top, the way a map legend is read.
   Colour arrives as --dot, the .legend-sq convention (#138). */
.elev-ramp {
  margin: var(--sp-2) 0 0;
  padding: 0;
  list-style: none;
  font-size: .78rem;
  line-height: 1.35;
}

.elev-ramp li {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
}

.elev-ramp i {
  flex: none;
  width: 22px;
  height: 13px;
  background: var(--dot, var(--muted));
  border: 1px solid color-mix(in srgb, var(--dot, var(--muted)) 60%, var(--text));
}

/* One strip, not twelve chips: the bands butt up against each other so the
   ramp reads as the continuous scale it is. */
.elev-ramp li + li i { border-top: 0; }

.elev-ramp span { color: var(--muted); }

/* ── Highest ground in view (map-peaks.js) ──────────────────────────────── */
/* A divIcon with a zero size and a zero anchor: the marker's origin IS the
   summit, and everything drawn hangs off that point rather than being centred
   on it. Leaflet would otherwise size the icon box for us and the triangle's
   tip — the part that means "here" — would land somewhere near the hill. */

.mn-peak-icon {
  background: none;
  border: 0;
}

.mn-peak {
  position: absolute;
  left: 0;
  bottom: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  transform: translateX(-50%);
  white-space: nowrap;
  pointer-events: auto;
}

/* The triangle sits directly over the point, the height rides above it. */
.mn-peak-mark {
  font-size: 15px;
  font-style: normal;
  line-height: 1;
  color: var(--warn);
  text-shadow: 0 0 2px #fff, 0 0 2px #fff, 0 0 3px rgba(0, 0, 0, .6);
}

.mn-peak-h {
  order: -1;
  margin-bottom: 1px;
  padding: 0 4px;
  border: 1px solid var(--line);
  border-radius: 3px;
  background: var(--card);
  color: var(--text);
  font-size: .7rem;
  font-weight: 600;
  box-shadow: 0 1px 3px rgba(0, 0, 0, .3);
}

.mn-peak-pop h4 {
  margin: 0 0 var(--sp-1);
  font-size: .95rem;
}

/* The ranked list under the switch in the Map display panel. A column of
   buttons rather than a list with buttons in it: each row is one target. */
.mn-peak-go {
  display: block;
  width: 100%;
  margin-top: 2px;
  text-align: left;
}

.legend-peak {
  color: var(--warn);
  font-size: 13px;
  line-height: 1;
}

/* ── Places & coordinates in the filter box (places.js) ─────────────────── */
/* The strip under a filter entry: what the text means on the ground, as
   against what it matches in the station list. Empty until Places writes it,
   and it collapses to nothing when there is nothing to say — :empty rather
   than [hidden], because the module writes innerHTML and never attributes. */

.search-places:empty { display: none; }

.search-places-in {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 1px;
  margin-top: var(--sp-1);
  padding: var(--sp-1) var(--sp-2);
  border-left: 3px solid var(--accent);
  background: var(--subtle);
  border-radius: 0 var(--r-sm, 4px) var(--r-sm, 4px) 0;
}

/* In the head row the strip has to take a line of its own: .filter-quick is a
   wrapping flex row that otherwise squeezes it in beside the clear buttons. It
   is a <span> there rather than a <div> — that row's container is a <span> and
   only phrasing content may go inside one — so it is given the block it would
   otherwise have had. */
.search-places--quick {
  display: block;
  flex: 1 1 100%;
  text-align: left;
}

.search-places--quick:empty { display: none; }

.mn-place-go {
  display: block;
  width: 100%;
  text-align: left;
  font-size: .8rem;
}

.mn-place-go .txt-muted { font-weight: 400; }

/* The one pin the module ever drops. A divIcon at a zero anchor, for the
   reason .mn-peak gives: the marker's origin is the coordinate, and the
   emoji hangs off it rather than being centred on it. */
.mn-place-icon {
  background: none;
  border: 0;
}

.mn-place {
  position: absolute;
  left: 0;
  bottom: 0;
  transform: translateX(-50%);
  font-size: 22px;
  line-height: 1;
  filter: drop-shadow(0 1px 2px rgba(0, 0, 0, .5));
}

.mn-place-pop h4 {
  margin: 0 0 var(--sp-1);
  font-size: .95rem;
}

/* ── Polar radio coverage (map-polar.js) ────────────────────────────────── */
/* The one on-map panel that is a dialog rather than a switch: nine fields, a
   station finder and a Draw button, in a flyout that is 300 px wide at most.
   So everything in it is a full-width row and the fieldsets are the only
   grouping — Radio Mobile's own three-column layout does not survive a column
   this narrow, and pretending otherwise is how a panel gets a sideways
   scrollbar. */

.polar-panel { min-width: 15rem; }

.polar-fs {
  margin: 0 0 var(--sp-2);
  padding: var(--sp-1) var(--sp-2) var(--sp-2);
  border: 1px solid var(--line);
  border-radius: var(--r-sm, 4px);
}

.polar-fs > legend {
  padding: 0 .3rem;
  color: var(--muted);
  font-size: .75rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: .04em;
}

.polar-find {
  width: 100%;
  margin-top: var(--sp-1);
}

.polar-picked { margin: var(--sp-1) 0 0; }

/* The hit list is capped at eight and still scrolls: a flyout is at most 42vh
   tall and this is one fieldset of six. */
.polar-hits {
  max-height: 9rem;
  overflow-y: auto;
  margin-top: var(--sp-1);
}

.polar-hit {
  display: flex;
  justify-content: space-between;
  gap: var(--sp-2);
  width: 100%;
  padding: 3px var(--sp-1);
  border: 0;
  border-radius: 3px;
  background: none;
  color: inherit;
  font: inherit;
  text-align: left;
  cursor: pointer;
}

.polar-hit:hover,
.polar-hit:focus-visible { background: var(--subtle); }

.polar-hit.is-here {
  background: var(--subtle);
  font-weight: 600;
}

.polar-actions {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-1);
}

.polar-run { color: var(--accent); }

/* The key, in the panel: the ramp as twelve rows, strongest first, the way the
   plot reads from the middle outwards. Colour arrives as --dot, the
   .legend-sq convention (#138). */
.polar-key {
  margin: var(--sp-2) 0 0;
  padding: 0;
  list-style: none;
  font-size: .74rem;
  line-height: 1.3;
}

.polar-key li {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
}

.polar-key i {
  flex: none;
  width: 20px;
  height: 11px;
  background: var(--dot, var(--muted));
}

.polar-key span { color: var(--muted); }

/* …and in the map legend, where there is no room for twelve rows: one strip. */
.legend-polar {
  display: flex;
  flex-direction: column;
  gap: 2px;
  width: 100%;
}

.legend-polar-ramp {
  display: flex;
  height: 10px;
  border: 1px solid var(--line);
}

.legend-polar-ramp i {
  flex: 1 1 auto;
  background: var(--dot, var(--muted));
}

/* ── My location + heading (mobile only) ─────────────────────────────────── */

.mn-locate a {
  font-size: 15px;
  text-align: center;
}

.mn-locate a.on {
  color: #1565c0;
  background: #e3f0fd;
}

.mn-loc-icon {
  background: none;
  border: none;
}

.mn-loc {
  position: relative;
  width: 46px;
  height: 46px;
}

.mn-loc-dot {
  position: absolute;
  left: 50%;
  top: 50%;
  width: 14px;
  height: 14px;
  margin: -7px 0 0 -7px;
  border-radius: 50%;
  background: #1e88e5;
  box-shadow: 0 0 0 3px #fff, 0 0 4px 3px rgba(0, 0, 0, .35);
}

/* compass wedge, rotated to the heading by app.js; masked out over the dot */
.mn-loc-cone {
  position: absolute;
  inset: 0;
  border-radius: 50%;
  transform-origin: 50% 50%;
  background: conic-gradient(from -28deg,
              rgba(30, 136, 229, .75), rgba(30, 136, 229, .05) 56deg);
  -webkit-mask: radial-gradient(circle at 50% 50%, transparent 9px, #000 10px);
  mask: radial-gradient(circle at 50% 50%, transparent 9px, #000 10px);
}

/* ── Move pin (map-move-pin.js) ──────────────────────────────────────────── */
/* The one draggable marker on the Stations map, and the panel that says where
   it has got to. A divIcon rather than Leaflet's default image icon, for the
   reason .mn-loc gives one line up: the default resolves its PNGs against the
   stylesheet's own URL, and this app is expected to open off a laptop with no
   network.

   Amber, which on this map means "the filter picked this one out" — the same
   thing it means here: of 3,174 pins, this is the one being operated. It is
   drawn over its own station's circle (zIndexOffset 3000), and the circle stays
   put as the "was here" mark. */
.mn-movepin-icon {
  background: none;
  border: none;
  cursor: grab;
}

.mn-movepin-icon:active { cursor: grabbing; }

.mn-movepin {
  position: relative;
  width: 34px;
  height: 34px;
}

.mn-movepin-ring {
  position: absolute;
  inset: 3px;
  border-radius: 50%;
  /* MAP_PIN_HIT's amber, written out rather than tokenised: app.js holds that
     colour as a JS constant because a canvas pin's stroke is a string and not a
     `var()`, and a second copy in :root that nothing reads back would be one
     more pair to keep in step. Kept here, named there. */
  border: 2px dashed #ffc400;
  background: rgba(255, 196, 0, .16);
  animation: mn-movepin-pulse 1.8s ease-in-out infinite;
}

.mn-movepin-dot {
  position: absolute;
  left: 50%;
  top: 50%;
  width: 12px;
  height: 12px;
  margin: -6px 0 0 -6px;
  border-radius: 50%;
  background: #ffc400;
  box-shadow: 0 0 0 3px #fff, 0 0 4px 3px rgba(0, 0, 0, .35);
}

/* The one animation on this map, and it earns its place: it is what tells the
   operator the mode is armed when the panel is scrolled out of view. Decorative
   — the dashed amber ring says which pin it is standing perfectly still — so it
   is left to the document's one reduced-motion policy at the foot of this file
   rather than given a second block of its own. There is exactly one such block
   and `npm run shell` counts them. */
@keyframes mn-movepin-pulse {
  0%, 100% { transform: scale(1);    opacity: 1; }
  50%      { transform: scale(1.18); opacity: .75; }
}

/* ── What is here (#186) ───────────────────────────────────────────────────
   The pin the tool drops, and the crosshair while it is armed. Deliberately
   not the move-pin marker's amber: that one means "this station is being
   moved and nothing is saved yet", and a mark that means "you asked about
   this spot" must not be mistaken for it. Cyan, still, and it takes no
   pointer — there is nothing to drag. */
.leaflet-container.mn-here-picking {
  cursor: crosshair;
}

.mn-here {
  position: relative;
  width: 30px;
  height: 30px;
}

.mn-here-ring {
  position: absolute;
  inset: 3px;
  border: 2px solid #00e5ff;
  border-radius: 50%;
  box-shadow: 0 0 0 1px rgba(0, 0, 0, .5), inset 0 0 0 1px rgba(0, 0, 0, .35);
}

.mn-here-dot {
  position: absolute;
  left: 50%;
  top: 50%;
  width: 6px;
  height: 6px;
  margin: -3px 0 0 -3px;
  border-radius: 50%;
  background: #00e5ff;
  box-shadow: 0 0 0 2px rgba(0, 0, 0, .5);
}

/* Bottom left of the map, where nothing else on it lives. */
.mn-movepin-panel {
  min-width: 15rem;
  max-width: 17rem;
  padding: var(--sp-2) var(--sp-3);
  border: 1px solid var(--border);
  border-radius: var(--r-md);
  background: var(--panel);
  color: var(--text);
  box-shadow: 0 2px 10px rgba(0, 0, 0, .22);
  font-size: .85rem;
}

.mn-movepin-head {
  display: flex;
  flex-direction: column;
  gap: .1rem;
  margin-bottom: var(--sp-1);
}

.mn-movepin-name { color: var(--muted); }

.mn-movepin-hint {
  margin: 0 0 var(--sp-2);
  color: var(--muted);
  font-size: .8rem;
}

/* Label / value pairs, not a table: three facts, and a <dl> is what three
   labelled facts are. */
.mn-movepin-read {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: .1rem var(--sp-2);
  margin: 0 0 var(--sp-2);
}

.mn-movepin-read dt { color: var(--muted); }

.mn-movepin-read dd {
  margin: 0;
  font-variant-numeric: tabular-nums;
}

/* The card's own arming button, and Copy lat, lon beside it, on one line under
   the coordinate boxes. `.pill-row` supplies the layout — this is only the pull
   up into the grid gap, the same as `.ef-hint` and `.ef-links` take. */
.ef-movepin { margin-top: -.35rem; }

/* Copy lat, lon flashes "✓ Copied" or "✗ Copy failed" in place of its label for
   1.6 s. Both are shorter than the label — 79 px and 105 px against 118 px as
   measured here — and a pill that shrinks and grows again shoves every pill
   after it sideways, which on the map callout is five links re-wrapping under
   the pointer. A min-width the longest state fits inside holds the row still.

   `ch` rather than `px` because the label is text and this has to track the
   font the pill is actually rendered in: 118 px is 16.6ch at the pill's size.
   18ch is that plus slack, deliberately — the label opens with an emoji, and
   emoji widths differ between platforms far more than letters do, so a figure
   that only just fitted here would stop binding on the first machine whose
   clipboard glyph is wider. The cost of the slack is a few pixels of padding
   on one pill in a row that wraps anyway. */
.mn-copy-latlon { min-width: 18ch; justify-content: center; }

/* ── Draw & measure ──────────────────────────────────────────────────────── */

/* While a tool is armed the click has to reach the map: station pins and ACMA
   squares would otherwise swallow the one near the site being drawn around. */
.leaflet-container.mn-drawing {
  cursor: crosshair;
}

/* Every interactive layer has to be named, not just the panes they sit in:
   Leaflet puts pointer-events:auto back on each one, and a descendant saying
   "auto" beats an ancestor saying "none". */
.leaflet-container.mn-drawing .leaflet-marker-pane,
.leaflet-container.mn-drawing .leaflet-overlay-pane,
.leaflet-container.mn-drawing .leaflet-interactive,
.leaflet-container.mn-drawing .leaflet-marker-icon {
  pointer-events: none;
}

.mn-draw-pin {
  background: none;
  border: none;
}

/* teardrop: a rounded square with one square corner, rotated onto its point */
.mn-draw-pin i {
  display: block;
  width: 16px;
  height: 16px;
  border-radius: 50% 50% 50% 0;
  transform: rotate(-45deg);
  background: var(--draw);
  box-shadow: 0 0 0 2px #fff, 0 1px 3px rgba(0, 0, 0, .45);
}

.mn-draw-pin.sel i,
.mn-draw-textbox.sel span {
  outline: 2px solid var(--draw);
  outline-offset: 3px;
}

/* free text annotation — its own label, so it carries no measurement */
.mn-draw-textbox {
  background: none;
  border: none;
  width: auto !important;
  height: auto !important;
}

.mn-draw-textbox span {
  display: inline-block;
  white-space: nowrap;
  transform: translate(-50%, -50%);
  padding: 1px 6px;
  border-radius: 4px;
  border: 1px solid var(--draw);
  background: rgba(255, 255, 255, .94);
  color: #1a1a1a;
  font-size: .8rem;
  font-weight: 600;
  box-shadow: 0 1px 3px rgba(0, 0, 0, .35);
}

/* the length / radius / area a shape carries on the map */
.leaflet-tooltip.mn-draw-label {
  background: rgba(255, 255, 255, .92);
  color: #1a1a1a;
  border: 1px solid var(--draw);
  border-radius: 3px;
  padding: 0 5px;
  font-size: .72rem;
  font-weight: 600;
  line-height: 1.5;
  white-space: nowrap;
  box-shadow: 0 1px 3px rgba(0, 0, 0, .3);
  pointer-events: none;
}

.leaflet-tooltip.mn-draw-label::before { display: none; }

/* The elevation profile's cursor on the map: the ground height under the dot
   running along the profiled line (see path-profile.js). Built like the draw
   label above it — its own near-white ground with fixed dark ink, so it holds
   its contrast over satellite imagery and over the light topo base alike, which
   is the deal every permanent label on this map takes. Blue border rather than
   the draw magenta, because the dot it belongs to is not a drawn shape. */
.leaflet-tooltip.mn-profile-label {
  background: rgba(255, 255, 255, .94);
  color: #0d2b45;
  border: 1px solid #2196f3;
  border-radius: 3px;
  padding: 0 5px;
  font-size: .72rem;
  font-weight: 600;
  line-height: 1.6;
  white-space: nowrap;
  box-shadow: 0 1px 3px rgba(0, 0, 0, .3);
  pointer-events: none;
}

.leaflet-tooltip.mn-profile-label::before { display: none; }

/* tool picker */
/* "Clear all", on its own row. It was the right-hand half of a .panel-header
   until #164 took the heading away — the MapChrome flyout this panel lives in
   carries the title now, and a header row with nothing at the left-hand end is
   not a header.
   It has a left-hand end again since #183: the KML export sits there and Clear
   all stays where it was, at the far edge, which is where the row's one
   destructive control belongs. */
.draw-head {
  display: flex;
  flex-wrap: wrap;
  gap: .3rem;
  justify-content: space-between;
}

/* Same metrics as .filter-reset beside it — a small panel button — under a
   name of its own because it is not a reset and a class that says so would be
   the next person's wrong turn. */
.draw-export {
  padding: .25rem .6rem;
  font-size: .78rem;
  white-space: nowrap;
}

.draw-tools {
  display: flex;
  flex-wrap: wrap;
  gap: .3rem;
  margin: .5rem 0 .1rem;
}

.draw-tool {
  display: inline-flex;
  align-items: center;
  gap: .3rem;
  padding: .32rem .5rem;
  font-size: .8rem;
}

.draw-tool.on {
  background: var(--draw);
  border-color: var(--draw);
  color: #fff;
}

.draw-tool-icon {
  font-size: .95rem;
  line-height: 1;
}

.draw-numeric {
  margin: .4rem 0;
}

.draw-numeric > summary {
  cursor: pointer;
  color: var(--muted);
  margin-bottom: .3rem;
}

.draw-form {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: .35rem;
}

.draw-field {
  display: block;
  font-size: .76rem;
  color: var(--muted);
}

.draw-field-wide {
  grid-column: 1 / -1;
  margin-top: .35rem;
}

.draw-field input {
  padding: .3rem .4rem;
  font-size: .82rem;
}

.draw-actions {
  display: flex;
  gap: .35rem;
  margin: .45rem 0 .1rem;
}

.draw-actions button {
  padding: .35rem .6rem;
  font-size: .8rem;
}

/* what has been drawn, with each shape's measurement */
.draw-list {
  margin-top: .5rem;
  border-top: 1px solid var(--border);
}

.draw-row {
  display: flex;
  align-items: center;
  gap: .25rem;
  border-bottom: 1px solid var(--border);
}

.draw-row.sel {
  background: var(--subtle);
}

.draw-row-main {
  flex: 1;
  display: flex;
  align-items: center;
  gap: .4rem;
  min-width: 0;
  border: none;
  background: none;
  text-align: left;
  padding: .35rem .2rem;
  font-size: .8rem;
}

.draw-row-text {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* The shape's own colour arrives as --ink (#136); --draw is the fallback for
   a row drawn before one has been picked. */
.draw-row-icon {
  color: var(--ink, var(--draw));
  flex-shrink: 0;
}

.draw-del {
  border: none;
  background: none;
  color: var(--muted);
  padding: .2rem .35rem;
  font-size: .85rem;
}

.draw-del:hover {
  color: var(--bad);
}

.draw-edit {
  padding: .1rem .2rem .5rem;
  border-bottom: 1px solid var(--border);
}

/* hand the stations inside a circle or rectangle to the list below the map */
.draw-pick {
  border: 1px solid var(--border);
  background: none;
  color: var(--muted);
  padding: .15rem .35rem;
  font-size: .7rem;
  white-space: nowrap;
  flex-shrink: 0;
}

.draw-pick:hover {
  color: var(--text);
  border-color: var(--accent);
}

/* colour picker: six presets that hold up on street, topo and satellite tiles,
   with the native control behind them for anything else */
.draw-colour {
  display: flex;
  align-items: center;
  gap: .45rem;
  margin: .45rem 0 .1rem;
}

.draw-colour-label {
  font-size: .78rem;
  color: var(--muted);
}

.draw-swatches {
  display: flex;
  align-items: center;
  gap: .25rem;
  flex-wrap: wrap;
}

.draw-swatch {
  width: 18px;
  height: 18px;
  padding: 0;
  border-radius: 50%;
  border: 1px solid var(--border);
  background: var(--sw);
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .5);
}

/* The ring sits outside the swatch so the colour itself is never covered. */
.draw-swatch.on {
  outline: 2px solid var(--text);
  outline-offset: 2px;
}

.draw-colour-input {
  width: 26px;
  height: 20px;
  padding: 0;
  border: 1px solid var(--border);
  border-radius: 3px;
  background: none;
  cursor: pointer;
}

/* ── Terrain path tools: elevation profile + link budget ─────────────────── */

/* Ground fill. Deliberately not a station-role colour and not the draw magenta:
   terrain is the backdrop the radio geometry is read against, so it stays
   quiet while the LOS and Fresnel lines stay loud. */
:root {
  --terrain-fill: #d9d0c1;
  --terrain-line: #8c7f6a;
}

[data-theme="dark"] {
  --terrain-fill: #3a3529;
  --terrain-line: #7d7259;
}

/* The rest of the profile plot: what is above the ground, and what is under it.
   The chart used to paint neither, so the plot area was the panel behind it —
   the picture had no edge, and the terrain line ran into the page at the top of
   the chart. `--profile-sky` is a wash rather than a colour: it has to read as
   "not the page" without competing with the LOS and Fresnel lines drawn across
   it, which is why it stays a few percent off the panel rather than a real sky
   blue. `--profile-earth` is the curvature arc's fill: a shade under the terrain
   above it in both themes, so it reads as the thing the terrain is standing on
   rather than as more terrain — it is the one band on the chart that is not a
   measurement of this path but of the planet it crosses. Its line is the pair's
   contrast, and carries the whole band on a short hop where the arc is barely a
   pixel tall. */
:root {
  --profile-sky:        #eaf2fa;
  --profile-earth:      #6f6455;
  --profile-earth-line: #4a4238;
}

[data-theme="dark"] {
  --profile-sky:        #101a26;
  --profile-earth:      #1c1913;
  --profile-earth-line: #9a917c;
}

/* Ground cover on the profile (see LandCover). One token per Sentinel-2
   land-cover class, in Esri's own legend hues so the band reads the same as
   the map anyone checks it against — lifted a little in the dark theme so the
   band stays a band against the darker terrain fill rather than sinking into
   it. Tokens rather than literals in land-cover.js, for the reason every other
   palette in the app is: a colour in a JavaScript file cannot follow the
   theme, and the chart is drawn in both.

   Built area is the one departure from Esri's legend, and it is a rule rather
   than a preference: red on this chart means an obstruction — the Fresnel
   intrusion overlay, the worst-point marker, an obstructed link on the map —
   and a brick-red built-up band sitting on the terrain read as one of those at
   a glance. It is black on light and white on dark instead: the highest
   contrast either theme has, which suits a class that is usually a thin band
   ten metres tall, and a colour nothing else on the chart is using. */
:root {
  --cover-water:   #419bdf;
  --cover-trees:   #397d49;
  --cover-flooded: #7a87c6;
  --cover-crops:   #e49635;
  --cover-built:   #111111;
  --cover-bare:    #a59b8f;
  --cover-snow:    #a8ebff;
  --cover-cloud:   #8a8a8a;
  --cover-range:   #c8c68a;
}

[data-theme="dark"] {
  --cover-water:   #5aaef0;
  --cover-trees:   #4f9e60;
  --cover-flooded: #8f9bd8;
  --cover-crops:   #f0a94a;
  --cover-built:   #f2f5f8;
  --cover-bare:    #b3a99c;
  --cover-snow:    #bdf0ff;
  --cover-cloud:   #9a9a9a;
  --cover-range:   #d6d49a;
}

.stations-card > summary,
.path-panel > summary,
.lb-panel > summary {
  display: flex;
  align-items: baseline;
  gap: .5rem;
  cursor: pointer;
  list-style: none;
  padding: .1rem 0;
}

.stations-card > summary::before,
.path-panel > summary::before,
.lb-panel > summary::before {
  content: '▸';
  color: var(--muted);
  font-size: .85rem;
}

.stations-card[open] > summary::before,
.path-panel[open] > summary::before,
.lb-panel[open] > summary::before { content: '▾'; }

.stations-card > summary h3,
.path-panel > summary h3,
.lb-panel > summary h3 {
  margin: 0;
  font-size: .95rem;
}

.path-panel > summary span,
.lb-panel > summary span { color: var(--muted); }

/* The stations card is deliberately not in the rule above, and this is the one
   place the two differ. That selector is `summary span` — every span *inside*
   the summary, at any depth — and the stations card's heading carries the live
   row-count badge, which has colours of its own and must keep them. So only the
   note beside the heading is muted here. */
.stations-card > summary > .small { color: var(--muted); }

.filter-card > .filter-panel,
.stations-card > .stations-card-body,
.path-body,
.lb-body { padding-top: .5rem; }

/* ── The filters card's head row (#181) ───────────────────────────────────
   The three cards below this one on the Stations tab are <details>, and their
   heads are <summary> — the rules above. This one is a button and a panel
   instead, because its head has to keep working while the card is shut: a
   <details> shows nothing but its summary when closed, and the alternative,
   putting the controls *inside* the summary, is the thing the note under
   .stations-card-actions already says not to do.

   So the row is styled to read as the same head as the other three — the same
   caret, the same 0.95 rem heading, the same muted note beside it — and then
   carries the search box and the two clear buttons on its right while the card
   is closed. It wraps rather than squeezing: the box asks for 16 rem and drops
   to its own line under a heading that has run out of room, which is a phone
   and needs no breakpoint to say so. */
.filter-card-head {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: .3rem .6rem;
  padding: .1rem 0;
}

.filter-card-title {
  margin: 0;
  font-size: .95rem;
}

/* A real button, so it is one tab stop with one accessible name and one
   aria-expanded — and stripped back to the text of the heading it is inside,
   so the row reads as a heading rather than as a control. The caret is the
   same character the <summary> cards draw, turned by aria-expanded rather than
   by [open], because that attribute is now where the state lives. */
.filter-toggle {
  display: inline-flex;
  align-items: baseline;
  gap: .5rem;
  padding: .1rem 0;
  border: 0;
  border-radius: 4px;
  background: none;
  color: inherit;
  font: inherit;
  cursor: pointer;
}

.filter-toggle::before {
  content: '▸';
  color: var(--muted);
  font-size: .85rem;
}

.filter-toggle[aria-expanded="true"]::before { content: '▾'; }

.filter-card-head > .small { color: var(--muted); }

/* Everything the shut card still has to *do*, pushed to the far end of the row
   and taking whatever width is left. `min-width: 0` on the flex item is what
   lets the textarea inside it shrink below its own intrinsic width instead of
   pushing the row wider than the page. */
.filter-quick {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: flex-end;
  gap: .3rem .45rem;
  flex: 1 1 16rem;
  min-width: 0;
  margin-left: auto;
}

.filter-quick[hidden] { display: none; }

/* The same textarea as the one in the panel — a paste of ALERT addresses keeps
   its line breaks here too — sized for a row rather than for a block. It grows
   with a paste like the panel's does (autoGrowSearch writes --grow for both),
   but max-height caps it at about three lines and it scrolls after that: this
   is a head row, and a head row that grows to eight lines is a panel. */
.filter-search--quick {
  flex: 1 1 8rem;
  min-width: 5rem;
  min-height: 1.9rem;
  max-height: 4.4rem;
  padding: .25rem .45rem;
  font-size: .84rem;
  resize: none;
}

/* How many search entries the row is not showing. Only ever drawn with a stack
   of two or more, and it is the one thing standing between "the box in the head
   is the search" and a second entry quietly filtering in the background. */
.filter-quick-more {
  flex: 0 0 auto;
  padding: .05rem .35rem;
  border: 1px solid var(--border);
  border-radius: 999px;
  font-size: .72rem;
  color: var(--muted);
  cursor: help;
}

.filter-quick-more[hidden] { display: none; }

/* "+ New" used to sit in a .panel-header beside the heading. The heading is a
   <summary> now, and a button inside one is a target that toggles the card as
   often as it is pressed — so the action moved into the body, on its own row,
   right-aligned where the panel header used to put it. */
.stations-card-actions {
  display: flex;
  justify-content: flex-end;
  margin-bottom: var(--sp-2);
}

/* verdict strip: the plain-English answer, before any of the numbers */
.path-verdict {
  display: flex;
  align-items: baseline;
  gap: .5rem;
  flex-wrap: wrap;
  padding: .4rem .6rem;
  border-radius: 5px;
  border-left: 4px solid var(--muted);
  background: var(--panel-sub);
  margin-bottom: .5rem;
}

.path-verdict.ok   { border-left-color: var(--ok);   }
.path-verdict.warn { border-left-color: var(--warn); }
.path-verdict.bad  { border-left-color: var(--bad);  }
.path-verdict.ok strong   { color: var(--ok);   }
.path-verdict.warn strong { color: var(--warn); }
.path-verdict.bad strong  { color: var(--bad);  }
.path-verdict span { color: var(--muted); }

.path-stats {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
  gap: .3rem .8rem;
  margin: 0 0 .5rem;
}

.path-stats > div {
  border-bottom: 1px solid var(--border);
  padding-bottom: .2rem;
}

.path-stats dt {
  font-size: .72rem;
  color: var(--muted);
  text-transform: uppercase;
  letter-spacing: .02em;
}

.path-stats dd {
  margin: 0;
  font-size: .85rem;
}

/* The Path row's two ends, one per line and clipped rather than wrapped. The
   height of this cell is what sets the height of the first row of the grid, so
   it has to be the same for "Boonah" and for "Upper Warrell Creek Rd @ Bridge"
   — otherwise every figure below it moves as the path changes. The full text
   is on the title attribute, which is where a clipped name goes. */
.path-ends {
  display: flex;
  flex-direction: column;
  line-height: 1.25;
}

.path-ends > span {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* The far end, marked as the far end without a second line to say so. */
.path-ends > span + span::before {
  content: '↳ ';
  color: var(--muted);
}

/* the chart scrolls inside itself rather than squashing — a profile narrower
   than about 640 px stops being readable */
.path-chart-wrap { overflow-x: auto; }

.path-chart {
  width: 100%;
  min-width: 620px;
  height: auto;
  display: block;
}

/* The chart's own half of the profile cursor. The crosshair rect over the plot
   is what catches the pointer, and a crosshair cursor over it says the chart
   can be read at a point rather than only looked at. */
.path-chart rect[onmousemove] { cursor: crosshair; }

.path-key {
  display: flex;
  gap: .9rem;
  flex-wrap: wrap;
  color: var(--muted);
  margin: .3rem 0 .1rem;
}

.path-key i {
  display: inline-block;
  width: 16px;
  height: 0;
  vertical-align: middle;
  margin-right: .25rem;
}

.path-key-los  { border-top: 2px solid var(--accent); }
.path-key-fres { border-top: 2px dashed var(--accent); }
.path-key-obs  { border-top: 3px solid var(--bad); }

.path-key-gnd {
  height: 8px !important;
  background: var(--terrain-fill);
  border: 1px solid var(--terrain-line);
}

/* The cover band's legend: one chip per class actually on this path, painted
   with that class's token. `--dot` is the same seam .legend-dot uses. */
.path-key-cover {
  height: 8px !important;
  background: var(--dot, var(--muted));
  border: 1px solid color-mix(in srgb, var(--dot, var(--muted)) 60%, var(--text));
}
.path-key-mast { border-top: 2px solid var(--map-repeater); }

/* The curvature arc's chip, built like the ground one so the two read as a
   pair: the ground of this path, and the ground of the planet under it. */
.path-key-earth {
  height: 8px !important;
  background: var(--profile-earth);
  border: 1px solid var(--profile-earth-line);
}

/* Where a cover band is drawn from the terrain up, the chart is asked to say
   so in words too; this is the line that does. */
.path-cover-note { margin: .2rem 0 0; }

/* The cover-height table under the profile: nine short rows, two columns of
   figures, editable in place. Reads like the antenna form above it. */
.cover-table td, .cover-table th { padding: .15rem .5rem; }
.cover-table input[type="number"] { width: 5.5em; }
.cover-table .is-over input { border-color: var(--warn); }
.cover-table .cover-swatch {
  display: inline-block;
  width: 12px; height: 12px;
  vertical-align: -1px;
  margin-right: .35rem;
  border-radius: 2px;
  background: var(--dot, var(--muted));
  border: 1px solid color-mix(in srgb, var(--dot, var(--muted)) 60%, var(--text));
}

/* The propagation settings form on the link budget: the network's premises,
   nine short fields in the same grid the antenna forms use. */
.lb-prop {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
  gap: .3rem .6rem;
  margin: .3rem 0 .2rem;
}
.lb-prop select, .lb-prop input { width: 100%; box-sizing: border-box; }
.lb-prop .is-unused { opacity: .55; }
.lb-propset, .path-cover-table { margin: .4rem 0; }
.lb-propset > summary, .path-cover-table > summary { cursor: pointer; }
.lb-prop .lb-flag { display: block; }

/* The model's readouts under the table — Radio Mobile's Radio Link window,
   as a definition list. */
.lb-readout {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
  gap: .2rem .9rem;
  margin: .5rem 0 .2rem;
}
.lb-readout > div { display: flex; justify-content: space-between; gap: .5rem; border-bottom: 1px dotted var(--border); }
.lb-readout dt { color: var(--muted); }
.lb-readout dd { margin: 0; text-align: right; font-variant-numeric: tabular-nums; }
.lb-warn-list { margin: .3rem 0 0 1.1rem; padding: 0; color: var(--warn); }
.lb-warn-list li { margin: .1rem 0; }


.path-form {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
  gap: .3rem .6rem;
  align-items: end;
  margin: .5rem 0 .2rem;
}

/* provenance under each box: "default · Field Station 1W" vs "edited", the same
   distinction the link budget draws on its own fields */
.path-src {
  font-size: .6rem;
  text-transform: uppercase;
  letter-spacing: .04em;
  font-weight: 400;
  color: var(--muted);
}

.path-warn {
  color: var(--warn);
  border-left: 3px solid var(--warn);
  padding-left: .5rem;
  margin: .4rem 0;
}

/* terrain unavailable. Loud on purpose: the alternative reading of a missing
   profile is a flat one, and a flat profile looks like a clear path. */
.path-fail {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: .3rem;
  padding: .6rem .7rem;
  border: 1px solid var(--bad);
  border-radius: 5px;
  background: color-mix(in srgb, var(--bad) 7%, transparent);
}

.path-fail span { color: var(--muted); }
.path-actions { margin-top: .5rem; }

/* ── link budget ── */

.lb-badge {
  font-size: .62rem;
  text-transform: uppercase;
  letter-spacing: .06em;
  color: #fff;
  background: var(--bad);
  border-radius: 3px;
  padding: .05rem .3rem;
  vertical-align: middle;
}

/* The fade margin in the card's top-right corner. `margin-left: auto` is the
   whole of the positioning: the summary is already a flex row, so the chip
   takes the space between itself and the strapline and sits hard right of it,
   at whatever width the card is. It is the same figure the table foot carries,
   in the same three bands, so the colours have to be those colours — hence the
   tokens rather than a palette of its own. Hidden until there is a margin to
   show: an empty pill in the corner is a card claiming to have computed
   something it has not. */
.lb-margin-chip {
  margin-left: auto;
  font-size: .8rem;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
  border: 1px solid currentColor;
  border-radius: 999px;
  padding: .05rem .45rem;
  color: var(--muted);
}

.lb-margin-chip.ok   { color: var(--ok);   }
.lb-margin-chip.warn { color: var(--warn); }
.lb-margin-chip.bad  { color: var(--bad);  }

/* never dismissible, always at the top of the card */
.lb-disclaimer {
  border: 1px solid var(--bad);
  border-left-width: 5px;
  border-radius: 5px;
  background: color-mix(in srgb, var(--bad) 9%, transparent);
  color: var(--text);
  padding: .5rem .7rem;
  margin-bottom: .6rem;
  font-size: .85rem;
}

.lb-disclaimer strong { color: var(--bad); }

.lb-controls {
  display: flex;
  align-items: end;
  gap: .8rem;
  flex-wrap: wrap;
  margin-bottom: .2rem;
}

/* The frequency box's source flag reads under the box, the way every other
   flag on this card does — the row is baseline-aligned, so it has to be a
   block of its own rather than a run of text beside the input. */
.lb-controls .draw-field .lb-flag { display: block; }

.lb-ends {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
  gap: .6rem;
  margin: .5rem 0;
}

.lb-end {
  border: 1px solid var(--border);
  border-radius: 5px;
  padding: .45rem .55rem;
  background: var(--panel-sub);
}

.lb-end-empty { border-style: dashed; }

.lb-end-head {
  display: flex;
  align-items: center;
  gap: .4rem;
}

.lb-end-head strong { flex: 1; }

.lb-tag {
  background: #c62828;
  color: #fff;
  border-radius: 3px;
  font-size: .7rem;
  padding: .05rem .3rem;
}

.lb-src {
  color: var(--muted);
  margin: .15rem 0 0;
}

/* ── finding an end by name, number or ALERT address ── */

.lb-find { margin: .35rem 0 .1rem; }

.lb-find-row {
  display: flex;
  align-items: center;
  gap: .35rem;
}

.lb-find-input {
  flex: 1;
  min-width: 0;
  border: 1px solid var(--border);
  border-radius: 4px;
  background: var(--panel);
  color: var(--text);
  padding: .25rem .35rem;
  font-size: .82rem;
}

.lb-find-x {
  flex: none;
  font-size: .74rem;
  padding: .22rem .45rem;
}

.lb-find-hint {
  color: var(--muted);
  margin: .2rem 0 0;
}

/* An armed end is the one the next pin, point or table row lands on, so it has
   to be tellable apart from the other one at a glance — the hint alone is a
   sentence to read, and the whole point of arming is that you are looking at
   the map rather than at this card. */
.lb-end.is-armed,
.lb-end-empty.is-armed {
  border-color: var(--accent);
  border-style: solid;
  box-shadow: inset 0 0 0 1px var(--accent);
}

.lb-find.is-armed .lb-find-input { border-color: var(--accent); }

.lb-find-lead,
.lb-find-none {
  color: var(--muted);
  margin: .3rem 0 .1rem;
}

/* Capped in the markup, so this never has to scroll far — but it scrolls
   anyway, because two of these side by side inside a card that already carries
   a table would otherwise push the budget itself off the screen. */
.lb-hits {
  display: flex;
  flex-direction: column;
  max-height: 190px;
  overflow-y: auto;
  border: 1px solid var(--border);
  border-radius: 4px;
  background: var(--panel);
}

.lb-hit {
  display: flex;
  align-items: baseline;
  gap: .4rem;
  text-align: left;
  background: none;
  border: 0;
  border-bottom: 1px solid var(--border);
  padding: .28rem .35rem;
  font-size: .82rem;
  color: inherit;
  cursor: pointer;
}

.lb-hit:last-child { border-bottom: 0; }
.lb-hit:hover { background: var(--subtle); }
.lb-hit-name { flex: 1; min-width: 0; }
.lb-hit-num { color: var(--muted); }

/* Already at one end or the other. Not disabled — picking end A's own station
   again is harmless, and a station that is at the *other* end is exactly what
   somebody swapping the two ends round is reaching for. */
.lb-hit-at {
  flex: none;
  font-size: .6rem;
  text-transform: uppercase;
  letter-spacing: .04em;
  color: var(--accent);
}

.lb-hit.is-here { background: color-mix(in srgb, var(--accent) 8%, transparent); }

.lb-fields {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
  gap: .25rem .5rem;
  margin-top: .4rem;
}

.lb-field {
  display: flex;
  flex-direction: column;
  font-size: .72rem;
  color: var(--muted);
  position: relative;
}

.lb-field input {
  border: 1px solid var(--border);
  border-radius: 4px;
  background: var(--panel);
  color: var(--text);
  padding: .2rem .3rem;
  font-size: .82rem;
  width: 100%;
}

.lb-field em {
  font-style: normal;
  opacity: .75;
}

/* an overridden value has to be visibly not a default — the whole point of
   auto-populating is that you can tell what you changed */
.lb-field.is-over input {
  border-color: var(--accent);
  background: color-mix(in srgb, var(--accent) 8%, var(--panel));
}

.lb-flag {
  font-size: .6rem;
  text-transform: uppercase;
  letter-spacing: .04em;
  color: var(--muted);
  font-weight: 400;
  min-height: .8rem;
}

.lb-field.is-over .lb-flag { color: var(--accent); }

.lb-table {
  width: 100%;
  border-collapse: collapse;
  font-size: .85rem;
}

.lb-table th {
  text-align: left;
  font-weight: 400;
  padding: .18rem .4rem .18rem 0;
}

.lb-table td { padding: .18rem .4rem; }

.lb-num {
  text-align: right;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

.lb-unit {
  color: var(--muted);
  width: 3rem;
}

.lb-note { color: var(--muted); }

/* running subtotals (EIRP, received signal) rule off from the terms above */
.lb-table .lb-sub th,
.lb-table .lb-sub td {
  border-top: 1px solid var(--border);
  font-weight: 600;
}

.lb-table .lb-missing th,
.lb-table .lb-missing td { color: var(--bad); }

.lb-margin th,
.lb-margin td {
  border-top: 2px solid var(--border);
  padding-top: .3rem;
  font-size: .95rem;
  font-weight: 700;
}

.lb-margin.ok   .lb-num { color: var(--ok);   }
.lb-margin.warn .lb-num { color: var(--warn); }
.lb-margin.bad  .lb-num { color: var(--bad);  }
.lb-margin .lb-note span { font-weight: 400; }

.lb-link {
  border: none;
  background: none;
  color: var(--accent);
  text-decoration: underline;
  cursor: pointer;
  padding: 0;
  font-size: inherit;
}

/* The chart above and the table are on different assumptions — a warning, not
   an error: both figures are real, they just are not the same figure. */
.lb-diverge {
  margin: .4rem 0 0;
  padding: .45rem .6rem;
  border-left: 3px solid var(--warn);
  background: color-mix(in srgb, var(--warn) 10%, transparent);
  border-radius: 0 4px 4px 0;
  color: var(--text);
}

.lb-eval {
  color: var(--muted);
  margin: .4rem 0 0;
}

.lb-compare {
  margin-top: .7rem;
  border-top: 1px solid var(--border);
  padding-top: .4rem;
}

.lb-compare > summary {
  cursor: pointer;
  color: var(--accent);
  font-size: .85rem;
}

.lb-compare-table {
  width: 100%;
  border-collapse: collapse;
  font-size: .78rem;
  margin-top: .4rem;
}

.lb-compare-table th,
.lb-compare-table td {
  border: 1px solid var(--border);
  padding: .22rem .4rem;
  text-align: left;
  vertical-align: top;
}

.lb-compare-table thead th { background: var(--thead); }

.lb-compare-table tbody th {
  font-weight: 600;
  width: 11rem;
}

/* the "not modelled" cells are the reason the table exists */
.lb-compare-table em {
  color: var(--bad);
  font-style: normal;
  font-weight: 600;
}

/* ── ACMA RRL interference layer ─────────────────────────────────────────── */

/* square swatch — ACMA transmitters are squares so they never read as
   MegaNet's circular station markers.

   Its colour arrives as `style="--dot: var(--acma-mech-imd3)"` (#138), which is
   the token reaching the element rather than the element deciding — the same
   move `.ad-dot` and `.maps-chip-dot` already make. It was `style="background:
   #7b1fa2"` on 1,536 swatches across the two RF tabs, the transmitter card, the
   map legend and the ACMA filter panel, which is 1,536 inline declarations no
   check could see and no theme could reach.

   The fallback is what makes the conversion incremental rather than a flag day:
   an unconverted caller's own `background:` still wins, so the three left in
   `workbench.js` draw exactly what they always did until #139 gets to them. The
   border stays a literal rgba — it is a hairline drawn to keep a pale swatch
   off a white panel, not a colour with an opinion. */
.legend-sq {
  width: 10px;
  height: 10px;
  display: inline-block;
  border: 1px solid rgba(0, 0, 0, .35);
  background: var(--dot, var(--muted));
}

/* The divIcon is a transparent, fixed-size hit box (ACMA_PIN_HIT_PX) with the
   square centred in it: the mark is half a field-station pin, but the click
   target stays the size it was before it shrank. */
.acma-div {
  background: none;
  border: none;
  display: flex;
  align-items: center;
  justify-content: center;
}

/* white ring, matching the station pins, so transmitters read against the base
   map and against each other. 1px of it, not the station pins' 2 — at half a
   field pin the mark is ~5px across, and a 2px ring on each side would read as
   a white blob rather than a mechanism-coloured square. */
/* Size and colour arrive as tokens (#136): the square is sized by threat score
   and coloured by mechanism, and both are data. --dot carries a `var()` now
   rather than a resolved literal, which is what a real style attribute on a
   divIcon lets you do — so the squares follow the theme without a redraw. */
.acma-sq {
  width: var(--sq, 10px);
  height: var(--sq, 10px);
  background: var(--dot, var(--muted));
  border: 1px solid rgba(0, 0, 0, .55);
  box-shadow: 0 0 0 1px rgba(255, 255, 255, .95), 0 0 2px 1px rgba(0, 0, 0, .3);
  box-sizing: border-box;
  flex: none;                  /* the flex hit box must not stretch the square */
}

/* MegaNet's own licensed transmitters get the repeater-blue dashed ring */
.acma-sq.mn {
  outline: 2px dashed #0b5cab;
  outline-offset: 2px;         /* clear of the white ring */
}

/* The ACMA filter block's own rows (#136). The five hand-written flex labels
   here were the fourth copy of the .check-label primitive #139 added; this is
   the tab-local spacing that goes with it, and nothing else. */
.acma-check { margin: var(--sp-1) 0; }
/* …and NOT .acma-row: that name is already taken, by the transmitter card's
   key/value row two hundred lines up, which is display:flex with a nowrap
   first span. Reusing it here put the "showing 500 of 2287 transmitters"
   note in a flex row that would not wrap, and 444 px of it hung off a 375 px
   phone (#136). */
.acma-note  { margin: var(--sp-1) 0; }
.acma-field { display: block; margin: var(--sp-2) 0; }
.acma-group { margin: var(--sp-2) 0; }
.acma-range { width: 100%; }
.acma-summary { cursor: pointer; }
.acma-help { margin-top: var(--sp-1); }

/* ── Survey marks & CORS sites (#120) ────────────────────────────────────────
   A CSS-border triangle rather than a circle (station pins) or a square (ACMA
   transmitters) — the map already has two dot shapes with a colour meaning of
   their own, and a third needs a silhouette, not just a hue, to read as
   distinct at a glance. It also happens to be the conventional GIS symbol for
   a trig/survey point.
   Unlike MapRivers and the ACMA squares, the colour is `var(--map-survey)`
   directly in the rule below rather than resolved in JS at draw time: a
   divIcon is a real DOM node against this stylesheet, so a theme switch
   repaints it for free and MapSurvey needs no repaint() hook in toggleTheme(). */
.mn-survey-div { background: none; border: none; }

.mn-survey-pin {
  width: 0;
  height: 0;
  border-left: 5px solid transparent;
  border-right: 5px solid transparent;
  border-bottom: 9px solid var(--map-survey);
  /* Dark outline then a light halo, the same two-layer trick the station pins'
     white ring and the ACMA squares' box-shadow use to separate a small shape
     from whatever basemap is under it. */
  filter: drop-shadow(0 0 1px rgba(0, 0, 0, .6)) drop-shadow(0 0 1.5px rgba(255, 255, 255, .9));
}

/* CORS sites are the rarer, more significant mark — bigger, and a glow in its
   own colour rather than a second DOM node to keep the divIcon to one element. */
.mn-survey-cors {
  border-left-width: 7px;
  border-right-width: 7px;
  border-bottom-width: 13px;
  filter: drop-shadow(0 0 1px rgba(0, 0, 0, .6)) drop-shadow(0 0 2px rgba(255, 255, 255, .95))
          drop-shadow(0 0 3px var(--map-survey));
}

/* ── RF Environment tab (#138) ───────────────────────────────────────────────
   Six inline styles and a 24-div histogram, replaced. The tab is otherwise
   .page / .panel / .control-row / the table pattern, which is the whole point
   of there being a system: a chart-and-table tab should need almost no CSS of
   its own. */

.rf-loading {
  text-align: center;
  padding: var(--sp-6) var(--sp-4);
}

/* The strip plot's own minimum. Below it the ±0.6 MHz span stops being readable
   as an axis at all — the .chart-scroll around it is what makes the rest of it
   reachable, on a phone and from a keyboard alike (pattern 11). */
.rf-strip-svg {
  width: 100%;
  min-width: 640px;
  height: auto;
  display: block;
}

.rf-strip-table,
.rf-hours-table {
  margin-top: var(--sp-3);
}

.rf-strip-table > summary,
.rf-hours-table > summary {
  cursor: pointer;
  font-size: var(--fs-sm);
  color: var(--accent);
}

.rf-analyse {
  margin: var(--sp-2) 0;
}

/* The hour-of-day histogram. Twenty-four bars whose *heights are data*, so the
   height arrives as `--h` on the element and this rule is what reads it — the
   element is not deciding anything, the measurement is. */
.rf-hours {
  display: flex;
  gap: 1px;
  height: 44px;
  align-items: end;
  max-width: 480px;
}

.rf-hour {
  flex: 1;
  display: flex;
  flex-direction: column;
  justify-content: end;
}

.rf-hour-bar {
  height: var(--h, 0);
  background: var(--map-line);
  opacity: .75;
}

.rf-hours-axis {
  display: flex;
  justify-content: space-between;
  max-width: 480px;
  color: var(--muted);
}

/* ── RF Changes tab ──────────────────────────────────────────────────────── */

.rfc-picker {
  position: relative;
}

.rfc-picker > summary {
  cursor: pointer;
}

/* The 88-repeater picker. It is `position: absolute`, and an absolutely
   positioned box still counts toward its containing block's scrollWidth — so
   with the picker open on a phone the *document* scrolled sideways by 520 px
   while the panel behind it sat inside the screen (#138). `max-width` against
   the viewport is what stops that; `right: 0` is what keeps a list opened near
   the right edge from hanging off it. Neither is a breakpoint, so neither goes
   in the responsive block: the rule is "never wider than the window", at every
   width. */
.rfc-picker-list {
  position: absolute;
  left: 0;
  z-index: 1200;
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: var(--r-md);
  box-shadow: var(--shadow-2);
  padding: var(--sp-2) var(--sp-3);
  margin-top: var(--sp-1);
  max-height: 300px;
  overflow-y: auto;
  min-width: 260px;
  max-width: calc(100vw - var(--sp-6));
  font-size: var(--fs-sm);
}

.rfc-pick {
  display: flex;
  gap: var(--sp-2);
  align-items: center;
  margin: var(--sp-1) 0;
}

/* A checkbox in a list of 88 is the smallest target on either RF tab, and the
   one most likely to be tapped in a vehicle. The row carries the 44 px rather
   than the box, so the label's text is part of the target. */
@media (pointer: coarse) {
  .rfc-pick {
    min-height: 2.75rem;
  }
}

/* corruption-series swatch in the timeline legend. Its colour arrives as a
   `--dot` token like every other swatch in the app (#138). */
.rfc-series-line {
  width: 16px;
  height: 3px;
  display: inline-block;
  background: var(--dot, var(--muted));
}

.rfc-legend {
  display: flex;
  gap: var(--sp-2) var(--sp-4);
  flex-wrap: wrap;
  margin: var(--sp-1) 0 0;
  padding: 0;
  list-style: none;
}

/* The timeline's own minimum. Same reasoning as .rf-strip-svg, one tab over —
   below 720 px the two-year axis stops being an axis, and .chart-scroll around
   it is what makes the rest reachable (pattern 11). */
.rfc-chart-svg {
  width: 100%;
  min-width: 720px;
  height: auto;
  display: block;
}

/* A mark opens the transmitter card, and the card is also on every row of the
   table below — which is the condition pattern 8 rests on, and the reason no
   mark is a tab stop. */
.rfc-mark {
  cursor: pointer;
}

.rfc-intro {
  margin: var(--sp-1) 0 var(--sp-2);
}

.rfc-note {
  margin: var(--sp-1) 0;
}

.rfc-analyse,
.rfc-common {
  margin: var(--sp-2) 0;
}

.rfc-steps-lead {
  margin: var(--sp-2) 0 var(--sp-1);
}

.rfc-steps {
  margin-bottom: var(--sp-2);
}

.rfc-grouping {
  margin-top: var(--sp-2);
}

.rfc-diff-group {
  margin-top: var(--sp-3);
}

.rfc-diff-group > h4 {
  margin: 0 0 var(--sp-1);
}

.rfc-diff-blurb {
  margin: 0 0 var(--sp-2);
}

.rfc-change {
  margin: var(--sp-1) 0;
  padding-left: var(--sp-3);
}

.rfc-field,
.rfc-formula {
  white-space: nowrap;
}

.rfc-blind > summary {
  cursor: pointer;
}

.rfc-blind-body {
  margin-top: var(--sp-2);
}

/* on the (long, scrolling) RF Changes page the transmitter card floats over
   the viewport instead of stretching the full page height */
.rfc-page .acma-card {
  position: fixed;
  top: 84px;
  right: 14px;
  bottom: 14px;
}

/* transmitter detail card overlaying the map's right edge */
.acma-card {
  position: absolute;
  top: .9rem;
  right: .9rem;
  bottom: .9rem;
  width: 370px;
  max-width: 82%;
  overflow-y: auto;
  z-index: 1100;            /* above leaflet panes and controls */
  background: var(--panel);
  color: var(--text);
  border: 1px solid var(--border);
  border-radius: 8px;
  box-shadow: 0 6px 22px rgba(0, 0, 0, .28);
  font-size: .88rem;
}

.acma-card-head {
  position: sticky;
  top: 0;
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: .5rem;
  padding: .55rem .6rem;
  background: var(--panel);
  border-bottom: 1px solid var(--border);
  z-index: 1;
}

.acma-card-head button {
  padding: 0 .45rem;
  font-size: 1rem;
  line-height: 1.4;
}

/* The card is a non-modal dialog that appears over the page, so it takes focus
   when it opens and Escape closes it back to whatever opened it (#138). It is
   given `tabindex="-1"` in JS rather than here because the attribute only makes
   sense while it is showing.

   No `outline: none` here, deliberately, and the allowlist is why it was worth
   checking rather than assuming: a mouse click that opens the card does not
   match `:focus-visible`, so the ring only appears when a *keyboard* opened it —
   which is precisely when somebody needs to be shown where focus went. The
   shared ring is inherited; only its offset is overridden, because a ring drawn
   2 px outside a `position: fixed` panel is clipped by the panel's own edge. */
.acma-card:focus-visible {
  outline: var(--focus-width) solid var(--focus);
  outline-offset: calc(var(--focus-offset) * -1);
}

/* One rule for the card's repeated small rows — a mechanism line, a co-sited
   device — which had `style="margin:.15rem 0"` written out on each. */
.acma-mech-row {
  margin: var(--sp-1) 0;
}

.acma-card-note {
  color: var(--muted);
  padding: var(--sp-2) var(--sp-3);
  margin: 0;
}

.acma-note {
  margin: var(--sp-1) 0;
}

.acma-note > summary,
.acma-cosited > summary {
  cursor: pointer;
}

.acma-note-body {
  white-space: pre-wrap;
}

/* A heading inside a summary is legitimate — it is the disclosure's own name —
   but it must not break the line the twisty sits on. */
.acma-cosited > summary > h4 {
  display: inline;
}

.acma-sect {
  padding: .45rem .6rem;
  border-bottom: 1px solid var(--border);
}

.acma-sect h4 {
  margin: 0 0 .3rem;
  font-size: .8rem;
  text-transform: uppercase;
  letter-spacing: .04em;
  color: var(--muted);
}

.acma-row {
  display: flex;
  justify-content: space-between;
  gap: .75rem;
  padding: .12rem 0;
}

.acma-row > span:first-child {
  color: var(--muted);
  white-space: nowrap;
}

.acma-row > span:last-child {
  text-align: right;
  overflow-wrap: anywhere;
}

/* The Stations map's own card (#175) — .acma-card's dress, anchored to the
   BOTTOM-LEFT instead. Everything else the map keeps at its edges is on the
   right: the icon column, the flyouts that open out of it, the pinned panels
   that stack under it, and the attribution line — and on a laptop-height
   map the column reaches far enough down that a card in the bottom-right
   corner had its close button under the full-screen icon. The left has the
   zoom control at the top and nothing at the bottom but the move-pin panel,
   which this card gives way to (map-move-pin.js). It caps its own height and
   scrolls inside, rather than running the full height of the map the way the
   ACMA and path cards do: it is a companion to the map, not a replacement
   for it. */
.stn-card {
  top: auto;
  right: auto;
  bottom: 1.2rem;
  left: .9rem;
  width: 340px;
  /* It grows until it is just shy of the zoom buttons, which are the only
     thing above it in this corner. The clearance is measured rather than
     guessed: .map-panel's own --sp-2 padding (8 px) puts the map's top edge
     below the card's containing block, Leaflet's control margin adds 10 and
     its two zoom buttons ~62 with their borders — ~80 px, and 5.75rem (92 px)
     leaves a hair of map showing under them. The card was capped at
     min(60%, 24rem) before the actions grew groups, which on a 720 px map
     was 24rem of a possible ~38 and put half the pills behind a scroll. */
  max-height: calc(100% - 1.2rem - 5.75rem);
  /* Under the callouts (Leaflet's popup pane is 700) and under the map's
     controls (1000) — the opposite of .acma-card's 1100, on purpose. A
     callout the person just opened on a pin near this corner draws over the
     card rather than vanishing behind it, and a flyout that reaches this far
     stays readable. Above the tooltip pane (650), where the station-name
     labels are. */
  z-index: 690;
}

/* The action row is the card's last section — no rule under it. It carries no
   padding of its own either: its groups do, so the rule between two groups
   runs the full width of the card the way the section borders above it do,
   rather than floating inset with a gap at each end. */
.stn-card-actions {
  border-bottom: 0;
  padding: 0;
}

/* One group of actions — a wrapping pill row (.pill-row supplies the layout),
   with a hairline over every group but the first. The line is the whole
   grouping: four named groups, no headings drawn, because at 340 px wide the
   headings would cost more height than the pills they label. See
   stationActionGroups() in app.js for what goes in each. */
.stn-card-group {
  padding: .45rem .6rem;
}

.stn-card-group + .stn-card-group {
  border-top: 1px solid var(--border);
}

/* The ALERT id list sits under the label/value rows. */
.stn-card-ids {
  margin-top: var(--sp-1);
}

/* On a phone the card is a bottom sheet: the map's full width, capped so the
   part of the map the tapped pin is on stays visible above it. 560 is
   BREAKPOINTS.xs — the same width isPhoneNav() reads. */
@media (max-width: 560px) {
  .stn-card {
    left: .5rem;
    right: .5rem;
    bottom: .5rem;
    width: auto;
    max-width: none;
    max-height: 48%;
    /* A sheet across the whole width has a right edge, and the icon column
       sits on it: at this height the full-screen icon lands exactly on the
       sheet's close button. So on a phone the sheet goes over the controls
       instead, as the ACMA card does at every width — it opens only on a
       deliberate tap and closes with one, and the icons are back the moment
       it is gone. */
    z-index: 1100;
  }
  /* Every callout fits a phone (#175), not just the station one: Leaflet
     writes the content's width inline from each popup's own maxWidth, and
     the survey-mark and ACMA callouts ask for 300 px, which with Leaflet's
     margins and the finger-sized close button below is wider than the map.
     max-width outranks an inline width, and 7rem is the two margins plus
     the page's own edges. */
  .leaflet-container .leaflet-popup-content {
    max-width: calc(100vw - 7rem);
  }
}

/* Finger-sized close targets on the callouts and the map cards (#175). Both
   branches of the query on purpose: pointer:coarse is a tablet or a phone at
   any width, and the width branch is the phone whether or not the browser
   admits to a coarse pointer — which is what makes this hold on a 375 px
   window in a test as it does in a hand. Sizes the controls only, never the
   layout, the convention every pointer:coarse block in this file keeps.
   Leaflet's own rule for the close button is `.leaflet-container
   a.leaflet-popup-close-button` and leaflet.css loads after this file, so the
   same selector here would lose the cascade on the tie — hence the extra
   .leaflet-popup class, one step more specific (the dark-theme note below
   records the same trap). */
@media (max-width: 560px), (pointer: coarse) {
  .leaflet-container .leaflet-popup a.leaflet-popup-close-button {
    width: 2.75rem;
    height: 2.75rem;
    font-size: 1.4rem;
    line-height: 2.75rem;
    padding: 0;
  }
  .leaflet-container .leaflet-popup-content {
    margin-right: 2.9rem;
  }
  .acma-card-head button {
    min-width: 2.75rem;
    min-height: 2.75rem;
  }
}

/* Find a control, at the top of the 👁️ Map display flyout (#186). The panel is
   a dozen switches, three sliders, four selects, a radio group and the ACMA
   block under four headings, and it scrolls — so it is sticky rather than
   scrolling away with the rows it is filtering, which is the one thing a search
   box in a scroller must not do. */
.mapd-find {
  position: sticky;
  top: 0;
  z-index: 1;
  padding-bottom: .35rem;
  background: var(--panel);
}

.mapd-find-box {
  width: 100%;
  box-sizing: border-box;
  padding: .3rem .5rem;
  border: 1px solid var(--border);
  border-radius: var(--r-sm);
  background: var(--panel-sub);
  color: var(--text);
  font: inherit;
  font-size: .82rem;
}

.mapd-find-box::placeholder { color: var(--muted); }

/* The find filter hides a row by setting the `hidden` attribute on it, which is
   the right signal for a screen reader and — on its own — not enough to hide
   anything here. Half these rows are labels this stylesheet gives
   `display: flex`, and an author rule beats the browser's own
   `[hidden] { display: none }` at the same specificity: the headings vanished
   and every checkbox stayed exactly where it was. Scoped to the on-map panels
   rather than declared globally, because that is the whole of where it is
   needed and a global !important on `[hidden]` is a rule nobody can get out
   from under later. */
.mn-mapctl-content [hidden] { display: none !important; }

/* Group headings inside the 👁️ Map display flyout (#175). The flyout scrolls,
   and a column of checkboxes past the fold gives nobody a reason to scroll
   it; a heading says what kind of thing is under it. */
/* …and since #191 it draws a rule as well as saying it. Uppercase small caps in
   --muted was the whole of the separation, and it is not enough: an eye going
   down a single column of checkboxes reads a heading as one more row unless
   something physically stops it. The rule is the section break; the space on
   both sides of it is what stops the rule reading as an underline for whatever
   sits above. Above rather than below, because a heading belongs to the block
   that follows it — a line under the words would fence the heading in with its
   own section instead of fencing off the previous one. */
.map-display-h {
  font-size: .72rem;
  font-weight: 600;
  letter-spacing: .04em;
  text-transform: uppercase;
  color: var(--muted);
  margin: var(--sp-4) 0 var(--sp-2);
  padding-top: var(--sp-3);
  border-top: 1px solid var(--border);
}

/* The first heading is no longer the block's first child — the find row is —
   so this reaches whichever heading is drawn first, hidden rows and all. There
   is nothing above it to divide from, so it takes neither rule nor space. */
.map-display-h:first-child,
.mapd-find + .filter-note + .map-display-h,
.map-display-h.mapd-h--first {
  margin-top: 0;
  padding-top: 0;
  border-top: 0;
}

/* The ACMA block is the panel's fifth section and arrives as a sibling
   .filter-block rather than as one of these headings, so the seam above it has
   to be drawn on its own top edge — otherwise the one break the panel most
   needs is the only one it does not have. */
#map-display-block + .filter-block {
  margin-top: var(--sp-4);
  padding-top: var(--sp-3);
  border-top: 1px solid var(--border);
}

/* The fade-margin thresholds and the Save button. Two short number boxes side
   by side and the button under them at full width: the boxes are a pair and
   read as one rule, and the button is the consequence of the rule rather than
   a third field. Inside a flyout that is already narrow, so the boxes are
   given a floor rather than a share of the row — a threshold that wraps to two
   digits per line is unreadable. */
.map-fade-bands {
  display: flex;
  flex-wrap: wrap;
  gap: .35rem .6rem;
  margin: .1rem 0 .35rem;
}

.map-fade-bands .draw-field {
  flex: 1 1 7.5rem;
}

.map-fade-bands .draw-field input {
  width: 100%;
}

.map-fade-bands button {
  flex: 1 1 100%;
  padding: .35rem .6rem;
}

.leaflet-container {
  background: var(--subtle);
}

/* Leaflet's own stylesheet is linked *after* this one in index.html, and it
   sets `background: white; color: #333` on both of these at the same
   specificity — so the two rules below have been losing the cascade since the
   day they were written, and every popup in the app has been white in the dark
   theme. `.leaflet-container` in front of each is the whole fix: same colours,
   one class more specific, no `!important`. Found at #170, when the callout's
   new pills — which do take the tokens — turned up dark on a white ground. */
.leaflet-container .leaflet-popup-content-wrapper,
.leaflet-container .leaflet-popup-tip {
  background: var(--panel);
  color: var(--text);
}

/* And the close button with them, which takes Leaflet's #757575 on white. */
.leaflet-container .leaflet-popup-close-button {
  color: var(--muted);
}

/* Footer action row on a station popup ("Show in the list below"), ruled off
   from the station detail above it. */
/* One action per line. Both are nowrap, and side by side they would stretch the
   popup past Leaflet's 300 px to fit "Show in the list below ↓  Open in ARRO
   admin ↗" on one row. */
/* Station popup (#136). The lines inside a popup were eight repetitions of an
   inline `font-size: .83rem`; the role pills were a literal background with
   white ink written per role.

   The pill keeps ROLE_COLOR's literal, through --pill, for the reason the Bit
   Flipper's do: a pill is its own ground, white ink over all four clears AA
   (5.03:1 at worst, the base-station orange), and var(--role-*) would put that
   same white text on the dark theme's *light* hues. Everything drawn on the
   page's own ground takes roleVar() instead. */
.mn-pop-line {
  font-size: .83rem;
}

.mn-pop-indent {
  padding-left: 1.1em;
}

/* The qualifier on a derived line — "indicative" after a wind region. Muted and
   a step down, because it is a caveat on the value beside it rather than part
   of the value: the eye should land on B2, then on the word telling it how far
   to trust B2. */
.mn-pop-note {
  color: var(--muted);
  font-size: .74rem;
}

.mn-pop-pill {
  background: var(--pill, var(--muted));
  /* White by default — every role hue clears AA with it (5.03:1 at worst).
     The ACMA mechanism pills pass --pill-ink instead, because a categorical
     palette has no single legible ink; see --acma-mech-*-ink (#136). */
  color: var(--pill-ink, #fff);
  padding: 1px 5px;
  border-radius: 999px;
  font-size: .78rem;
  margin-right: 2px;
}

/* A note under a card's heading, and the same standing on its own in an empty
   card. `.small` already carries the muted colour. */
.st-note { margin: var(--sp-2) 0 0; }
.st-note-pad { padding: var(--sp-2) 0; margin: 0; }

/* The load-a-file page, and the field that needs a gap above it. */
.empty-page { margin: var(--sp-6) auto; }
.empty-card { text-align: center; padding: var(--sp-6) var(--sp-5); }
.empty-card h2 { margin: 0 0 var(--sp-3); }
.empty-actions { margin-top: var(--sp-5); }
.filter-field--spaced { margin-top: var(--sp-2); }

/* A wrapping row of pills since #170, not a column of links — the layout comes
   from .pill-row, which this carries alongside; what is left here is the rule
   that separates the actions from the station's own details above them. */
.mn-popup-actions {
  margin-top: .45rem;
  padding-top: .4rem;
  border-top: 1px solid var(--border);
}

/* The "Actions (N) ▾" expander under the callout's detail (#175). Its own row
   so the pill sits on a line of its own under the identity, with the same
   gap the actions row keeps — when the row is open it follows directly. */
.mn-popup-more {
  margin-top: .45rem;
}

.leaflet-control-attribution,
.leaflet-control-scale-line {
  font-size: .72rem;
}

/* The direction-arrow canvas (map-arrows.js) — one canvas over the whole view,
   in a pane of its own just above the one the links are drawn on. Positioned
   rather than laid out: MapArrows moves it with L.DomUtil.setPosition every
   time the view settles, exactly as Leaflet moves its own renderer canvas, and
   it never takes a pointer — an arrow is not a click target, the line under it
   is. */
.mn-arrow-canvas {
  position: absolute;
  left: 0;
  top: 0;
  pointer-events: none;
}

/* ── The credit line, under the map instead of on it ────────────────────────
   mapAttributionBelow() (map-controls.js) lifts Leaflet's attribution control
   out of the map's bottom-right corner and drops its container into this strip,
   immediately below the map. The reasoning is there; what is here is the
   undoing of Leaflet's own styling for it, because everything that made it a
   floating box over the map is wrong for a line of credits under one: it is not
   positioned, it does not float, it carries no white plate of its own, and it
   takes the page's muted text colour so it reads the same in both themes.

   Every map in the app gets this — the strip is created by addBaseLayers, which
   all seven call — so nothing else that wants the foot of a map has to fight it
   for the space. */
.mn-map-attrib {
  padding: .25rem .15rem 0;
  line-height: 1.35;
}

.mn-map-attrib .leaflet-control-attribution {
  position: static;
  float: none;
  clear: none;
  margin: 0;
  padding: 0;
  background: none;
  box-shadow: none;
  color: var(--muted);
}

.mn-map-attrib .leaflet-control-attribution a {
  color: var(--muted);
  text-decoration: underline;
}

/* Folded 1200 → 1400 by #109's breakpoint pass, and *up* rather than down,
   which is the direction that removes the marginal case instead of moving it.
   .crud-layout's two columns have hard minimums — minmax(320px,1fr) and
   minmax(360px,1fr) with a 1rem gap, so 696 px before the page padding. With
   the nav (236) and the help panel (260) both open, a 1200 px window leaves
   704 px: it fitted by eight pixels. At 1100 — the next step down — it would
   not have fitted at all. */
@media (max-width: 1400px) {
  .crud-layout {
    grid-template-columns: 1fr;
  }
}

@media (max-width: 1100px) {
  .layout {
    grid-template-columns: 1fr;
  }

  .sidebar {
    position: static;
  }
}

/* Folded 720 → 700 by #109's breakpoint pass — the only one of the three folds
   with nothing behind it. Two values twenty pixels apart, in a file where five
   other blocks already said 700, were never two decisions. */
@media (max-width: 700px) {
  /* minmax(0, 1fr), not 1fr — the base rule two hundred lines up is careful to
     write it that way and this override had dropped it (#136). `1fr` alone is
     `minmax(auto, 1fr)`, which lets the widest item's min-content set the
     track: the station editor's notes textarea took the one-column form to
     426 px on a 375 px screen, and the page scrolled sideways. */
  .form-grid {
    grid-template-columns: minmax(0, 1fr);
  }

  .map-canvas {
    height: 340px;
  }
}

/* ── Side navigation ────────────────────────────────────────────────────────
   Eleven tabs in one flat row said nothing about how they relate, and wrapped
   to a second line on a narrow window. They are a left rail now, grouped under
   headings, and collapsible to icons alone (--mn-nav / 'mn-nav' in
   localStorage). The rail is a column of the page rather than a band above it,
   so it costs the Stations panes no height — --mn-chrome counts the header
   only. Collapsing changes the width of every map on the page; app.js calls
   invalidateSize() once the transition below has finished. */

#app-shell {
  display: flex;
  align-items: stretch;
}

#tab-nav {
  flex: 0 0 auto;
  width: var(--mn-nav, 214px);
  background: var(--panel);
  border-right: 1px solid var(--border);
}

/* Added by app.js once the first render has settled — the opening paint must
   land at its final width, or the map built alongside it caches a container
   size measured mid-slide. */
#tab-nav.nav-ready {
  transition: width .16s ease;
}

#tab-nav.collapsed {
  width: var(--mn-nav-rail, 56px);
}

/* The nav itself stretches so its border runs the full height of the page;
   only the contents stick, and only as far as one screen.

   "One screen" is the screen *below the banner*, not 100dvh. The header is part
   of the document and scrolls away with it, so at the top of the page a 100dvh
   rail begins at the banner's foot and therefore ends --mn-chrome px past the
   fold. The rail's own scrollbar cannot reach into that overhang — it moves the
   content within the box, and the last of the box is off screen — so the bottom
   two entries of a twenty-two entry nav were unreachable until the whole window
   had been scrolled down, at every laptop height measured (and 96 px worth at
   1024 px wide, where the banner wraps to two rows). Taking the measured chrome
   off the height is what makes the rail fit the part of the screen it can
   actually be seen in; `top: 0` then holds it there once the banner has gone. */
.nav-inner {
  position: sticky;
  top: 0;
  max-height: calc(100dvh - var(--mn-chrome, 96px));
  overflow-y: auto;
  overscroll-behavior: contain;
  padding: .5rem .45rem 1rem;
  display: flex;
  flex-direction: column;
  gap: .1rem;
}

.nav-group + .nav-group {
  margin-top: .55rem;
  padding-top: .55rem;
  border-top: 1px solid var(--border);
}

/* ── Find a tab (#108) ──────────────────────────────────────────────────────
   Nineteen tabs is past the size where a column of labels is something you
   scan. Expanded, this is a filter box over the list below it; collapsed, there
   is no room for a box, so it is the one rail button that opens the nav with
   the cursor already in it (app.js: focusNavFind). Ctrl+K does the same from
   anywhere. */

.nav-find {
  margin-bottom: .4rem;
}

.nav-search {
  width: 100%;
  padding: .34rem .5rem;
  /* A form field, so --ctl-border rather than --border: it is the edge of a
     control and has to clear 3:1 against what is behind it, which --border
     (a divider between two surfaces) does not (#109). */
  border: 1px solid var(--ctl-border);
  border-radius: var(--r-md);
  background: var(--bg);
  color: var(--text);
  font: inherit;
  font-size: var(--fs-sm);
}

/* The rail's stand-in for the box. Shaped as a .tab-btn rather than styled from
   scratch so the rail stays one column of identical targets. */
.nav-find-btn {
  display: none;
  align-items: center;
  gap: .55rem;
  width: 100%;
  padding: .42rem .45rem;
  border: 1px solid transparent;
  border-radius: 8px;
  background: transparent;
  color: var(--text);
  font-size: .92rem;
  text-align: left;
  white-space: nowrap;
  overflow: hidden;
}

.nav-find-btn:hover {
  background: var(--subtle);
  border-color: var(--border);
}

#tab-nav.collapsed .nav-search { display: none; }
#tab-nav.collapsed .nav-find-btn { display: flex; justify-content: center; gap: 0; }

/* How many of the nineteen survived the query, for whoever cannot see that the
   list got shorter. Kept in the DOM at all times — a live region added to the
   page at the moment it has something to say is a live region that says it to
   nobody — and collapsed to nothing while there is no query. */
.nav-found {
  margin: .45rem .45rem 0;
  font-size: .72rem;
  color: var(--muted);
}

.nav-found:empty { display: none; }
#tab-nav.collapsed .nav-found { display: none; }

.nav-none {
  margin: .3rem .45rem;
  font-size: .82rem;
  color: var(--muted);
}

/* The match Enter will open. Marked rather than moved: sorting the winners to
   the top would reshuffle the list under the cursor on every keystroke, and the
   groups are the thing being learnt. Never drawn as the active tab is — this is
   "press Enter for this one", not "you are here". */
#tab-nav .tab-btn.nav-best {
  background: var(--subtle);
  border-color: var(--border);
}

.nav-enter {
  margin-left: auto;
  padding-left: .4rem;
  font-size: .8rem;
  color: var(--muted);
}

#tab-nav .tab-btn.active .nav-enter { color: inherit; }

.nav-heading {
  margin: 0 0 .25rem;
  padding: 0 .45rem;
  font-size: .68rem;
  font-weight: 700;
  letter-spacing: .06em;
  text-transform: uppercase;
  color: var(--muted);
  white-space: nowrap;
  overflow: hidden;
}

.nav-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: .1rem;
}

#tab-nav .tab-btn,
#tab-nav .nav-toggle {
  display: flex;
  align-items: center;
  gap: .55rem;
  width: 100%;
  padding: .42rem .45rem;
  border: 1px solid transparent;
  border-radius: 8px;
  background: transparent;
  color: var(--text);
  font-size: .92rem;
  text-align: left;
  white-space: nowrap;
  overflow: hidden;
}

#tab-nav .tab-btn:hover,
#tab-nav .nav-toggle:hover {
  background: var(--subtle);
  border-color: var(--border);
}

#tab-nav .nav-toggle {
  margin-bottom: .35rem;
  color: var(--muted);
  font-size: .85rem;
}

/* The icon holds the rail's width on its own, so a label appearing or
   disappearing never shifts it sideways. */
.nav-icon {
  flex: 0 0 1.4rem;
  width: 1.4rem;
  text-align: center;
  font-size: 1.05rem;
  line-height: 1.35;
}

.nav-label {
  overflow: hidden;
  text-overflow: ellipsis;
}

/* Collapsed: labels and headings are clipped rather than removed, so every
   button keeps its accessible name and the title attribute does the rest. */
#tab-nav.collapsed .nav-label,
#tab-nav.collapsed .nav-heading,
#help-panel.collapsed .nav-label {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

#tab-nav.collapsed .nav-inner {
  padding-left: .3rem;
  padding-right: .3rem;
}

#tab-nav.collapsed .tab-btn,
#tab-nav.collapsed .nav-toggle {
  justify-content: center;
  gap: 0;
  padding-left: .2rem;
  padding-right: .2rem;
}

/* The active tab is the one piece of the nav that has to survive a glance.
   After the :hover rule deliberately — the two are the same specificity, and
   an active tab that pales on hover reads as though it had been left. */
#tab-nav .tab-btn.active {
  background: #0b5cab;
  color: #fff;
  border-color: #0b5cab;
}

[data-theme="dark"] #tab-nav .tab-btn.active {
  background: #2a6fb0;
  border-color: #2a6fb0;
}

/* A phone has no width to give away: 236 px of nav plus a filter pane that
   won't go below ~225 px does not fit in 390 px, whatever the two are set to.
   So past this breakpoint the nav stops being a column of the page at all —
   the rail comes out of the layout, giving the tables and maps back their 56 px,
   and the tab list opens as a drawer over the content from the header's ☰. The
   main area never changes width, so no map ever needs re-measuring; switchTab()
   and the backdrop both close the drawer again. */
@media (max-width: 560px) {
  #tab-nav,
  #tab-nav.collapsed {
    width: 0;
    border-right: 0;
    overflow: visible;          /* the drawer is a fixed child of a 0-wide box */
  }

  #tab-nav.collapsed .nav-inner {
    display: none;              /* nothing left to show once the rail is gone */
  }

  #tab-nav:not(.collapsed) .nav-inner {
    position: fixed;
    inset: 0 auto 0 0;
    width: min(var(--mn-nav, 236px), 78vw);
    max-height: none;
    background: var(--panel);
    border-right: 1px solid var(--border);
    box-shadow: 0 0 24px rgba(0, 0, 0, .3);
    z-index: 1200;
  }

  .hdr-nav {
    display: inline-flex;
    align-items: center;
    justify-content: center;
  }
}

/* ── Help panel ─────────────────────────────────────────────────────────────
   The right-hand counterpart to the nav rail — see issue #104 and README §20.
   It borrows the nav's interaction contract on purpose (collapse to a strip and
   never to nothing; --mn-help / 'mn-help' in localStorage; re-measure the maps
   once the width transition has finished, not during it), and differs only
   where a reference surface should:

   * Collapsed is the default at every width, not just under 900 px.
   * Under 560 px it becomes a drawer, as the nav does — but app.js closes one
     when the other opens, so a phone never carries two.

   The widths in between need no special case: the Stations tab already drops to
   a single column at 1100 px, so nav + filter pane + help only ever hold width
   together above that, where there is width to hold. --mn-help narrows once on
   the way down all the same, so the map keeps a usable share of a 1200 px
   laptop with everything open. */

#help-panel {
  flex: 0 0 auto;
  width: var(--mn-help-rail, 44px);
  background: var(--panel);
  border-left: 1px solid var(--border);
}

/* Expanded is the exception, because collapsed is the default the markup ships
   with — the pre-JS paint then matches what app.js will almost always settle
   on, and a stored "expanded" is applied before .help-ready allows the slide. */
#help-panel:not(.collapsed) {
  width: var(--mn-help, 300px);
}

#help-panel.help-ready {
  transition: width .16s ease;
}

/* Stretches so the border runs the page's full height; only the contents stick,
   and only as far as one screen — the screen below the banner, for the reason
   written out over .nav-inner. As .nav-inner. */
.help-inner {
  position: sticky;
  top: 0;
  max-height: calc(100dvh - var(--mn-chrome, 96px));
  overflow-y: auto;
  overscroll-behavior: contain;
  padding: .5rem .4rem 1rem;
  display: flex;
  flex-direction: column;
  gap: .1rem;
}

#help-panel .help-toggle {
  display: flex;
  align-items: center;
  gap: .55rem;
  width: 100%;
  margin-bottom: .35rem;
  padding: .42rem .45rem;
  border: 1px solid transparent;
  border-radius: 8px;
  background: transparent;
  color: var(--muted);
  font-size: .85rem;
  text-align: left;
  white-space: nowrap;
  overflow: hidden;
}

#help-panel .help-toggle:hover {
  background: var(--subtle);
  border-color: var(--border);
}

#help-panel.collapsed .help-toggle {
  justify-content: center;
  gap: 0;
  padding-left: .2rem;
  padding-right: .2rem;
}

/* Unlike the nav's labels, this is removed rather than clipped when collapsed.
   A clipped label keeps a button's accessible name, which is why the nav does
   it; several hundred words of prose left in the accessibility tree behind a
   closed panel is not the same trade. */
#help-panel.collapsed .help-body {
  display: none;
}

.help-body {
  padding: 0 .35rem;
  font-size: .86rem;
  line-height: 1.5;
}

.help-tab {
  display: flex;
  align-items: center;
  gap: .4rem;
  margin: .1rem 0 .55rem;
  font-size: .98rem;
  line-height: 1.25;
}

.help-summary {
  margin: 0;
  color: var(--text);
}

.help-empty {
  color: var(--muted);
}

.help-h {
  margin: .95rem 0 .35rem;
  font-size: .68rem;
  font-weight: 700;
  letter-spacing: .06em;
  text-transform: uppercase;
  color: var(--muted);
}

.help-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: .35rem;
}

/* The prose lists read as prose and take a bullet; the "See also" list is a
   column of buttons and would look punctured by one. */
.help-list.help-bullets {
  list-style: disc;
  padding-left: 1.05rem;
  gap: .45rem;
}

/* The walkthrough, on the two tabs that have one (#105). The drawing scales to
   whatever the rail is — 300 px, or 260 px below 1400 px, or the full width of
   a phone drawer — so it is authored with a viewBox and no width, and the rule
   below is what actually sizes it. A fixed width here would either overflow the
   narrow case or waste the wide one. */
.help-fig {
  margin: .1rem 0 0;
}

.help-fig svg {
  display: block;
  width: 100%;
  height: auto;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--panel-sub);
}

/* Numbers, because the steps are an order — "click the centre, then the edge"
   is the whole content of that instruction. */
.help-steps {
  margin: .55rem 0 0;
  padding-left: 1.25rem;
  display: flex;
  flex-direction: column;
  gap: .3rem;
}

.help-fig figcaption {
  margin-top: .5rem;
  color: var(--muted);
  font-size: .81rem;
  line-height: 1.45;
}

.help-body a {
  color: var(--accent);
}

/* A "Read more" entry that opens something already in the app rather than
   leaving it. It is a button because it does something, and it looks like the
   links beside it because to the reader it is the same offer. */
.help-inline {
  padding: 0;
  border: 0;
  background: none;
  color: var(--accent);
  font: inherit;
  text-align: left;
  text-decoration: underline;
  cursor: pointer;
}

.help-link {
  display: flex;
  align-items: center;
  gap: .45rem;
  width: 100%;
  padding: .32rem .4rem;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: transparent;
  color: var(--text);
  font-size: .84rem;
  text-align: left;
  cursor: pointer;
}

.help-link:hover {
  background: var(--subtle);
}

/* Everything open on a 1200 px laptop is 236 px of nav plus a 320 px filter pane
   plus this; 300 px of help would leave the Stations map under a third of the
   window. The panel gives up the width rather than the map. */
@media (max-width: 1400px) {
  :root { --mn-help: 260px; }
}

/* Past here the panel gives up its column, for the reason the nav gives up its
   rail: 44 px of it is a ninth of a 390 px screen and the tables and maps need
   it more. Expanded it becomes a drawer from the right, exactly as the nav's
   becomes one from the left, and app.js closes whichever was open when the
   other opens.

   Collapsed is where the two part company. The nav could move its toggle into
   the header because ☰ already had the slot on the left; there is no slot on
   the right, and measuring a seventh header button at 390 px put the banner on
   another row — 30 px of chrome, permanently, to save a control that only
   matters when you go looking for it. So the strip stays visible and stops
   taking layout width instead: a fixed tab on the screen edge. Either way the
   main area never changes width, so no map needs re-measuring here. */
@media (max-width: 560px) {
  #help-panel,
  #help-panel.collapsed,
  #help-panel:not(.collapsed) {
    width: 0;
    border-left: 0;
    overflow: visible;          /* both forms are fixed children of a 0-wide box */
  }

  /* The edge tab. Vertically centred to stay clear of Leaflet's zoom and layer
     controls, which sit in the map's top corners, and below the backdrop's
     1150 so the nav drawer dims it along with everything else. */
  #help-panel.collapsed .help-inner {
    position: fixed;
    inset: 50% 0 auto auto;
    transform: translateY(-50%);
    max-height: none;
    padding: 0;
    border: 1px solid var(--border);
    border-right: 0;
    border-radius: 10px 0 0 10px;
    background: var(--panel);
    box-shadow: 0 2px 10px rgba(0, 0, 0, .18);
    z-index: 1100;
  }

  #help-panel.collapsed .help-toggle {
    width: 2rem;
    height: 2.9rem;
    margin: 0;
    font-size: 1rem;
  }

  #help-panel:not(.collapsed) .help-inner {
    position: fixed;
    inset: 0 0 0 auto;
    width: min(var(--mn-help, 300px), 86vw);
    max-height: none;
    padding: .6rem .7rem 1.5rem;
    background: var(--panel);
    border-left: 1px solid var(--border);
    box-shadow: 0 0 24px rgba(0, 0, 0, .3);
    z-index: 1200;
  }
}

/* Main content area */
#main-content {
  flex: 1 1 auto;
  min-width: 0;
  min-height: calc(100dvh - var(--mn-chrome, 96px));
}

/* …except on the Stations tab, whose two panes already size themselves to the
   screen — the fill-the-screen minimum would only put a second scrollbar on
   the page behind them. */
#main-content:has(> .map-layout) {
  min-height: 0;
}

/* Colour tokens used in analysis/editor */
.warn-text  { color: #c7401a; }
.ok-text    { color: #107c10; }

/* The Stations map. Sized here rather than inline on the element, so that the
   phone breakpoint can have an opinion about it — 62vh of map plus a 360 px
   floor left a 664 px screen with barely a band of page under it. */
/* The panel the map sits in (#136). `position: relative` is what the map note,
   the transmitter card and the path card are positioned against; the tighter
   padding is the map's own, because a map does not want a panel's gutter
   around it. Was an inline style on the div. */
.map-panel {
  padding: var(--sp-2);
  position: relative;
}

#leaflet-map {
  height: min(62vh, 720px);
  min-height: 360px;
  border-radius: 6px;
}

/* The map's own rectangle, and the positioning anchor for everything drawn over
   it — the note strip, the ACMA, path and station cards. It exists because the
   credit line moved off the map and into the panel under it (#186): a card
   anchored to the bottom of .map-panel would be anchored to the bottom of the
   credits, which on a phone put the station sheet across them and past half the
   map's height. The stage is the map; the credit line is the panel's next
   child, outside it. */
.mn-map-stage {
  position: relative;
}

/* Full screen: the whole working surface — map, match note, ACMA and path
   cards, the corner controls — is positioned inside .map-panel, so fixing the
   panel to the viewport takes everything along and the Leaflet map is never
   moved or rebuilt (toggleMapFullscreen, app.js). 1900 sits above the header
   (1300), the drawers (1200) and the mem modal (1500) but below #app-modal
   (2000), so the bug reporter still opens on top of a full-screen map. The
   #leaflet-map selector carries the id so it outweighs the phone height
   override, which is also id-only. */
.map-panel.is-full {
  position: fixed;
  inset: 0;
  z-index: 1900;
  margin: 0;
  border-radius: 0;
  /* A column, since the credit line moved off the map and under it: the map
     takes whatever is left after the strip has had the one or two lines it
     needs, instead of being 100% of a panel that now holds two things. Every
     other child of this panel is absolutely positioned, so the column has
     exactly the two items in it. */
  display: flex;
  flex-direction: column;
}

.map-panel.is-full > .mn-map-stage {
  flex: 1 1 auto;
  min-height: 0;
}

.map-panel.is-full #leaflet-map {
  height: 100%;
  min-height: 0;
  border-radius: 0;
}

/* ── Stations tab: side by side (#186) ─────────────────────────────────────
   The map in one column, everything normally under it in the other, and a
   divider that drags. Off by default; app.js toggles `is-split` on the same
   #stations-main the single column uses, so nothing in the DOM moves and the
   Leaflet map is never rebuilt — the reasoning is at toggleStationsSplit().

   With the split off, the three wrappers have to be invisible: the two panels
   are the grid items the stack has always laid out, so .stn-split-map has to
   disappear into it (`display: contents`) and the divider has to not exist. The
   right-hand wrapper is a grid of its own either way — same 1rem gap as the
   stack it sits in, so the cards below the map are spaced identically whichever
   way the tab is facing.

   This is not #165's filter rail coming back. What sat beside the map then was
   the map's *settings*; what sits beside it now is the map's *answer*. */
.stn-split-map { display: contents; }

.stn-split-rest {
  display: grid;
  gap: 1rem;
  align-content: start;
  min-width: 0;
}

.stn-split-bar { display: none; }

.stn-split.is-split {
  grid-template-columns: minmax(0, var(--mn-split, 58%)) 10px minmax(0, 1fr);
  align-items: stretch;
  gap: 0;
  /* Both columns scroll inside the viewport rather than the page scrolling
     past them — which is the whole request: the map stays in view while the
     list beside it is read.

     --mn-split-h is measured from where this element actually starts
     (syncStationsSplitHeight, app.js) and is the figure that makes that true.
     The calc() behind it is the fallback for the frame before the first
     measurement, and it is deliberately the *approximate* one it always was:
     --mn-chrome covers the banner and the memory strip but not #main-content's
     own padding or the gap above the panel, so it left the page scrolling
     behind two columns that were each already scrolling. Three scroll regions
     where there should be one. */
  height: var(--mn-split-h, calc(100dvh - var(--mn-chrome, 0px) - 2rem));
  min-height: 360px;
}

.stn-split.is-split > .stn-split-map {
  display: flex;
  min-width: 0;
  overflow: hidden;      /* the map fills this column; it never scrolls */
}

/* The map panel becomes the column, and the map inside it takes whatever is
   left after the credit line under it. Same shape as .map-panel.is-full, for
   the same reason — a panel holding two things in a column instead of one
   thing with a fixed height. */
.stn-split.is-split > .stn-split-map > .map-panel {
  flex: 1 1 auto;
  display: flex;
  flex-direction: column;
  min-width: 0;
  margin: 0;
}

.stn-split.is-split > .stn-split-map > .map-panel > .mn-map-stage {
  flex: 1 1 auto;
  min-height: 0;
}

.stn-split.is-split > .stn-split-map > .map-panel #leaflet-map {
  height: 100%;
  min-height: 240px;
}

.stn-split.is-split > .stn-split-rest {
  overflow-y: auto;
  padding: 0 var(--sp-1) var(--sp-2);
}

/* The divider itself: a full-height hit target with a hairline down the middle,
   which is what makes an 10 px grab area look like a 1 px rule. */
.stn-split.is-split > .stn-split-bar {
  display: block;
  position: relative;
  cursor: col-resize;
  border: 0;
  background: transparent;
  touch-action: none;    /* the pointer drag is ours, not the page's scroll */
}

.stn-split.is-split > .stn-split-bar::before {
  content: "";
  position: absolute;
  inset: var(--sp-2) 4px;
  border-radius: 999px;
  background: var(--border);
}

.stn-split.is-split > .stn-split-bar:hover::before,
.stn-split.is-split > .stn-split-bar:focus-visible::before {
  background: var(--accent);
}

/* While the divider is being dragged the pointer is over the map, the table or
   anything else the bar has been pulled across, and none of them should be
   selecting text or fighting for the cursor. */
body.mn-split-dragging {
  cursor: col-resize;
  user-select: none;
}

/* The split folds back to one column at `lg` (#186). Two columns of about
   400 px are two things too narrow to read rather than two things in view,
   which is the only thing the split is for. The *setting* is untouched — this
   is a fold, not a switch-off, so a laptop docked to a wide screen finds its
   split again where it left it.

   These two blocks are here, immediately under the rules they undo, rather than
   with the other `lg` and `sm` rules eight hundred lines up: every selector is
   the same one at the same specificity, so **source order is the whole of what
   decides**, and up there the base rules came later and won. The first version
   of this was written up there and folded nothing — 381 px of map in a 688 px
   column at 1000 px wide, which is exactly the state the fold exists to
   prevent.

   Every declaration the split makes is undone rather than left to interact with
   the single column, the map's own height included: with the wrappers back to
   `display: contents` the panel is a plain block again, and a map left on
   `flex: 1 1 auto` inside one collapses to its min-height. */
@media (max-width: 1100px) {
  .stn-split.is-split {
    grid-template-columns: minmax(0, 1fr);
    gap: 1rem;
    height: auto;
    min-height: 0;
  }

  .stn-split.is-split > .stn-split-map {
    display: contents;
    overflow: visible;
  }

  .stn-split.is-split > .stn-split-bar { display: none; }

  .stn-split.is-split > .stn-split-rest {
    overflow-y: visible;
    padding: 0;
  }

  .stn-split.is-split > .stn-split-map > .map-panel {
    display: block;
  }

  .stn-split.is-split > .stn-split-map > .map-panel > .mn-map-stage {
    flex: 0 1 auto;
  }

  .stn-split.is-split > .stn-split-map > .map-panel #leaflet-map {
    height: min(62vh, 720px);
    min-height: 360px;
  }
}

/* And the phone's own map height, restated at the specificity the rule above
   uses — `#leaflet-map` alone is outranked by it, so without this a 390 px
   screen with the split remembered as on would take the laptop figure. */
@media (max-width: 700px) {
  .stn-split.is-split > .stn-split-map > .map-panel #leaflet-map {
    height: 52dvh;
    min-height: 260px;
  }
}

/* Code elements */
code {
  font-family: monospace;
  font-size: .88em;
  background: var(--subtle);
  border: 1px solid var(--border);
  border-radius: 4px;
  padding: .05rem .3rem;
}

/* ── ALERT Packets tab ──────────────────────────────────────────────────────── */
.pkt label { display: block; font-size: 13px; color: var(--muted); margin: 0 0 4px; }
.pkt .sub { color: var(--muted); font-size: .9rem; margin: .1rem 0 .9rem; }
.pkt .err { color: var(--bad); font-size: .88rem; margin-top: 10px; white-space: pre-wrap; }
.pkt .framing-note { margin-top: 12px; font-size: .88rem; }
.pkt .spec { font-size: .8rem; color: var(--muted); margin-top: 8px; }

.pkt button.primary { border-color: #107c10; }
.pkt button.ghost {
  background: transparent; color: var(--accent); border: 1px solid var(--accent);
  font-weight: 500; padding: .4rem .7rem; font-size: .8rem; border-radius: 8px; cursor: pointer;
}
.pkt button.ghost:hover { filter: brightness(1.1); }

/* layout rows */
.pkt .row { display: flex; gap: 14px; flex-wrap: wrap; align-items: flex-end; }
.pkt .row > div { flex: 1; min-width: 130px; }
.pkt .row > .grow2 { flex: 3; min-width: 280px; }
.pkt .row > .fit { flex: 0 0 auto; min-width: 0; }

/* bit visualisation */
/* The four words read as one message, so they stay on a single row: the tab is
   wide enough for all four at full size, and anything narrower scrolls sideways
   rather than folding word 4 onto a second line. */
.pkt .bitwords { display: flex; flex-wrap: nowrap; gap: 14px; margin-top: 8px; overflow-x: auto; padding-bottom: 4px; }
.pkt .bitword { display: flex; flex-direction: column; gap: 4px; flex: 0 0 auto; }
/* Grid items are min-width:auto by default, which would let that unwrapped bit
   row push the whole page wider than the viewport. Let the cards shrink so the
   bits scroll inside their own card instead. */
.pkt > .panel { min-width: 0; }
.pkt .bitword .wlabel { font-size: 11px; color: var(--muted); text-align: center; letter-spacing: .4px; }
.pkt .bitrow { display: flex; }
.pkt .bit {
  width: 26px; height: 32px; display: flex; align-items: center; justify-content: center;
  font: 600 14px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  border-right: 1px solid rgba(0,0,0,.35); cursor: default;
}
.pkt .bit:first-child { border-radius: 6px 0 0 6px; }
.pkt .bit:last-child { border-radius: 0 6px 6px 0; border-right: 0; }
.pkt .lblrow { display: flex; }
.pkt .lblrow span { width: 26px; text-align: center; font: 9.5px ui-monospace, Menlo, monospace; color: var(--muted); }
.pkt .f-frame { background: var(--c-frame); color: var(--c-frame-t); }
.pkt .f-A { background: var(--c-addr); color: var(--c-addr-t); }
.pkt .f-D { background: var(--c-data); color: var(--c-data-t); }
.pkt .f-K { background: var(--c-ident); color: var(--c-ident-t); }
.pkt .f-R, .pkt .f-C { background: var(--c-crc); color: var(--c-crc-t); }
.pkt .f-B, .pkt .f-BS, .pkt .f-VCO, .pkt .f-DE { background: var(--c-batt); color: var(--c-batt-t); }
.pkt .f-HD { background: var(--c-hd); color: var(--c-hd-t); }
.pkt .f-S { background: var(--c-status); color: var(--c-status-t); }
.pkt .bit.kbad { outline: 2px solid var(--bad); outline-offset: -2px; }
.pkt .bit.hl { outline: 2px solid var(--text); outline-offset: -2px; filter: brightness(1.15); }

/* legend */
.pkt .legend { display: flex; flex-wrap: wrap; gap: 8px 16px; margin-top: 12px; font-size: .78rem; color: var(--muted); }
/* The swatches take their colour as --dot (#140), the .legend-sq convention. */
.pkt .legend i { display: inline-block; width: 13px; height: 13px; border-radius: 3px; margin-right: 5px; vertical-align: -2px; background: var(--dot, var(--muted)); }
.pkt .pkt-block { margin-top: var(--sp-3); }
.pkt .pkt-sub-follow { margin-top: 0; }

/* field table */
.pkt table.fields { border-collapse: collapse; width: 100%; table-layout: auto; margin-top: 14px; font-size: .85rem; }
.pkt table.fields th {
  position: static; background: transparent; color: var(--muted); font-weight: 500; text-align: left;
  padding: 5px 10px; border-bottom: 1px solid var(--border); font-size: 12px; letter-spacing: .4px;
  overflow: visible; white-space: normal;
}
.pkt table.fields td {
  padding: 6px 10px; border-bottom: 1px solid var(--border); vertical-align: top;
  overflow: visible; white-space: normal;
}
.pkt table.fields tr:hover td { background: transparent; }
.pkt table.fields tr.frow { cursor: pointer; }
.pkt table.fields tr.frow:hover td { background: var(--subtle); }
.pkt table.fields code { font-family: ui-monospace, Menlo, Consolas, monospace; }
.pkt .swatch { display: inline-block; width: 11px; height: 11px; border-radius: 3px; margin-right: 7px; vertical-align: -1px; background: var(--dot, var(--muted)); }
.pkt .val { font-weight: 700; font-size: 1rem; }
.pkt .stn { color: var(--ok); font-weight: 600; }
.pkt .stn.none { color: var(--muted); font-weight: 400; }

/* decode result cards */
/* A <details> since #140 — the div-with-onclick it replaced was mouse-only.
   The [open] behaviour is the element's own, so the old .open display rules
   are gone rather than restated. */
.pkt .fmtcard { border: 1px solid var(--border); border-radius: 10px; margin-top: 16px; overflow: hidden; }
.pkt .fmtcard.best { border-color: var(--ok); }
.pkt .fmthead { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 12px 16px; background: var(--subtle); cursor: pointer; list-style: none; }
.pkt .fmthead::-webkit-details-marker { display: none; }
.pkt .fmthead h4 { margin: 0; font-size: .98rem; flex: 1; }
.pkt .caret { color: var(--muted); font-size: 12px; }
.pkt .fmtbody { padding: 6px 16px 18px; }

/* status badges (override the global pill colours within the packet tab) */
.pkt .badge { display: inline-block; padding: 2px 10px; border-radius: 99px; font-size: 12px; font-weight: 700; background: transparent; color: inherit; }
.pkt .badge.ok   { background: color-mix(in srgb, var(--ok) 16%, transparent);   color: var(--ok); }
.pkt .badge.bad  { background: color-mix(in srgb, var(--bad) 14%, transparent);  color: var(--bad); }
.pkt .badge.warn { background: color-mix(in srgb, var(--warn) 16%, transparent); color: var(--warn); }

/* encoder output */
.pkt .outbits {
  background: var(--subtle); border: 1px solid var(--border); border-radius: 8px; padding: 12px 14px; margin-top: 14px;
  font: 15px ui-monospace, Menlo, Consolas, monospace; word-break: break-all;
}
.pkt .outbits b { color: var(--accent); font-weight: 600; }
.pkt .copybtn { margin-left: 10px; }

/* cheat-sheet */
.pkt .pkt-summary { cursor: pointer; color: var(--accent); font-size: .9rem; }
.pkt .pkt-cheat li { margin: 6px 0; color: var(--muted); }

@media (max-width: 700px) {
  .pkt .bit { width: 19px; height: 26px; font-size: 11px; }
  .pkt .lblrow span { width: 19px; font-size: 8px; }
}

/* ── HFEM tab (#154) ──────────────────────────────────────────────────────────
   Shares the .pkt shell with the two ALERT decoders — same panels, badges,
   field tables and disclosure cards — because it is the same kind of tab and a
   third visual language for "here is a decoded message" would be a cost with
   no buyer. Everything below is what this format needs that those two do not. */

/* The line as it arrived, above everything derived from it. Same treatment the
   ERT-A2 tab gives a raw frame. */
.hf .hf-raw {
  background: var(--subtle);
  border: 1px solid var(--border);
  border-radius: var(--r-md);
  padding: var(--sp-2);
  margin-bottom: var(--sp-2);
  font-size: .82rem;
  overflow-x: auto;
  white-space: pre-wrap;
  word-break: break-all;
}

.hf h5.hf-h,
.hf h4.hf-h {
  margin: var(--sp-3) 0 var(--sp-1);
  font-size: .82rem;
  letter-spacing: .4px;
  color: var(--muted);
  text-transform: uppercase;
}

/* M=1 is a property of the whole message, so it is drawn across the whole
   message. A column would be the version of this the eye skips. */
.hf .hf-banner { margin-bottom: var(--sp-3); }

.hf .fmtcard.hf-maint { border-color: var(--warn); }
.hf .fmtcard.hf-bad   { border-color: var(--bad); }

/* A row whose reading was taken in maintenance, in the flat measurements
   table. Tinted rather than coloured: the row still has to be readable, and
   the M badge beside the site is what actually says it — colour is the second
   channel here, never the only one. */
.hf .hf-row-maint td { background: color-mix(in srgb, var(--warn) 8%, transparent); }

.hf .hf-notes { margin: var(--sp-1) 0 0; padding-left: 1.1rem; }
.hf .hf-notes li { margin: var(--sp-1) 0; color: var(--muted); font-size: .85rem; }

.hf .hf-plain { margin: 0; padding-left: 1.1rem; }
.hf .hf-plain li { margin: 2px 0; }

.hf .hf-stn { font-weight: 600; }

.hf .hf-del { padding: .1rem .45rem; line-height: 1; }

@media (pointer: coarse) {
  /* This tab's own controls at the 44 px floor EPIC #107's Definition of Done
     sets — applied here from the first commit rather than from a conversion,
     which is what "born converted" is supposed to mean. */
  .hf .hf-del,
  .hf .link-btn,
  .hf .pkt-summary {
    min-height: 2.75rem;
    display: inline-flex;
    align-items: center;
  }
}

/* ── ALERT2 / ERT-A2 tab ────────────────────────────────────────────────────── */
/* Shares the .pkt shell — same panels, buttons, badges, field tables and bit
   cells. Everything below is what a whole serial line needs that a single 32-bit
   message did not: a capture box, a summary, and the byte-by-byte anatomy. */

.a2 .a2-input {
  width: 100%; box-sizing: border-box; resize: vertical; min-height: 110px;
  font: 12.5px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  white-space: pre; overflow-wrap: normal; overflow-x: auto;
}
.a2 button.ghost.on { background: var(--accent); color: #fff; }

/* summary chips */
.a2 .a2-stats { display: flex; flex-wrap: wrap; gap: 10px; }
.a2 .a2-stat {
  border: 1px solid var(--border); border-radius: 10px; padding: 8px 14px;
  background: var(--subtle); min-width: 92px;
}
.a2 .a2-stat b { display: block; font-size: 1.25rem; line-height: 1.1; }
.a2 .a2-stat span { font-size: .72rem; color: var(--muted); letter-spacing: .3px; }
.a2 .a2-stat.warn { border-color: var(--warn); }
.a2 .a2-stat.bad  { border-color: var(--bad); }

/* view switcher + options */
.a2 .a2-vtabs { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 12px; }
.a2 .a2-vspacer { flex: 1 1 auto; }
.a2 .a2-vtab {
  background: transparent; border: 1px solid var(--border); color: var(--text);
  padding: .4rem .9rem; border-radius: 8px; cursor: pointer; font-size: .85rem;
}
.a2 .a2-vtab.on { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; }
.a2 .a2-opts {
  display: flex; flex-wrap: wrap; gap: 8px 18px; align-items: center;
  font-size: .82rem; color: var(--muted); padding: 8px 0 12px; border-bottom: 1px solid var(--border);
}
.a2 .a2-cb { display: inline-flex; align-items: center; gap: 5px; cursor: pointer; margin: 0; }
.a2 .a2-num input { width: 62px; margin-left: 5px; padding: .2rem .3rem; }

/* which wire format the capture is read as — same chip as the view switcher,
   so the two rows of buttons read as the same kind of control */
.a2 .a2-modes { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 10px; }

/* result tables — wide, so they scroll inside the panel rather than the page */
.a2 .a2-tablewrap { overflow-x: auto; }
.a2 table.a2-table { min-width: 640px; }
.a2 table.a2-table td { white-space: nowrap; }
.a2 tr.a2-badrow td { background: color-mix(in srgb, var(--bad) 8%, transparent); }
.a2 .a2-link { color: var(--accent); cursor: pointer; font-size: .8rem; }

/* a row is a selection shared with the map, so it has to look clickable and it
   has to show which one is picked from anywhere in the table's width */
.a2 tr.a2-row { cursor: pointer; }
.a2 tr.a2-row:hover td { background: var(--subtle); }
.a2 tr.a2-row.sel td {
  background: color-mix(in srgb, var(--accent) 14%, transparent);
  box-shadow: inset 0 1px 0 var(--accent), inset 0 -1px 0 var(--accent);
}
.a2 tr.a2-row.sel td:first-child { box-shadow: inset 1px 1px 0 var(--accent), inset 0 -1px 0 var(--accent); }
.a2 tr.a2-row.sel td:last-child  { box-shadow: inset -1px 1px 0 var(--accent), inset 0 -1px 0 var(--accent); }
/* A .link-btn button since #140 (the action is in-page); the dotted underline
   stays, because it is what separates "this name goes somewhere" from the
   selection the row itself carries. */
.a2 .a2-stlink { color: var(--accent); text-decoration: none; border-bottom: 1px dotted currentColor; cursor: pointer; }
.a2 .a2-stlink:hover { text-decoration: none; border-bottom-style: solid; }

/* RSSI, coloured on the same scale as the map pins */
.a2 .a2-rssi {
  display: inline-flex; align-items: baseline; gap: 3px;
  font: 600 .85rem ui-monospace, Menlo, Consolas, monospace;
  border-left: 3px solid var(--rssi, var(--border)); padding-left: 6px;
}
.a2 .a2-rssi i { font-size: .68rem; font-style: normal; font-weight: 400; opacity: .7; }

/* coverage map */
.a2 .a2-map { height: 420px; border-radius: 8px; border: 1px solid var(--border); }
.a2 .a2-legend { display: flex; flex-wrap: wrap; gap: 6px 14px; align-items: center; margin-top: 8px; font-size: .78rem; }
.a2 .a2-lkey { display: inline-flex; align-items: center; gap: 5px; color: var(--muted); }
/* Colour arrives as --dot (#140), the .legend-sq convention. */
.a2 .a2-lkey i { width: 11px; height: 11px; border-radius: 50%; border: 1px solid #fff; display: inline-block; background: var(--dot, var(--muted)); }

/* the two capture methods, side by side at the top of the reference */
.a2 .a2-methods { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 14px; margin: 4px 0 14px; }
/* min-width:0 — a grid item defaults to min-content, and the sample frame inside
   each card is one long unbreakable line. Without this the card grows to fit it
   and takes the page sideways with it instead of letting .a2-raw scroll. */
.a2 .a2-method { border: 1px solid var(--border); border-radius: 10px; padding: 12px 14px; background: var(--subtle); min-width: 0; }
.a2 .a2-method h4 { margin: 0 0 8px; font-size: .95rem; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.a2 .a2-method .a2-raw { background: var(--bg); }
.a2 .a2-method ul.pkt-cheat { margin-bottom: 0; }

/* binary frame anatomy: one row per TLV element, indented by nesting */
.a2 .a2-tlv { display: grid; gap: 4px; margin: 8px 0; }
/* Nesting depth arrives as --depth (#140) — the indent is layout, the depth is data. */
.a2 .a2-tlvrow { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; font-size: .82rem; margin-left: calc(var(--depth, 0) * 22px); }
.a2 .a2-tlvrow .a2-chip { min-width: 58px; }
.a2 .a2-tlvlen { font-size: .72rem; color: var(--muted); min-width: 34px; }
.a2 .a2-tlvval { min-width: 0; overflow-x: auto; }
.a2 .a2-tlvval code { font-size: .76rem; white-space: pre; }
.a2 .r-rssi { background: var(--ok); color: #fff; }

/* frame anatomy: header field chips */
.a2 .a2-chips { display: flex; flex-wrap: wrap; gap: 4px; margin: 8px 0; }
.a2 .a2-chip {
  display: inline-flex; flex-direction: column; align-items: center; justify-content: center;
  border-radius: 6px; padding: 3px 7px; min-width: 34px; color: #fff;
}
.a2 .a2-chip b { font: 600 12px ui-monospace, Menlo, Consolas, monospace; }
.a2 .a2-chip i { font-size: 8.5px; font-style: normal; opacity: .85; letter-spacing: .2px; white-space: nowrap; }
.a2 .a2-chip.unsure { opacity: .55; }
.a2 .r-ident  { background: var(--c-ident);  color: var(--c-ident-t); }
.a2 .r-addr   { background: var(--c-addr);   color: var(--c-addr-t); }
.a2 .r-time   { background: var(--c-time);   color: var(--c-time-t); }
.a2 .r-status { background: var(--c-status); color: var(--c-status-t); }
.a2 .r-len    { background: var(--c-hd);     color: var(--c-hd-t); }
.a2 .r-data   { background: var(--c-data);   color: var(--c-data-t); }
.a2 .r-pack   { background: linear-gradient(160deg, var(--c-data) 0 45%, var(--c-addr) 45% 100%); color: #fff; }
.a2 .r-bad    { background: var(--bad); color: #fff; }
.a2 .r-status.bad { background: var(--bad); color: #fff; }

/* frame anatomy: payload bytes, grouped one reading at a time */
.a2 .a2-bytes { display: flex; flex-wrap: wrap; gap: 12px; margin: 8px 0 4px; }
.a2 .a2-bgroup { display: inline-flex; gap: 2px; padding: 3px; border-radius: 8px; border: 1px solid transparent; }
.a2 .a2-bgroup.hl { border-color: var(--text); background: var(--subtle); }
.a2 .a2-byte {
  display: inline-flex; flex-direction: column; align-items: center; justify-content: center;
  border-radius: 5px; padding: 4px 6px; min-width: 30px;
}
.a2 .a2-byte b { font: 700 13px ui-monospace, Menlo, Consolas, monospace; }
.a2 .a2-byte i { font-size: 8px; font-style: normal; opacity: .85; white-space: nowrap; }
.a2 .a2-byte.hl { outline: 2px solid var(--text); outline-offset: -2px; }

/* frame anatomy: one reading, spelled out */
.a2 .a2-rec { border: 1px solid var(--border); border-radius: 8px; margin-top: 10px; overflow: hidden; }
.a2 .a2-rec.bad { border-color: var(--bad); }
.a2 .a2-rec.hl  { border-color: var(--text); }
.a2 .a2-rec-head { background: var(--subtle); padding: 6px 12px; font-size: .85rem; font-weight: 600; }
.a2 .a2-rec-head .spec { display: inline; font-weight: 400; margin-left: 6px; }
.a2 .a2-rec-body { display: flex; flex-wrap: wrap; gap: 18px; padding: 10px 12px; align-items: flex-start; }
.a2 .a2-rec-bits { text-align: center; flex: 0 0 auto; }
.a2 .a2-rec-maths { flex: 1 1 320px; min-width: 0; font-size: .85rem; display: grid; gap: 6px; }
.a2 .a2-rec-maths code { font-size: .8rem; color: var(--muted); }

.a2 .a2-raw { background: var(--subtle); border-radius: 8px; padding: 8px 12px; margin-top: 10px; overflow-x: auto; }
.a2 .a2-raw code { font-size: .78rem; white-space: pre; }
.a2 .a2-warn { margin: 8px 0 0; padding-left: 18px; font-size: .82rem; color: var(--warn); }
/* h4 in the reference panel, h5 inside a frame card (#140 — the cards sit a
   level deeper, under the Decoded panel's h3 and their own summary h4). */
.a2 h4.a2-h, .a2 h5.a2-h { margin: 16px 0 0; font-size: .82rem; letter-spacing: .4px; color: var(--muted); text-transform: uppercase; }

/* The engineering value under a reading's raw count. */
.a2 .a2-follow { margin-top: 2px; }

/* The binary element reference's TAG column — indent by nesting depth, which
   arrives as --depth like the tlv rows above. */
.a2 .a2-el-tag { padding-left: calc(6px + var(--depth, 0) * 18px); }

/* EPIC #107's 44 px floor for this tab's own tap targets (#140): the mode and
   view tabs and the option checkboxes are the controls a capture is worked
   through with. .row-open already has its floor app-wide. */
@media (pointer: coarse) {
  .a2 .a2-vtab {
    min-height: 2.75rem;
    display: inline-flex;
    align-items: center;
  }
  .a2 .a2-cb { min-height: 2.75rem; }
}

/* ambiguous-address picker */
.a2 .a2-ambig { display: flex; gap: 14px; padding: 10px 0; border-top: 1px solid var(--border); flex-wrap: wrap; }
.a2 .a2-ambig-id { font: 700 1rem ui-monospace, Menlo, Consolas, monospace; min-width: 90px; }
.a2 .a2-ambig button { margin: 2px 6px 2px 0; }

@media (max-width: 700px) {
  .a2 .a2-stat { flex: 1 1 40%; min-width: 0; }
  .a2 .a2-rec-body { gap: 10px; }
  /* Same trade as the other maps on a small screen: sized against the viewport
     so there is still page left underneath it. */
  .a2 .a2-map { height: min(420px, 48dvh); min-height: 240px; }
  .a2 .a2-methods { grid-template-columns: 1fr; }
}

/* ── Radio Path Maps tab (#108 renamed it; #137 restyled it) ─────────────────
   The map navigator: a Queensland basin drawing, region and subregion chips, a
   file list, and a PDF/image viewer beside them. #137's pass moved every colour
   here into a token, every state on the basin drawing into a data-state, and
   every inline layout out of network-maps.js. */
.maps-layout {
  display: grid;
  grid-template-columns: minmax(380px, 460px) 1fr;
  gap: var(--sp-4);
  padding: var(--sp-4);
  align-items: start;
}
/* Both columns hold something that can be wider than they are — a file name
   with no spaces in it on the left, an iframe on the right — and a grid item is
   min-width:auto by default. Without this the left column grows to its longest
   filename and takes the page sideways with it. .panel already carries the same
   rule; .maps-left is an <aside>, which does not. */
.maps-left {
  position: sticky;
  top: var(--sp-4);
  min-width: 0;
}

.maps-search-label {
  display: grid;
  gap: var(--sp-1);
  margin-top: var(--sp-3);
  font-size: var(--fs-sm);
  color: var(--muted);
}

.maps-hint {
  margin: var(--sp-2) 0 0;
}

.maps-basin {
  margin-top: var(--sp-2);
  border: 1px solid var(--border);
  border-radius: var(--r-md);
  background: var(--subtle);
  padding: var(--sp-1);
  max-height: 360px;
  display: flex;
  justify-content: center;
}
.maps-basin-svg { width: 100%; height: auto; max-height: 344px; display: block; }

/* The drawing is a shortcut for the chip row under it (pattern 8 in
   docs/design-system.md): the whole of it is one role="img" with a name, and
   none of its hundred polygons is a tab stop. Which means every state a polygon
   can be in has to be expressible without :focus — so they are data-states set
   by applyRegionHighlight(), and the colour is a custom property the JS points
   at a token rather than a value the JS computes. */
.maps-basin-svg polygon {
  fill: var(--basin, var(--muted));
  stroke: var(--basin, var(--border));
  stroke-opacity: .85;
  fill-opacity: .32;
  transition: fill-opacity var(--motion-fast) var(--ease);
}
.maps-basin-svg polygon[data-state="unmapped"] { fill-opacity: .06; }
.maps-basin-svg polygon[data-state="on"]       { fill-opacity: .32; }
.maps-basin-svg polygon[data-state="active"]   { fill-opacity: .62; stroke-opacity: .9; }
.maps-basin-svg polygon[data-state="off"]      { fill-opacity: .08; stroke-opacity: .3; }
.maps-basin-svg polygon[data-region]:not([data-region=""]) { cursor: pointer; }
.maps-basin-svg polygon[data-region]:not([data-region=""]):hover { fill-opacity: .6; }

.maps-chips { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
.maps-chips-spaced { margin-top: var(--sp-2); }
.maps-chip {
  display: inline-flex;
  align-items: center;
  gap: var(--sp-1);
  padding: var(--sp-1) var(--sp-2);
  border: 1px solid var(--ctl-border);
  border-radius: var(--r-pill);
  background: var(--panel-sub);
  color: var(--text);
  font-size: var(--fs-sm);
  cursor: pointer;
  line-height: var(--lh-tight);
}
.maps-chip:hover { background: color-mix(in srgb, var(--accent) 12%, var(--panel-sub)); }
/* The pressed chip. --primary-text rather than a literal white, because that
   pair is in the contrast contract and `#fff` on --accent is not. */
.maps-chip.active {
  background: var(--accent);
  color: var(--primary-text);
  border-color: var(--accent);
}
.maps-chip-dot {
  width: 9px;
  height: 9px;
  border-radius: var(--r-pill);
  display: inline-block;
  background: var(--dot, var(--muted));
}
.maps-count {
  font-size: var(--fs-xs);
  background: color-mix(in srgb, var(--muted) 22%, transparent);
  border-radius: var(--r-pill);
  padding: 0 var(--sp-1);
}
/* Was `opacity: .8` on the count. On the pressed chip that put white at 80% over
   the accent — 3.6:1, under AA for the one number in the row anybody reads. The
   dimming is in the background wash now, where it costs nothing. */
.maps-chip.active .maps-count { background: color-mix(in srgb, var(--primary-text) 25%, transparent); }
.maps-chip-hint { display: none; }

.maps-reset {
  padding: var(--sp-1) var(--sp-2);
  font-size: var(--fs-sm);
  border-radius: var(--r-md);
}

/* file list */
.maps-file-list { list-style: none; margin: var(--sp-2) 0 0; padding: 0; max-height: 42vh; overflow: auto; }
.maps-file {
  display: grid;
  grid-template-columns: 1fr auto;
  gap: var(--sp-2);
  align-items: center;
  padding: var(--sp-2);
  margin: var(--sp-1) 0;
  border: 1px solid var(--border);
  border-radius: var(--r-md);
  background: var(--panel-sub);
}
.maps-file-main { min-width: 0; }
.maps-file:hover { background: color-mix(in srgb, var(--accent) 8%, var(--panel-sub)); }
.maps-file.selected { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 16%, var(--panel-sub)); }
.maps-file-name { font-size: var(--fs-sm); word-break: break-word; }
.maps-badges { display: flex; flex-wrap: wrap; gap: var(--sp-1); margin-top: var(--sp-1); }
.badge.maps-badge { font-size: var(--fs-xs); background: color-mix(in srgb, var(--muted) 16%, transparent); color: var(--muted); }
.maps-file-actions { display: flex; gap: var(--sp-1); align-items: center; }
.maps-file-actions button { padding: var(--sp-1) var(--sp-2); font-size: var(--fs-sm); }

.maps-list-header { margin-top: var(--sp-2); }
.maps-list-header h2 { font-size: var(--fs-md); }

.maps-newtab {
  text-decoration: none;
  color: var(--accent);
  border: 1px solid var(--ctl-border);
  border-radius: var(--r-md);
  padding: var(--sp-1) var(--sp-2);
  font-size: var(--fs-sm);
  white-space: nowrap;
  background: var(--panel);
}
.maps-newtab:hover { background: color-mix(in srgb, var(--accent) 10%, var(--panel)); }

/* station → map suggestions */
.maps-suggest-note { margin: var(--sp-3) 0 var(--sp-1); }
.maps-suggest-item {
  border: 1px solid var(--border);
  border-radius: var(--r-md);
  padding: var(--sp-2);
  margin: var(--sp-1) 0;
  background: color-mix(in srgb, var(--accent) 6%, var(--panel-sub));
}
.maps-suggest-station { font-size: var(--fs-sm); font-weight: 600; margin-bottom: var(--sp-1); }
.maps-suggest-maps { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
.maps-suggest-map {
  padding: var(--sp-1) var(--sp-2);
  font-size: var(--fs-sm);
  border: 1px solid var(--accent);
  border-radius: var(--r-pill);
  background: var(--panel);
  color: var(--accent);
  cursor: pointer;
  text-align: left;
}
.maps-suggest-map:hover { background: var(--accent); color: var(--primary-text); }

/* viewer */
.maps-viewer-panel { display: flex; flex-direction: column; padding: 0; overflow: hidden; min-height: calc(100vh - 160px); }
.maps-viewer-toolbar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-3);
  padding: var(--sp-2) var(--sp-3);
  border-bottom: 1px solid var(--border);
  background: var(--panel-sub);
  flex-wrap: wrap;
}
.maps-current { min-width: 0; }
.maps-current strong { display: block; }
.maps-current .small { word-break: break-all; }
.maps-toolbar-actions { flex-wrap: nowrap; flex: 0 0 auto; }
/* Sized from the opened file's real page proportions (set via --maps-aspect
   in JS once known) so an A3/A4 map isn't cropped when fit to width; the
   1:√2 fallback keeps it at least A4-shaped before that's detected. */
.maps-viewer {
  width: 100%;
  aspect-ratio: var(--maps-aspect, 0.70711);
  min-height: 480px;
  background: var(--subtle);
  display: flex;
}
.maps-viewer > * { flex: 1; min-width: 0; min-height: 0; }
.maps-view-frame { width: 100%; height: 100%; border: 0; display: block; }
.maps-view-img { width: 100%; height: 100%; object-fit: contain; background: #fff; display: block; }
.maps-empty {
  display: grid;
  place-items: center;
  height: 100%;
  text-align: center;
  color: var(--muted);
  padding: var(--sp-6);
  background: var(--subtle);
}

@media (max-width: 1100px) {
  .maps-layout { grid-template-columns: 1fr; }
  .maps-left { position: static; }
  .maps-viewer-panel { min-height: 70vh; }
  .maps-viewer { min-height: 480px; }
}

@media (max-width: 560px) {
  /* A phone. The page gutter is worth more as content than as margin, the
     viewer stops trying to hold half a screen of nothing before a map is
     opened, and the basin drawing gives up a third of its height — it is a
     region picker, and the chips underneath it do the same job in a row. */
  .maps-layout { padding: var(--sp-2); gap: var(--sp-2); }
  .maps-basin { max-height: 240px; }
  .maps-basin-svg { max-height: 232px; }
  .maps-viewer-panel { min-height: 0; }
  .maps-viewer { min-height: 320px; }
  /* The name and the actions stop competing for one row: 375 px cannot hold a
     filename, Prev, Next and a link, and the wrap was landing the buttons under
     a path that had already broken across three lines. */
  .maps-toolbar-actions { width: 100%; justify-content: flex-start; }
}

/* ── Pass Ranges tab (#137) ──────────────────────────────────────────────────
   The tab had no section here at all: it was one inline `max-width:1100px;
   margin:auto;padding:1rem` wrapper and six more inline styles inside it. The
   wrapper is `.page` now (1100 is the default, so it does not even set
   --page-max), and what is left is the one thing genuinely local to this tab —
   the filter box's label. Everything else it needed turned out to be shared and
   is up with the table pattern and .stats: .row-link, .row-open, .table-empty,
   .stats-row, .stat-bad. */
.pr-filter {
  display: grid;
  gap: var(--sp-1);
  margin-top: var(--sp-3);
  max-width: 420px;
  font-size: var(--fs-sm);
  color: var(--muted);
}

/* ── Export tab (#141) ───────────────────────────────────────────────────────
   Four inline `padding:.25rem .5rem;font-size:.8rem` buttons, an inline gap and
   two inline margins. The tab's shape is the shared `.layout` (a sidebar and a
   column, stacking at --bp-lg on its own), so this is only what was inline. */
.exp-btn-sm    { padding: var(--sp-1) var(--sp-2); font-size: var(--fs-xs); }
.exp-head-acts { display: flex; gap: var(--sp-1); align-items: center; }
.exp-note      { margin-top: var(--sp-1); }

/* The sign-in gate's one line (#191). It stands where a download button was, so
   it is marked as an aside rather than as an error: nothing has gone wrong, and
   everything else on the tab is still readable. */
.exp-gate-note {
  margin: var(--sp-2) 0 0;
  padding: var(--sp-2);
  border: 1px dashed var(--border);
  border-radius: var(--r-md);
  background: var(--bg);
  color: var(--muted);
}

/* The generate panel packs tighter than the app-wide stack: its three children
   are a header, the file list and one note about a path, which read as one
   thing rather than three. */
.exp-main-panel { gap: var(--sp-3); }
.exp-repeaters  { margin-top: var(--sp-4); }

/* The datastore panel this tab hosts (rendered by datastore.js). Its markup
   carried eight inline styles, and one of them was a real defect rather than a
   style: the divider under "Showing …" was `1px solid var(--line)`, and --line
   is the token #109 found referenced and never defined. An invalid var()
   invalidates the whole declaration, so that border has never been drawn. #109
   fixed the three occurrences it knew about; this is the fourth. */
.db-loaded {
  margin-bottom: var(--sp-2);
  padding-bottom: var(--sp-2);
  border-bottom: 1px solid var(--border);
}

.db-sub    { margin-top: var(--sp-1); }
.db-host   { margin-top: var(--sp-1); }
.db-auth   { margin-top: var(--sp-1); }
.db-reload { padding: var(--sp-1) var(--sp-2); font-size: var(--fs-xs); margin-top: var(--sp-1); }

/* ── ARRO Launcher tab (#141) ────────────────────────────────────────────────
   Like Pass Ranges before #137, this tab had no section here: one inline
   `max-width:1000px;margin:auto;padding:1rem;display:grid;gap:1rem` wrapper and
   sixteen more inline styles inside it, four of which were `color:var(--muted)`
   on an element that already carried `.small` — which sets that colour. The
   wrapper is `.page` with --page-max, and what is left below is the two things
   genuinely local to this tab: the id row, and the recents list. */

.arro-lede  { margin: var(--sp-2) 0 0; max-width: 90ch; }
.arro-hint  { margin: var(--sp-1) 0 0; }
.arro-flush { margin: 0; }
.arro-none  { color: var(--muted); }

.arro-search  { margin-top: var(--sp-2); }
.arro-results { margin-top: var(--sp-2); }

/* Three boxes that are one thought: two ids, or a URL carrying both. They wrap
   rather than shrink — an id box narrower than the id in it is worse than a
   second row. */
.arro-ids {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-3);
  align-items: flex-end;
  margin-top: var(--sp-2);
}

.arro-id-field {
  display: grid;
  gap: var(--sp-1);
  flex: 0 0 12rem;
  font-size: var(--fs-md);
  color: var(--muted);
}

.arro-id-field--wide { flex: 1 1 18rem; min-width: 14rem; }
.arro-optional       { font-weight: 400; }

.arro-actions    { margin-top: var(--sp-3); }
.arro-action-row { display: flex; flex-wrap: wrap; gap: var(--sp-2); align-items: center; }

/* The two small buttons in a dense row — the results table's "Use" and the
   recents "Use"/"Clear". One class rather than the three different inline
   padding/font-size pairs that were here. */
.arro-btn-sm {
  padding: var(--sp-1) var(--sp-2);
  font-size: var(--fs-xs);
}

.arro-row-acts {
  white-space: nowrap;
  display: flex;
  gap: var(--sp-1);
  align-items: center;
}

.arro-recents { margin-top: var(--sp-2); }

.arro-recent-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: var(--sp-1);
}

.arro-recent-list li {
  display: flex;
  gap: var(--sp-2);
  align-items: baseline;
  flex-wrap: wrap;
}

.arro-recent-name { font-weight: 600; }

/* ── Serial Monitor tab ─────────────────────────────────────────────────────── */
.serial { max-width: 1100px; margin: auto; padding: 1rem; display: grid; gap: 1rem; }
.serial .sub { color: var(--muted); font-size: .92rem; margin: .35rem 0 0; max-width: 90ch; }
.serial .panel-header h2 { margin: 0; }

/* #140's conversion classes — the spellings the tab's inline styles kept
   re-deriving, named once. */
.ser-build { opacity: .55; font-weight: normal; }
.ser-known { margin-top: var(--sp-2); font-size: var(--fs-xs); }
.ser-known-label { opacity: .7; }
.ser-port-status { font-size: var(--fs-xs); margin-top: var(--sp-1); }
.ser-f-port--wide { grid-column: 1 / -1; }
.ser-help-p  { margin: var(--sp-1) 0 0; }
.ser-help-ol { margin: var(--sp-1) 0 0 1.1rem; padding: 0; }

.serial button.ghost {
  background: transparent; color: var(--accent); border: 1px solid var(--accent);
  font-weight: 500; padding: .4rem .7rem; font-size: .82rem; border-radius: 8px; cursor: pointer;
}
.serial button.ghost:hover { filter: brightness(1.1); }

.ser-warn { border-color: var(--warn); background: color-mix(in srgb, var(--warn) 8%, var(--panel)); }
.ser-warn h3 { margin: 0 0 .4rem; }
.ser-warn p { margin: 0; color: var(--muted); font-size: .9rem; }

#serial-conns { display: grid; gap: 1rem; }
.ser-empty p { margin: 0; color: var(--muted); }

.ser-conn { padding: 1rem; }
.ser-conn.ser-open   { border-color: var(--ok); }
.ser-conn.ser-error  { border-color: var(--bad); }

.ser-conn-head { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; margin-bottom: .75rem; }
.ser-conn-name { font-weight: 700; font-size: 1.02rem; }
.ser-conn-port { margin-left: auto; }

/* setup form */
.ser-form {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
  gap: .7rem;
  align-items: end;
}
.ser-form label { display: grid; gap: .3rem; font-size: .85rem; color: var(--muted); }
.ser-f-name { grid-column: 1 / -1; }
.ser-f-port { display: grid; gap: .3rem; font-size: .85rem; color: var(--muted); }
.ser-port-row { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; min-height: 38px; }
.ser-port-ok { color: var(--ok); font-weight: 600; font-size: .88rem; }
.ser-mode-note { margin: .75rem 0 0; font-size: .85rem; color: var(--muted); }
.ser-err { margin: .6rem 0 0; font-size: .85rem; color: var(--bad); font-weight: 600; }
.ser-actions { display: flex; gap: .6rem; margin-top: 1rem; flex-wrap: wrap; }

/* live view */
.ser-toolbar { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; }
.ser-check { display: inline-flex; align-items: center; gap: .3rem; font-size: .82rem; color: var(--muted); }
.ser-check input { width: auto; }
.ser-substats { display: flex; justify-content: space-between; gap: 1rem; flex-wrap: wrap;
  margin: .6rem 0; font-size: .82rem; color: var(--muted); }
.ser-cfg { font-weight: 600; }

.ser-send { display: flex; gap: .5rem; margin-bottom: .6rem; align-items: center; }
.ser-send input[type="text"] { flex: 1; }
.ser-send select { width: auto; min-width: 120px; }

.ser-log {
  font: 12.5px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  background: var(--subtle);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: .5rem .6rem;
  height: 320px;
  overflow: auto;
  white-space: pre-wrap;
  word-break: break-word;
}
.ser-line { display: flex; gap: .6rem; align-items: baseline; padding: .02rem 0; }
.ser-log-text .ser-line { white-space: pre-wrap; }
.ser-ts { color: var(--muted); flex: 0 0 auto; opacity: .8; }
.ser-linebody { flex: 1; min-width: 0; }
.ser-sys .ser-linebody  { color: var(--accent); font-style: italic; }
.ser-err .ser-linebody  { color: var(--bad); font-weight: 600; }
.ser-tx  .ser-linebody  { color: var(--warn); }
.ser-tx-arrow { font-weight: 700; }

/* hex dump columns */
.ser-hex .ser-linebody { display: grid; grid-template-columns: auto 1fr auto; gap: 1rem; }
.ser-hex-off   { color: var(--muted); }
.ser-hex-bytes { letter-spacing: .02em; }
.ser-hex-ascii { color: var(--muted); white-space: pre; }

/* alert decode line */
.ser-alert-hex  { color: var(--muted); }
.ser-alert-id   { font-weight: 700; color: var(--accent); }
.ser-alert-val  { font-weight: 600; }
.ser-alert-stn  { color: var(--ok); font-weight: 600; }
.ser-alert-stn.none { color: var(--muted); font-weight: 400; }
.ser-link { color: var(--accent); cursor: pointer; text-decoration: underline; font-size: .92em; }

/* inline status/format badges (self-contained, theme independent) */
.ser-badge { display: inline-block; padding: 1px 9px; border-radius: 99px; font-size: 11.5px; font-weight: 700;
  background: color-mix(in srgb, var(--muted) 18%, transparent); color: var(--muted); white-space: nowrap; }
.ser-badge.ok   { background: color-mix(in srgb, var(--ok) 16%, transparent);   color: var(--ok); }
.ser-badge.bad  { background: color-mix(in srgb, var(--bad) 14%, transparent);  color: var(--bad); }
.ser-badge.warn { background: color-mix(in srgb, var(--warn) 16%, transparent); color: var(--warn); }

@media (max-width: 700px) {
  .ser-conn-port { margin-left: 0; width: 100%; }
  .ser-hex .ser-linebody { grid-template-columns: 1fr; gap: .1rem; }
}

/* ── Modal (bug reporter) ──────────────────────────────────────────────────── */
.modal-overlay {
  position: fixed;
  inset: 0;
  z-index: 2000;
  display: none;                 /* toggled to flex when opened */
  align-items: flex-start;
  justify-content: center;
  padding: 4vh 1rem;
  background: rgba(8, 15, 24, .55);
  overflow: auto;
}

.modal-card {
  width: 100%;
  max-width: 560px;
  background: var(--panel);
  color: var(--text);
  border: 1px solid var(--border);
  border-radius: 12px;
  box-shadow: 0 12px 40px rgba(0, 0, 0, .35);
  padding: 1.1rem 1.25rem 1.25rem;
}

.modal-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
}
.modal-head h2 { margin: 0; font-size: 1.2rem; }

.modal-card .sub {
  color: var(--muted);
  font-size: .9rem;
  margin: .4rem 0 .2rem;
}

.modal-x {
  border: none;
  background: transparent;
  color: var(--muted);
  font-size: 1.5rem;
  line-height: 1;
  padding: 0 .35rem;
  cursor: pointer;
}
.modal-x:hover { color: var(--text); }

.modal-form {
  display: grid;
  gap: .8rem;
  margin-top: .9rem;
}
.modal-form label {
  display: grid;
  gap: .3rem;
  font-size: .9rem;
  color: var(--muted);
}

/* Every other label in the form stacks its caption over its field; this one is
   a tick with its caption beside it. Written as `.modal-form .check-inline` to
   out-weigh the `.modal-form label` grid above — as a bare class it lost, and
   the box sat on a line of its own above the words it belonged to. */
.modal-form .check-inline {
  display: flex;
  flex-direction: row;
  align-items: flex-start;
  gap: .5rem;
}
.check-inline input { width: auto; margin-top: .15rem; }

.req { color: var(--bad); font-weight: 700; }

.diag-preview summary {
  cursor: pointer;
  color: var(--muted);
  font-size: .88rem;
}
.diag-pre {
  margin: .55rem 0 0;
  max-height: 30vh;
  overflow: auto;
  padding: .6rem .7rem;
  background: var(--subtle);
  border: 1px solid var(--border);
  border-radius: 8px;
  font-size: .78rem;
  white-space: pre-wrap;
  word-break: break-word;
}

.modal-foot {
  display: flex;
  justify-content: flex-end;
  flex-wrap: wrap;
  gap: .5rem;
  margin-top: 1.1rem;
}

/* The generic shell (Modal.open) — a card with a scrolling body, for the times
   the content is longer than the window rather than a form the height of one. */
/* Wide enough for a 720-wide figure and its padding, and no wider — prose in a
   card broader than that is a paragraph with a margin of empty card beside it. */
.modal-card--wide { max-width: 762px; }
/* …except when it is holding the readings table (#191), which is seven columns
   and the reason somebody opened it full screen. :has() rather than a second
   modifier class because Modal.open() takes one boolean and this is the
   table's own business, not the dialog shell's. Browsers without :has() get the
   762 px above, which is narrow but not broken. */
.modal-card--wide:has(#ad-table-modal) { max-width: min(1200px, 94vw); }
.modal-card:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
.modal-body {
  margin-top: .7rem;
  max-height: 76vh;
  overflow: auto;
  overscroll-behavior: contain;
}

/* ── "How the 357 filter works" ──────────────────────────────────────────────
   Prose narrower than the figures: a paragraph is unreadable at the width a
   flowchart needs, so the figures are allowed the full card and the text is
   not. */
.f357 { font-size: .9rem; line-height: 1.55; }
.f357 p  { margin: .5rem 0; max-width: 68ch; color: var(--text); }
.f357 ul { margin: .5rem 0; padding-left: 1.1rem; max-width: 68ch; }
.f357 li { margin: .3rem 0; }
.f357 h3 {
  margin: 1.15rem 0 .2rem;
  font-size: .98rem;
  padding-bottom: .2rem;
  border-bottom: 1px solid var(--border);
}
.f357 code { font-size: .85em; background: var(--subtle); padding: .05rem .25rem; border-radius: 4px; }
.f357-fig {
  display: block;
  width: 100%;
  height: auto;
  max-width: 720px;
  margin: .7rem auto .3rem;
  background: var(--panel);
}
.f357-src { color: var(--muted); font-size: .82rem; margin-top: 1.2rem !important; }

/* ── Interference Workbench tab ────────────────────────────────────────────── */

.wb-page { padding: 1rem; }

.wb-layout {
  display: grid;
  grid-template-columns: 320px minmax(0, 1fr) 320px;
  gap: 1rem;
  align-items: start;
}

.wb-rail { min-width: 0; }

/* #139's conversion classes — the spellings the tab's 97 inline styles kept
   re-deriving, named once. */
.wb-field {                       /* a stacked label + control in the setup rail */
  display: grid;
  gap: var(--sp-1);
  margin-top: var(--sp-2);
  font-size: var(--fs-sm);
  color: var(--muted);
}
.wb-field > input,
.wb-field > textarea { color: var(--text); }
.wb-note  { margin: var(--sp-2) 0 0; }   /* the footnote under a panel's content */
.wb-tight { margin: var(--sp-1) 0; }     /* body lines inside a dense panel */
.wb-block { margin-top: var(--sp-2); }   /* a sub-section within a panel */
.wb-case-row {
  display: flex;
  gap: var(--sp-2);
  align-items: center;
  margin-top: var(--sp-2);
}
.wb-chip-label { margin-bottom: var(--sp-1); }
.wb-sus-body { padding: var(--sp-1) 0; }
.wb-why-body { margin-top: var(--sp-1); }
.wb-verdict-title {
  margin: var(--sp-1) 0;
  font-size: var(--fs-xl);
}
.wb-prose { max-width: 75ch; }
.wb-map {
  height: 430px;
  border-radius: var(--r-md);
}
.wb-m-station { min-width: 140px; }
.wb-banner button { margin-left: var(--sp-2); }

/* The H5 pair table: same shape as .bf-table (auto layout inside a scrolling
   wrapper) at the width its five columns need. */
.wb-h5-table {
  table-layout: auto;
  min-width: 560px;
}
.wb-auto-table { table-layout: auto; }

@media (pointer: coarse) {
  /* EPIC #107's 44 px floor for this tab's own tap targets (#139): the chips'
     swap/remove, the picker's add buttons, the threat rows and the concept
     drawer's list — an investigation gets tapped through in a vehicle as much
     as any filter bar does. align-items keeps a short chip's text centred in
     the taller box rather than floating at its top (design-system §4). */
  .wb-chip .link-btn,
  .wb-pick-row button,
  .wb-threat,
  .wb-drawer-item,
  .wb-drawer-head button {
    min-height: 2.75rem;
  }
  .wb-chip { align-items: center; }
}

@media (max-width: 1400px) {
  .wb-layout { grid-template-columns: 300px minmax(0, 1fr); }
  .wb-layout > aside:last-child { grid-column: 1 / -1; }
}

/* Folded 1000 → 1100 by #109's breakpoint pass. Between the two the Workbench
   was holding a 300 px rail beside a main column that, with both shell rails
   open, had about 204 px left — so the step it lost was one it was not really
   surviving anyway. */
@media (max-width: 1100px) {
  .wb-layout { grid-template-columns: 1fr; }
  .wb-layout > aside:last-child { grid-column: auto; }
}

/* the Workbench page scrolls, so the transmitter card floats like on RF Changes */
.wb-page .acma-card {
  position: fixed;
  top: 84px;
  right: 14px;
  bottom: 14px;
}

/* H5 warning banner — shown above everything else when address pairs collide */
.wb-banner {
  border: 1px solid var(--warn);
  background: color-mix(in srgb, var(--warn) 10%, var(--panel));
  border-radius: 10px;
  padding: .8rem .9rem;
  font-size: .92rem;
}

.wb-verdict { border-left: 4px solid var(--accent); }

.wb-notes {
  margin-top: .5rem;
  border-top: 1px dashed var(--border);
  padding-top: .4rem;
}
.wb-notes p { margin: .2rem 0; }

/* hypothesis ranking rows */
.wb-hyp { border-bottom: 1px solid var(--border); padding: .3rem 0; }
.wb-hyp:last-of-type { border-bottom: none; }
.wb-hyp summary {
  display: grid;
  grid-template-columns: 2.2rem minmax(9rem, 1fr) minmax(4rem, 180px) 3.2rem;
  gap: .6rem;
  align-items: center;
  cursor: pointer;
  list-style: none;
}
.wb-hyp summary::-webkit-details-marker { display: none; }
.wb-hyp-rank { color: var(--muted); font-size: .82rem; }
.wb-hyp-name { font-size: .92rem; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wb-hyp-score { font-variant-numeric: tabular-nums; text-align: right; font-size: .9rem; }
.wb-hyp-bar {
  height: 8px;
  border-radius: 999px;
  background: color-mix(in srgb, var(--muted) 16%, transparent);
  overflow: hidden;
}
.wb-hyp-bar > span {
  display: block;
  height: 100%;
  width: var(--w, 0%);   /* the score reaches CSS as a token, not an inline width (#139) */
  background: var(--accent);
  border-radius: 999px;
}
.wb-hyp-body { padding: .2rem 0 .4rem 2.8rem; }
@media (max-width: 700px) { .wb-hyp-body { padding-left: .5rem; } }

/* expandable arithmetic under each score */
.wb-arith {
  margin-top: .3rem;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--subtle);
  padding: .45rem .6rem;
}

/* suspects (right rail) reuse the hypothesis grid */
.wb-sus { border-bottom: 1px solid var(--border); padding: .25rem 0; }
.wb-sus:last-of-type { border-bottom: none; }
.wb-sus summary {
  display: grid;
  grid-template-columns: 2rem minmax(7rem, 1fr) minmax(3rem, 90px) 3rem;
  gap: .5rem;
  align-items: center;
  cursor: pointer;
  list-style: none;
}
.wb-sus summary::-webkit-details-marker { display: none; }
.wb-sus-name { font-size: .9rem; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* ACMA suspect rows */
/* A <button> since #139 (the action is in-page — pattern: .link-btn's
   argument), styled as the row it always was, so the resets undo the app's
   button chrome rather than restate the row. */
.wb-threat {
  display: grid;
  grid-template-columns: 14px minmax(0, 1fr) 2.6rem;
  gap: .5rem;
  align-items: center;
  width: 100%;
  padding: .3rem .2rem;
  border: none;
  background: none;
  border-radius: 6px;
  color: var(--text);
  text-align: left;
  text-decoration: none;
  font: inherit;
  font-size: .9rem;
  cursor: pointer;
}
.wb-threat:hover { background: var(--subtle); }
.wb-threat-name { min-width: 0; }

/* selection chips */
.wb-chips { display: flex; flex-wrap: wrap; gap: .3rem; }
.wb-chip {
  display: inline-flex;
  align-items: center;
  gap: .3rem;
  max-width: 100%;
  padding: .18rem .5rem;
  border-radius: 999px;
  font-size: .8rem;
  border: 1px solid var(--border);
}
.wb-chip a { text-decoration: none; color: inherit; opacity: .7; }
.wb-chip a:hover { opacity: 1; }
.wb-chip-name {
  max-width: 11ch;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.wb-chip-aff  { background: color-mix(in srgb, var(--bad) 12%, transparent); }
.wb-chip-good { background: color-mix(in srgb, var(--ok) 12%, transparent); }
.wb-chip-miss { border-style: dashed; opacity: .8; }

/* station picker results */
.wb-pick-list {
  margin-top: .4rem;
  border: 1px solid var(--border);
  border-radius: 8px;
  overflow: hidden;
}
.wb-pick-row {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: .5rem;
  padding: .35rem .5rem;
  font-size: .86rem;
  border-bottom: 1px solid var(--border);
}
.wb-pick-row:last-child { border-bottom: none; }
.wb-pick-row button { padding: .2rem .45rem; font-size: .76rem; }

/* routing / pass-range matrix */
.wb-matrix { table-layout: auto; min-width: 520px; }
.wb-matrix th.wb-m-h {
  vertical-align: bottom;
  height: 110px;
  padding: .3rem .2rem;
  min-width: 26px;
  max-width: 34px;
}
.wb-matrix th.wb-m-h span {
  writing-mode: vertical-rl;
  transform: rotate(180deg);
  font-size: .76rem;
  font-weight: 500;
  max-height: 100px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  display: inline-block;
}
.wb-matrix td.wb-m { text-align: center; color: var(--muted); }
.wb-matrix td.wb-m.hit { color: var(--accent); font-size: 1rem; }
.wb-matrix tr.wb-row-aff td:first-child { border-left: 3px solid var(--bad); }
.wb-matrix tr.wb-row-aff td { background: color-mix(in srgb, var(--bad) 7%, transparent); }
.wb-matrix tr.wb-row-good td:first-child { border-left: 3px solid var(--ok); }
.wb-matrix tr.wb-row-good td { background: color-mix(in srgb, var(--ok) 6%, transparent); }
.wb-matrix tfoot td { font-variant-numeric: tabular-nums; }

/* education layer: dotted-underline tooltip terms */
.wb-term {
  border-bottom: 1px dotted var(--muted);
  cursor: help;
  position: relative;
}
.wb-term-link { cursor: pointer; }
.wb-term:hover::after,
.wb-term:focus::after {
  content: attr(data-tip);
  position: absolute;
  left: 0;
  bottom: calc(100% + 6px);
  z-index: 1500;
  width: 270px;
  max-width: 74vw;
  padding: .5rem .6rem;
  border-radius: 8px;
  background: var(--panel);
  color: var(--text);
  border: 1px solid var(--border);
  box-shadow: 0 6px 18px rgba(0, 0, 0, .25);
  font-size: .8rem;
  font-weight: 400;
  line-height: 1.4;
  white-space: normal;
}

.wb-why { margin-top: .6rem; }
.wb-why summary {
  cursor: pointer;
  color: var(--accent);
  font-size: .85rem;
}

/* concept drawer (education tier 3) — slide-over on the right edge */
.wb-drawer {
  position: fixed;
  top: 84px;
  right: 14px;
  bottom: 14px;
  width: 400px;
  max-width: 88vw;
  overflow-y: auto;
  z-index: 1300;
  background: var(--panel);
  color: var(--text);
  border: 1px solid var(--border);
  border-radius: 10px;
  box-shadow: 0 8px 28px rgba(0, 0, 0, .3);
  font-size: .9rem;
}
.wb-drawer-head {
  position: sticky;
  top: 0;
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: .5rem;
  padding: .6rem .7rem;
  background: var(--panel);
  border-bottom: 1px solid var(--border);
}
.wb-drawer-head button { padding: 0 .5rem; font-size: 1rem; }
.wb-drawer-body { padding: .6rem .8rem 1rem; }
.wb-drawer-body p { margin: .5rem 0; }
/* A <button> since #139 — same reasoning as .wb-threat. */
.wb-drawer-item {
  display: block;
  width: 100%;
  padding: .4rem .5rem;
  margin: .15rem 0;
  border: 1px solid var(--border);
  background: none;
  border-radius: 8px;
  color: var(--accent);
  text-align: left;
  text-decoration: none;
  font: inherit;
  font-size: .88rem;
  cursor: pointer;
}
.wb-drawer-item:hover { background: var(--subtle); }

/* ── Network View tab ──────────────────────────────────────────────────────
   The ghosting graph. Ported from a standalone page that owned the whole
   viewport and shipped its own palette; everything below is expressed in the
   app's theme tokens instead, so the graph flips with the rest of the app and
   the sidebar reads like every other sidebar. The stage sizes itself off
   --mn-chrome the same way the Stations panes do — app.js measures the header,
   and the SVG's viewBox is set to the stage's own pixel size so a nav collapse
   widens the window onto the graph rather than stretching it. */

.nv-layout {
  display: grid;
  grid-template-columns: 320px minmax(0, 1fr);
  gap: 1rem;
  padding: 1rem;
  align-items: start;
}

.nv-side {
  height: calc(100dvh - var(--mn-chrome, 150px) - 2rem);
  overflow-y: auto;
  overscroll-behavior: contain;
  padding-right: .4rem;
  min-width: 0;
}

/* Graph and map side by side, both live at once — the two panels answer
   different questions (relationship structure vs. physical location) off the
   same filtered node set, so both stay on screen together rather than one
   replacing the other behind a toggle. */
.nv-main {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  gap: 1rem;
  min-width: 0;
  height: calc(100dvh - var(--mn-chrome, 150px) - 2rem);
}

.nv-stage,
.nv-map {
  position: relative;
  height: 100%;
  padding: 0;
  overflow: hidden;
  min-width: 0;
  background:
    radial-gradient(circle at 22% 12%, color-mix(in srgb, var(--panel) 92%, transparent) 0, transparent 34%),
    var(--panel-sub);
}

#nv-map-canvas {
  width: 100%;
  height: 100%;
}

/* Direction markers on confirmed links (see refreshNvMap). A plain divIcon
   with no leaflet-div-icon class already drops Leaflet's default white box —
   this just keeps it from swallowing pointer events the pins and lines under
   it want. */
.nv-map-arrow {
  pointer-events: none;
  line-height: 0;
}

/* Pass-range repeater pins (see refreshNvMap / visibleRepeaterInfo). A diamond
   rather than the circle every station pin on this map uses, so a repeater
   shown only because its pass ranges cover an address in view can't be misread
   as a station taking part in a computed or confirmed ghosting edge. */
.nv-repeater-pin span {
  display: block;
  width: 12px;
  height: 12px;
  background: var(--dot, var(--muted));
  transform: rotate(45deg);
  border: 2px solid #fff;
  box-shadow: 0 0 0 1px rgba(0, 0, 0, .35);
}

/* The same diamond, inline and static, as a legend swatch next to the map. */
.nv-repeater-pin--legend {
  display: inline-block;
  width: 12px;
  height: 12px;
  vertical-align: -1px;
  margin-right: 2px;
}
.nv-repeater-pin--legend span {
  box-shadow: 0 0 0 1px var(--muted);
}

.nv-stage svg {
  display: block;
  width: 100%;
  height: 100%;
  touch-action: none;        /* the pointer drag owns pan and node dragging */
  cursor: grab;
}

.nv-stage svg:active { cursor: grabbing; }

/* ── graph marks ── */

/* Strokes are held to a screen width rather than a world one, so zooming out to
   frame a large graph thins the lines to nothing no longer. --nv-scale is set by
   applyView on every view change. */
.nv-edge,
.nv-conf { vector-effect: non-scaling-stroke; }

.nv-edge {
  fill: none;
  stroke: color-mix(in srgb, var(--muted) 55%, transparent);
  stroke-width: 1;
}

.nv-edge--same {
  stroke: color-mix(in srgb, var(--accent) 55%, transparent);
  stroke-width: 1.4;
  stroke-dasharray: 4 3;
}

/* Observed relationships are the point of the graph, so they are the only
   thing drawn at full strength — and the only thing with an arrowhead, because
   they are the only edges with a direction. */
.nv-conf {
  stroke: var(--map-line);
  stroke-width: 2;
  stroke-linecap: round;
}

.nv-conf--recip { stroke-width: 3; }
.nv-conf--same  { stroke-dasharray: 6 4; }
.nv-arrowhead   { fill: var(--map-line); }

.nv-node { cursor: grab; }
.nv-node:active { cursor: grabbing; }

.nv-node circle {
  stroke: var(--panel);
  stroke-width: 1.5;
  transition: stroke-width .12s ease;
}

.nv-node:hover circle { stroke: var(--text); stroke-width: 2.5; }
/* Allowlisted `outline: none` #2 of 2 (#109's focus policy). An SVG <g> has no
   box to outline — browsers draw the ring around the group's bounding box,
   which on a force-directed graph is a rectangle nowhere near the node. The
   replacement is the line under it: the node's own circle takes the accent
   stroke at 3 px, which is the ring, drawn on the shape that has focus. */
.nv-node:focus-visible { outline: none; }
.nv-node:focus-visible circle { stroke: var(--accent); stroke-width: 3; }

/* A search hit wears the same amber ring a filter match wears on the Stations
   map, so "the thing you asked for" looks the same everywhere in the app. */
.nv-node--hit circle {
  stroke: var(--hit-ring);
  stroke-width: 3;
}

.nv-node--unresolved circle { stroke-dasharray: 3 2; }

.nv-label {
  font-size: calc(10px / var(--nv-scale, 1));
  fill: var(--muted);
  pointer-events: none;
  paint-order: stroke;
  stroke: var(--panel-sub);
  stroke-width: 3px;
  stroke-linejoin: round;
  vector-effect: non-scaling-stroke;
}

.nv-hide-labels .nv-label { display: none; }

/* ── stage furniture ── */

.nv-top {
  position: absolute;
  top: .6rem;
  left: .7rem;
  right: .7rem;
  display: flex;
  gap: .4rem;
  flex-wrap: wrap;
  pointer-events: none;
  z-index: 2;
}

.nv-pill {
  background: color-mix(in srgb, var(--panel) 88%, transparent);
  border: 1px solid var(--border);
  border-radius: 999px;
  padding: .2rem .7rem;
  font-size: .78rem;
  color: var(--text);
  backdrop-filter: blur(6px);
}

.nv-pill--quiet { color: var(--muted); }

.nv-cap {
  position: absolute;
  left: .7rem;
  right: .7rem;
  bottom: .6rem;
  margin: 0;
  padding: .4rem .7rem;
  border-radius: 8px;
  background: color-mix(in srgb, var(--panel) 92%, transparent);
  border: 1px solid var(--border);
  color: var(--muted);
  z-index: 2;
  pointer-events: none;
}

.nv-tip {
  position: absolute;
  z-index: 4;
  max-width: 240px;
  padding: .45rem .6rem;
  border-radius: 8px;
  background: var(--panel);
  border: 1px solid var(--border);
  box-shadow: 0 8px 28px rgba(0, 0, 0, .22);
  font-size: .82rem;
  pointer-events: none;
}

.nv-detail {
  position: absolute;
  top: 2.6rem;
  right: .7rem;
  bottom: .7rem;
  width: min(330px, 62%);
  z-index: 3;
  overflow-y: auto;
  padding: .8rem .9rem 1rem;
  border-radius: 10px;
  background: var(--panel);
  border: 1px solid var(--border);
  box-shadow: 0 10px 34px rgba(0, 0, 0, .24);
}

.nv-detail h3 { margin: 0 1.4rem .2rem 0; font-size: 1rem; }
.nv-detail h4 {
  margin: .9rem 0 .3rem;
  font-size: .72rem;
  letter-spacing: .08em;
  text-transform: uppercase;
  color: var(--muted);
}

.nv-close {
  position: absolute;
  top: .4rem;
  right: .4rem;
  padding: 0 .45rem;
  font-size: 1.1rem;
  line-height: 1.4;
}

.nv-rel {
  padding: .35rem .5rem;
  margin: .25rem 0;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--panel-sub);
  font-size: .84rem;
}

.nv-rel span { display: block; color: var(--muted); }
.nv-ev { font-style: italic; }

.nv-warn {
  padding: .45rem .6rem;
  border-radius: 8px;
  border: 1px solid var(--border);
  background: color-mix(in srgb, var(--warn) 12%, transparent);
  color: var(--text);
  margin: .2rem 0 .6rem;
}

.nv-link {
  display: inline-block;
  padding: .35rem .6rem;
  border: 1px solid var(--border);
  border-radius: 8px;
  color: var(--accent);
  text-decoration: none;
  font-size: .85rem;
}

.nv-link:hover { background: var(--subtle); }

.nv-neigh { display: flex; flex-wrap: wrap; gap: .3rem; }

.nv-chip {
  width: auto;
  padding: .2rem .5rem;
  font-size: .78rem;
  border: 1px solid var(--border);
  border-radius: 999px;
  background: var(--panel-sub);
  cursor: pointer;
}

.nv-chip span {
  color: var(--muted);
  max-width: 9rem;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  display: inline-block;
  vertical-align: bottom;
}

/* ── sidebar controls ── */

.nv-stats {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: .4rem;
  margin-top: .7rem;
}

.nv-stat {
  background: var(--panel-sub);
  border: 1px solid var(--border);
  border-radius: 9px;
  padding: .4rem .5rem;
}

.nv-stat b    { display: block; font-size: 1.05rem; }
.nv-stat span { font-size: .68rem; color: var(--muted); }
.nv-src       { color: var(--muted); margin: .5rem 0 0; }

.nv-check {
  display: flex;
  align-items: center;
  gap: .5rem;
  margin: .35rem 0;
  font-size: .87rem;
}

.nv-check input { width: 16px; height: 16px; margin: 0; accent-color: var(--accent); }
.nv-check span:last-child:not(:first-of-type) { margin-left: auto; color: var(--muted); }

.nv-facet { border-top: 1px solid var(--border); padding: .4rem 0; }
.nv-facet:first-of-type { border-top: 0; }

.nv-facet > summary {
  cursor: pointer;
  font-size: .8rem;
  letter-spacing: .06em;
  text-transform: uppercase;
  color: var(--muted);
  padding: .2rem 0;
}

.nv-range {
  display: grid;
  grid-template-columns: 1fr 3.2rem;
  gap: .5rem;
  align-items: center;
  margin: .6rem 0 .4rem;
}

.nv-range input { padding: 0; accent-color: var(--accent); }
.nv-val { text-align: right; font-size: .8rem; font-weight: 600; }

.nv-legend {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: .25rem .5rem;
  margin-top: .5rem;
  font-size: .76rem;
  color: var(--muted);
}

.nv-dot {
  width: 9px;
  height: 9px;
  border-radius: 50%;
  display: inline-block;
  margin-right: .35rem;
  background: var(--dot, var(--muted));
}

/* Paragraph spacing in the rail and the cards (#140). Eight ad-hoc margins
   between .15rem and .6rem became these three, off the token scale: a note
   under the thing it explains, the same with room for what follows, and one
   standing on its own between two blocks. `.small` already carries the muted
   colour, so the inline `color: var(--muted)` that rode with every one of them
   was restating what the class had already said. */
.nv-p       { margin: var(--sp-1) 0 0; }
.nv-p-lead  { margin: var(--sp-1) 0 var(--sp-2); }
.nv-p-tight { margin: var(--sp-1) 0; }
.nv-row-lead { margin-bottom: var(--sp-2); }
.nv-select  { margin-top: var(--sp-1); }

/* The direction marker's rotation is data — the bearing from candidate to
   target — so it arrives as a token and the rule does the drawing. */
.nv-map-arrow svg { transform: rotate(var(--rot, 0deg)); }

/* The repeater popup's "pass range" tag, and the sentence under it. */
.nv-pop-pill {
  background: var(--dot, var(--muted));
  color: #fff;
  padding: 1px 5px;
  border-radius: 999px;
  font-size: .76rem;
  margin-left: 4px;
}
.nv-pop-sub {
  display: block;
  font-size: .82rem;
  margin-top: 4px;
}

.nv-drop {
  border: 1.5px dashed var(--border);
  border-radius: 9px;
  background: var(--panel-sub);
  padding: .9rem;
  text-align: center;
  color: var(--muted);
  font-size: .85rem;
  margin-bottom: .5rem;
}

.nv-drop--active { border-color: var(--accent); color: var(--accent); }

/* Below this the sidebar stops being a column and becomes a block above the
   graph, which then takes a fixed slice of the viewport rather than all of it —
   a graph pinned to full height on a phone leaves no way to scroll to the
   controls that drive it. */
@media (max-width: 900px) {
  .nv-layout { grid-template-columns: 1fr; }
  .nv-side   { height: auto; overflow: visible; }
  .nv-main   { grid-template-columns: 1fr; height: auto; gap: .75rem; }
  .nv-stage,
  .nv-map    { height: 52dvh; }
  .nv-detail { width: min(330px, 86%); }
}

/* ── ARRO Data tab ────────────────────────────────────────────────────────────
   Same two-column shape as the Network View: a column of controls that stays
   put, and a chart that takes whatever is left. The chart is the point, so it
   gets the height and the sidebar scrolls independently. */

.ad-wrap   { padding: .75rem; }
.ad-layout {
  display: grid;
  grid-template-columns: 320px minmax(0, 1fr);
  gap: .75rem;
  align-items: start;
}
.ad-side {
  height: calc(100dvh - var(--mn-chrome, 150px) - 2rem);
  overflow-y: auto;
  overscroll-behavior: contain;
  padding-right: .3rem;
  min-width: 0;
}
.ad-main { min-width: 0; }

.ad-drop {
  border: 1.5px dashed var(--border);
  border-radius: 9px;
  background: var(--panel-sub);
  padding: 1rem .9rem;
  text-align: center;
  color: var(--muted);
  font-size: .85rem;
}
.ad-drop--active { border-color: var(--accent); color: var(--accent); background: var(--subtle); }
.ad-drop-sub     { margin: var(--sp-1) 0 var(--sp-2); }
.ad-drop-busy    { margin-top: var(--sp-1); }

/* "Choose files…" and "Demo data" (#191). Centred with everything else in the
   drop zone, and wrapping rather than shrinking — the rail is 320 px and the
   second of the two is a link-weight button, so a row that squeezed them would
   squeeze the wrong one. */
.ad-drop-acts {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-1) var(--sp-3);
  align-items: center;
  justify-content: center;
}

/* The mark legend (#191): the shape a switch puts on the chart, drawn beside
   the switch. Inline with the text baseline rather than floated beside it, so a
   label that wraps takes its mark with it. currentColor is the whole point of
   the three tone classes — the SVG is written once and coloured by where it is
   standing. */
.ad-mark {
  display: inline-block;
  vertical-align: -.15em;
  flex: none;
}
.ad-mark--bad   { color: var(--bad); }
.ad-mark--warn  { color: var(--warn); }
.ad-mark--muted { color: var(--muted); }

/* ── Editing readings (#191) ─────────────────────────────────────────────────
   The editor stands in the readout strip under the chart, where the crosshair
   line normally is, because a selection is the same kind of thing: what the
   chart is currently about. Bordered rather than free-floating, so it reads as
   a thing that is holding something rather than as text that has appeared. */
.ad-edit {
  display: grid;
  gap: var(--sp-2);
  padding: var(--sp-2) var(--sp-3);
  border: 1px solid var(--accent);
  border-radius: var(--r-md);
  background: var(--subtle);
}
.ad-edit-head {
  display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-1) var(--sp-3);
}
.ad-edit-head .ad-x { margin-left: auto; }
.ad-edit-row {
  display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-1) var(--sp-2);
}
.ad-edit-row label { display: flex; align-items: center; gap: .3rem; color: var(--muted); }
.ad-qbox { width: 6rem; }
/* Delete is the one control here that cannot be undone, so it is the one
   control here that is coloured. */
.ad-edit-del { color: var(--bad); border-color: var(--bad); }
.ad-edit-note { margin: 0; color: var(--muted); }
.ad-picked-n { color: var(--accent); }

/* A picked row, and an edited one. Two channels because they stack: a row can
   be both, and a background that means two things at once means neither. */
.ad-row--picked  > td { background: var(--subtle); }
.ad-row--edited  > td:first-child { box-shadow: inset 3px 0 0 var(--warn); }
.ad-row-pick     { width: 1.6rem; }
.ad-row-verdict  { white-space: nowrap; }
.ad-edited-dot   { color: var(--warn); font-weight: 700; }

/* A cell that can be typed into. Borderless until it is hovered or focused, so
   three hundred rows do not read as three hundred boxes — the affordance
   arrives under the pointer, and the keyboard gets it from the focus ring. */
.ad-cell {
  width: 100%; min-width: 4rem; max-width: 8rem;
  padding: .1rem .25rem;
  font: inherit; color: inherit;
  background: none; border: 1px solid transparent; border-radius: 4px;
}
.ad-cell:hover { border-color: var(--border); }
/* The border and the ground change on focus; the focus *ring* stays, because a
   text box in a table is exactly the shape a keyboard user needs one on, and
   the two allowlisted `outline: none` rules in this file are both elements with
   no box worth ringing (see test/shell.mjs). A border colour is not a ring. */
.ad-cell:focus { border-color: var(--accent); background: var(--panel); }
.ad-cell--q { max-width: 5rem; }

.ad-table-acts {
  display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--sp-2);
  justify-content: space-between;
}
.ad-table-acts .ad-table-note { flex: 1 1 16rem; }
/* Full screen the table is the only thing on the screen, so it takes the height
   the modal card gives it rather than the .tall cap the in-page one uses. */
.ad-table-full { max-height: min(70dvh, 46rem); overflow: auto; }

.ad-note { min-height: 1.1rem; margin: .35rem 0 .1rem; color: var(--muted); }
.ad-note--bad { color: var(--bad); }

.ad-panel { margin-top: .6rem; padding: .6rem .7rem .7rem; }
.ad-panel .panel-header { margin-bottom: .35rem; }

/* A panel's own heading is an h2 under the shell's h1 (#141 — the tab used to
   open at h3, which told a screen reader it was a subsection of something that
   is not there). A section *inside* a panel is the h3, and it is a heading
   rather than a bold line so it appears in a heading list at all; the type step
   is what stops it reading as a second panel. */
.ad-panel > .panel-header h2 { font-size: var(--fs-md); }

.ad-subhead {
  padding: 0;
  border: 0;
  margin: var(--sp-2) 0 var(--sp-1);
}

.ad-subhead-h { font-size: var(--fs-xs); }

.ad-filt-lede { margin: 0 0 var(--sp-1); }

/* One import. The colour swatch doubles as the legend key, so it sits with the
   name rather than in a legend of its own. */
.ad-series {
  border-top: 1px solid var(--border);
  padding: .45rem 0 .5rem;
}
.ad-series:first-of-type { border-top: 0; }
.ad-series--sel { background: var(--subtle); }
.ad-series-top { display: flex; align-items: center; gap: .35rem; }
.ad-series-name {
  flex: 1; min-width: 0;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  font-size: .87rem;
}
.ad-series-meta { color: var(--muted); margin-top: .18rem; display: flex; flex-wrap: wrap; gap: .3rem; align-items: center; }
.ad-series-meta select { font-size: .75rem; padding: 0 .15rem; }
.ad-swatch {
  width: 20px; height: 20px; padding: 0;
  border: 1px solid var(--border); border-radius: 4px;
  background: none; cursor: pointer; flex: none;
}

/* ── The colour control (#191) ───────────────────────────────────────────────
   A dot that is the series' colour, and a disclosure holding twelve named
   swatches plus the native picker for anything not among them. A <details>
   rather than a popover: no positioning, no outside-click handler, no
   z-index — it pushes the card down while it is open, which on a 320 px rail
   is the honest way to show a grid. */
.ad-colour { flex: none; position: relative; }
.ad-colour > summary {
  display: block;
  width: 20px; height: 20px;
  border: 1px solid var(--border); border-radius: 4px;
  background: var(--sw, var(--accent));
  cursor: pointer;
  list-style: none;
}
.ad-colour > summary::-webkit-details-marker { display: none; }
.ad-colour > summary::marker { content: ''; }
.ad-colour > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.ad-colour[open] > summary { box-shadow: 0 0 0 2px var(--accent); }

/* Out of the flow, so opening the grid does not re-lay the card's head row and
   push the name and the ✕ around. Anchored to the card rather than to the
   20 px summary, hence `left: 0` on a container that is only as wide as the
   dot — `position: relative` on .ad-colour makes that the dot's own corner,
   which is where the grid should hang from. */
.ad-colour-pop {
  position: absolute;
  z-index: 6;
  top: calc(100% + .25rem);
  left: 0;
  width: max-content;
  max-width: 15rem;
  padding: var(--sp-2);
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: var(--r-md);
  box-shadow: 0 4px 14px rgba(0,0,0,.16);
}
.ad-swatches {
  display: grid;
  grid-template-columns: repeat(6, 1.35rem);
  gap: .25rem;
}
/* The border is --text at a low alpha rather than --border, so the white swatch
   has an edge on a white panel and the black one has an edge on a dark one —
   the two that are deliberately invisible on one ground each (see AD_SWATCHES)
   would otherwise be an invisible square in the grid as well as on the chart. */
.ad-sw {
  width: 1.35rem; height: 1.35rem; padding: 0;
  border: 1px solid color-mix(in srgb, var(--text) 45%, transparent);
  border-radius: 4px;
  background: var(--sw);
  cursor: pointer;
}
/* The one that is currently set. A ring rather than a tick, because a tick over
   a dark swatch and a tick over a light one cannot both be legible. */
.ad-sw.is-on { box-shadow: 0 0 0 2px var(--panel), 0 0 0 4px var(--accent); }
.ad-sw:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.ad-colour-any {
  display: flex; align-items: center; gap: .35rem;
  margin-top: var(--sp-2); color: var(--muted);
}
.ad-colour-any input { width: 2rem; height: 1.35rem; padding: 0; border: 1px solid var(--border); }
.ad-x {
  border: 0; background: none; color: var(--muted);
  cursor: pointer; padding: 0 .25rem; font-size: .9rem; line-height: 1;
}
.ad-x:hover { color: var(--bad); }
.ad-unlinked { color: var(--warn); }
.ad-warn { color: var(--warn); margin-top: .2rem; }

.ad-cfg-row {
  display: flex; align-items: center; justify-content: space-between;
  gap: .5rem; margin: .2rem 0; font-size: .8rem;
}
.ad-cfg-row input { width: 84px; }

/* The picker's rows are a label above a full-width field rather than a label
   beside a number box — a station name does not fit in 84 px. Same class, one
   modifier, so the two read as the same kind of row. */
.ad-cfg-row--block  { display: block; }
.ad-cfg-row--spaced { margin-top: var(--sp-1); }
/* Scoped, because `.ad-cfg-row input` above is (0,1,1) and would otherwise hold
   a station-name box at the 84 px a number box wants. */
.ad-cfg-row .ad-field-input { width: 100%; margin-top: var(--sp-1); }
.ad-cfg-chk { display: flex; align-items: center; gap: .35rem; font-size: .8rem; margin: .25rem 0; }
.ad-cfg-note { color: var(--muted); margin: .45rem 0 0; }
.ad-panel-acts { display: flex; gap: .6rem; align-items: center; flex-wrap: wrap; }

/* One filter, one switch, and the settings it governs. A filter that is off
   keeps its settings on screen — greyed, not gone — so the panel does not
   reflow every time a switch is flicked and "what did that one do?" stays a
   click away from being answered. */
.ad-filt { border-top: 1px solid var(--border); padding: .45rem 0 .3rem; }
.ad-filt:first-of-type { border-top: 0; padding-top: .15rem; }
.ad-filt-head {
  display: flex; align-items: center; gap: .4rem;
  font-size: .82rem; font-weight: 600; cursor: pointer;
}
.ad-filt-body { padding-left: 1.35rem; }
.ad-filt--off .ad-filt-head { color: var(--muted); font-weight: 500; }
/* A switched-off filter's mark dims with its heading rather than staying the
   one bright thing in a greyed row (#191). */
.ad-filt--off .ad-mark { color: inherit; }
.ad-filt--off .ad-filt-body { opacity: .5; }

/* One quality code per row: a tick and the letters on the left, how many
   readings carry it and what they span on the right (revision 92).

   `.ad-cfg-row input { width: 84px }` above is (0,1,1) and would stretch each
   checkbox into an 84 px bar, so the box is pulled back to its intrinsic size
   here — the same override `.ad-field-input` needed, for the same reason.

   The rows stay legible with the filter switched off, at the .5 opacity every
   other filter body dims to, because the counts are half the point of the
   control: somebody works out *which* code to exclude by reading them, and that
   reading happens before the switch is flicked, not after. */
.ad-qual-row { gap: .75rem; align-items: baseline; }
.ad-qual-row input[type="checkbox"] { width: auto; flex: none; }
.ad-qual-code { display: inline-flex; align-items: baseline; gap: .35rem; min-width: 0; }
.ad-qual-code b { overflow-wrap: anywhere; }
/* Right-aligned and tabular so the counts form a column that can be scanned
   down rather than a ragged edge that has to be read word by word. */
.ad-qual-n {
  color: var(--muted); text-align: right; white-space: nowrap;
  font-variant-numeric: tabular-nums;
}

/* A link that has to read as part of a sentence. .btn-link is a bordered
   button, which inside a run of stats or a paragraph shoulders the words apart
   and announces itself as a control it is not. */
.ad-inline-link {
  display: inline; border: 0; background: none; padding: 0;
  font: inherit; color: var(--accent);
  text-decoration: underline; text-underline-offset: 2px; cursor: pointer;
}
.ad-inline-link.ad-inline-link:hover { text-decoration-thickness: 2px; }

/* Two charts of the same window, folded away until asked for — and, since #141,
   the same numbers as a table, folded away beside it. Same treatment, because
   they are the same kind of thing: a second look at what the chart is already
   showing. The table is part 3 of the chart pattern (docs/design-system.md §3),
   which is why it is not optional furniture: it is the only route to the values
   that does not involve pointing at a pixel. */
.ad-compare,
.ad-table { margin-top: .6rem; border-top: 1px solid var(--border); padding-top: .4rem; }
.ad-compare > summary,
.ad-table > summary {
  cursor: pointer; font-size: .85rem; font-weight: 600;
  padding: .2rem 0; list-style-position: outside;
}
.ad-compare > summary .small,
.ad-table > summary .small { font-weight: 400; color: var(--muted); }
.ad-table-note { margin: var(--sp-2) 0 0; }
.ad-compare-grid {
  display: grid; grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: .6rem; margin-top: .5rem;
}
.ad-compare-grid figure { margin: 0; min-width: 0; }
.ad-compare-grid figcaption {
  font-size: .8rem; font-weight: 600; margin-bottom: .25rem;
  display: flex; gap: .35rem; align-items: baseline;
}
.ad-compare-grid figcaption .small { font-weight: 400; color: var(--muted); }
.ad-compare-grid svg {
  display: block; width: 100%; height: auto;
  border: 1px solid var(--border); border-radius: 8px; background: var(--panel);
}
@media (max-width: 700px) {
  .ad-compare-grid { grid-template-columns: 1fr; }
}

/* The chart toolbar. Three rows, and the split between them is the point: what
   the chart draws, then the window on the record and what happens over it, then
   the ways to take it away. A rule between each, a caption over every cluster.

   Rows rather than one wrapping run, because a flat run re-groups itself at
   every width — at 1440 the checkboxes sat beside the axis buttons and at 1100
   they did not, so what looked like a group was an accident of the viewport.
   Rows that each wrap internally keep the grouping the same at every width,
   which is the property a caption is claiming. */
.ad-toolbar {
  display: flex; flex-direction: column;
  gap: var(--sp-2); margin-bottom: var(--sp-2);
  padding: var(--sp-2) var(--sp-3);
  border: 1px solid var(--border); border-radius: var(--r-md);
  background: var(--panel-sub);
}
/* `stretch`, so every caption in a row starts at the same line however tall the
   controls under it are — a row of buttons is taller than a row of ticks, and
   bottom-aligning the groups put their captions at two different heights. The
   controls take the slack instead, centred inside the group. */
.ad-toolbar-row {
  display: flex; flex-wrap: wrap;
  align-items: stretch; gap: var(--sp-2) var(--sp-4);
}
/* The divider between the two rows. On the row rather than between the groups
   inside it: a vertical rule between wrapping groups leads the next line the
   moment one wraps, which reads as a broken layout rather than a separator. */
.ad-toolbar-row + .ad-toolbar-row {
  border-top: 1px solid var(--border); padding-top: var(--sp-2);
}
.ad-grp { display: flex; flex-direction: column; gap: var(--sp-1); min-width: 0; }
.ad-grp-cap {
  font-size: var(--fs-xs); color: var(--muted);
  text-transform: uppercase; letter-spacing: .04em; line-height: 1;
}
.ad-grp-body {
  display: flex; flex: 1; flex-wrap: wrap;
  align-items: center; gap: var(--sp-1) var(--sp-2);
}
.ad-tool-grp { display: flex; gap: .25rem; align-items: center; flex-wrap: wrap; }
.ad-num { width: 76px; }
/* The two Fixed-range boxes only exist while Fixed is chosen, so they arrive
   beside the axis buttons rather than under them. */
.ad-yrange { gap: var(--sp-1); }
.ad-chk { display: flex; align-items: center; gap: .25rem; font-size: .78rem; color: var(--muted); }
/* A tick whose filter is not running. The control is `disabled` too, so this is
   the visible half of a state the accessibility tree already has. */
.ad-chk--off { opacity: .45; }

/* The sensor list's ticks sit against a two-part label — name, then address —
   so they align on the text rather than on the box's middle. */
.ad-sensor-row { gap: var(--sp-1); align-items: baseline; font-size: var(--fs-sm); color: var(--text); }

.ad-field-none   { margin: var(--sp-1) 0; }
.ad-field-noaddr { margin-top: var(--sp-1); }
.ad-field-run    { width: 100%; margin-top: var(--sp-2); }
.ad-field-msg    { margin-top: var(--sp-1); }
.ad-flush        { margin: 0; }

/* Segmented control: one choice out of a few, reading as a single object. */
.ad-seg { display: inline-flex; border: 1px solid var(--border); border-radius: 7px; overflow: hidden; }
.ad-seg--wrap { flex-wrap: wrap; }
.ad-seg button {
  border: 0; border-radius: 0; margin: 0;
  padding: .26rem .55rem; font-size: .78rem;
  background: var(--panel); color: var(--muted);
  border-left: 1px solid var(--border);
}
.ad-seg button:first-child { border-left: 0; }
.ad-seg button.on { background: var(--accent); color: #fff; }

/* ── The plot and its two navigators (#191) ──────────────────────────────────
   A 2×2 grid: the chart, the vertical navigator beside it, the whole-record
   strip under it, and the corner between the two navigators left empty. The
   grid is what makes each navigator exactly as long as the axis it steers —
   which is not a nicety: a track that is a different length from its axis puts
   the window box somewhere other than where the window is, and the whole
   gesture is "drag the box to where you want the window", so the two have to
   line up to the pixel. */
.ad-plot {
  position: relative;
  display: grid;
  grid-template-columns: minmax(0, 1fr) 46px;
  grid-template-rows: auto auto;
  gap: .4rem;
}

.ad-stage {
  position: relative;
  grid-column: 1; grid-row: 1;
  height: clamp(280px, 52dvh, 620px);
  border: 1px solid var(--border);
  border-radius: 9px;
  background: var(--panel);
  overflow: hidden;
  touch-action: none;      /* the chart handles its own pan and zoom */
}
.ad-stage:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.ad-stage svg { display: block; width: 100%; height: 100%; cursor: crosshair; }

/* Reset and full screen, over the chart's top-right corner. Absolute rather
   than a grid cell of their own: they sit *on* the picture, which is what says
   they are about the picture. Right of the vertical navigator's left edge would
   overlap it, so they are inset from the stage's own right edge instead. */
.ad-stage-acts {
  position: absolute;
  z-index: 4;
  top: .45rem;
  right: calc(46px + .4rem + .45rem);
  display: flex;
  gap: .25rem;
}
.ad-stage-btn {
  display: flex; align-items: center; justify-content: center;
  width: 1.9rem; height: 1.9rem; padding: 0;
  font-size: .95rem; line-height: 1;
  background: var(--panel); color: var(--muted);
  border: 1px solid var(--border); border-radius: 7px;
  box-shadow: 0 1px 4px rgba(0,0,0,.1);
  cursor: pointer;
}
.ad-stage-btn:hover,
.ad-stage-btn[aria-pressed="true"] { color: var(--accent); border-color: var(--accent); }

/* The overview reads as three grips and the cursor names each before it is
   pressed: pointer on the open strip (press brings the window there), grab on
   the window itself (drag moves it), ew-resize on the invisible zones drawn
   over its edges (drag widens or narrows it). The zone rects come from
   drawOv() carrying only a class — hit shapes, painted at fill-opacity 0 —
   and everything visible over them, the handles included, is drawn with
   pointer-events="none" so a painted shape can never answer the pointer in
   place of the zone that knows what a press there means. */
.ad-ov {
  display: block; width: 100%; height: 56px;
  grid-column: 1; grid-row: 2;
  border: 1px solid var(--border); border-radius: 7px;
  background: var(--panel); cursor: pointer;
  touch-action: none;
}
.ad-ov-mid  { cursor: grab; }
.ad-ov-edge { cursor: ew-resize; }

/* …and the same three grips down the right, turned ninety degrees. Same
   height as the stage beside it, and ns-resize rather than ew- because the
   edges it offers are horizontal ones. */
.ad-vov {
  display: block; width: 46px; height: 100%;
  grid-column: 2; grid-row: 1;
  border: 1px solid var(--border); border-radius: 7px;
  background: var(--panel); cursor: pointer;
  touch-action: none;
}
.ad-vov-mid  { cursor: grab; }
.ad-vov-edge { cursor: ns-resize; }
.ad-ov-mid:active,
.ad-vov-mid:active { cursor: grabbing; }

/* While a drag is running, the gesture states the cursor for the whole element
   instead of leaving it to whichever zone the pointer happens to be over. A
   resize routinely takes the pointer off the end of the track or up onto the
   chart with the gesture still live — the element holds the pointer capture —
   and without this the double arrow flicks back to a plain pointer halfway
   through, which reads as the cursor being unreliable. `!important` because it
   has to beat the zone rules above on the SVG's own children. */
.ad-ov.is-resizing,  .ad-ov.is-resizing  * { cursor: ew-resize !important; }
.ad-vov.is-resizing, .ad-vov.is-resizing * { cursor: ns-resize !important; }
.ad-ov.is-moving,  .ad-ov.is-moving  *,
.ad-vov.is-moving, .ad-vov.is-moving * { cursor: grabbing !important; }

/* Full screen (#191). The same shape as the map's own: fixed over everything,
   below #app-modal so the bug reporter still opens on top, and a column so the
   chart takes whatever the strip under it does not need. The grid inside is
   untouched — it is still a stage, a column beside it and a strip below — only
   the height it is laid out in has changed. */
.ad-plot.is-full {
  position: fixed;
  inset: 0;
  z-index: 1900;
  padding: .5rem;
  background: var(--bg);
  grid-template-rows: minmax(0, 1fr) auto;
}
.ad-plot.is-full .ad-stage { height: 100%; min-height: 0; }

.ad-tip {
  position: absolute; z-index: 5; pointer-events: none;
  background: var(--panel); border: 1px solid var(--border); border-radius: 7px;
  box-shadow: 0 2px 10px rgba(0,0,0,.14);
  padding: .35rem .5rem; font-size: .76rem; max-width: 300px;
}
.ad-tip-t { color: var(--muted); margin-bottom: .15rem; }
.ad-tip-r { display: flex; align-items: center; gap: .3rem; white-space: nowrap; }
/* What the reading did either side of itself, under the row it belongs to.
   Indented past the legend dot so it reads as part of that row rather than as
   a row of its own, and muted so the value above stays what the eye lands on —
   the differences are the second question, asked while already looking. */
.ad-tip-d {
  color: var(--muted); white-space: nowrap; font-size: .93em;
  margin: 0 0 .15rem calc(9px + .3rem);
}
.ad-tip-d b { color: var(--text); font-weight: 600; }
.ad-tip-d b.txt-warn { color: var(--warn); }

/* The legend key beside a series name, in the readout, the tooltip and the
   pinned reading. Its colour is the series' own, so it arrives as a token on
   the element — `style="--dot:#0b5cab"` — rather than as an inline `background`.
   That is the distinction #109's proving-ground check and #137's basin fills
   both rest on: a custom property on an element is the system reaching it; a
   declaration is the element deciding. */
.ad-dot {
  width: 9px; height: 9px; border-radius: 50%; display: inline-block; flex: none;
  background: var(--dot, var(--muted));
}

.ad-readout {
  margin-top: .5rem; min-height: 2.6rem;
  border: 1px solid var(--border); border-radius: 9px;
  background: var(--panel-sub); padding: .5rem .6rem;
}
.ad-read-hover, .ad-stats { display: flex; flex-wrap: wrap; gap: .35rem .9rem; align-items: center; font-size: .8rem; }
.ad-read-item, .ad-stat { display: flex; align-items: center; gap: .3rem; }

/* The pinned reading: everything the export said about one row, plus why the
   filter reached the verdict it did. */
.ad-pin-head { display: flex; align-items: center; gap: .4rem; }
.ad-pin-head b { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ad-pin-grid {
  display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
  gap: .1rem .8rem; margin: .35rem 0 .2rem;
}
.ad-pin-grid div { display: flex; gap: .35rem; font-size: .8rem; }
.ad-pin-grid span { color: var(--muted); min-width: 66px; }
/* The two Δ cells carry a second line — the gap either side and the rate
   across it — which will not fit beside the figure in a 150 px column. They
   wrap it under the figure rather than squeezing the column for everyone, and
   the indent keeps it hanging off its own label. */
.ad-pin-grid .ad-delta { flex-wrap: wrap; }
.ad-pin-grid .ad-delta .ad-delta-note {
  flex: 1 0 100%; min-width: 0; margin-left: calc(66px + .35rem);
}
.ad-pin-why { color: var(--muted); }
.ad-badge { font-size: .7rem; padding: .05rem .4rem; border-radius: 20px; border: 1px solid var(--border); }
.ad-badge--ok   { color: var(--ok);   border-color: var(--ok); }
.ad-badge--bad  { color: var(--bad);  border-color: var(--bad); }
.ad-badge--warn { color: var(--warn); border-color: var(--warn); }
.ad-badge--dup  { color: var(--muted); }
/* Field Data's provenance chip. Accent rather than a neutral grey because its
   whole job is to be noticed — the one thing that must never be ambiguous on
   this tab is which system the numbers came from. */
.ad-badge--src  { color: var(--accent); border-color: var(--accent); }

/* ── Field Data (#114) ───────────────────────────────────────────────────── */

/* The line above the chart that says where these readings came from and at what
   resolution. Sits inside the chart column so it cannot be scrolled away from
   the thing it is describing. */
.ad-prov {
  display: flex; flex-wrap: wrap; gap: .35rem; align-items: baseline;
  padding: .35rem .5rem; margin: 0 0 .4rem;
  border: 1px solid var(--border); border-left: 3px solid var(--accent);
  border-radius: 6px; background: var(--subtle);
  font-size: .8rem;
}
.ad-prov > span:not([class]) { color: var(--muted); }

.ad-field-picked {
  display: flex; align-items: center; gap: .4rem;
  margin: .35rem 0 .1rem; padding: .3rem .4rem;
  border: 1px solid var(--border); border-radius: 6px; background: var(--subtle);
}
.ad-field-picked b { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* The station search results. Capped in the markup, so this never has to scroll
   far — but it is scrollable anyway, because a sidebar that grows past the fold
   pushes the sensor list and the Load button off screen. */
.ad-field-hits { display: flex; flex-direction: column; max-height: 220px; overflow-y: auto; margin: .3rem 0; }
.ad-field-hit {
  text-align: left; background: none; border: 0; border-bottom: 1px solid var(--border);
  padding: .3rem .2rem; font-size: .82rem; color: inherit; cursor: pointer;
}
.ad-field-hit:hover { background: var(--subtle); }
.ad-field-hit:last-child { border-bottom: 0; }

.ad-field-sensors { margin-top: .2rem; }
.ad-field-dates { display: flex; align-items: center; gap: .35rem; margin-top: .3rem; }
.ad-field-dates input { flex: 1; min-width: 0; }

/* What the datastore holds, under what the registry says it should. Ruled off
   above rather than boxed: it is a second answer to the same question the
   Sensors list answers, and a box would read as a different subject. */
.ad-field-probe {
  margin-top: .5rem;
  padding-top: .3rem;
  border-top: 1px solid var(--border);
}

/* Capped in the markup at AD_PROBE_ADDR_CAP, so this rarely has to scroll —
   but a station with thirty addresses would otherwise push the window, the
   resolution and the Load button off the bottom of the rail. */
.ad-probe-found {
  display: flex; flex-direction: column;
  max-height: 260px; overflow-y: auto;
  margin: .3rem 0;
}

.ad-probe-row { align-items: flex-start; }
.ad-probe-row input { margin-top: .2rem; }

/* The small print on a row: which registry it is in, or which station row it
   is filed under. A tag rather than a sentence because there is one per line
   and thirty sentences is a paragraph nobody reads. */
.ad-probe-tag {
  display: inline-block;
  padding: 0 .3rem;
  border: 1px solid var(--border);
  border-radius: var(--r-pill);
  color: var(--muted);
  white-space: nowrap;
}

/* Readings filed under a *different* station than the one picked. Marked in
   the accent rather than in a warning colour: it is not an error, it is the
   normal way a workshop rig or an unresolved address looks, and the sentence
   under the list is what explains it. */
.ad-probe-tag--other {
  border-color: var(--accent);
  color: var(--accent);
}

.ad-empty { max-width: 620px; margin: 2rem auto; padding: 1.5rem; }
.ad-empty h2 { margin: 0 0 .6rem; }
.ad-empty p { color: var(--muted); margin: .5rem 0; }

@media (max-width: 900px) {
  .ad-layout { grid-template-columns: 1fr; }
  .ad-side   { height: auto; overflow: visible; }
  .ad-stage  { height: 54dvh; }
  /* The vertical navigator is a pointer gesture in a 46 px column, which on a
     phone is a target nobody can hit and a column the chart cannot spare. The
     axis is still fully steerable from the toolbar's four modes and its two
     number boxes, which is what it was before this existed. */
  .ad-plot   { grid-template-columns: minmax(0, 1fr); }
  .ad-vov    { display: none; }
  .ad-stage-acts { right: .45rem; }

  /* Stacked, the rail comes first in the DOM and put about a thousand pixels of
     imports and filter settings above the chart — on the tab whose entire point
     is the chart. Same trade the map tabs already make above (`.map-layout >
     .sidebar { order: 2 }`), and the same reasoning: on a phone the thing the
     tab is for goes first.

     Only once there is a chart to put there (#141). With nothing loaded the
     "main" column is a paragraph explaining what to drop on the tab, and the
     rail is the drop zone and the station picker — so with no data the input
     is the point, and it keeps the top of the screen. */
  .ad-wrap--charted .ad-side { order: 2; }
}

/* ── Message Log tab ─────────────────────────────────────────────────────────
   The arrival log. Two jobs the layout has to do: keep the table the first
   thing on the page (the tray above it is collapsed by default and stays a
   one-line summary until opened), and survive a phone — which is what the
   narrow view, the fixed-width station-name column and the wrap rules below
   are for. Everything else is the shared table/panel system. */

.ml-wrap { display: flex; flex-direction: column; gap: var(--sp-3); padding: var(--sp-3); }

.ml-header { flex-wrap: wrap; gap: var(--sp-2); }
.ml-header-tools { display: flex; flex-wrap: wrap; gap: var(--sp-2); align-items: center; margin-left: auto; }

.ml-viewswitch { display: inline-flex; border: 1px solid var(--ctl-border); border-radius: var(--r-md); overflow: hidden; }
.ml-seg {
  border: 0; background: var(--panel); color: inherit; cursor: pointer;
  padding: .25rem .6rem; font-size: var(--fs-sm);
}
.ml-seg + .ml-seg { border-left: 1px solid var(--ctl-border); }
.ml-seg--on { background: var(--accent); color: #fff; }

.ml-btn {
  border: 1px solid var(--ctl-border); border-radius: var(--r-md);
  background: var(--panel); color: inherit; cursor: pointer;
  padding: .2rem .55rem; font-size: var(--fs-sm);
}
.ml-btn:hover { background: var(--subtle); }

/* The filter rail. A flex wrap rather than a grid: the blocks are different
   shapes and the row is allowed to break wherever it has to. */
.ml-filters { display: flex; flex-wrap: wrap; gap: var(--sp-3); align-items: flex-end; margin: var(--sp-2) 0; }
.ml-f { display: flex; flex-direction: column; gap: .2rem; font-size: var(--fs-sm); }
.ml-f > span, .ml-f label > span:first-child { color: var(--muted); font-size: var(--fs-xs); }
.ml-f--find { flex: 1 1 260px; }
.ml-f--find textarea {
  width: 100%; resize: vertical; min-height: 1.9rem;
  font: inherit; font-size: var(--fs-sm);
}
.ml-f--win { flex-direction: row; flex-wrap: wrap; gap: .25rem; align-items: center; }
.ml-f--dates { flex-direction: row; gap: var(--sp-2); }
.ml-f--dates label { display: flex; flex-direction: column; gap: .2rem; }
.ml-f--run { display: flex; gap: var(--sp-2); align-items: center; }

.ml-chip {
  border: 1px solid var(--ctl-border); border-radius: var(--r-pill);
  background: var(--panel); color: inherit; cursor: pointer;
  padding: .18rem .6rem; font-size: var(--fs-sm);
}
.ml-chip--on { background: var(--accent); border-color: var(--accent); color: #fff; }

.ml-check { display: inline-flex; gap: .3rem; align-items: center; font-size: var(--fs-sm); cursor: pointer; }

.ml-more summary { cursor: pointer; font-size: var(--fs-sm); color: var(--muted); }
.ml-more-grid { display: flex; flex-wrap: wrap; gap: var(--sp-3); align-items: flex-end; padding: var(--sp-2) 0; }

/* The column chooser — a small dropdown, not a modal. */
.ml-colpick { position: relative; }
.ml-colpick summary {
  cursor: pointer; font-size: var(--fs-sm); list-style: none;
  border: 1px solid var(--ctl-border); border-radius: var(--r-md);
  background: var(--panel); padding: .2rem .55rem;
}
.ml-colpick[open] summary { background: var(--subtle); }
.ml-colpick-list {
  position: absolute; right: 0; z-index: 40; min-width: 200px;
  display: flex; flex-direction: column; gap: .3rem;
  background: var(--panel); border: 1px solid var(--border); border-radius: var(--r-md);
  box-shadow: var(--shadow-2); padding: var(--sp-3); margin-top: .25rem;
}

.ml-note { margin: .2rem 0 var(--sp-2); }
.ml-flush { color: var(--muted); padding: var(--sp-3) 0; }

/* The wrapper's height comes from the tab's own Short/Tall/Max switch,
   emitted as --ml-h on the element (the --dot pattern: a custom property is
   the system reaching the element). Scoped under .ml-wrap so every other
   height-capped table keeps the shared 42vh; the fallback matches the
   default step so a wrapper missing the property looks the same. */
.ml-wrap .table-wrap.tall { max-height: var(--ml-h, 68vh); }

/* The table. Time is the leftmost data column and its cell is the row's
   expand button; the station name is capped so it cannot spend the whole of
   a phone's width on one long site name. */
.ml-table td, .ml-table th { white-space: nowrap; }

/* Column grips — a slim strip on each header's right edge that drags the
   column's width. Inside the th (which clips overflow) rather than straddling
   the border, wide enough to hit with a mouse, and visible only when pointed
   at: a faint bar says "this edge moves". The phone pass hides them below
   900px, where dragged widths are neutralised anyway. */
.ml-table th { position: relative; }
.ml-col-grip {
  position: absolute; top: 0; right: 0; width: 9px; height: 100%;
  cursor: col-resize; touch-action: none;
}
.ml-col-grip::after {
  content: ''; position: absolute; top: 18%; bottom: 18%; right: 3px;
  width: 2px; border-radius: 1px; background: transparent;
}
.ml-col-grip:hover::after,
.ml-col-grip:active::after { background: var(--ctl-border); }
.ml-pick { width: 1.6rem; text-align: center; }
.ml-ts { padding-right: .4rem; }
.ml-expand {
  border: 0; background: none; color: inherit; cursor: pointer;
  font: inherit; padding: 0; text-decoration: underline;
  text-decoration-color: var(--ctl-border); text-underline-offset: 2px;
}
.ml-expand:hover { text-decoration-color: var(--accent); }
.ml-name { max-width: 11em; overflow: hidden; text-overflow: ellipsis; }
.ml-raw { font-weight: 600; }
.ml-row--sel td { background: var(--rx-bg); }
.ml-row--open td { border-bottom: 0; }

/* The detail drawer — the ad-pin grid's shape, one row per fact, the label in
   the muted column. */
.ml-detail td { background: var(--subtle); }
.ml-det-grid { display: flex; flex-direction: column; gap: .3rem; padding: .3rem .2rem; white-space: normal; }
.ml-det-grid > div { display: flex; flex-wrap: wrap; gap: .4rem; align-items: baseline; font-size: var(--fs-sm); }
.ml-det-grid > div > span:first-child { color: var(--muted); min-width: 84px; font-size: var(--fs-xs); }
.ml-det-grid .small { color: var(--muted); }
.ml-payload pre {
  margin: 0; padding: .4rem .5rem; max-height: 240px; overflow: auto;
  background: var(--panel); border: 1px solid var(--border); border-radius: var(--r-sm);
  white-space: pre-wrap; word-break: break-word;
}
.ml-frame { white-space: normal; word-break: break-all; }

/* Claiming an address from the drawer (#172). It sits inside the Station cell of
   the detail grid, which is a flex row of baseline-aligned pieces — so this takes
   the whole width and stacks, or the picker would try to share a line with the
   sentence explaining why the row is unresolved. No panel of its own: the drawer
   is already a surface, and a second one inside it reads as a dialog that is not
   one. */
.ml-claim {
  flex: 1 0 100%;
  display: flex;
  flex-direction: column;
  gap: var(--sp-1);
  margin-top: var(--sp-1);
}

.ml-claim label { display: flex; flex-direction: column; gap: .15rem; }
.ml-claim input { width: 100%; max-width: 26rem; }
.ml-claim-note { color: var(--muted); margin: 0; }

/* The hit list. Capped in height rather than in nothing: 25 matches is the cap
   the other pickers use and 25 rows would push the rest of the drawer off the
   screen on a phone. */
.ml-claim-hits {
  display: flex;
  flex-direction: column;
  gap: 1px;
  max-height: 12rem;
  overflow-y: auto;
  max-width: 26rem;
  border: 1px solid var(--border);
  border-radius: var(--r-sm);
}

.ml-claim-hit {
  display: flex;
  justify-content: space-between;
  gap: var(--sp-2);
  align-items: baseline;
  text-align: left;
  width: 100%;
  border: 0;
  border-radius: 0;
  padding: .25rem .45rem;
  background: transparent;
}

.ml-claim-hit:hover,
.ml-claim-hit:focus-visible { background: var(--subtle); }

/* The tray. Collapsed it is one summary line; open it is the plot and the map
   side by side, stacking on anything narrower than a laptop. */
.ml-tray summary { cursor: pointer; padding: .2rem 0; }
/* The tray is the map, full width — it shared the row with a value-against-time
   plot until that plot came out, and the height goes up with the width so the
   map is worth the space it now has all of. */
.ml-tray-body { padding-top: var(--sp-3); }
.ml-mapbox { min-width: 0; }
.ml-map { height: 460px; border: 1px solid var(--border); border-radius: var(--r-md); }
.ml-map-note { color: var(--muted); margin: .3rem 0 0; }

@media (max-width: 1100px) {
  .ml-map { height: 360px; }
}
@media (max-width: 700px) {
  .ml-f--dates { flex-wrap: wrap; }
  .ml-map { height: 260px; }
}

/* ══ Mobile pass ═══════════════════════════════════════════════════════════
   Everything below is width- or pointer-conditional and deliberately lives at
   the end of the file, so it lands after the component rules it tunes without
   any of them needing to be written twice. Three separate questions are being
   answered, and they are not the same question:

     · ≤ 900 px  — a tablet held upright. The width at which the header's
                   button labels stop fitting on one row, and at which a table
                   is better scrolled than squeezed. The layout above already
                   stacks its columns by itself around here.
     · ≤ 560 px  — a phone. Where the nav rail leaves the layout (see the nav
                   section above) and the banner folds down to a single row.
     · coarse    — a finger rather than a mouse, whatever the width. Hit areas,
                   not layout.
   ────────────────────────────────────────────────────────────────────────── */

/* ── Inspections tab (#116) ──────────────────────────────────────────────────
   The six paper sheets, digitised. The layout is doing one job that is not
   ordinary form styling: a person who has filled these in on paper has to
   recognise the thing on screen. So the banner-headed block is the unit — a
   shaded strip with the section's printed name, its print-order number, and
   the sheet's own variant note under it — and the boxes inside one sit in a
   dense grid rather than a column, the way a fine merged-cell grid reads on
   A4.

   Nothing here touches a shared rule or a token; every selector is `insp-`
   and scoped to this tab, which is what #113's constraint 2 asks of a tab
   that is not #109. */

.insp-page {
  padding: 1rem;
  display: grid;
  gap: .75rem;
  max-width: 1180px;      /* an A4 page is not 2000 px wide, and neither is a form */
  margin: 0 auto;
}

.insp-sub { color: var(--muted); margin: .2rem 0 .4rem; }

.insp-head-actions { display: flex; gap: .5rem; flex-wrap: wrap; }

.insp-note { padding: 1rem; }
.insp-bad  { border-left: 3px solid var(--bad); }

/* The picker: search on the left, drafts and recent visits on the right. */
.insp-cols {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  gap: .75rem;
  align-items: start;
}

.insp-search { display: grid; gap: .3rem; font-size: .9rem; color: var(--muted); }

.insp-hits {
  display: grid;
  gap: .2rem;
  margin: .6rem 0;
  max-height: 22rem;
  overflow: auto;
}

.insp-hit {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: .75rem;
  text-align: left;
  width: 100%;
  padding: .35rem .5rem;
}

.insp-hit-name { font-weight: 600; }
.insp-muted    { color: var(--muted); }

.insp-list { display: grid; gap: .1rem; }

.insp-row {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: .5rem;
  padding: .3rem 0;
  border-bottom: 1px solid var(--border);
}

.insp-row:last-child { border-bottom: 0; }

.insp-drop {
  margin-left: auto;
  padding: .1rem .4rem;
  font-size: .78rem;
  border-color: #c7401a;
  color: #c7401a;
}

/* ── The form ── */

.insp-form-head .insp-cfg {
  display: grid;
  gap: .3rem;
  font-size: .9rem;
  color: var(--muted);
  max-width: 26rem;
}

.insp-sheet { display: block; margin-top: .25rem; }

.insp-section { padding: 0 .9rem .9rem; }

/* The shaded banner cell the sheets print above every block. Full-bleed inside
   the panel, because on paper it spans the section's width. */
.insp-banner {
  display: flex;
  align-items: baseline;
  gap: .6rem;
  margin: 0 -.9rem .6rem;
  padding: .45rem .9rem;
  background: var(--subtle);
  border-bottom: 1px solid var(--border);
  border-radius: 10px 10px 0 0;
  font-weight: 700;
}

.insp-banner-ord {
  font-weight: 400;
  font-size: .75rem;
  color: var(--muted);
  min-width: 1.2rem;
}

.insp-banner-label { font-size: 1rem; }

/* The sheet's own words about what this configuration does differently, straight
   off meganet.inspection_config_section.variant_note. */
.insp-variant {
  margin: -.3rem 0 .6rem;
  padding-left: 1.8rem;
  color: var(--muted);
  font-style: italic;
}

.insp-block {
  margin: .9rem 0 .35rem;
  font-size: .85rem;
  text-transform: uppercase;
  letter-spacing: .04em;
  color: var(--muted);
}

.insp-subblock { margin: .6rem 0 .3rem; font-size: .85rem; }

.insp-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
  gap: .5rem .7rem;
}

.insp-field { display: grid; gap: .2rem; align-content: start; }
.insp-field.full { grid-column: 1 / -1; }

.insp-label {
  display: block;
  font-size: .82rem;
  color: var(--muted);
  line-height: 1.25;
}

.insp-hint {
  display: block;
  font-size: .72rem;
  font-style: italic;
  opacity: .85;
}

.insp-field input,
.insp-field select,
.insp-field textarea { width: 100%; }

/* Calibration grids and the serial-number panel. A printed grid is wide and
   stays wide; it scrolls inside its own box rather than stretching the page. */
.insp-scroll { overflow-x: auto; margin: .3rem 0 .5rem; }

.insp-table { border-collapse: collapse; width: 100%; font-size: .85rem; }

.insp-table th,
.insp-table td {
  border: 1px solid var(--border);
  padding: .2rem .3rem;
  text-align: left;
  vertical-align: middle;
}

.insp-table thead th {
  background: var(--subtle);
  font-size: .75rem;
  font-weight: 600;
  white-space: nowrap;
}

.insp-table input,
.insp-table select { width: 100%; min-width: 5.5rem; }

.insp-table .insp-ord    { width: 2rem; color: var(--muted); text-align: center; }
.insp-table .insp-rowhead { background: var(--subtle); white-space: nowrap; }

/* The printed 6% rule, read against what has actually been typed. */
.insp-rule {
  margin: .3rem 0 0;
  padding: .35rem .5rem;
  border-left: 3px solid var(--border);
  background: var(--subtle);
  border-radius: 0 6px 6px 0;
}

.insp-rule-on { border-left-color: var(--warn); }

/* The instruction at the foot of every inspection sheet, and the live version
   of it once a departure rating has been given. */
.insp-footnote { margin: .6rem 0 0; color: var(--muted); font-style: italic; }

.insp-prompt {
  margin-top: .6rem;
  padding: .6rem .7rem;
  border: 1px solid var(--warn);
  border-left-width: 3px;
  border-radius: 6px;
  background: var(--subtle);
}

.insp-prompt p { margin: .3rem 0 0; }

/* A box the sheet prints and the schema cannot yet hold. Visible on purpose. */
.insp-gap {
  margin-top: .7rem;
  padding: .5rem .7rem;
  border: 1px dashed var(--border);
  border-radius: 6px;
}

.insp-gap ul { margin: .3rem 0 .3rem 1.1rem; }

/* What this form does not print, listed rather than left as an absence. */
.insp-absent { border-style: dashed; }
.insp-absent-list { margin: .3rem 0 0 1.1rem; color: var(--muted); }
.insp-absent-list li { margin-bottom: .2rem; }

.insp-foot { display: grid; gap: .5rem; }

/* A tablet in the field is the primary device for this tab, so the two-column
   picker folds well before the phone breakpoint and the field grid gets wider
   cells rather than more of them. */
@media (max-width: 900px) {
  .insp-cols { grid-template-columns: minmax(0, 1fr); }
  .insp-page { padding: .6rem; }
}

@media (max-width: 560px) {
  .insp-grid { grid-template-columns: minmax(0, 1fr); }
  .insp-banner { margin-left: -.6rem; margin-right: -.6rem; padding-left: .6rem; }
  .insp-section { padding: 0 .6rem .6rem; }
  .insp-variant { padding-left: 0; }
}


/* ── Site Maintenance tab (#117) ─────────────────────────────────────────────
   The Council Maintenance Tasks sheet. Visually the same family as the
   Inspections tab above — same banner-headed block, same dense field grid —
   because it comes out of the same workbook and is printed with the same
   conventions, and a crew that recognises one should recognise the other.

   It is a separate set of selectors rather than a shared one, and that is the
   deliberate reading of #113's constraint 2: a tab that is not #109 owns its
   own region and nothing else, so that #107's per-tab issues can restyle one
   tab without moving another. Where the two tabs genuinely have to agree —
   tokens, the panel, the button row — they already do, through the shared
   rules neither of them touches. */

.mnt-page {
  padding: 1rem;
  display: grid;
  gap: .75rem;
  max-width: 1180px;      /* an A4 page is not 2000 px wide, and neither is a form */
  margin: 0 auto;
}

.mnt-sub { color: var(--muted); margin: .2rem 0 .4rem; }

.mnt-head-actions { display: flex; gap: .5rem; flex-wrap: wrap; }

.mnt-note { padding: 1rem; }
.mnt-bad  { border-left: 3px solid var(--bad); }

/* The picker: search and the outstanding-visit list on the left, drafts and
   past forms on the right. */
.mnt-cols {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  gap: .75rem;
  align-items: start;
}

.mnt-search { display: grid; gap: .3rem; font-size: .9rem; color: var(--muted); }

.mnt-hits {
  display: grid;
  gap: .2rem;
  margin: .6rem 0;
  max-height: 22rem;
  overflow: auto;
}

.mnt-hit {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: .75rem;
  text-align: left;
  width: 100%;
  padding: .35rem .5rem;
}

.mnt-hit-name { font-weight: 600; }
.mnt-muted    { color: var(--muted); }

.mnt-list { display: grid; gap: .1rem; }

.mnt-row {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: .5rem;
  padding: .3rem 0;
  border-bottom: 1px solid var(--border);
}

.mnt-row:last-child { border-bottom: 0; }

.mnt-drop {
  margin-left: auto;
  padding: .1rem .4rem;
  font-size: .78rem;
  border-color: #c7401a;
  color: #c7401a;
}

/* ── The form ── */

.mnt-section { padding: 0 .9rem .9rem; }

.mnt-banner {
  display: flex;
  align-items: baseline;
  gap: .6rem;
  margin: 0 -.9rem .6rem;
  padding: .45rem .9rem;
  background: var(--subtle);
  border-bottom: 1px solid var(--border);
  border-radius: 10px 10px 0 0;
  font-weight: 700;
}

.mnt-banner-ord {
  font-weight: 400;
  font-size: .75rem;
  color: var(--muted);
  min-width: 1.2rem;
}

.mnt-banner-label { font-size: 1rem; }

.mnt-block {
  margin: .9rem 0 .35rem;
  font-size: .85rem;
  text-transform: uppercase;
  letter-spacing: .04em;
  color: var(--muted);
}

.mnt-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
  gap: .5rem .7rem;
}

.mnt-field { display: grid; gap: .2rem; align-content: start; }
.mnt-field.full { grid-column: 1 / -1; }

.mnt-label {
  display: block;
  font-size: .82rem;
  color: var(--muted);
  line-height: 1.25;
}

.mnt-hint {
  display: block;
  font-size: .72rem;
  font-style: italic;
  opacity: .85;
}

.mnt-field input,
.mnt-field select,
.mnt-field textarea { width: 100%; }

/* The data-quality grid. Two rows and four columns on paper, and it stays that
   shape rather than folding into a column. */
.mnt-scroll { overflow-x: auto; margin: .3rem 0 .5rem; }

.mnt-table { border-collapse: collapse; width: 100%; font-size: .85rem; }

.mnt-table th,
.mnt-table td {
  border: 1px solid var(--border);
  padding: .2rem .3rem;
  text-align: left;
  vertical-align: middle;
}

.mnt-table thead th {
  background: var(--subtle);
  font-size: .75rem;
  font-weight: 600;
  white-space: nowrap;
}

.mnt-table input,
.mnt-table select { width: 100%; min-width: 5.5rem; }

.mnt-table .mnt-rowhead { background: var(--subtle); white-space: nowrap; }

/* Where a form came from, when an inspection sent somebody. */
.mnt-linked {
  margin: .5rem 0 0;
  padding: .35rem .5rem;
  border-left: 3px solid var(--border);
  background: var(--subtle);
  border-radius: 0 6px 6px 0;
}

.mnt-prompt {
  margin-top: .6rem;
  padding: .6rem .7rem;
  border: 1px solid var(--warn);
  border-left-width: 3px;
  border-radius: 6px;
  background: var(--subtle);
}

.mnt-prompt p { margin: .3rem 0 0; }

/* A box the sheet prints and the schema cannot yet hold. Visible on purpose. */
.mnt-gap {
  margin-top: .7rem;
  padding: .5rem .7rem;
  border: 1px dashed var(--border);
  border-radius: 6px;
}

.mnt-gap ul { margin: .3rem 0 .3rem 1.1rem; }
.mnt-gap p  { margin: .3rem 0 0; }

.mnt-foot { display: grid; gap: .5rem; }

@media (max-width: 900px) {
  .mnt-cols { grid-template-columns: minmax(0, 1fr); }
  .mnt-page { padding: .6rem; }
}

@media (max-width: 560px) {
  .mnt-grid { grid-template-columns: minmax(0, 1fr); }
  .mnt-banner { margin-left: -.6rem; margin-right: -.6rem; padding-left: .6rem; }
  .mnt-section { padding: 0 .6rem .6rem; }
}


/* ── Attachments (#149) ──────────────────────────────────────────────────────
   The one panel three sections across two tabs render: the inspection photo
   checklist, the Council sheet's canister-configuration screenshot and its
   gauge-board photo. It is a shared region rather than two per-tab ones for the
   same reason attachments.js is one file — the two tabs above deliberately do
   not share selectors, but this is not either tab's, it is a component both
   host, and copying it into both regions would make a change to it two edits.

   Everything it needs from the tabs it sits in comes through the shared tokens,
   so a #107 U-issue restyling either tab moves this with it rather than around
   it. Per #113's constraint 2, a change to the tokens themselves is #109's. */

.att-panel { margin-top: .8rem; }

.att-head {
  margin: 0 0 .2rem;
  font-size: .92rem;
}

.att-muted { color: var(--muted); }
.att-bad   { color: var(--bad); }
.att-ok    { color: var(--ok); }

.att-add {
  display: flex;
  align-items: center;
  gap: .6rem;
  flex-wrap: wrap;
  margin: .4rem 0 .2rem;
}

/* The picker is a real <input type="file"> rather than a button driving a
   hidden one: a tablet's camera-or-gallery sheet is the browser's, and routing
   it through a click handler is how that gets lost on one platform. */
.att-file {
  display: inline-flex;
  align-items: center;
  gap: .5rem;
  padding: .35rem .55rem;
  border: 1px dashed var(--border);
  border-radius: 6px;
  background: var(--subtle);
  cursor: pointer;
  max-width: 100%;
}

.att-file input[type="file"] { max-width: 100%; font-size: .82rem; }
.att-file span { white-space: nowrap; }

.att-limits { margin: .2rem 0 0; }

.att-list {
  display: grid;
  gap: .5rem;
  margin-top: .5rem;
}

.att-item {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto;
  gap: .6rem;
  align-items: start;
  padding: .5rem;
  border: 1px solid var(--border);
  border-radius: 6px;
}

/* Fixed box rather than intrinsic size, so a list of photos does not reflow as
   each signed URL arrives — on a slow link that is the whole panel jumping
   under a caption somebody is typing. */
.att-thumb {
  width: 72px;
  height: 72px;
  object-fit: cover;
  border-radius: 4px;
  background: var(--subtle);
  border: 1px solid var(--border);
}

.att-thumb-file {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  font-size: .72rem;
  letter-spacing: .04em;
  color: var(--muted);
}

.att-meta { display: grid; gap: .2rem; min-width: 0; }

.att-name {
  font-weight: 600;
  overflow-wrap: anywhere;
}

.att-caption { width: 100%; margin-top: .15rem; }

.att-actions {
  display: flex;
  flex-direction: column;
  gap: .3rem;
}

.att-actions button { white-space: nowrap; }

@media (max-width: 560px) {
  .att-item { grid-template-columns: auto minmax(0, 1fr); }
  .att-actions { grid-column: 1 / -1; flex-direction: row; }
}


/* iOS Safari zooms the page when a field smaller than 16 px takes focus, and
   never zooms back out. Every text-entry control in the app is .9 rem or less,
   so on a touch device they are all bumped to exactly 16 px — the one number
   that buys the zoom off. */
@media (pointer: coarse) {
  input[type="search"],
  input[type="text"],
  input[type="number"],
  input[type="date"],
  input[type="password"],
  input[type="email"],
  input[type="tel"],
  textarea,
  select {
    font-size: 16px;
  }

  /* A 13 px checkbox is a coin toss with a fingertip. These are the whole
     interaction on the filter, RF-changes and export panels, so they get the
     platform's own minimum and the rows around them room to breathe. */
  input[type="checkbox"],
  input[type="radio"] {
    width: 1.15rem;
    height: 1.15rem;
    flex: 0 0 auto;
  }

  /* The box is only the visible part of the target — the label around it is
     what actually gets tapped, and at 24 px it clears WCAG's minimum by 0.
     Any label wrapping a box gets a finger-sized row instead. */
  label:has(> input[type="checkbox"]),
  label:has(> input[type="radio"]) {
    min-height: 2rem;
    align-items: center;
  }

  .filter-row  { padding: .3rem .15rem; }
  .checklist label { padding: .35rem 0; }

  /* Fields sized for a dense desktop row — the RF Changes date picker, the
     inline filter boxes — get a floor too. Checkboxes, radios and sliders are
     excluded: they are sized above, and a min-height would only stretch them
     out of shape. */
  input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not([type="color"]),
  select {
    min-height: 2rem;
  }

  input[type="color"] { min-height: 1.75rem; }

  /* Network View sets its own 16 px boxes; the finger does not care which
     panel it is in. */
  .nv-check input[type="checkbox"] { width: 1.15rem; height: 1.15rem; }

  /* …and #140's own controls at 44 px, the floor EPIC #107's Definition of
     Done sets for the tab being converted: the neighbour chips, the detail
     card's close, the filter group headers, the check rows and the ARRO link
     out. inline-flex with centred content for the same reason #137's chips
     take it — without it a 26 px chip inside a 44 px box has its text at the
     top and the bottom of the target reads as a gap. The facet summary keeps
     its own display, because changing it takes the disclosure marker with it. */
  .nv-chip,
  .nv-close,
  .nv-link {
    min-height: 2.75rem;
    display: inline-flex;
    align-items: center;
    justify-content: center;
  }
  .nv-facet > summary,
  .nv-check {
    min-height: 2.75rem;
  }

  /* Buttons that were sized to sit inside a dense row still have to be hit.
     The visual size is unchanged where it already cleared the bar; these only
     raise the floor. */
  button,
  .btn-link {
    min-height: 2rem;
  }

  /* …and the smallest ones, which are text-sized by design, get a hit area
     rather than a bigger box: padding is cheaper than a redesign. */
  .filter-only,
  .filter-reset,
  .filter-actions button {
    min-height: 2rem;
    padding-top: .3rem;
    padding-bottom: .3rem;
  }

  /* #137's tabs go to 44 px rather than the 2 rem floor above, which is what
     EPIC #107's Definition of Done asks for and what WCAG 2.5.5 (AAA) sets. The
     app-wide floor stays 2 rem: 32 px clears 2.5.8 (AA, 24 px) comfortably and
     raising every button in nineteen tabs is not this issue's to do. Where the
     two differ, the rule is the one the U-issue's own controls are held to —
     these are chips and icon buttons on a map browser, tapped on a phone in a
     vehicle, and they are the case 44 px exists for.

     `align-items: center` matters as much as the height: without it a 28 px
     chip inside a 44 px box has its text at the top and the bottom 16 px of the
     target look like a gap between rows. */
  .maps-chip,
  .maps-reset,
  .maps-newtab,
  .maps-suggest-map,
  .maps-file-actions button {
    min-height: 2.75rem;
    display: inline-flex;
    align-items: center;
    padding-top: var(--sp-1);
    padding-bottom: var(--sp-1);
  }

  /* A row is the target on Pass Ranges, and it is already finger-sized; the
     name inside it is a control of its own, so it gets its own hit area rather
     than relying on the row's. */
  .row-open {
    min-height: 2.75rem;
    display: inline-flex;
    align-items: center;
  }

  /* #141's own controls, at the same 44 px and for the same reason as #137's:
     the ARRO Data toolbar is thirty controls in four groups, and the segmented
     buttons were 28 px tall by design — a dense desktop row that a fingertip
     hits by luck. The colour swatch is the extreme case at 20 px square.

     `align-items: center` for the same reason it is on the chips above: without
     it the text sits at the top of a 44 px box and the bottom third reads as a
     gap. */
  .ad-seg button,
  .ad-x,
  .ad-field-hit,
  .ad-inline-link,
  .ad-tool-grp button,
  .arro-btn-sm,
  .arro-row-acts a,
  .stn-arro,
  .exp-btn-sm,
  .db-reload {
    min-height: 2.75rem;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    padding-top: var(--sp-1);
    padding-bottom: var(--sp-1);
  }

  /* The station hits are a left-aligned list, not a row of buttons. */
  .ad-field-hit { justify-content: flex-start; }

  /* …and the one that cannot grow into a box, because a colour input *is* its
     swatch: it gets the height instead, and the row around it the room. */
  .ad-swatch { width: 2.4rem; height: 2.4rem; }
  .ad-colour > summary { width: 2.4rem; height: 2.4rem; }
  .ad-swatches { grid-template-columns: repeat(4, 2.2rem); gap: .35rem; }
  .ad-sw { width: 2.2rem; height: 2.2rem; }

  /* A link that has to read as part of a sentence cannot become a 44 px block
     without breaking the sentence — it takes the hit area as padding, the same
     trade .filter-only makes above. */
  .ad-inline-link {
    display: inline;
    padding-top: 0;
    padding-bottom: 0;
    min-height: 0;
  }

  /* The "only" shortcut hides until hover where there is a pointer. There is
     no hover here, and the rule above it already says so — this just makes
     sure the row leaves it space. */
  .filter-row-side { gap: .4rem; }

  /* EPIC #107's 44 px floor for the Stations tab's own controls (#136): the
     filter rows (a whole panel of them, and the thing most often tapped in a
     vehicle), the two clear buttons above them, the map display's checkboxes
     and the legend's own toggles. The station table's names already have it —
     .row-open is up at the top of this block, put there by #137. */
  .filter-row,
  .filter-check,
  .filter-actions button,
  .filter-resets button,
  .acma-check {
    min-height: 2.75rem;
    align-items: center;
  }

  /* A filter row's own label is the tap target, and it must fill the row it is
     in rather than leaving the taller box only partly live. */
  .filter-row-label { min-height: 2.75rem; display: flex; align-items: center; }

  /* A range slider is a 16 px-tall target dragged sideways. Give the thumb
     something to hold onto. */
  input[type="range"] { height: 1.75rem; }
}

@media (max-width: 900px) {
  /* The four action labels run to about 520 px, which is what makes the banner
     wrap to a second and third row — a tablet in portrait was giving it 109 px.
     Icons alone from here down; the title attribute keeps the tooltip and the
     button keeps its accessible name, since the label is hidden, not removed
     from the accessibility tree. */
  .hdr-btn {
    justify-content: center;
    padding: 0;
    width: 2.4rem;
    height: 2.4rem;
  }

  .hdr-label { display: none; }
  .hdr-ico   { font-size: 1.15rem; }

  /* 1 rem of page padding on every side of every panel, on a screen that is
     itself only 26 rem wide, is most of a column of text given to margins. */
  /* .page is the shared centred-column wrapper (#109) and folds with the two
     tab-specific layouts that predate it. */
  .layout,
  .page,
  .wb-page { padding: .75rem; }

  .panel     { padding: .75rem; }
  .panel-sub { padding: .65rem; }
  .stack     { gap: .75rem; }

  /* Panel headings inherit the browser's h1/h2 sizes, which are set for a
     document and not for a 340 px column — "RF Environment — licensed
     transmitters" was running to three lines before the tab had said anything.
     A tighter scale, and one that keeps the steps between levels. */
  #main-content h1 { font-size: 1.3rem; }
  #main-content h2 { font-size: 1.12rem; }
  #main-content h3 { font-size: 1rem; }
  #main-content h1,
  #main-content h2,
  #main-content h3 { line-height: 1.25; }

  /* Tables are the densest thing in the app and the first to run out of room.
     Trimming the cell padding and a step of type is worth roughly one extra
     column before anything has to scroll sideways. */
  table { font-size: .84rem; }
  th, td { padding: .38rem .4rem; }

  /* The global `table-layout: fixed` divides whatever width there is between
     the columns and ellipsises the rest, which is right on a laptop and
     useless on a phone: the nine-column Stations table came out as "S…  Ro…
     Ne…  Al…" over values reading "5…  61…  -…". Automatic layout instead —
     each column takes what it needs, and the table scrolls inside its wrapper
     when the total does not fit. `min-width: 100%` keeps the narrow tables
     filling the panel rather than huddling at the left. */
  .table-wrap table {
    table-layout: auto;
    min-width: 100%;
  }

  .table-wrap th,
  .table-wrap td {
    overflow: visible;
    text-overflow: clip;
  }

  /* …and the <colgroup> has to let go, or the rule above does nothing (#137).
     A `<col style="width:16%">` is still honoured under `table-layout: auto` —
     as a strong hint rather than a division, but strong enough that Pass
     Ranges' six columns came out at 16% and 8% of a 340 px wrapper and wrapped
     "Pass ranges" onto four lines while the automatic layout it was supposed to
     have got no say. The percentages are a proportion picked for a laptop; on a
     phone the proportion is not the thing that matters, the content is.

     `!important` because the widths are inline on the element and nothing else
     reaches them — and because this is the system overriding a per-table hint
     at a breakpoint, which is the case the keyword exists for rather than a
     specificity fight with another rule. It does not remove the columns'
     proportions above --bp-md, where they are right. */
  .table-wrap col { width: auto !important; }

  /* The rule above just neutralised dragged column widths, so the grips that
     set them go too — a 9px strip is no target for a finger, and the Columns
     chooser is the path that works here. */
  .ml-col-grip { display: none; }

  /* Sideways scroll is invisible until you try it, so the wrapper says it is
     there: the shadow only paints while there is more table to the right. */
  .table-wrap {
    background:
      linear-gradient(to right, var(--panel) 30%, transparent) left / 24px 100% no-repeat,
      linear-gradient(to left,  var(--panel) 30%, transparent) right / 24px 100% no-repeat,
      radial-gradient(farthest-side at 0 50%,   rgba(0,0,0,.16), transparent) left / 9px 100% no-repeat,
      radial-gradient(farthest-side at 100% 50%, rgba(0,0,0,.16), transparent) right / 9px 100% no-repeat;
    background-attachment: local, local, scroll, scroll;
  }

  /* A map that is 420 px tall on a 660 px screen leaves no page under it.
     Sized against the viewport instead, so it still reads as a map on a phone
     and still fills a tablet. The Stations map is the same trade at 62vh with
     a 360 px floor — the floor is what actually bites, so it goes. */
  .map-canvas  { height: min(420px, 52dvh); }
  #leaflet-map { height: 52dvh; min-height: 260px; }

  /* The scroll-capped tables were capped against the viewport already, but at
     a fraction picked for a laptop; on a phone 42vh of a table is a sliver. */
  .table-wrap.tall   { max-height: 60vh; }
  .table-wrap.medium { max-height: 50vh; }
}

@media (max-width: 560px) {
  /* The banner, folded. The buttons keep their icons and drop their labels,
     the tagline goes, and what is left is one row about 3 rem tall instead of
     the three wrapped rows (≈175 px, a quarter of the screen) it was. */
  header {
    align-items: center;
    gap: .5rem;
    padding: .5rem .6rem;
  }

  header h1 {
    margin: 0;
    font-size: 1.05rem;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }

  .header-sub { font-size: .72rem; }

  /* The tagline is the only line here that never changes, so it is the one
     that goes. The station count stays and loses its separator with it. */
  .header-tag { display: none; }
  .header-stats:not(:empty)::before { content: none; }

  /* Four square buttons plus the ☰ and the title fit a 390 px row exactly once
     nothing is allowed to wrap. */
  .header-actions {
    gap: .3rem;
    flex-wrap: nowrap;
  }

  .layout,
  .page,
  .wb-page { padding: .5rem; }

  .panel     { padding: .6rem; }
  .panel-sub { padding: .55rem; }

  /* The bug reporter was a card floating in 4vh of dimmed page. On a phone it
     is the page: full width, and tall enough that the form does not open
     already scrolled. */
  .modal-overlay { padding: 0; }

  .modal-card {
    max-width: none;
    min-height: 100dvh;
    border: 0;
    border-radius: 0;
    padding: 1rem .85rem 1.5rem;
  }

  .modal-foot { justify-content: stretch; }
  .modal-foot button { flex: 1 1 auto; }

  /* The card is already the whole screen here, so the body scrolls with the
     page rather than inside a box of its own. */
  .modal-body { max-height: none; overflow: visible; }

  /* .button-row spaces its buttons to both ends, which on a phone wraps them
     and then strands the last one alone against the right edge. Packed to the
     left, a wrapped row still reads as a list. */
  .button-row { justify-content: flex-start; }
}

/* Below ~380 px (an iPhone SE, a folded phone) the title is competing with the
   actions for the same row, and losing that contest helps nobody — the title
   shrinks rather than pushing a button off the edge. */
@media (max-width: 380px) {
  header h1 { font-size: .95rem; }
  .hdr-btn,
  .hdr-nav { width: 2.15rem; height: 2.15rem; }
  .hdr-ico { font-size: 1.05rem; }
}


/* ── Inspection History tab (#118) ───────────────────────────────────────────
   Reading a saved visit back, and printing it.

   Its own set of selectors, like the two form tabs it reads for and for the
   same reason (#113's constraint 2): a tab that is not #109 owns its region and
   nothing else. It looks like both of them on purpose — the same banner-headed
   block, the same dense grid — because it is the same workbook, printed with
   the same conventions.

   The one thing it does differently is the box itself. A value on this tab is a
   `<span>` with a rule under it, not a disabled input: a disabled input prints
   as whatever the browser thinks a dead form control looks like, and on screen
   it reads as something a person could have typed in and did not. A value under
   its printed label, with a line under it, is what the paper is. */

.hist-page {
  padding: 1rem;
  display: grid;
  gap: .75rem;
  max-width: 1180px;      /* an A4 page is not 2000 px wide, and neither is a form */
  margin: 0 auto;
}

.hist-sub { color: var(--muted); margin: .2rem 0 .4rem; }

.hist-head-actions { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; }

.hist-note { padding: 1rem; }
.hist-bad  { border-left: 3px solid var(--bad); }
.hist-bad-text:empty { display: none; }
.hist-bad-text { color: var(--bad); margin: .4rem 0 0; }
.hist-muted { color: var(--muted); }

.hist-search { display: grid; gap: .3rem; font-size: .9rem; color: var(--muted); }

.hist-hits {
  display: grid;
  gap: .2rem;
  margin: .6rem 0;
  max-height: 18rem;
  overflow: auto;
}

.hist-hit {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: .75rem;
  text-align: left;
  width: 100%;
  padding: .35rem .5rem;
}

.hist-hit-name { font-weight: 600; }

/* ── The list ── */

.hist-scroll { overflow-x: auto; margin: .3rem 0 .5rem; }

.hist-table { border-collapse: collapse; width: 100%; font-size: .85rem; }

.hist-table th,
.hist-table td {
  border: 1px solid var(--border);
  padding: .3rem .4rem;
  text-align: left;
  vertical-align: top;
}

.hist-table thead th {
  background: var(--subtle);
  font-size: .75rem;
  font-weight: 600;
  white-space: nowrap;
}

.hist-date { white-space: nowrap; font-variant-numeric: tabular-nums; }
.hist-actions { white-space: nowrap; }

/* A visit the printed instruction at the foot of the sheet applies to. The
   marker is on the rating rather than the whole row, because the row is
   otherwise unremarkable and a wash of colour down the table would say
   "something is wrong here" about a site that was simply visited. */
.hist-row-flagged .hist-date { font-weight: 600; }

.hist-flag {
  display: inline-block;
  padding: .05rem .35rem;
  border: 1px solid var(--warn);
  border-radius: 999px;
  font-size: .74rem;
  white-space: nowrap;
}

/* ── One record ── */

.hist-doc { display: grid; gap: .75rem; }

.hist-doc-head { padding: .9rem; }

/* The Bureau's lockup sits top-left of every sheet in the workbook. The logo
   image is not in this repository, so this is a wordmark in its place — the
   right shape and position, and no invented artwork. */
.hist-brand {
  display: flex;
  align-items: baseline;
  gap: .5rem;
  padding-bottom: .4rem;
  border-bottom: 2px solid var(--accent, #0b5cab);
  margin-bottom: .5rem;
}

.hist-brand-mark {
  font-weight: 700;
  letter-spacing: .01em;
  color: var(--accent, #0b5cab);
}

.hist-brand-sub {
  font-size: .78rem;
  text-transform: uppercase;
  letter-spacing: .12em;
  color: var(--muted);
}

.hist-doc-title { margin: 0; font-size: 1.1rem; }
.hist-doc-form  { margin: .1rem 0 .5rem; font-weight: 600; }
.hist-heading   { margin-top: .5rem; }
.hist-import    { color: var(--warn); }

.hist-section { padding: 0 .9rem .9rem; }

.hist-banner {
  display: flex;
  align-items: baseline;
  gap: .6rem;
  margin: 0 -.9rem .6rem;
  padding: .45rem .9rem;
  background: var(--subtle);
  border-bottom: 1px solid var(--border);
  border-radius: 10px 10px 0 0;
  font-weight: 700;
}

.hist-banner-ord   { font-weight: 400; color: var(--muted); }
.hist-banner-label { font-size: 1rem; }

.hist-variant {
  margin: -.3rem 0 .6rem;
  padding-left: 1.8rem;
  color: var(--muted);
  font-style: italic;
}

.hist-block {
  margin: .9rem 0 .35rem;
  font-size: .85rem;
  text-transform: uppercase;
  letter-spacing: .04em;
  color: var(--muted);
}

.hist-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
  gap: .5rem .7rem;
}

.hist-field { display: grid; gap: .1rem; align-content: start; min-width: 0; }

.hist-label {
  display: block;
  font-size: .82rem;
  color: var(--muted);
  line-height: 1.25;
}

.hist-hint {
  display: block;
  font-size: .72rem;
  font-style: italic;
  opacity: .85;
}

/* The line under a value is the line the box is drawn on. A box nobody filled
   in keeps its line and shows an em dash — which is what a blank box on paper
   is, and is not the same thing as a section that was never printed. */
.hist-value {
  display: block;
  min-height: 1.35rem;
  padding: .1rem .15rem;
  border-bottom: 1px solid var(--border);
  overflow-wrap: anywhere;
  white-space: pre-wrap;
  /* The recorded value is the one thing on this page the reader came for, and
     it is outnumbered ten to one by labels and boilerplate. Green and a little
     bolder means the eye can sweep a record and land only on what was written
     down. --ok here means "this is the data", not "this value is fine" — the
     blank-box rule below keeps the em dashes muted, so only filled boxes pop. */
  color: var(--ok);
  font-weight: 600;
}

.hist-field-blank .hist-value { color: var(--muted); font-weight: 400; }

/* The same emphasis for values inside a record's grid tables, where cells are
   wrapped individually because not every column is a value — the transcription
   table's Box column and the QC tables' parameter column are labels, and
   history.js knows which is which (tableHtml's labelCols). */
.hist-cell-value {
  color: var(--ok);
  font-weight: 600;
}

.hist-empty {
  margin: .2rem 0 0;
  padding: .4rem .6rem;
  border-left: 3px solid var(--border);
  background: var(--subtle);
  border-radius: 0 6px 6px 0;
  color: var(--muted);
}

.hist-flagline {
  margin: .5rem 0 0;
  padding: .4rem .6rem;
  border: 1px solid var(--warn);
  border-left-width: 3px;
  border-radius: 6px;
  background: var(--subtle);
}

.hist-footnote { margin: .6rem 0 0; color: var(--muted); font-style: italic; }

.hist-absent { border-style: dashed; }
.hist-absent-list { margin: .3rem 0 0 1.1rem; color: var(--muted); }
.hist-absent-list li { margin-bottom: .2rem; }

.hist-doc-foot { padding: .6rem .9rem; }

/* Photos. A fixed box so the layout does not reflow as each signed URL arrives
   — the same reasoning as .att-thumb, at reading size rather than thumbnail
   size, because this is the copy somebody prints and files. */
.hist-files {
  display: flex;
  flex-wrap: wrap;
  gap: .6rem;
  margin: .3rem 0 .2rem;
}

.hist-file { margin: 0; max-width: 15rem; }

.hist-file img {
  width: 15rem;
  height: 11rem;
  object-fit: cover;
  border: 1px solid var(--border);
  border-radius: 4px;
  background: var(--subtle);
}

.hist-file-cap { display: block; color: var(--muted); overflow-wrap: anywhere; }
.hist-file-doc { padding: .3rem 0; }

@media (max-width: 900px) {
  .hist-page { padding: .6rem; }
}

@media (max-width: 560px) {
  .hist-grid { grid-template-columns: minmax(0, 1fr); }
  .hist-banner { margin-left: -.6rem; margin-right: -.6rem; padding-left: .6rem; }
  .hist-section { padding: 0 .6rem .6rem; }
  .hist-variant { padding-left: 0; }
  .hist-file, .hist-file img { max-width: 100%; width: 100%; }
}


/* ── Map Generator tab ───────────────────────────────────────────────────────
   The control rail reuses the shared .layout/.panel/.control-row system; what
   is new here is the little view-picker map, the compact per-role pin grid,
   the colour rows, the layer-file list, and the preview — which always draws
   on white, in both themes, because the thing being previewed is a sheet of
   paper (or a billet), not a UI surface. */

.mg-minimap {
  height: 240px;
  border: 1px solid var(--border);
  border-radius: var(--r-md);
  overflow: hidden;
}

/* ── Centre the sheet on a station (#176) ──
   The same shape as the link budget's end finder — a search row, a lead line
   and a capped list of hits — because it is the same gesture and an operator
   who has used one should recognise the other. Its own classes rather than
   .lb-*: that card's rules carry an "armed end" state this box has no idea
   about, and borrowing them would tie two unrelated panels together. */

.mg-find { margin: 0 0 var(--sp-1); }

.mg-find-row {
  display: flex;
  align-items: center;
  gap: .35rem;
}

.mg-find-input {
  flex: 1;
  min-width: 0;
  border: 1px solid var(--border);
  border-radius: 4px;
  background: var(--panel);
  color: var(--text);
  padding: .25rem .35rem;
  font-size: .82rem;
}

.mg-find-x {
  flex: none;
  font-size: .74rem;
  padding: .22rem .45rem;
}

.mg-find-none { margin-top: .3rem; }

/* Capped at MG_FIND_CAP in the markup, so this only scrolls when the panel is
   already short — a phone, or a pinned flyout. */
.mg-hits {
  display: flex;
  flex-direction: column;
  max-height: 190px;
  overflow-y: auto;
  border: 1px solid var(--border);
  border-radius: 4px;
  background: var(--panel);
  margin-top: .2rem;
}

.mg-hit {
  display: flex;
  align-items: baseline;
  gap: .4rem;
  text-align: left;
  background: none;
  border: 0;
  border-bottom: 1px solid var(--border);
  padding: .28rem .35rem;
  font-size: .82rem;
  color: inherit;
  cursor: pointer;
}

.mg-hit:last-child { border-bottom: 0; }
.mg-hit:hover:not(:disabled) { background: var(--subtle); }
.mg-hit:disabled { cursor: default; opacity: .6; }
.mg-hit-name { flex: 1; min-width: 0; }
.mg-hit-num { color: var(--muted); }

.mg-hit-at {
  flex: none;
  font-size: .6rem;
  text-transform: uppercase;
  letter-spacing: .04em;
  color: var(--accent);
}

.mg-hit.is-here { background: color-mix(in srgb, var(--accent) 8%, transparent); }

.mg-row { margin-top: var(--sp-2); align-items: center; }

.mg-hint {
  margin: var(--sp-1) 0 0;
  color: var(--muted);
  font-size: var(--fs-xs);
  line-height: var(--lh-body);
  flex-basis: 100%;
}

.mg-check {
  display: flex;
  align-items: center;
  gap: .45rem;
  font-size: var(--fs-sm);
  padding: .18rem 0;
}

.mg-inline {
  display: inline-flex;
  align-items: center;
  gap: .45rem;
  font-size: var(--fs-sm);
  color: var(--muted);
  margin-left: 1.55rem;
}

.mg-num    { width: 7.5rem; }
.mg-num-sm { width: 4.5rem; }
.mg-inline .mg-num-sm, .mg-pin-grid .mg-num-sm { width: 4.5rem; }

.mg-sub {
  margin: var(--sp-3) 0 var(--sp-1);
  font-size: var(--fs-md);
}

/* Mode picker: three radios that read as one segmented row. */
.mg-modes {
  display: grid;
  gap: .35rem;
  margin-bottom: var(--sp-2);
}

.mg-mode {
  display: flex;
  align-items: center;
  gap: .5rem;
  padding: .4rem .6rem;
  border: 1px solid var(--ctl-border);
  border-radius: var(--r-md);
  font-size: var(--fs-sm);
  cursor: pointer;
}

.mg-mode.on {
  border-color: var(--accent);
  background: var(--subtle);
}

.mg-colors {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: .25rem .8rem;
  margin-top: var(--sp-2);
}

.mg-color-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: .5rem;
  font-size: var(--fs-xs);
  color: var(--muted);
}

.mg-color-row input[type="color"] {
  width: 2.4rem;
  padding: 0;
  border: 1px solid var(--ctl-border);
  border-radius: var(--r-sm);
  background: var(--panel);
}

/* Per-role pin styles: role · on · shape · size · filled, one row per role. */
.mg-pin-grid {
  display: grid;
  grid-template-columns: auto auto minmax(0, 1fr) auto auto;
  gap: .3rem .6rem;
  align-items: center;
  font-size: var(--fs-sm);
  margin: var(--sp-1) 0 var(--sp-2);
}

.mg-pin-head {
  font-size: var(--fs-xs);
  color: var(--muted);
}

.mg-pin-grid input[type="checkbox"] { justify-self: center; }

.mg-pin-grid.is-off, .control-row.is-off { opacity: .5; }

.mg-wide { flex: 1 1 14rem; }

/* The preview: a sheet on a desk. White stays white in dark theme — it is
   the paper being previewed, not the app. */
.mg-preview {
  margin-top: var(--sp-2);
  padding: var(--sp-3);
  background: var(--subtle);
  border: 1px solid var(--border);
  border-radius: var(--r-md);
  overflow: auto;
  text-align: center;
}

.mg-preview svg {
  max-width: 100%;
  height: auto;
  background: #ffffff;
  box-shadow: var(--shadow-2);
}

/* Generating: the sheet dims and a ring turns over it. The wrapper is what
   carries the state, so the ring holds its place over a preview that scrolls,
   and the last sheet stays legible underneath rather than being wiped — it is
   still the honest answer until the next one lands. */
.mg-preview-wrap { position: relative; }

.mg-preview-wrap.is-busy .mg-preview {
  min-height: 9rem;
  border-color: var(--accent);
}

.mg-preview-wrap.is-busy .mg-preview svg { opacity: .3; }

.mg-busy-ring { display: none; }

.mg-preview-wrap.is-busy .mg-busy-ring {
  display: block;
  position: absolute;
  top: 50%;
  left: 50%;
  width: 2.75rem;
  height: 2.75rem;
  margin: -1.375rem 0 0 -1.375rem;
  border: 3px solid var(--border);
  border-top-color: var(--accent);
  border-radius: 50%;
  animation: mg-busy-spin .85s linear infinite;
  pointer-events: none;
}

/* No reduced-motion block of its own: the document-wide policy at the end of
   this file stops the spin, and what is left still says "working" — the ring
   sits there with its accent segment, beside #mg-status saying "Generating…". */
@keyframes mg-busy-spin { to { transform: rotate(360deg); } }

.mg-status {
  margin: var(--sp-1) 0 0;
  color: var(--muted);
  font-size: var(--fs-sm);
}

.mg-notes {
  margin: var(--sp-1) 0 0;
  padding-left: 1.2rem;
  color: var(--warn);
  font-size: var(--fs-xs);
}

.mg-file-count { margin: 0 0 var(--sp-2); font-size: var(--fs-sm); }

.mg-file-list {
  list-style: none;
  margin: var(--sp-2) 0 0;
  padding: 0;
  max-height: 40vh;
  overflow: auto;
  display: grid;
  gap: .2rem;
}

.mg-file-list li {
  display: flex;
  align-items: center;
  gap: .5rem;
  font-size: var(--fs-sm);
}

.mg-file-list li label {
  display: flex;
  align-items: center;
  gap: .5rem;
  flex: 1 1 auto;
  min-width: 0;
}

.mg-file-name {
  font-family: var(--font-mono);
  font-size: var(--fs-xs);
  overflow-wrap: anywhere;
}

.mg-file-meta {
  color: var(--muted);
  font-size: var(--fs-xs);
  white-space: nowrap;
}

.mg-btn-sm {
  padding: var(--sp-1) var(--sp-2);
  font-size: var(--fs-xs);
}

/* The send-to-generator button under the Stations map display controls. */
.mg-send-row { margin-top: var(--sp-2); }

@media (max-width: 700px) {
  .mg-minimap { height: 200px; }
  .mg-colors { grid-template-columns: minmax(0, 1fr); }
}


/* ── Print (#118) ────────────────────────────────────────────────────────────
   The workbook's own print setup is A4 portrait, one page per station visit,
   Arial with 8pt labels and 10pt bold banner headings, and an explicit print
   area on most sheets. This is that, applied to the read-only record — which is
   the whole reason #118 renders a record as values under labels rather than as
   a form full of controls.

   Everything but the record is removed rather than hidden behind a wrapper
   class: printing any other tab of this app was never a designed act, and a
   page of nav rail and map controls is not a useful default for any of them.
   So the shell goes, and what is left is whatever #main-content is showing.

   `print-color-adjust: exact` is on the banners and nothing else. A section
   banner is a shaded strip on the paper form and reads as a heading because of
   it; the rest of the page is black on white either way, and forcing colour
   across a whole document is how a printer ends up laying down ink for a
   background nobody asked for. */

/* ── Reduced motion — one policy (#109) ──────────────────────────────────────
   This used to be two blocks a thousand lines apart, each turning off one
   rail's width transition, and everything else in the app — hovers, the
   memory strip's grow, the drawers, whatever U1–U6 add next — animated
   regardless. The policy is now stated once and applies to the whole document:

     Anything decorative stops. Anything load-bearing keeps its duration.

   The second half is not a hedge. app.js re-measures every Leaflet map
   NAV_TRANSITION_MS + 40 after a rail moves, because a map measured mid-slide
   caches the wrong container width and renders grey tiles with offset clicks.
   Setting those two transitions to 0s is safe — the timeout still fires, just
   after the slide has already finished — but setting them to `0.01ms` via a
   blanket `* { transition-duration: 0.01ms !important }` and *also* killing
   the `transitionend` semantics elsewhere is how that class of bug comes back.
   So the rails are named here explicitly, at `none`, and the blanket rule
   below covers everything that is only there to look nice.

   Scroll behaviour is included: a smooth scroll is motion whether or not it
   was written as an animation. */
@media (prefers-reduced-motion: reduce) {
  #tab-nav.nav-ready,
  #help-panel.help-ready {
    transition: none;
  }

  *,
  *::before,
  *::after {
    animation-duration: .01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: .01ms !important;
    scroll-behavior: auto !important;
  }
}

@page {
  size: A4 portrait;
  margin: 12mm;
}

@media print {
  html, body {
    background: #fff;
    color: #000;
    font-family: Arial, Helvetica, sans-serif;
    font-size: 8pt;
  }

  /* The shell. None of it is part of a form. */
  header,
  #tab-nav,
  #help-panel,
  #mem-bar,
  #nav-backdrop,
  #help-backdrop,
  .modal-backdrop,
  .hist-head,
  .hist-tools,
  .hist-picker,
  .hist-head-actions,
  .panel-header button { display: none !important; }

  /* The list panel is deliberately *not* hidden. Printing the timeline for a
     station is a useful thing to do — it is the "what has happened here" page —
     and a Ctrl-P on the list that produced a blank sheet would be a bug rather
     than a decision. Its heading carries the station name for exactly that
     reason. */

  /* The layout is a flex row sized against the viewport, which means nothing on
     paper. Let the content set the height and let the pages fall where they
     fall. */
  #app-shell { display: block; }

  #main-content {
    min-height: 0;
    overflow: visible;
  }

  .hist-page {
    max-width: none;
    padding: 0;
    gap: 4mm;
  }

  .hist-doc { gap: 4mm; }

  /* A panel is a card on screen and a region of a page here. */
  .panel {
    border: 1px solid #999;
    border-radius: 0;
    box-shadow: none;
    background: #fff;
    break-inside: avoid;
    page-break-inside: avoid;
  }

  .hist-banner {
    background: #e8eef4;
    border-bottom: 1px solid #999;
    border-radius: 0;
    font-size: 10pt;
    -webkit-print-color-adjust: exact;
    print-color-adjust: exact;
  }

  .hist-banner-label { font-size: 10pt; }

  .hist-brand { border-bottom: 2px solid #0b5cab; }
  .hist-brand-mark { color: #0b5cab; }

  .hist-label, .hist-hint, .hist-muted, .hist-empty, .hist-block { color: #444; }
  /* On paper the record stays monochrome — the whole print block is a
     conversion to the A4 form's ink — so the on-screen green becomes black
     and keeps only the weight. Values still stand out from the #444 labels
     without spending colour ink. */
  .hist-value { border-bottom: 1px solid #999; color: #000; font-weight: 700; }
  .hist-cell-value { color: #000; font-weight: 700; }

  /* The Open button is the one cell in the timeline that means nothing on
     paper. Its column stays, so the header row and the body still line up. */
  .hist-actions button { display: none; }

  .hist-table { font-size: 7.5pt; }
  .hist-table th, .hist-table td { border: 1px solid #999; }
  .hist-table thead th { background: #f0f0f0;
    -webkit-print-color-adjust: exact; print-color-adjust: exact; }

  /* A grid wider than the page scrolls on screen and cannot on paper, so it
     shrinks its type instead of being cut off at the margin. */
  .hist-scroll { overflow: visible !important; }

  .hist-file img { width: 60mm; height: 45mm; }

  /* A file that is not an image is a button on screen and a name on paper. */
  .hist-file-doc button {
    border: 0;
    padding: 0;
    background: none;
    color: #000;
    text-decoration: none;
  }

  .hist-flagline, .hist-empty { background: #fff; }

  /* A link's href printed after it is noise here: every link on this page is an
     in-app button. */
  a[href]::after { content: none !important; }
}
