/* Maverick Wx — Tropical Weather (the dashboard) and the API reference.
   Loads after tokens.css and shell.css, which own the palette, the masthead, the
   contextual title bar, the panel component and every shared control.

   What is left here is what only the storm pages have: the wind-tier classification ramp,
   the storm rail's rows, the instrument stats readout, the product frames, provenance,
   and the API reference sheet. */

/* ============================================================================
   Classification chips (meteorological guardrail)
   ==========================================================================*/

/* Coloured by a wind-speed tier that applies to EVERY basin — wind is wind. The
   Saffir-Simpson *number* is printed only on NHC basins (AL/EP/CP); elsewhere the chip
   carries the humanized status ("Typhoon", "Tropical Storm") and never an SS label. The
   rule lives in site/assets/wx-classify.1b99f39a.js; this is only its colour ramp, and the values
   are load-bearing — do not retune them to match a palette. */
/* Sentence case in the interface face. A classification is a *name* — "Category 4",
   "Tropical Storm" — not a measured value, so it was in the wrong face; and eleven
   characters of tracked-out uppercase at 11px is harder to read than the same word set
   normally, which matters most on the label that says how dangerous something is. */
.chip {
  display: inline-block; padding: 3px 10px;
  font-size: var(--fs-micro); font-family: var(--font-body); font-weight: 700;
  letter-spacing: 0; text-transform: none; white-space: nowrap;
  color: var(--wx-chip-ink); background: var(--wx-chip-ground);
  /* A route badge on the network: each storm reads as a transit line, so its category chip
     takes the rounded line-bullet shape. Shape only — the ramp grounds and ink are the
     meteorological vocabulary and are never touched. */
  border-radius: var(--radius-node);
}
/* Measured, not eyeballed. Three of these carried white text at 2.62:1, 2.86:1 and
   3.15:1 — under half the 4.5:1 a 11px uppercase label needs, and the large-text
   exception does not apply at this size.

   The *background* hexes are untouched, because the comment above is right that they are
   load-bearing: they are the Saffir-Simpson ramp and retuning them to suit a palette
   would misstate a category. What changes is the ink on top, which carries no
   meteorological meaning at all. Dark ink on the bright members takes them to 5.31, 5.80
   and 4.83 respectively — the same trade c1 and c2 already made. */
/* The ramp itself, named once.
 *
 * Deliberately here and not in `tokens.css`. These are not palette: `test_contrast.py`
 * puts it exactly right — "the category ramp is meteorological vocabulary, not palette" —
 * and a theme file is the one place a later reader would feel entitled to retune a colour
 * to suit an appearance. They do not change between light and dark for the same reason a
 * category does not.
 *
 * What they were was written twice. `.radar-storm-dot.tier-*` in radar.css carried the
 * same eight literals for the same eight categories, so a Saffir-Simpson value could be
 * corrected on the chip and left standing on the map — the two surfaces disagreeing about
 * what a Category 4 looks like, with nothing to catch it. Custom properties at `:root`
 * reach radar.css, which loads after this file on every page that has a map.
 *
 * The ink is here for the same reason: it pairs with the grounds and was repeated five
 * times. It carries no meteorological meaning — it is the contrast fix the comment below
 * describes — but it belongs with the pair it completes. */
:root {
  --wx-tier-unknown: #64748b;
  --wx-tier-td: #4b9fe0;
  --wx-tier-ts: #17b3a3;
  --wx-tier-c1: #ffd43b;
  --wx-tier-c2: #ff9f1c;
  --wx-tier-c3: #f4632e;
  --wx-tier-c4: #e01e5a;
  --wx-tier-c5: #b5179e;
  /* Dark ink for the bright half of the ramp. See the contrast note above. */
  --wx-tier-ink: #1a2733;
  /* The two chips that carry no category, as literals for the same reason the ramp is
     literal: a chip does not follow the theme. Every tier chip is one fixed pair in both
     appearances — a Category 4 is the same object at midnight — so the chip with no tier
     and the chip whose storm has ended have to be fixed too, or the one object on the page
     that never themes would suddenly theme on its two neutral variants. They are The
     Advisory's own ink and paper, written out rather than referenced, and the test that
     resolves every chip ground against this block is what keeps them from drifting. */
  --wx-chip-ground: #14161a;   /* The Advisory's ink, written out */
  --wx-ended-ground: #c9cbc7;  /* its rule grey — 9.30:1 under --wx-tier-ink */
  /* Pure white, and NOT the interface's paper (#f9f9f7). This is the ramp's ink for the
     dark half of the ramp, the sibling of --wx-tier-ink above, and it was measured against
     grounds this system is forbidden to retune: paper measures 4.42:1 on --wx-tier-c4 and
     4.51:1 on --wx-tier-unknown, so the warmer white fails Category 4 outright and passes
     the unknown tier by a hundredth. A chip's ink is part of the ramp's language rather
     than ours, and the ramp was drawn for white. 18.11:1 on --wx-chip-ground. */
  --wx-chip-ink: #ffffff;
}
.chip.tier-unknown { background: var(--wx-tier-unknown); }
.chip.tier-td { background: var(--wx-tier-td); color: var(--wx-tier-ink); }
.chip.tier-ts { background: var(--wx-tier-ts); color: var(--wx-tier-ink); }
.chip.tier-c1 { background: var(--wx-tier-c1); color: var(--wx-tier-ink); }
.chip.tier-c2 { background: var(--wx-tier-c2); color: var(--wx-tier-ink); }
.chip.tier-c3 { background: var(--wx-tier-c3); color: var(--wx-tier-ink); }
.chip.tier-c4 { background: var(--wx-tier-c4); }
.chip.tier-c5 { background: var(--wx-tier-c5); }
/* A major hurricane (category 3+) carries a second, non-colour signal. The ring is inset so
   it reads at any chip size and survives a forced-colours mode, where the ramp does not. */
.chip.major { box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.45) inset; }
.chip.small { padding: 2px var(--sp-2); font-size: var(--fs-micro); }
/* A storm whose agency has stopped forecasting it leaves the ramp entirely.

   The ramp answers "how dangerous is this system", in the present tense, and it is the
   loudest element on the page it sits on. Over a system that ended three days ago that is
   the page's most emphatic claim and its least true one, which is how a final-warning page
   came to read as current activity (audit M-05). The classification is still worth showing —
   it says what the system was — so the word stays and only the alarm goes. Neutral ink on a
   neutral ground, 8.04:1, and no major-hurricane ring: that ring is a danger signal too. */
.chip.chip--ended {
  background: var(--wx-ended-ground); color: var(--wx-tier-ink); box-shadow: none; font-weight: 600;
}

/* Invest risk chips — a formation *probability*, not a wind tier, so a separate ramp
   that cannot be mistaken for one. */
.chip.risk-chip { color: #fff; }
/* A text-safe variant, not a new ramp colour — the distinction matters.

   #e0483a is the shared risk-high identity: the rail dot, the radar marker and the
   outlook graphics all use it, and `tests/web/test_radar.py` exists to stop those three
   drifting apart. It is untouched.

   What it cannot do is carry text. Under white it is 4.07:1 and under dark ink 3.73:1 —
   no choice of ink rescues it — and the chip is the one place the colour becomes a
   *background* for an 11px label. There is also no single red that clears AA on both
   grounds this project renders on: white here, dark navy (#0e2233) in the graphics. They
   pull in opposite directions, so one hex cannot serve both, and pretending otherwise
   would just move the failure somewhere less visible.

   So the chip gets a darker sibling of the same hue: 5.17:1 under white, plainly the
   same danger red, and clear of the tier ramp it must never be confused with. */
.chip.risk-chip.risk-high { background: #cc3323; }
.chip.risk-chip.risk-medium { background: #f2a53b; color: #1a2733; }
.chip.risk-chip.risk-low { background: #e6cf45; color: #1a2733; }
.chip.risk-chip.risk-unknown { background: #93a4b5; color: #1a2733; }

/* ============================================================================
   The record's list — what the rail used to be
   ==========================================================================*/

/* One column. A bulletin has no sidebar: it issues a header block, a headline, the
   product and the readings, in that order, and stops.

   What stood here was ~125 lines describing a 420px column — `.rail-overview`,
   `.storm-list`, `.storm-item`, `.invest-item`, their marker grids, and the `--panel-w`
   step from 300px to 420px at 1280. All of it drew a second copy of the readout's own
   rows. `systemsReadout()` said as much in a comment: above the drawer breakpoint "the
   rail is the list, so the storms half of this is a second rendering of it and is not
   shown". Measured at 1280 the column was 90% empty with two storms in it and 96% empty
   with none, and in a quiet season the one sentence it held was already printed 230px
   above it in the title strip.

   One of the two renderings had to go and the readout is the one that stays: six columns
   per storm rather than two, values set in the `LABEL...VALUE` field line DESIGN.md calls
   the system's most-used component, and rows that are already the links. The risk swatch
   survived the move: `.invest-item .invest-risk.risk-*` was the same four colours as
   `.systems-table .invest-risk.risk-*` further down this file, which is now the only copy.

   `.panel` itself is untouched. Radar's layer controls and the NWS Guide's section nav are
   real rails with content of their own. */
body[data-page="index"] .shell { display: block; max-width: var(--wrap); }

/* ============================================================================
   Storm cards (the fallback grid, used when active.json carries no overviews)
   ==========================================================================*/

.storm-grid { display: grid; gap: var(--sp-5); grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); }
.card {
  border: 1px solid var(--edge);
  overflow: hidden; display: flex; flex-direction: column; cursor: pointer;
  transition: border-color var(--dur) var(--ease);
}
.card:hover { border-color: var(--ink); }

/* The card thumbnail shows the whole graphic.
 *
 * It used to crop the chrome away — `aspect-ratio: 1.4` with `translateY(-23.5%)`, trimming
 * a 0.15 header and a 0.085 stats strip off the top and a 0.06 footer off the bottom. That
 * worked because those were *fractions* of the figure's height and so identical for every
 * product, and it existed because the chrome was a foreign navy broadcast frame that took
 * 29.5% of the picture: the site was throwing away nearly a third of every graphic.
 *
 * Both halves of that have gone. The chrome is the site's own enamel now, and it is a slim
 * title strip and a footer rule rather than three bands — so there is nothing to hide. And
 * the bands are physical heights rather than fractions (app/render/frame.py FrameSpec), so
 * their *share* of a figure varies with the map's aspect and a single percentage could no
 * longer be right for every product anyway. A constant that cannot be correct is worse than
 * no constant.
 *
 * No `aspect-ratio` either: the image sets its own height. The graphics still vary in shape
 * (about 1.12 to 1.45 across the map products), and a fixed box would either letterbox them
 * or crop them by an amount nothing here can know. The aspect pass makes every master one
 * ratio; a regular grid can come back then, from the real number, rather than from a guess
 * now. This is the fallback grid — it renders only when active.json carries no overviews —
 * so ragged card heights are a small price for not re-introducing the constant. */
.card .thumb { display: block; position: relative; overflow: hidden; background: var(--skeleton); }
.card .thumb img { display: block; width: 100%; height: auto; }
/* With no image there is nothing to set the box's height, so the placeholder sets its own
   rather than collapsing to a hairline. */
.card .thumb-empty {
  display: flex; align-items: center; justify-content: center; min-height: 180px;
  color: var(--muted); font-size: var(--fs-bulletin);
}
.card .body { padding: 14px var(--sp-4) var(--sp-4); display: flex; flex-direction: column; gap: var(--sp-2); }
.card-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.card .name { font-family: var(--font-body); font-weight: 700; font-size: var(--fs-lead); margin: 0; line-height: 1.2; color: var(--ink); }
.card .meta { color: var(--muted); font-size: var(--fs-bulletin); margin: 0; font-family: var(--font-mono); font-variant-numeric: tabular-nums; }

/* ============================================================================
   The pane — overview map and storm detail
   ==========================================================================*/

/* The map is the page. Everything around it is a caption.
 *
 * It used to sit inside a bordered card with its own padded action bar underneath, which
 * on a laptop left the actual graphic occupying about half the pane. The frame is a single
 * hairline now, the graphic runs edge to edge inside it, and the full-resolution control is
 * a quiet strip rather than a block.
 *
 * This is one of the three things that keep a frame under the One Plane Rule, and the
 * reason is that a graphic is not content this system sets: it arrives with its own dark
 * ground and its own palette, so without a boundary its edge would be an unexplained cut
 * across the paper. A rule above and below cannot do that job. */
.overview-map {
  position: relative; margin: 0;
  border: 1px solid var(--enamel-strong); background: var(--enamel-wash); overflow: hidden;
}
.overview-map img, .overview-map picture { display: block; width: 100%; height: auto; }
/* The map is an inset on the diagram, not the diagram itself — the network and its numbers
   are the point. On a narrow column it fills the width at its natural (short) height. */
.overview-map .product-open img,
.overview-map .product-open picture { display: block; width: 100%; height: auto; }

/* Wide screens: the diagram lays the map inset and its caption side by side, so the fold
   uses its width instead of stranding a centred graphic in an empty half. The inset is now
   sized by HEIGHT and shrink-wraps to the map, so there are no letterbox mats; the caption
   fills the space beside it; the storm board and everything below span the full measure. */
@media (min-width: 1040px) {
  body[data-page="index"] #app .pane {
    display: grid;
    grid-template-columns: max-content 1fr;
    column-gap: var(--sp-7);
    row-gap: var(--sp-4);
    align-items: start;
  }
  body[data-page="index"] #app .pane > * { grid-column: 1 / -1; }
  body[data-page="index"] #app .pane > .overview-map { grid-column: 1; margin: 0; }
  body[data-page="index"] #app .pane > .updated--limits {
    grid-column: 2; margin: 0; align-self: start; max-width: 52ch;
  }
  .overview-map .product-open img,
  .overview-map .product-open picture {
    width: auto; height: min(46vh, 380px); max-width: 100%;
  }
}

/* The region strip sits directly on the map's top edge — a control for the thing below it,
   not a band floating above the pane. */
.pane > .choicebar { margin-bottom: var(--sp-3); }

.back-link {
  cursor: pointer; font: inherit; font-size: var(--fs-bulletin); font-weight: 600;
  color: var(--link); background: none; border: none; padding: var(--sp-1) 0;
  text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: 2px;
  margin-bottom: var(--sp-4);
}
.back-link:hover { text-decoration: underline; }

/* The selected-storm readout in the overview pane: the numbers, the official track, and
   the two ways onward. Deliberately not the full briefing — see renderDetailPane(). */
.readout { display: flex; flex-direction: column; gap: var(--sp-5); }
.readout-actions { display: flex; gap: var(--sp-3); flex-wrap: wrap; }
.readout-actions .btn { flex: 0 1 auto; }
.readout > .updated { margin-top: 0; }
@media (max-width: 560px) {
  /* The briefing is the primary action and must not end up a third of a row wide. */
  .readout-actions { flex-direction: column; align-items: stretch; }
}

/* ============================================================================
   The briefing's section navigator
   ==========================================================================*/

/* A storm page is six sections and about 6,000px tall, and it had no local navigation at
   all (audit M-15). This is it: pinned under the masthead, listing the sections this
   issuance actually published, in the page's own order — Official Track ahead of Model
   Guidance, which is the ordering the page exists to assert.

   It is a bar of links, so keyboard operation is the browser's and needs no script. What
   the stylesheet has to get right is that the bar never covers the thing it just scrolled
   to: `--subnav-h` is measured by shell.js and every anchored section subtracts it. */
.productnav {
  position: sticky;
  /* Only the bars that are themselves sticky — the title strip scrolls away. */
  top: calc(var(--masthead-h) + var(--banner-h));
  z-index: 20;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-1) var(--sp-4);
  margin: 0 0 var(--sp-5);
  padding: var(--sp-2) 0;
  background: var(--paper);
  border-bottom: 1px solid var(--line);
}
.productnav-list {
  display: flex; flex-wrap: wrap; align-items: center;
  gap: 2px var(--sp-1); margin: 0; padding: 0; list-style: none; min-width: 0;
}
.productnav-link {
  display: inline-block; padding: 5px var(--sp-2);
  font-size: var(--fs-bulletin); font-weight: 600; color: var(--ink-2); text-decoration: none;
  white-space: nowrap;
}
.productnav-link:hover { background: var(--wash); color: var(--ink); }
.productnav-link:focus-visible { outline: var(--focus-ring); outline-offset: var(--focus-offset); }
/* The section a reader is currently in. A weight and a rule rather than a colour alone,
   so it survives greyscale and a colour-vision difference like every other state here. */
.productnav-link[aria-current] {
  color: var(--ink); background: var(--wash-strong);
  box-shadow: inset 0 -2px 0 var(--ink);
}
/* An anchored section must clear the masthead AND this bar, or the navigator lands on top
   of the heading it was asked to show. Marked by the renderer rather than matched by "has
   an id", so a footnote id inside a section does not quietly acquire a scroll offset. */
[data-briefing-section] {
  scroll-margin-top: calc(var(--masthead-h) + var(--banner-h) + var(--subnav-h) + var(--sp-4));
}
/* These take focus (tabindex="-1") when the navigator sends a reader to them — the skip-link
   pattern, and never by Tab. A ring drawn around a screen-tall section says nothing the
   scroll has not already said, so the section itself is not outlined; the heading inside it
   is what the reader and the screen reader both land on. */
[data-briefing-section]:focus { outline: none; }

.productnav-tools {
  display: flex; align-items: center; flex-wrap: wrap; gap: var(--sp-1) var(--sp-3);
  font-size: var(--fs-bulletin);
}
/* The local-time control. UTC is not going anywhere — this adds a second reading — so the
   control is quiet furniture rather than a switch that looks like it changes the data. */
.productnav-toggle {
  padding: 4px var(--sp-2); border: 1px solid var(--edge);
  background: var(--paper); color: var(--muted);
  font: inherit; font-size: var(--fs-bulletin); font-weight: 600; cursor: pointer;
}
.productnav-toggle:hover { color: var(--ink); border-color: var(--ink); }
.productnav-toggle:focus-visible { outline: var(--focus-ring); outline-offset: var(--focus-offset); }
.productnav-toggle[aria-pressed="true"] {
  background: var(--wash-strong); border-color: var(--ink); color: var(--ink);
}
.productnav-terms { font-size: var(--fs-bulletin); font-weight: 600; white-space: nowrap; }

/* Short landscape viewports — a phone held sideways, 844x390 — are the case a sticky bar
   is most dangerous on: the masthead and this navigator together would eat a third of the
   screen and pin it there, on a page whose point is a map. Below this height the bar
   scrolls with the page. It is still the first thing under the title strip, so it is still
   how a reader finds the forecast table; it simply stops occupying a viewport that has
   none to spare. */
@media (max-height: 560px) {
  .productnav { position: static; }
}

/* ============================================================================
   Per-storm detail
   ==========================================================================*/

/* Instrument readout: a hairline interior grid (1px gaps over the --line ground) inside a
   solid --ink bezel — it reads as a measured panel, not a floating card. */
/* A measured readout: hairline interior grid over a --line ground, inside a hairline
   border. The bezel was a solid 1px of --ink, which at Clear Sky weights was the darkest
   rule anywhere on the site and framed the numbers like a warning. */
.stats-panel {
  display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 0;
  border-top: 1px solid var(--line); border-bottom: 1px solid var(--line);
  overflow: hidden; margin: 0 0 var(--sp-6);
}
/* The hairlines are drawn by the *cells*, not by a 1px gap over a --line ground.
   With six tiles and four columns the second row has two empty grid tracks, and a ground
   showing through them read as two broken tiles — a grey slab beside the numbers. A cell
   that draws its own edges leaves an empty track as plain surface, which is what it is. */
.stats-panel .stat {
  background: var(--paper); padding: var(--sp-4); gap: 3px;
  box-shadow: inset -1px -1px 0 var(--line);
}
.stats-panel .stat-label { font-size: var(--fs-bulletin); letter-spacing: 0; line-height: 1.3; }
.stats-panel .stat-value { font-size: var(--fs-lead); font-weight: 600; letter-spacing: -0.01em; line-height: 1.05; }
.stats-panel .stat-value .u { font-size: var(--fs-micro); }
.stats-panel .stat-sub { font-size: var(--fs-micro); }

/* Semantic forecast content and official handoff sit beside—not inside—static graphics. */
.official-source, .forecast-table {
  margin: var(--sp-6) 0; padding: var(--sp-5) 0;
  border-top: 1px solid var(--line); border-bottom: 1px solid var(--line);
}
.official-source h2, .forecast-table h2 { margin-top: 0; font-size: var(--fs-lead); }
/* A timeline with no rows: the section keeps its heading and its frame and drops the
   padding that was holding a table's worth of space (M-11). `.statebox` supplies its own. */
.forecast-table--empty { padding: var(--sp-5) var(--sp-5) var(--sp-4); }
.forecast-table--empty .statebox { padding: var(--sp-3) 0 0; }
.official-source p { max-width: var(--wrap-prose); }
.official-source .btn { margin-top: var(--sp-2); }
.official-safety { margin-bottom: 0; font-size: var(--fs-bulletin); color: var(--muted); }
/* The same block on a system that has ended. No accent, no primary button: what is left is a
   record and a way into the agency's archive, and neither is an instruction. */
.official-source--final { background: var(--wash); }
.official-ident {
  margin: 0 0 var(--sp-2); font-size: var(--fs-bulletin); font-weight: 600; color: var(--ink-2);
}
/* Mono for the measured value and nothing else — the issuance's number is a name. */
.official-ident time {
  margin-left: 2px; font-family: var(--font-mono);  font-weight: 500;
  color: var(--muted); font-variant-numeric: tabular-nums;
}
.official-ident .ident-final { font-weight: 500; color: var(--muted); }
/* Kept as a selector because the API reference still uses it; the storm page's handoff is
   an ordinary `.btn--primary` now, so this no longer sets a monospaced button label. */

.forecast-scroll { overflow-x: auto; }
.forecast-table table { width: 100%; min-width: 520px; border-collapse: collapse; }
.forecast-table th, .forecast-table td {
  padding: var(--sp-3) var(--sp-3); text-align: left; border-bottom: 1px solid var(--line);
  vertical-align: top;
}
.forecast-table th {
  font-size: var(--fs-bulletin); letter-spacing: 0; text-transform: none; font-weight: 600;
  color: var(--muted); border-bottom-color: var(--line-strong);
}
.forecast-table td { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
.forecast-table tbody tr:last-child td { border-bottom: 0; }
/* The forecast hour — "+48 h" — under the instant it belongs to. It is the reading a
   forecaster actually takes from this table, so it is on every row rather than in a
   column of its own that a phone would have to drop. */
.forecast-hour {
  display: block; margin-top: 2px; font-size: var(--fs-micro); color: var(--muted);
}
/* The Status column. Three states, three weights of emphasis: the centre's own word plain,
   a term with somewhere to look it up dotted, and ours marked as ours.

   The dotted rule rather than the link colour: site links are unstyled until hover, so a
   glossary link set as one would be invisible, and set in accent blue it would be the
   loudest thing in a column of plain forecast stages. A dotted underline is the
   defined-term affordance and reads as "there is more about this word", which is what it
   is. */
.status-none { color: var(--muted); }
.forecast-table td .status-term {
  color: inherit; text-decoration: underline dotted; text-underline-offset: 3px;
  text-decoration-color: var(--line-strong);
}
.forecast-table td .status-term:hover {
  text-decoration-color: currentColor; text-decoration-thickness: 2px;
}
/* "derived" rides with the label rather than under it: on a JTWC storm every row carries one,
   and a second line per row would double the height of the table to say one thing nine
   times. Small, muted, and never in the interface face — it is a qualifier on a value. */
.status-derived {
  margin-left: var(--sp-2); padding: 1px 5px;
  background: var(--wash); border: 1px solid var(--line);
  font-size: var(--fs-micro); font-weight: 500; color: var(--muted);
  text-transform: none; letter-spacing: 0; white-space: nowrap;
}
/* The notes under the table — the cycle the forecast hours count from, the em-dash footnote,
   and what "derived" means. Prose about a table, so they are set as prose, not as another
   empty-state box. */
.forecast-note {
  margin: var(--sp-3) 0 0; padding: 0; border: 0; text-align: left;
  font-size: var(--fs-bulletin); color: var(--muted); max-width: var(--wrap-prose);
}
.forecast-note + .forecast-note { margin-top: var(--sp-2); }
.forecast-note time { font-variant-numeric: tabular-nums; }

.products { display: grid; gap: var(--sp-6); margin-top: var(--sp-2); }
.product {
  background: var(--paper); border: 1px solid var(--line);
  overflow: hidden; margin: var(--sp-6) 0;
}
/* A heading, set like one. It was 12px uppercase mono in --muted — a legend caption for
   the most important graphic on the page, which read as chrome rather than as a title. */
.product h2 {
  margin: 0; padding: var(--sp-3) var(--sp-4); font-size: var(--fs-lead);
  text-transform: none; letter-spacing: var(--track-title); color: var(--ink);
  border-bottom: 1px solid var(--line); font-weight: 700;
}
/* The official forecast track. It is the reason most people open this page, so it gets the
   emphasis the old layout gave to whatever happened to be first — a slightly stronger
   frame, and no border between its heading and the map. */
.product--primary { border-color: var(--ink); }

/* A product panel with nothing to draw. The frame and the heading stay — the panel is still
   the answer to "where is the model guidance for this storm" — and every rule that sizes
   this box for a graphic is simply absent: no aspect ratio, no map ground, no min-height.
   What is left is `.statebox`, which is as tall as its own sentence (audit M-11).

   The quieter surface is the only visual difference. It says "this one is not a graphic"
   before the words do, and it is the informational card, not the alarm one: an issuance
   that published no guidance is the pipeline working. */
.product--unavailable { background: var(--wash); }
.product--unavailable h2 { border-bottom-color: var(--divider); }

/* Model guidance says what it is before the reader looks at it. A spaghetti plot is the
   most arresting graphic on the page and the least authoritative thing on it; a note
   underneath would be read after the damage. */
.product-note {
  margin: 0; padding: var(--sp-3) var(--sp-4);
  background: var(--wash); color: var(--ink); font-weight: 600;
  border-bottom: 1px solid var(--edge);
  font-size: var(--fs-bulletin); font-weight: 600;
}
.product picture, .product img { display: block; width: 100%; height: auto; }
/* Same inset discipline as the overview: a storm page stacks four of these, so an uncapped
   2400px master turned the page into six screens. Each is capped; the official track — the
   reason most people open the page — is allowed a little more height than the guidance
   plots below it.
 *
 * The cap is a **width** now, and there is no mat behind it. It was `max-height` with
 * `object-fit: contain` on an `--enamel-wash` ground, which is the only way to stack four
 * graphics of four different shapes without the page lurching — and the masters really did
 * run from 0.76 to 1.90. They are all 16:10 now (`FrameSpec.aspect`), so a width cap
 * produces exactly the same rendered picture as the height cap did, without the bars either
 * side of it: 400px of height at 16:10 *is* 640px of width, and that is what `contain` was
 * already resolving to.
 *
 * Keeping it as a height cap would have been the smaller diff and the wrong one — `contain`
 * only does anything when the box and the image disagree about shape, so leaving it in would
 * leave the page still prepared for a disagreement that can no longer happen. */
.product .product-open img, .product .product-open picture {
  display: block; width: 100%; height: auto;
  max-width: min(73.6vh, 640px);   /* min(46vh, 400px) tall, at 16:10 */
  margin-inline: auto;
}
.product--primary .product-open img, .product--primary .product-open picture {
  max-width: min(89.6vh, 800px);   /* min(56vh, 500px) tall */
}

/* What a cone is, above the graphic; whose it is and what it does not show, below it.

   The note is above the picture for the same reason the "not the official forecast" note
   is: this is the misreading the graphic invites, and a caption read after the fact is a
   caption read too late. It is not the alarm palette — nothing is wrong — but it is not
   throwaway either, so it sits on the informational surface with a rule to hold the eye. */
.cone-note {
  margin: 0; padding: var(--sp-3) var(--sp-4);
  background: var(--wash); border-bottom: 1px solid var(--line);
  font-size: var(--fs-bulletin); color: var(--ink-2); max-width: none;
}
.cone-facts {
  padding: var(--sp-3) var(--sp-4); border-top: 1px solid var(--line);
  font-size: var(--fs-bulletin); color: var(--muted);
}
.cone-facts p { margin: 0; max-width: var(--wrap-prose); }
.cone-facts p + p { margin-top: var(--sp-2); }
/* The measured half of the line — the instants — in mono; the words around them are not. */
.cone-meta time {
  font-family: var(--font-mono);  font-variant-numeric: tabular-nums;
}
/* Wind radii: the one thing on the page that speaks to the size of the storm, and the
   place its ABSENCE has to be as visible as its presence. Set as a list rather than a
   table — three rows of four numbers is a list, and a table here would out-weigh the
   forecast timeline. */
.cone-radii { margin-top: var(--sp-2); }
.radii-list {
  margin: var(--sp-2) 0 0; padding: 0; list-style: none;
  display: grid; gap: 2px;
}
.radii-list li { display: flex; flex-wrap: wrap; gap: var(--sp-2); }
.radii-band { font-weight: 700; color: var(--ink-2); min-width: 3.5em; }
.radii-quadrants { font-variant-numeric: tabular-nums; }

/* A defined term, linked on its first use. The same dotted affordance the Status column
   already used, because it is the same promise — "there is more about this word" — and a
   reader should not have to learn two. Site links are unstyled until hover, so a glossary
   link set as an ordinary one would be invisible; set in accent blue it would be the
   loudest thing in a sentence about wind speed. */
.term {
  color: inherit; text-decoration: underline dotted; text-underline-offset: 3px;
  text-decoration-color: var(--line-strong);
}
.term:hover { text-decoration-color: currentColor; text-decoration-thickness: 2px; }
.term:focus-visible { outline: var(--focus-ring); outline-offset: var(--focus-offset); }
/* Inside a stat tile the term is the unit — already small and already muted — so it keeps
   the tile's colour and only carries the dots.

   No `opacity` here, and that is a measurement rather than a preference: the unit is
   `--muted` at 13px, which clears AA at 5.66:1 on the tile, and three quarters of it is
   #818f9c at 3.31:1. Axe caught it. A dotted rule is the affordance; fading the word is
   not part of it. */
.stats-panel .stat-value .term { text-decoration-color: currentColor; }

/* The local reading, beside the UTC one and never instead of it (M-43). Lighter than the
   stamp it follows, because the published time is the one that is authoritative. */
.localtime {
  font-family: var(--font-body); font-weight: 400; color: var(--muted);
   white-space: nowrap;
}

/* `$$`, on the section rule, in Micro/Quiet — DESIGN.md's record terminator, set as the
   source sets it. See `recordEnd()` in app.js for what it marks and why the glyphs are
   hidden from the accessibility tree.

   It carries the rule that used to sit on `.terms-foot`. A rule above the glossary link
   said "the page ends somewhere near here"; the same rule under `$$` says where. */
.record-end {
  margin: var(--sp-4) 0 0; padding-top: var(--sp-3);
  border-top: 1px solid var(--line);
}
.record-end-mark {
  font-family: var(--font-mono); font-size: var(--fs-micro); font-weight: 600;
  letter-spacing: var(--track-micro); color: var(--muted);
}

/* The standing way out of the vocabulary, at the foot of the page (M-36). Muted, because
   it is for the reader who needs it and furniture for the one who does not. It sat at the
   foot of the storm rail until the rail went, and it sits under the terminator now: it is
   the site talking, not the bulletin. */
.terms-foot {
  margin-top: var(--sp-3);
}
/* 24x24 is a floor, not a look. This measured 198x17 at 390px coarse and at 1280px fine,
   and it is `:only-child` in `.terms-foot` — so WCAG 2.5.8's inline exception, which
   forgives a link sitting in a run of sentence text, does not reach it. `display: block`
   plus the padding takes the target to the full column width by 25px without moving the
   words a pixel: the underline still starts where it started, and the extra height is the
   gap between the rule above and the text, which was empty space a thumb was already aiming
   at and missing. Height rather than a min-height because the line box is 17px and a
   min-height on an inline element does nothing. */
.terms-link {
  display: block; padding: 4px 0; font-size: var(--fs-bulletin);
  font-weight: 600; color: var(--muted);
}
.terms-link:hover { text-decoration-thickness: 2px; }

/* The aid ledger: which models are on a guidance plot, and which are not.
   Collapsed by default and loaded on open. It is the answer to a question most readers
   never ask — but the plot's selection is exactly where this site's guidance and another
   site's diverge, so the reader who does ask is owed the per-aid reason rather than a
   paragraph of generalities. */
.aid-ledger { border-top: 1px solid var(--line); font-size: var(--fs-bulletin); }
.aid-ledger > summary {
  padding: var(--sp-3) var(--sp-4); cursor: pointer; color: var(--muted);
  font-weight: 600;
}
.aid-ledger > summary:hover { color: var(--ink); }
.aid-ledger > summary:focus-visible { outline: 2px solid var(--ink); outline-offset: -2px; }
.aid-ledger-body { padding: 0 var(--sp-4) var(--sp-4); }
/* A label inside the panel, not an outline level: the storm page's headings run h1 -> h2
   per product section, and a sub-heading here would insert a level the outline does not have. */
.aid-ledger-label {
  margin: var(--sp-4) 0 var(--sp-2);
  font-size: var(--fs-bulletin); color: var(--muted); font-weight: 700;
}
/* Same rule as every other table on the site: wide content scrolls inside its own box
   rather than making the page scroll sideways. */
.aid-table-scroll { overflow-x: auto; }
.aid-table { border-collapse: collapse; width: 100%; min-width: 420px; }
.aid-table th, .aid-table td {
  text-align: left; padding: 5px var(--sp-2); border-bottom: 1px solid var(--line);
  vertical-align: top;
}
.aid-table th { color: var(--muted); font-weight: 600; white-space: nowrap; }
.aid-table td:first-child {
  font-family: var(--font-mono); font-weight: 600; white-space: nowrap; color: var(--ink);
}
.aid-table td:last-child { color: var(--muted); }

.provenance { margin-top: var(--sp-8); font-family: var(--font-body); font-size: var(--fs-remarks); }
.provenance h2 { font-size: var(--fs-lead); }
.provenance .lede { margin-bottom: var(--sp-4); }
.provenance table { border-collapse: collapse; width: 100%; max-width: 720px; }
.provenance th, .provenance td { text-align: left; padding: 7px var(--sp-3); border-bottom: 1px solid var(--line); vertical-align: top; }
.provenance th { color: var(--muted); font-weight: 600; white-space: nowrap; width: 190px; }
.provenance td { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }

/* ============================================================================
   Systems readout (the tables under the overview map)
   ==========================================================================*/

/* The numbers the map cannot carry at the size it is shown. See the block comment above
   systemsReadout() in assets/app.js for the measurements that made this necessary; the
   short version is that the global frame renders at ~1.2 CSS pixels per degree on a phone,
   where a map label lands under six pixels tall. Everything here is the published manifest
   read out as text, so no reading depends on resolving type on an image. */

.readout-head {
  margin: var(--sp-5) 0 var(--sp-3); font-size: var(--fs-lead);
}

/* The captions under and around the map are prose, and prose has a measure. These three
   ran the full width of the pane — 136, 148 and 92 characters a line at 1440px, against a
   comfortable 65–75 — because nothing capped them and the pane is 1076px wide. The token
   already existed and was already used by the editorial pages. */
.updated--limits,
.remnants p,
.systems-readout > .updated { max-width: var(--wrap-prose); }

/* The two storms on the ended shelf are destinations in a list, not links inside a
   sentence, so 2.5.8 applies to them as it does to the footer's columns. They measured
   18px. */
.remnants-list a { display: inline-flex; align-items: center; min-height: 24px; }

/* The tables are the one thing on these pages allowed to scroll sideways, and only inside
   their own box — the page body must never scroll horizontally. */
.systems-scroll {
  overflow-x: auto; margin-bottom: var(--sp-6);
  border: 1px solid var(--enamel-strong); background: var(--paper);
}

.systems-table { border-collapse: collapse; width: 100%; font-size: var(--fs-bulletin); }
/* The caption is the first painted thing inside a bordered box, and it had a bottom
   padding only — so its text sat 2px below the border and 1px inside the left edge, which
   was *within* the 8px corner radius the box carried at the time, while every cell below it
   was inset 14px. The one detector finding on this page that survived verification. The
   radius is gone with the ground swap; the inset it argued for is still right.

   `--sp-3` because that is what `th` and `td` already use horizontally: the caption is a
   label for the columns, so it should start where the columns start rather than at some
   inset of its own. The bottom stays `--sp-2`, which is the gap to the header row and was
   never the problem. */
.systems-table caption {
  text-align: left; padding: var(--sp-3) var(--sp-3) var(--sp-2); color: var(--muted);
  font-family: var(--font-body); font-size: var(--fs-bulletin);
  text-transform: none; letter-spacing: 0; font-weight: 600;
}
/* A quiet ruled header, not a navy band. The band was the heaviest element on the page
   after the map, and a column heading is the least important text in a table. */
/* The service board's column header: the enamel key band, tinted, with an enamel rule under
   it. Quiet — a column heading is the least important text in a table — but unmistakably
   part of the network rather than a grey UI strip. */
.systems-table th {
  text-align: left; padding: var(--sp-2) var(--sp-3); white-space: nowrap;
  background: var(--enamel-wash); color: var(--muted);
  font-family: var(--font-body); font-weight: 600; font-size: var(--fs-bulletin);
  letter-spacing: 0; text-transform: none;
  border-bottom: 1px solid var(--enamel-strong);
}
.systems-table td {
  padding: var(--sp-3); border-bottom: 1px solid var(--network); vertical-align: top;
  white-space: nowrap;
}
.systems-table tbody tr:hover { background: var(--enamel-wash); }

/* The route spine. On the storm board every row is a station on the network's line: an
   enamel line runs down the name column and each storm sits on it at an interchange node.
   The node is porcelain ringed in enamel — a uniform network mark; the storm's category is
   carried in colour by its route bullet inline. Desktop only: below 720px the board becomes
   stacked records and the mobile rules null these pseudo-elements, so the spine never
   collides with the record layout. */
.readout-block--storms .systems-table tbody td:first-child {
  position: relative; padding-left: var(--sp-7);
}
.readout-block--storms .systems-table tbody td:first-child::before {
  content: ""; position: absolute; left: 13px; top: 0; bottom: 0;
  width: 2px; background: var(--network);
}
.readout-block--storms .systems-table tbody td:first-child::after {
  content: ""; position: absolute; left: 8px; top: calc(var(--sp-3) + 2px);
  width: 12px; height: 12px; border-radius: var(--radius-node);
  background: var(--paper); border: 2px solid var(--enamel); box-sizing: border-box;
}
/* The line does not run past the last station. */
.readout-block--storms .systems-table tbody tr:last-child td:first-child::before {
  bottom: auto; height: calc(var(--sp-3) + 8px);
}
.systems-table .cell-value {
  display: block; font-family: var(--font-mono); font-variant-numeric: tabular-nums;
  color: var(--ink);
}
/* The secondary line carries the unit conversion or the relative age — subordinate to the
   figure above it, never competing with it. */
.systems-table .cell-sub {
  display: block; margin-top: 2px; font-size: var(--fs-micro); color: var(--muted);
  font-family: var(--font-mono);
}
/* The freshness component inside an advisory cell: one line, no ground of its own. */
.systems-table .freshness { margin-top: 3px; font-size: var(--fs-micro); }
.systems-table .chip { margin-right: var(--sp-2); vertical-align: middle; }
.systems-table .invest-risk {
  display: inline-block; width: 12px; height: 12px; margin-right: var(--sp-2);
  vertical-align: middle; background: var(--muted);
}
.systems-table .invest-risk.risk-high { background: #e0483a; }
.systems-table .invest-risk.risk-medium { background: #f2a53b; }
.systems-table .invest-risk.risk-low { background: #e6cf45; }
.systems-table .invest-risk.risk-unknown { background: #93a4b5; }

/* WCAG 2.2 AA 2.5.8: a target that is not inline in a sentence needs 24x24 CSS px, and
   these measured 20.1px tall — the storm name is the only way into a storm from this
   table, and it was the smallest control on the page. Inline-flex rather than padding so
   the chip beside it keeps its baseline. */
.row-link {
  cursor: pointer; font: inherit; font-weight: 650; color: var(--link);
  background: none; border: none; padding: 0; vertical-align: middle;
  display: inline-flex; align-items: center; min-height: 24px;
  /* Colour was the entire signal that a storm's name is the way into it. Ink alone would
     make it one more value in a row of values, so the underline is not decoration here —
     it is the affordance. */
  text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: 3px;
}
.row-link:hover { text-decoration-thickness: 2px; }
/* On a touch device the same control carries the shell's 44px bar. The 24px above is the
   criterion floor and stays the desktop density; this is the promise `maverickwx-redesign.md`
   makes for a coarse pointer. Height only — the width of a storm's name is the width of the
   word a thumb is aiming at, and padding it to a fixed 44 would put dead zone either side of
   "Iselle" inside a table cell that has its own column rhythm. */
@media (pointer: coarse) {
  .row-link { min-height: 44px; }
}

/* Below 721px a seven-column table can only scroll, and a reader who has scrolled three
   columns right has lost the header row that says what they are looking at. So each row
   becomes its own *record* and every cell restates its own label from the `data-label`
   attribute the markup already carries.

   A record, not a card. It was a card: `border: 1px solid var(--line)` on four sides, a
   `--paper` fill, and a `--wash` band behind the name cell. That is the object The One
   Plane Rule abolished — it survived here because it lives inside a media query the ground
   swap's box sweep did not open, on the device the primary reader is holding. Content is
   ruled, not boxed, so the frame and both grounds are gone and what separates one storm
   from the next is a rule and a blank line.

   The shape left behind is the source's own. A bulletin sets a record as a header line, a
   `----` rule, then its field lines:

       SUMMARY OF 1100 AM EDT...INFORMATION
       ----------------------------------------
       LOCATION...24.3N 58.1W
       MAXIMUM SUSTAINED WINDS...60 MPH...95 KM/H

   which is exactly a row: the storm's name, the rule under it that was already there, and
   the `LABEL...VALUE` cells below. Removing the box did not cost the structure anything;
   it revealed that the structure was already right. */
@media (max-width: 720px) {
  /* The wrapper is a scroll container, and here it does not scroll — so it draws no box
     either. It used to, and so did every row inside it: measured at 390px when both still
     had a fill and an 8px radius, wrapper 20→370 and row 21→369, both `#fff`, both 8px,
     with nothing between them. That was the detector's "children flush against border+bg",
     and the answer was never to inset the rows: the frame belongs to the scrolling mode,
     which begins at 721px, and the rows should not have been framed at all.

     `margin-bottom` is deliberately not reset. It is the gap to the sentence below the
     table, not part of the frame. */
  .systems-scroll {
    overflow-x: visible;
    border: 0; background: transparent;
  }
  /* With no box around it the caption is a label above a run of records rather than the
     first text inside a container, so it takes the section's own left edge — the same one
     the `h2` above it, the record rules and the field labels below it sit on. The 12px it
     had is a container inset, and there is no longer a container. It costs the caption 0.4
     of a contrast ratio to sit on the canvas rather than the surface (5.66:1 → 5.26:1
     light, and 8.46:1 → 9.24:1 dark, which is the direction that matters least);
     `tokens.css` records both. */
  .systems-table caption { padding-left: 0; padding-right: 0; }
  .systems-table thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
  /* The field line, which is the source's own form: `LABEL...VALUE` on one line, the way an
     NHC bulletin sets `MAXIMUM SUSTAINED WINDS...60 MPH`. It replaces a grid of labelled
     pairs that stacked each label above its value.

     The reason it is structural rather than decorative is measured: no monospace face fits
     the bulletin's 69-column measure on a phone (46 columns at 15px in the 350px a 390px
     screen leaves; 69 would need a 10.14px body). The column cannot come to the phone, so
     the phone gets the line — and the line is what the source already publishes, so nothing
     is invented to make a table narrow.

     The minimum is the width of the longest label plus its longest value plus a readable
     leader, not a round number: below it the value wraps under the dots and the pairing
     stops reading. One column to about 520px, two above it. */
  .systems-table tbody tr {
    display: grid; grid-template-columns: repeat(auto-fit, minmax(232px, 1fr));
    margin-bottom: var(--sp-5);
  }
  /* The blank line between records, not a gap after the last one — below it is the table's
     own closing sentence, which is not another record. */
  .systems-table tbody tr:last-child { margin-bottom: 0; }
  /* The name cell is the record's header line, and is not a field line: it has no label to
     sit opposite, and a leader running to a storm's own name would be leading nowhere. The
     rule under it is the bulletin's `----`; it is the only rule a record carries, which is
     why the blank line above has to do the separating. */
  .systems-table tbody td:first-child {
    grid-column: 1 / -1; border-bottom: 1px solid var(--line);
    display: block; padding-bottom: var(--sp-2);
  }
  /* No horizontal padding: it was a container inset and there is no container. A field
     line's label starts at the same column as the caption above it and the rule under the
     record's name, which is what makes a run of them read as a block of set text rather
     than as the contents of an invisible box. */
  .systems-table td {
    border-bottom: 0; padding: 3px 0; white-space: normal;
    display: flex; align-items: baseline; flex-wrap: wrap;
  }
  /* Label, leader, value — in that visual order, from a DOM order that has no leader in it.
     The leader is the cell's own `::after` moved between the two by `order`, which is why
     the label is `::before` and the value keeps its element: three pieces, two pseudo
     -elements and one real node, is exactly what a cell can carry. */
  .systems-table td::after {
    content: ""; order: 2; flex: 1 1 12px; min-width: 12px; align-self: center;
    margin: 0 6px; border-bottom: 1px dotted var(--line-strong);
  }
  .systems-table td .cell-value { order: 3; flex: 0 1 auto; text-align: right; }
  /* The secondary value takes its own line under the first by default. It is a *different*
     reading, not the same one restated — an area's "7-day 60%" and "2-day 20%" are two
     forecasts, and a flex row set them as `7-day2-day` with nothing between them, which is
     how this was caught. The storms table overrides it below, because there the pair really
     is one value in two units. */
  .systems-table td .cell-sub {
    order: 4; flex-basis: 100%; margin-top: 2px; text-align: right;
  }
  /* The advisory cell's second line is a freshness component, not a value, so it takes the
     whole width under the line rather than competing with the leader. */
  .systems-table td .freshness { order: 5; flex-basis: 100%; margin-top: 2px; }
  /* "45 kt 52 mph", not "45 kt" over "52 mph". The conversion is the same value in the
     reader's own units — it belongs beside the figure, and stacked it cost a line in every
     cell of every card, which is six lines a storm on the screen with the fewest. The
     advisory cell's second line is a freshness component, not a `.cell-sub`, so it keeps
     its own row. */
  /* A value and its unit are one thing and break as one. Inline but wrapping, "120 kt 138
     mph" split after the number and left "mph" alone on the next line, and "Sep 1, 15:00Z"
     came apart into three. The column minimum above is what makes this safe: 108px holds
     the longest of them, and where it cannot — a 320px phone — the grid drops to two
     columns rather than overflowing the card. It is the storms table only: an area's cells
     hold "Area of interest" and a coordinate pair, which are longer than any column here
     and have to be free to wrap. */
  .readout-block--storms .systems-table td .cell-value { display: inline; white-space: nowrap; }
  .readout-block--storms .systems-table td .cell-sub {
    display: inline; margin-top: 0; margin-left: var(--sp-2); white-space: nowrap;
    flex: none; flex-basis: auto;
  }
  /* The label sits on the line rather than above it, at the cell's own size: in the source
     both halves of `LABEL...VALUE` are the same size, and shrinking the label to 12px would
     make the leader connect two things that do not look like a pair. */
  .systems-table td::before {
    content: attr(data-label); order: 1; flex: none; margin-bottom: 0;
    font-family: var(--font-body); font-size: var(--fs-bulletin); letter-spacing: 0;
    text-transform: none; font-weight: 400; color: var(--muted);
  }
  .systems-table tbody td:first-child::before,
  .systems-table tbody td:first-child::after { content: none; }
}

/* --- The forecast timeline on a narrow screen -------------------------------
 *
 * Four columns of coordinates and knots cannot be shrunk to 375px and stay readable, and a
 * horizontally scrolling table is a table whose header row is off-screen by the third
 * column. So each forecast point becomes a block on a vertical timeline: the valid time
 * and its forecast hour are the anchor, the three readings sit under it as labelled pairs,
 * and a rule down the left connects them in the order they happen.
 *
 * The markup is unchanged — it is still a <table> with a real <thead>, so the semantics a
 * screen reader relies on survive; the header is moved off-screen rather than removed, and
 * every cell restates its own label from the `data-label` the renderer writes. */
@media (max-width: 700px) {
  .forecast-scroll { overflow-x: visible; }
  .forecast-table table { min-width: 0; }
  .forecast-table thead {
    position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%);
  }
  .forecast-table tbody tr {
    display: grid; grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
    gap: var(--sp-2) var(--sp-4);
    position: relative;
    padding: var(--sp-3) 0 var(--sp-4) var(--sp-5);
    border-bottom: 0;
  }
  /* The connecting rule, and a mark at each point. Decorative: the order is already the
     document order, and every row names its own valid time. */
  .forecast-table tbody tr::before {
    content: ""; position: absolute; left: 4px; top: 0; bottom: 0;
    border-left: 2px solid var(--line);
  }
  .forecast-table tbody tr:first-child::before { top: 12px; }
  .forecast-table tbody tr:last-child::before { bottom: auto; height: 12px; }
  .forecast-table tbody tr::after {
    content: ""; position: absolute; left: 0; top: 8px;
    width: 10px; height: 10px;
    background: var(--paper); border: 2px solid var(--ink);
  }
  .forecast-table td {
    border-bottom: 0; padding: 0; white-space: normal;
  }
  .forecast-table td[data-label="Valid time"] {
    grid-column: 1 / -1; font-weight: 600; color: var(--ink);
  }
  .forecast-table td[data-label="Valid time"] .forecast-hour { display: inline; margin-left: var(--sp-2); }
  .forecast-table td::before {
    content: attr(data-label); display: block; margin-bottom: 1px;
    font-family: var(--font-body); font-size: var(--fs-bulletin); font-weight: 600;
    color: var(--muted);
  }
  .forecast-table td[data-label="Valid time"]::before { content: none; }
}

/* ============================================================================
   Recently ended (the landing page's shelf for just-retired storms)
   ==========================================================================*/

/* Deliberately quiet — this is a record, not an alarm (the same "note, not a warning"
   register conditionBanner() uses for the ended/inactive states in assets/app.js). */
/* Recently ended: an archive shelf directly under a list of systems that are being warned
   on. It must not read like one of them (audit M-05), so it is set in the muted ink the rest
   of the site reserves for a held state, behind a rule that separates it from live content. */
.remnants { margin-top: var(--sp-7); padding-top: var(--sp-5); border-top: 1px solid var(--divider); }
.remnants[data-state="held"] .readout-head, .remnants[data-state="held"] .remnants-list li {
  color: var(--muted);
}
.remnants-list { list-style: none; margin: 0 0 var(--sp-4); padding: 0; }
.remnants-list li {
  padding: var(--sp-2) 0; border-bottom: 1px solid var(--line);
  font-size: var(--fs-bulletin);
}
.remnants-list li:last-child { border-bottom: 0; }
.remnants-list a { font-weight: 650; color: var(--link); text-decoration: underline;
  text-decoration-thickness: 1px; text-underline-offset: 2px; }
.remnants-list .meta { color: var(--muted); font-family: var(--font-mono); }

/* ============================================================================
   API reference (docs.html) — a reading page, not a dashboard
   ==========================================================================*/

.doc { max-width: 820px; }
.doc h2 { margin-top: var(--sp-7); font-size: var(--fs-lead); }
/* One plane: a dark navy slab on a light reading page was the last coloured surface on the
   site, and the recessed ground plus a rule separates it without inventing a second theme
   inside the page. */
.doc pre { background: var(--wash); color: var(--ink); padding: 14px var(--sp-4); border: 1px solid var(--line); overflow-x: auto; }
.doc table { border-collapse: collapse; width: 100%; }
.doc th, .doc td { text-align: left; padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid var(--line); vertical-align: top; }
.doc code { background: color-mix(in srgb, var(--paper) 82%, var(--muted)); padding: 1px 5px; }
.doc pre code { background: none; padding: 0; color: inherit; }
/* Endpoint paths get readable steel emphasis so they stand out as endpoints. */
.doc code.endpoint { color: var(--ink); background: var(--code-bg); font-weight: 600; }
/* The endpoints table reads as an API contract: a ruled, mono-uppercase header band. */
.doc .endpoints th {
  background: var(--ink); color: var(--paper); font-family: var(--font-mono); font-weight: 600;
  font-size: var(--fs-micro); letter-spacing: 0.12em; text-transform: uppercase; border-bottom: 0;
}
.doc .endpoints td.cache { font-family: var(--font-mono); white-space: nowrap; }
.doc .endpoints td.cache code { background: none; padding: 0; color: inherit; }
.doc .endpoints td.cache-warn { color: var(--ink); font-weight: 600; }
.doc .endpoints td.cache-ok { color: var(--muted); }

/* --- Storm API reference -------------------------------------------------------------
   The reference used to be one wide table whose "what it is" column ran to a full
   paragraph per row, which reads as a wall and wraps badly on a phone. Each endpoint is
   its own block now, so the URL, the prose and the cache behaviour each get their own
   line rather than competing for one cell. */

/* These three replaced inline `style=` attributes. Inline styles are a `style-src-attr`
   violation under the CSP now soaking, and the alternative — granting
   `style-src-attr 'unsafe-inline'` — would re-open the whole directive for three paddings.
   Found by driving a browser against the report-only policy, not by reading the source. */
.wrap--padded { padding-top: var(--sp-6); padding-bottom: var(--sp-8); }
.lede--placeholder { padding: var(--sp-6) var(--gutter); }

.api-base {
  margin: var(--sp-6) 0 var(--sp-7); padding: var(--sp-4) 0;
  border-top: 1px solid var(--line); border-bottom: 1px solid var(--line);
}
.api-base h2 { margin: 0 0 var(--sp-2); font-size: var(--fs-lead); }
.copyrow { display: flex; gap: var(--sp-3); align-items: center; flex-wrap: wrap; margin: 0 0 var(--sp-2); }
/* The base URL is a 60-character opaque hostname; it has to be able to break, or it
   pushes the page into a horizontal scroll on a phone. */
.copyrow .url { font-family: var(--font-mono); font-size: var(--fs-bulletin); word-break: break-all; }

.codeblock { margin: var(--sp-4) 0; border: 1px solid var(--line); background: var(--paper); }
.codeblock-head { display: flex; gap: var(--sp-3); align-items: center; justify-content: space-between; padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid var(--line); flex-wrap: wrap; }
.codeblock-label { font-size: var(--fs-bulletin); color: var(--muted); }
.doc .codeblock pre { margin: 0; }

.endpoint-doc { margin: var(--sp-6) 0; padding-top: var(--sp-5); border-top: 1px solid var(--line); scroll-margin-top: var(--sp-4); }
.endpoint-doc-head { display: flex; gap: var(--sp-2); align-items: center; flex-wrap: wrap; margin-bottom: var(--sp-3); }
.doc .endpoint-doc-head .endpoint-path { font-family: var(--font-mono); font-size: var(--fs-bulletin); word-break: break-all; background: none; padding: 0; }
.doc .endpoint-doc-head .base-url { color: var(--muted); }

.fieldtable { margin: var(--sp-3) 0; }
.doc .fieldtable th { width: 30%; font-family: var(--font-mono); font-size: var(--fs-bulletin); font-weight: 500; }

.cacheline { font-size: var(--fs-bulletin); color: var(--muted); margin: var(--sp-3) 0 0; }
.cacheline .cache { font-family: var(--font-mono); }
.cacheline .cache code { background: none; padding: 0; color: inherit; }
.cacheline .cache-warn { color: var(--ink); font-weight: 600; }
.cacheline .cache-ok { color: var(--muted); }

/* Ruled on all four sides rather than flagged with a thick left border: a coloured — or now
   inky — 3px border-left is the cheap version of a callout, and with the accent gone it would
   have been the heaviest ink on a reading page. The recessed ground separates and the rule
   closes it. */
.callout {
  border-top: 1px solid var(--line); border-bottom: 1px solid var(--line);
  background: var(--wash); padding: var(--sp-3) var(--sp-4); margin: var(--sp-4) 0;
}
/* The callout is ruled on all four sides since the accent pass; a caution variant thickens
   the rule rather than tinting it, because a tint is the thing this system does not have. */
.callout--warn { border-color: var(--ink); border-width: 2px; }

/* Small enough not to compete with the URL beside it, which is the thing being read. */
.doc .copy { font-size: var(--fs-micro); letter-spacing: var(--track-micro); padding: 3px 10px; white-space: nowrap; }

/* A reading page keeps the prose measure and does not need the dashboard grid. */
body[data-layout="page"] main > .wrap { padding-top: var(--sp-6); padding-bottom: var(--sp-8); }

/* ============================================================================
   Full-resolution image viewer (assets/viewer.mjs)

   The published graphics are 2400px and were drawn to be read at that size; on a
   390px phone they are scaled to a sixth. This is the way back to the pixels.

   Zoom is the browser's: the stage sets `touch-action` and scrolls, so pinch,
   double-tap and trackpad zoom behave the way they do everywhere else. A
   transform-based zoom of our own would have to re-implement momentum, bounds and
   the reader's own accessibility settings, and would get all three worse.
   ==========================================================================*/

.viewer[hidden] { display: none; }

.viewer {
  position: fixed; inset: 0; z-index: 100;
  display: grid; grid-template-rows: auto 1fr auto;
  background: var(--paper);
  color: var(--ink);
}

/* The page behind must not scroll while the viewer is up. */
body.viewer-open { overflow: hidden; }

.viewer-bar,
.viewer-foot {
  display: flex; align-items: center; gap: var(--sp-4);
  padding: var(--sp-3) var(--sp-4);
  background: color-mix(in srgb, #000 22%, var(--paper));
}
.viewer-bar { justify-content: space-between; border-bottom: 1px solid var(--line-strong); }
.viewer-foot { justify-content: space-between; border-top: 1px solid var(--line-strong); flex-wrap: wrap; }

.viewer-title {
  margin: 0; font-family: var(--font-body); font-weight: 600;
  font-size: var(--fs-lead); color: var(--ink);
}
.viewer-bar-actions { display: flex; align-items: center; gap: var(--sp-2); }

.viewer-zoom,
.viewer-close {
  font-family: var(--font-body); font-size: var(--fs-bulletin);
  background: transparent; color: var(--ink);
  border: 1px solid var(--edge); padding: 8px 12px; cursor: pointer;
  min-height: 44px;
}
.viewer-zoom:hover,
.viewer-close:hover { border-color: var(--ink); }
.viewer-close { min-width: 44px; font-size: var(--fs-bulletin); }

.viewer-stage {
  overflow: auto;
  display: grid; place-items: center;
  padding: var(--sp-3);
  /* Let the browser own pinch-zoom rather than intercepting the gesture. */
  touch-action: pinch-zoom pan-x pan-y;
  -webkit-overflow-scrolling: touch;
}
/* The stage is a tab stop — a named region a reader can put the focus on and pan with the
   arrow keys — so it has to look like one (audit M-28). A negative offset keeps the ring
   inside the scroll container rather than under the bar and the foot.

   The ring is `--focus-ring` like everywhere else now, and that is the point of clothing
   this subtree in the night palette: --ink resolves to the light value inside the viewer, so
   the shared ink ring is already the paper-on-ink form DESIGN.md asks a reversed control
   for. It used to be a literal #8bbcff, because the shared ring was an accent drawn for
   white and this chrome was navy in both themes. */
.viewer-stage:focus-visible { outline: var(--focus-ring); outline-offset: -3px; }

.viewer-img {
  display: block;
  max-width: 100%; max-height: 100%;
  height: auto; width: auto;
  background: var(--wash-strong);
}
/* Actual size: the image stops being constrained and the stage scrolls instead. */
.viewer.is-actual .viewer-stage { place-items: start; }
.viewer.is-actual .viewer-img { max-width: none; max-height: none; }

.viewer-meta {
  margin: 0; font-family: var(--font-body); font-size: var(--fs-bulletin); color: var(--muted);
}
/* What the stage's keyboard contract is, in the reader's own view rather than only in an
   `aria-describedby`. A sighted keyboard user needs it as much as a screen-reader one. */
.viewer-help {
  margin: 0; font-family: var(--font-body); font-size: var(--fs-bulletin); color: var(--muted);
}
.viewer-links { margin: 0; display: flex; flex-wrap: wrap; gap: var(--sp-4); }
.viewer-save {
  font-family: var(--font-body); font-size: var(--fs-bulletin); color: var(--ink-2);
  display: inline-flex; align-items: center; min-height: 44px;
  background: transparent; border: 0; padding: 0; cursor: pointer;
}
.viewer-save:hover { color: var(--ink); }
/* The two saves are links and carry the site's in-text underline; the copy is a button and
   would not. They are one row of peers, so it takes the underline too rather than reading
   as a different kind of thing. */
.viewer-copy { text-decoration: underline; }
.viewer-save:focus-visible { outline: var(--focus-ring); outline-offset: 3px; }
/* Whether the save happened, whether the link copied. Empty until there is something to
   say, and it must not reserve a line while it is (`:empty` keeps the foot from jumping). */
.viewer-status {
  margin: 0; font-family: var(--font-body); font-size: var(--fs-bulletin); color: var(--ink-2);
  flex-basis: 100%;
}
.viewer-status:empty { display: none; }

/* --- The product figure that opens it -------------------------------------- */

.product-open {
  display: block; width: 100%; padding: 0; border: 0; background: none;
  cursor: zoom-in; font: inherit; color: inherit; text-align: left;
}
.product-open img { display: block; width: 100%; height: auto; }
.product-open { text-decoration: none; }
.product-open:focus-visible { outline: 2px solid var(--ink); outline-offset: 3px; }

/* The strip under the graphic is the button's own caption, not a second control: one
   figure, one target, one name (audit M-27). It stays a strip rather than a block beside
   the graphic because inside a framed map it was costing ~72px of vertical space directly
   under the thing the page exists to show; the graphic is what should dominate, and the
   words that enlarge it are subordinate to it by definition. */
/* The inset's label band — the enamel strip a transit map prints its title and key on. It
   ties the graphic (which arrives with its own palette) to the interface through the one
   committed colour. Role tokens are re-pointed to their on-enamel siblings so the label, the
   full-res link and the dimensions all land readable on the band. */
.product-actions {
  display: flex; flex-wrap: wrap; gap: var(--sp-3); align-items: center;
  padding: var(--sp-2) var(--sp-3);
  border-top: 1px solid var(--enamel-strong); background: var(--enamel); color: var(--on-enamel);
  --ink: var(--on-enamel);
  --link: var(--on-enamel);
  --muted: var(--on-enamel-muted);
}
.product-fullres {
  font-family: var(--font-body); font-size: var(--fs-bulletin); font-weight: 600;
  color: var(--link); text-decoration: underline; text-decoration-thickness: 1px;
  text-underline-offset: 2px;
  display: inline-flex; align-items: center; min-height: 44px;
}
/* The affordance is the whole figure now, so the words respond to the figure being
   hovered rather than only to the pointer being on them. */
.product-open:hover .product-fullres { text-decoration-thickness: 2px; }
.product-dims { font-family: var(--font-mono); font-size: var(--fs-bulletin); color: var(--muted); }
/* A bare figure (a storm product block) keeps the strip flush with its own frame; inside
   `.overview-map` the frame is the figure itself, so the strip needs no extra margin. */
.product-figure { margin: 0; }

/* What the picture is, in words.
 *
 * Hidden above 640px, where the graphic is large enough to carry its own header and the
 * heading above it has already said which storm and which product — a third copy would be
 * the duplication M-27 was about. Below 640px the figure is a thumbnail of a *different*
 * picture, drawn small with its annotations taken out, and a reader scrolling past a strip
 * of them needs the caption to tell one from the next. */
.product-label { display: none; }

@media (max-width: 639px) {
  .product-label {
    display: inline-flex; align-items: center;
    font-family: var(--font-body); font-size: var(--fs-bulletin); font-weight: 600;
    color: var(--ink); flex: 1 1 100%;
  }
  /* The name is the caption's first line; the action and the size follow on the second. */
  .product-actions { row-gap: var(--sp-1); padding-block: var(--sp-2); }
  .product-fullres { min-height: 32px; }
}

/* ---------------------------------------------------------------------------
   One order, at every width (retiring the audit M-06 reorder)
   -------------------------------------------------------------------------*/

/* There was a `flex` + `order` shuffle here that put the readout in front of the map below
   640px. It is gone, and so is the reason for it.

   M-06 measured a real thing: at a phone's width the map was 318 CSS px of a graphic drawn
   to be read at 2400, it filled the fold, and the first legible storm number sat at y≈1072.
   The numbers were moved in front of it deliberately. What changed is the fact, not the
   judgement. The rail went, and with it the six-row skeleton a phone reserved for a closed
   drawer: the figure started at y=1432 at 390px and now starts at 521. The frame was
   redrawn before that (`overview_chrome`), taking the compact chrome from 0.46 of the
   figure to 0.17 — 83% map where it was 54% — and the 480px rendition from 480x351 to
   480x300. The figure's own box is 254-322 CSS px across the phone widths in the matrix —
   well under half a screen. A graphic that no longer fills the fold no longer has to be
   stepped over.

   Retiring the shuffle is worth more than the ordering it bought. Visual order and source
   order are the same thing again at every width, which is what the DOM, the tab ring and a
   screen reader all read — and the last time they disagreed, two `order` rules landed on
   top of each other and put `What the map is showing` **859px above the map it names**. A
   layout with one order cannot do that. */
@media (max-width: 639px) {
  /* The readout leads its own block; nothing is stacked above it inside the section. */
  .systems-readout > .readout-head:first-child { margin-top: 0; }
}

/* ---------------------------------------------------------------------------
   One list, and it is the readout
   -------------------------------------------------------------------------*/

/* The readout used to be hidden above 900px, where the rail carried the same rows —
   "whichever list the reader can actually see is the one that renders; there is never a
   width where both are visible and never one where neither is". The rail is gone, so there
   is one list and it renders at every width.

   The formation sentence goes with it. It was written twice — once in the pane caption for
   the widths where the readout was hidden, once in the readout for the widths where it was
   not — with a pair of media queries making sure exactly one appeared. The readout always
   appears now, so its copy is the only copy and the caption's is unconditional dead weight.
   `.caption-areas` is no longer emitted; this rule would have hidden it on half the
   viewports and shown a duplicate sentence on the other half. */

@media (prefers-reduced-motion: reduce) {
  .viewer, .viewer-stage { scroll-behavior: auto; }
}

/* ============================================================================
   Accessibility fixes from the Phase 7 axe pass
   ==========================================================================*/

/* WCAG 1.4.1 (Use of Color). A link sitting inside a block of text must be
   distinguishable by something other than its colour, and ours was not: measured at
   2.88:1 against the surrounding prose where 3:1 is the floor for colour alone. Rather
   than push the link colour further from the body text — which fights the palette and
   still fails for anyone who cannot see hue at all — the links are underlined.

   Scoped to links that genuinely sit *in* text. Nav items, buttons, facet rows and
   cards are standalone targets with their own affordances; underlining those would be
   noise, and the rule does not ask for it. `.btn` is excluded explicitly because a
   button styled as a link inside a paragraph is still a button.

   The MapLibre attribution is vendor markup we do not author, but it renders on our
   page and fails the same way, so it is covered here too. */
p a:not(.btn):not(.panel-item),
li a:not(.btn):not(.panel-item),
.callout a,
footer .shell-inner a,
.maplibregl-ctrl-attrib-inner a {
  text-decoration: underline;
  text-underline-offset: 0.15em;
}

/* WCAG 1.4.3 (Contrast). Inline `<code>` inherited the muted prose colour and then sat
   on a chip that darkens the card, which left it at 4.29:1 — under the 4.5:1 floor. Code
   should read *more* clearly than the prose around it, not less, so it takes the full ink
   colour: 11.93:1 in light, 10.25:1 in dark. */
.doc code {
  color: var(--ink);
}
/* NOT inside a <pre>, which is a dark block with light text: the rule above is more
   specific than the inheritance carrying that light colour, so without the `color:
   inherit` on `.doc pre code` above, every code block renders dark-on-dark at 1.08:1 —
   invisible. Caught by re-running axe after the fix, which is the point of re-running. */

/* A code chip inside a link double-decorates, and the darkened chip was what pushed the
   link colour under the floor (4.14:1). Dropping the chip there puts the link back on the
   card: 5.28:1 light, 8.52:1 dark. It still reads as code — it is still monospace — and
   still reads as a link, now underlined. */
.doc a code {
  background: none;
  padding: 0;
  color: inherit;
}

/* The storm page ships one line — "Loading…" — and JavaScript then replaces it with three
   full-size graphics and a page of text. The footer therefore starts just under the
   masthead and is shoved several thousand pixels down the moment the data lands.
   Measured: a cumulative layout shift of 0.82, where 0.1 is the boundary of "good" and
   0.25 is already "poor". It is the single worst number on the site.

   Reserving a viewport of height means the footer starts *below the fold*, so when the
   real content arrives nothing the reader can see moves. The class lives on the
   placeholder rather than on `#app`, so it disappears on its own the moment the render
   replaces it — no cleanup to forget, and no empty space left behind on a short page. */
.app-loading {
  min-height: 100vh;
}

/* ============================================================================
   The storm archive
   ==========================================================================*/

/* It was a grid of near-identical bordered cards, one per storm, growing every season.
   Nine of those is a wall and a hundred is unusable — and the questions people bring here
   ("which Atlantic storms in 2025", "when did Erin end") are comparisons down a column,
   which is what a table is for. */

.filter-bar {
  display: flex; gap: var(--sp-4); flex-wrap: wrap; align-items: flex-end;
  padding: var(--sp-4) 0; margin-bottom: var(--sp-4);
  border-top: 1px solid var(--line); border-bottom: 1px solid var(--line);
}
.filter-field { display: flex; flex-direction: column; gap: var(--sp-1); min-width: 0; flex: 1 1 200px; }
.filter-field > span { font-size: var(--fs-bulletin); font-weight: 600; color: var(--muted); }
.filter-field .input { min-width: 0; }
.filter-check {
  display: flex; align-items: center; gap: var(--sp-2); min-height: 42px;
  font-size: var(--fs-bulletin); flex: 0 0 auto;
}
.filter-bar .btn { flex: 0 0 auto; margin-bottom: 0; }
@media (max-width: 560px) {
  .filter-bar { flex-direction: column; align-items: stretch; }
  .filter-field { flex: 1 1 auto; }
}

.archive-count { color: var(--muted); font-size: var(--fs-bulletin); margin: 0 0 var(--sp-4); }

.archive-scroll {
  overflow-x: auto; border: 1px solid var(--line);
  background: var(--paper);
}
.archive-table { border-collapse: collapse; width: 100%; font-size: var(--fs-bulletin); }
.archive-table th {
  text-align: left; padding: var(--sp-3); white-space: nowrap;
  background: var(--wash); color: var(--muted);
  font-family: var(--font-body); font-weight: 600; font-size: var(--fs-bulletin);
  border-bottom: 1px solid var(--line-strong);
}
.archive-table td {
  padding: var(--sp-3); border-bottom: 1px solid var(--line); vertical-align: top;
}
.archive-table tbody tr:last-child td { border-bottom: 0; }
.archive-table tbody tr:hover { background: var(--wash); }
.archive-table .cell-value {
  display: block; font-family: var(--font-mono); font-variant-numeric: tabular-nums;
  color: var(--ink); white-space: nowrap;
}
.archive-table .cell-sub {
  display: block; margin-top: 2px; font-size: var(--fs-micro); color: var(--muted);
}
.archive-name { font-family: var(--font-body); font-weight: 700; font-size: var(--fs-bulletin); }
/* A value the column has nothing to print. Muted, so a column of dashes reads as absence
   rather than as data — the same weight the storm page gives its Status em dash. */
.archive-table .cell-none { color: var(--muted); }

/* The three states, defined once above the table that uses them (audit M-37). A closed
   <details> is one line; the chips inside are the same chips the column prints, so a reader
   matches the definition to the cell by shape rather than by reading a word twice. */
.archive-legend { margin: 0 0 var(--sp-3); font-size: var(--fs-bulletin); }
.archive-legend > summary {
  cursor: pointer; color: var(--muted); font-weight: 600; padding: var(--sp-1) 0;
}
.archive-legend > summary:hover { color: var(--ink); }
.archive-legend > summary:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
.archive-legend-list {
  display: grid; grid-template-columns: max-content 1fr; gap: var(--sp-2) var(--sp-4);
  margin: var(--sp-3) 0 0; padding: var(--sp-4) 0;
  border-top: 1px solid var(--line); border-bottom: 1px solid var(--line);
}
.archive-legend-list dt { margin: 0; }
.archive-legend-list dd { margin: 0; color: var(--ink-2); max-width: var(--wrap-prose); }
@media (max-width: 560px) {
  .archive-legend-list { grid-template-columns: 1fr; gap: var(--sp-1); }
  .archive-legend-list dd { margin-bottom: var(--sp-3); }
}

/* The footnote the em dashes point at. Prose about a table, set as prose — the same
   treatment `.forecast-note` gives the identical rule one page over. */
.archive-note {
  margin: var(--sp-3) 0 0; font-size: var(--fs-bulletin); color: var(--muted);
  max-width: var(--wrap-prose);
}

/* Below 900px the six columns become a labelled block per storm. The table, its <thead>
   and its scopes all survive — the header is moved off-screen, never removed — so a screen
   reader still gets a table and a sighted reader gets something readable at 320px.

   It was 760px, and the reflow did fire there — the audit's "cards false" at 700 and 720px
   was reading the six <th> elements the reflow keeps on purpose, not a breakpoint that had
   failed to trigger (audit M-38). What it was right about is the band above it: at 768px
   this table is 726 CSS px inside a 728px container, which is six columns of mono values
   averaging 120px, and the first classification longer than "Tropical Storm" pushes it into
   its own sideways scroller. A comparison table that has to be scrolled sideways is not a
   comparison table, so the reflow now starts where the compression does. */
@media (max-width: 900px) {
  .archive-scroll { overflow-x: visible; border: 0; background: none; }
  .archive-table thead {
    position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%);
  }
  /* 150px rather than 130: the widest thing this table prints is a classification
     ("Post-Tropical Cyclone"), and a track narrower than its own value is the compression
     this breakpoint exists to end, one level down. Five fields fit across the widest card,
     which is the whole row under its heading. */
  .archive-table tbody tr {
    display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
    gap: var(--sp-2) var(--sp-4);
    background: var(--paper); border: 1px solid var(--line);
    padding: var(--sp-3); margin-bottom: var(--sp-3);
  }
  .archive-table tbody tr:hover { background: var(--paper); }
  .archive-table td { border-bottom: 0; padding: 0; }
  /* A labelled block has its own line to wrap onto. `nowrap` is there to keep a column of
     values aligned in the table, and there is no column here. */
  .archive-table .cell-value { white-space: normal; }
  .archive-table td[data-label="Storm"] { grid-column: 1 / -1; }
  .archive-table td::before {
    content: attr(data-label); display: block; margin-bottom: 1px;
    font-family: var(--font-body); font-size: var(--fs-bulletin); font-weight: 600;
    color: var(--muted);
  }
  .archive-table td[data-label="Storm"]::before { content: none; }
}

/* ============================================================================
   The API reference's endpoint index
   ==========================================================================*/

/* 300 lines of reference whose only navigation was the browser's scrollbar. On a wide
   screen there is room to say what is on the page; below the breakpoint it is the shared
   drawer, because a 300px column of contents beside a 320px viewport is not an index.

   Every anchor in it already existed — the deep links other people have bookmarked are
   unchanged, and `tests/web/test_api_docs.py` checks each entry still resolves. */
.doc-index { font-size: var(--fs-bulletin); }
.doc-index nav { display: flex; flex-direction: column; }
.doc-index .panel-label { margin-top: var(--sp-4); }
.doc-index .panel-label:first-child { margin-top: 0; }
.doc-index .panel-item { padding-block: var(--sp-2); font-size: var(--fs-bulletin); }
.doc-index code { background: none; padding: 0;  }

/* The pane holds the prose. `.doc` already caps its measure; what changes here is that it
   no longer needs `.wrap` to centre it, because the shell grid is doing that. */
body[data-page="docs"] .doc { max-width: 820px; }
body[data-page="docs"] .pane { padding-bottom: var(--sp-8); }

/* An anchored section must not land under the sticky masthead. */
.doc h2[id], .doc .endpoint-doc[id] { scroll-margin-top: calc(var(--masthead-h) + var(--sp-4)); }

/* ============================================================================
   About & methodology
   ==========================================================================*/

/* The three clocks, as a definition list rather than three paragraphs: they are one idea
   with three named parts, and separating them by name is what makes the distinction
   between "when the agency issued it" and "when we drew it" legible at a glance. */
.about-clocks {
  display: grid; grid-template-columns: max-content 1fr; gap: var(--sp-2) var(--sp-5);
  margin: var(--sp-4) 0 var(--sp-6); padding: var(--sp-4) 0;
  border-top: 1px solid var(--line); border-bottom: 1px solid var(--line);
}
.about-clocks dt { font-weight: 700; color: var(--ink); }
.about-clocks dd { margin: 0; color: var(--ink-2); }
@media (max-width: 560px) {
  .about-clocks { grid-template-columns: 1fr; gap: var(--sp-1); }
  .about-clocks dd { margin-bottom: var(--sp-3); }
}

/* The status vocabulary, in the same shape. Definitions run longer than the clocks do, so
   the term sits above its definition rather than beside it at every width — and each term is
   a link target, which is what a Status cell points at. The offset keeps the masthead off
   the term a reader was sent to. */
.about-terms { margin: var(--sp-4) 0 var(--sp-6); }
.about-terms dt {
  margin-top: var(--sp-4); font-weight: 700; color: var(--ink);
  scroll-margin-top: calc(var(--masthead-h) + var(--sp-5));
}
.about-terms dt:first-child { margin-top: 0; }
.about-terms dt:target { background: var(--wash-strong); border-left: 2px solid var(--ink); padding-left: var(--sp-2); margin-left: calc(var(--sp-2) * -1 - 2px); }
.about-terms dd { margin: var(--sp-1) 0 0; color: var(--ink-2); max-width: var(--wrap-prose); }

/* The one paragraph on the site that must not be skimmed past. Not the alarm palette —
   this is standing advice, not a fault — but weighted so the eye stops on it. */
.about-safety {
  padding: var(--sp-4) var(--sp-5);
  border-top: 2px solid var(--ink); border-bottom: 2px solid var(--ink);
  background: var(--wash); color: var(--ink); font-weight: 500;
}

/* The 404's routes onward. A row of middot-separated buttons reads as one broken sentence;
   these are four separate destinations and one of them is the likely one. */
.notfound-actions { display: flex; gap: var(--sp-3); flex-wrap: wrap; margin: 0; }

/* ============================================================================
   Two-column reference tables on a narrow screen
   ==========================================================================*/

/* The provenance block and the API reference's field tables are the same shape: a label
   column and a value column, `<th scope="row">` beside `<td>`. Both were forcing the page
   into a horizontal scroll at 320px — the provenance `th` is a fixed 190px of nowrap text,
   and the field tables have none of the scroll wrappers the wider tables got.
   Measured at 320: 352px and 375px of page against 320px of screen.

   Below 640px each row becomes label-above-value. The markup is untouched, so the row
   headers and their scopes survive; only the visual arrangement changes. */
@media (max-width: 640px) {
  .provenance table, .doc .fieldtable { display: block; }
  .provenance tbody, .doc .fieldtable tbody { display: block; }
  .provenance tr, .doc .fieldtable tr {
    display: block; padding: var(--sp-3) 0; border-bottom: 1px solid var(--line);
  }
  .provenance tr:last-child, .doc .fieldtable tr:last-child { border-bottom: 0; }
  .provenance th, .provenance td,
  .doc .fieldtable th, .doc .fieldtable td {
    display: block; width: auto; padding: 0; border-bottom: 0; white-space: normal;
  }
  .provenance th, .doc .fieldtable th { margin-bottom: var(--sp-1); }
}

/* A long identifier — an endpoint path, a snapshot digest, a storm id — must break rather
   than push the page sideways. `anywhere` and not `break-word`: these have no spaces to
   break at, which is the whole problem. */
code, .endpoint-path, .provenance td, .mono { overflow-wrap: anywhere; }
