/* ---------------------------------------------------------------------------
 * seasons.css — the archive bar and the two doors into it. §57.16, §57.30 step 4.
 *
 * ==> NOT ONE HARDCODED COLOUR AND NOT ONE RAW PIXEL LITERAL. <== Every value
 * is a custom property `applyTokens()` already publishes from tokens.js. That
 * is what makes the bar turn sepia on its own the instant the palette is
 * forced: it is written against the same variables as every panel, so the
 * archive costs the theme system nothing and cannot drift out of step with it.
 *
 * ==> THE BAR IS GONE AND SO IS THE LAYOUT VARIABLE IT COST. <== Step 5,
 * §57.38b. `#seasons-bar` was `position: fixed` to the bottom of the viewport,
 * so the drawer and the control cluster — both fixed to the same edge — had to
 * be pushed up by its height or they sat on top of it. That was
 * `--seasons-bar-h`, published on <html> and subtracted from in three places.
 * All four are deleted. Its two jobs are now two pills that cost no layout at
 * all: `seasons/pill.js` at the top is the way out, `seasons/status-pill.js`
 * at the bottom says what is drawn, and both sit at a z-index above the globe
 * rather than pushing anything off it. That strip of screen goes back to the
 * globe on every archive view, which on a phone was the whole argument.
 *
 * Those overrides are scoped to `html[data-seasons="on"]`, which outranks the
 * bare `#drawer` and `#controls` rules on specificity — so the live app's own
 * stylesheet needed no edit at all, and there is no `!important` anywhere in
 * here. A media query adds no specificity, which is why the two width cases
 * below are restated rather than written once: they have to sit inside the
 * same breakpoints panels.css uses or they win at the wrong width.
 * ------------------------------------------------------------------------- */

html[data-seasons="on"] {
  /* ==> A WALL ROW IS ONE TOUCH TARGET AND THE TOKEN SAYS SO RATHER THAN A
     NUMBER. <== §57.36 froze 44px on glass, and `SEASONS.wallRowPx` carries
     the same figure for the arithmetic in `lib/wall-index.js`. Writing
     `--touch-target` here rather than `44px` means the two can never disagree
     about what a touch target is: if that floor ever moves, the wall moves
     with it instead of quietly falling under it. */
  --seasons-wall-row-h: var(--touch-target);

  /* A quiet year is not a destination, so it does not take a touch target.
     Tall enough to read a year at `--type-small` and no taller — a run of
     forty of these at row height would drown the years that carry storms. */
  --seasons-wall-hair-h: 18px;

  /* ==> THE MARK RATIOS ARE SHARED, BECAUSE THERE ARE TWO DOTS NOW. <== §57.53.
     The Wall of Years sizes its dot after layout (4px to 14px); the season
     board's roster swatch is a fixed 12px from `ui/panels.css`. The MARKS on
     them have to be the same shape or the same fact reads as two different
     ones two taps apart, so the ratios live here once and each surface applies
     them to its own dot. §12 — a pattern used twice is extracted before the
     second use, and this is the second use.

     The values are the mockup's, which is the version Aaron judged: the
     triangle is a little under three-quarters of the dot wide and a little
     under three-quarters of its own width tall, and the bar is a sixteenth of
     the dot. */
  --mark-w-ratio: 0.72;
  --mark-lf-h-ratio: 0.72;
  --mark-ret-h-ratio: 0.16;

  /* Clear of the dot's edge without floating free of it. Both marks, both
     surfaces. */
  --mark-gap: 1.5px;

  /* ==> THE LANDFALL TRIANGLE IS SIZED OFF THE DOT, NEVER OFF A PIXEL COUNT.
     <== §57.30 step 14 sub-step 4. `--wall-dot` is measured after layout and
     ranges from `SEASONS.wallDotMin` (4) to `wallDotMax` (14) depending on how
     busy the basin's widest season is, so a fixed triangle would be a speck
     against a 14px dot and wider than a 4px one.

     The floors exist because at `wallDotMin` the arithmetic gives 2.88px by
     2.07px, and below about 3px a triangle stops reading as a triangle and
     starts reading as dirt on the screen. */
  --wall-lf-w: max(3px, calc(var(--wall-dot, 7px) * var(--mark-w-ratio)));
  --wall-lf-h: max(2.5px, calc(var(--wall-lf-w) * var(--mark-lf-h-ratio)));

  /* Kept as its own name because `--wall-strip-pad` below does arithmetic on
     it and cannot read a percentage off an element it does not own. */
  --wall-lf-gap: var(--mark-gap);

  /* ==> THE RETIRED BAR. A LINE, AND DELIBERATELY NOT A DOT. <== §57.52.
     Aaron asked for a small dot above the storm dot, same ink and size as the
     landfall triangle, and it was measured before it was built. At 390px the
     busiest basin draws a 6px dot, falling to 4px whenever a sort column is
     showing or on the desktop side rail. A circle matching the triangle's
     WIDTH is 4.32px sitting above a 6px dot — 72% of the storm dot itself and
     2.2x the triangle's ink — which reads as a second storm rather than as a
     mark on one. A circle matching the triangle's INK is 2.92px, and 2.19px
     once a sort column is up, which is under the 3px floor where the triangle
     already found that a mark stops reading as a mark. There is no diameter
     that is both legible and not mistakable for another dot.

     A line survives the small sizes because it only has to hold 1px in one
     dimension, and it is the shape furthest from both the dot and the
     triangle — which matters because 109 of the 122 retired storms in the
     record also came ashore, so almost every barred dot wears the triangle too.

     Same width as the triangle so the two marks share a column, and the same
     neutral ink for the same reason it is neutral down there: retirement is a
     fact about the NAME, and strength is what the dot already says. */
  --wall-ret-w: var(--wall-lf-w);
  --wall-ret-h: max(1px, calc(var(--wall-dot, 7px) * var(--mark-ret-h-ratio)));
  --wall-ret-gap: var(--mark-gap);

  /* ==> THE STRIP'S BREATHING ROOM ANSWERS TO WHICHEVER MARK NEEDS MORE. <==
     The slot clips, so both the glow bleeding outward and the triangle hanging
     below have to fit inside this padding or they are cut off. The glow wins at
     small dot sizes and the triangle wins at large ones — at `wallDotMax` the
     triangle reaches 9.8px against the glow's 8px — so it is a `max()` rather
     than a number, and neither can be starved by a change to the other. */
  /* ==> THE BAR IS IN THE `max()` EVEN THOUGH IT NEVER WINS TODAY. <== §57.52.
     The padding is applied top AND bottom off one value, and the triangle
     below is 0.52 of the dot tall against the bar's 0.16 — so the triangle
     wins at every dot size in the range and the bar fits inside the room it
     already asks for. It is listed anyway for the reason the other two are:
     the slot CLIPS, so a retune of the bar that nobody thought to trace back
     to this line would cut the mark off at the top with nothing on screen
     saying why. Neither mark can be starved by a change to the other. */
  --wall-strip-pad: max(
    var(--dot-glow-blur),
    calc(var(--wall-lf-gap) + var(--wall-lf-h) + 1px),
    calc(var(--wall-ret-gap) + var(--wall-ret-h) + 1px)
  );
}

/* --- the pill: the way back to the live globe ---------------------------- */

/* ==> THE ONE THING ON SCREEN THAT MUST NEVER BE COVERED. <== It is the only
   way out of the archive — Escape steps the drawer back and closes it, it
   never leaves — so it sits above the drawer (30) and the control cluster (40)
   at the same rung the bar holds for the same reason.

   THE CENTRING AND THE SIZING FIGHT, AND `#storm-pill` ALREADY LOST THAT FIGHT
   TWICE (ui/panels.css). Setting `left` alone silently caps a fixed element at
   half the screen; setting `left` AND `right` stops it growing to its contents.
   So: centre with the transform, which asks nothing of the box, and clamp with
   `max-width`. Copied deliberately rather than re-derived — that comment is the
   record of two glass faults and this pill has the same shape. */
#seasons-pill {
  position: fixed;
  top: var(--safe-top);
  left: 50%;
  transform: translateX(-50%);
  width: max-content;
  max-width: calc(100vw - 2 * var(--pill-inset));
  z-index: 50;
  min-height: var(--touch-target);
  padding: 0 var(--space-comfy);
  display: flex;
  align-items: center;
  gap: var(--space-tight);
  background: var(--glass-raised);
  backdrop-filter: blur(var(--glass-blur-raised));
  -webkit-backdrop-filter: blur(var(--glass-blur-raised));
  border: 1px solid var(--glass-border);
  border-radius: 999px;
  color: var(--text-primary);
  font-family: var(--font-ui);
  font-size: var(--type-body);
  font-weight: 600;
  cursor: pointer;
  box-shadow: 0 4px 20px var(--glass-shadow);
  -webkit-appearance: none;
  appearance: none;
  /* Transform and opacity only (§9). */
  transition: transform var(--duration-instant) var(--ease-swap),
              opacity var(--duration-instant) var(--ease-swap);
}

/* Sized off the text rather than a pixel literal, so it tracks the label at
   every type scale instead of becoming a speck or a billboard. */
.seasons-pill-chevron {
  flex: 0 0 auto;
  width: 1.1em;
  height: 1.1em;
}

.seasons-pill-text {
  white-space: nowrap;
}

#seasons-pill:active { transform: translateX(-50%) scale(0.97); }
#seasons-pill:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; }

/* Hover is a CAPABILITY, never a device (§Input). A finger has none, and a
   touchscreen laptop has both. */
@media (hover: hover) {
  #seasons-pill:hover { background: var(--hover); }
}

/* --- the pill along the bottom ------------------------------------------- */

/* ==> THE SECOND PILL, AND IT SAYS WHAT IS DRAWN. §57.38, §57.38b. <== The
   archive has two: `#seasons-pill` above is the way out, this one is the
   caption. They are two elements because one element cannot be in two places
   saying two things.

   ==> IT IS THE DELETED BAR'S JOB WITHOUT THE BAR'S COST. <== `#seasons-bar`
   was fixed to this same edge and full width, so the drawer and the control
   cluster had to be pushed up by its height. This is a lozenge at
   `z-index: 30` — the same layer `#storm-pill` sits on — so nothing is pushed
   anywhere and that strip of screen is the globe's again.

   MEASUREMENTS ARE `#storm-pill`'s, COPIED RATHER THAN RE-DERIVED. It is the
   same shape doing the same job on the same edge, and its own comment records
   two glass faults about centring a fixed element: `left` alone silently caps
   the width at half the screen, and `left` plus `right` stops it growing to
   its contents. `left: 50%` with a transform is what survived both. */
#seasons-status-pill {
  position: fixed;
  transform: translateX(-50%);
  bottom: var(--safe-bottom);
  /* ==> CENTRED ON THE WINDOW, THE SAME AS THE PILL AT THE TOP. <== Aaron,
     2026-08-28. A version that shifted right by half the rail so it centred on
     the visible globe was built and rejected on glass: the pill moving when
     the drawer opens reads worse than it sitting still, and the two pills must
     agree with each other before either agrees with the rail. */
  left: 50%;
  width: max-content;
  max-width: calc(100vw - 2 * var(--pill-inset));
  /* ==> BELOW THE DRAWER, AND THAT IS WHERE IT DIFFERS FROM ITS MODEL. <==
     Glass, 2026-08-28: it painted THROUGH the open sheet. `#storm-pill` is at
     30 and so is `#drawer`, which is a tie broken by stylesheet order — and
     `seasons.css` loads after `panels.css`, so this pill won it. The live pill
     gets away with 30 only because it sets `data-hidden` whenever the drawer
     opens; this one has no such rule, because it is meant to still be there
     when the sheet comes down. So it sits UNDER the drawer by z-index instead,
     which is also what "it costs the globe no layout" was always supposed to
     mean. */
  z-index: 20;
  min-height: var(--touch-target);
  padding: 0 var(--space-comfy);
  display: flex;
  align-items: center;
  background: var(--glass-raised);
  backdrop-filter: blur(var(--glass-blur-raised));
  -webkit-backdrop-filter: blur(var(--glass-blur-raised));
  border: 1px solid var(--glass-border);
  border-radius: 999px;
  /* ==> MUTED, NOT PRIMARY, AND THAT IS THE ONE DELIBERATE DIFFERENCE FROM THE
     PILL ABOVE. <== This is a caption about what is on the globe; the top pill
     is the only way out. If they carried the same weight the eye would have to
     choose between them, and the one that must never be missed would lose
     half the time. */
  color: var(--text-secondary);
  font-family: var(--font-ui);
  font-size: var(--type-small);
  cursor: pointer;
  box-shadow: 0 4px 20px var(--glass-shadow);
  -webkit-appearance: none;
  appearance: none;
  /* Transform and opacity only (§9). */
  transition: transform var(--duration-instant) var(--ease-swap),
              opacity var(--duration-instant) var(--ease-swap);
}

/* ==> `hidden` HAS TO BE SAID OUT LOUD, BECAUSE `display: flex` ABOVE BEATS IT.
   <== Glass, 2026-08-28: the pill rendered as an empty capsule over the globe
   before the archive had a sentence for it. The browser's own `[hidden]` rule
   is a bare element selector and loses to an id, so setting the property in
   JavaScript did nothing at all visually. Every `display` an id rule declares
   needs this beside it. */
#seasons-status-pill[hidden] { display: none; }

/* One line, and it ellipses rather than wrapping. A two-line lozenge on a
   390px phone is a panel, and this is a caption. */
.seasons-status-text {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

#seasons-status-pill:active { transform: translateX(-50%) scale(0.97); }
#seasons-status-pill:focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: 2px;
}

/* Hover is a CAPABILITY, never a device (§Input). */
@media (hover: hover) {
  #seasons-status-pill:hover { background: var(--hover); }
}

/* ==> WIDE SCREENS DO NOT GET IT, AND `btn-storms` IS WHY THAT IS SAFE. <==
   `#storm-pill` sets this precedent: above 720px the drawer is a docked rail
   and the control cluster is the navigation, so a lozenge floating over the
   globe is redundant chrome. The job it does on a phone — reopening the
   archive's drawer — is `#btn-storms`'s up here, and that button is visible in
   the archive for exactly this reason. */
/* ==> IT SHOWS AT EVERY WIDTH NOW, AND SO DOES THE LIVE ONE. <== Aaron,
   2026-08-28. This copied `#storm-pill`'s wide-screen hide, and that rule
   turned out to be resting on a premise that had already died — the rail is
   NOT open by default at any width, so a desktop reader arrived at a globe
   with no caption. Both pills are unhidden together, because the pair is the
   point: they are the same furniture on the same edge and having one appear
   only on a phone was never a decision anybody made.

   `#btn-storms` keeps the job it took at step 5 — it is a second road to the
   drawer, not a replacement for this pill, and on a wide screen the two are
   now both available. */

/* --- the season clock: the FAB and the scrubber. §57.67 slice C ----------- */

/* ==> THE PLAY CONTROL IS A CLUSTER BUTTON WITH ONE EXTRA RULE, NOT A SIXTH
   KIND OF FLOATING CONTROL. <== It carries `.control` from index.html, so the
   44px target, the glass, the shadow, the focus ring, the press scale and the
   hover-capability rule all arrive with it. `seasons/clock-control.js`
   prepends it to `#controls`, which is a column flex container where DOM order
   is both the visual order and the tab order — so it lands at the TOP of the
   stack and first in the keyboard walk together (Aaron's call 1, 2026-08-31:
   above the location button).

   ==> `[hidden]` HAS TO BE SAID OUT LOUD, AND THIS IS THE THIRD TIME. <==
   `.control` declares `display: grid`, and the browser's own `[hidden]` rule
   is a bare attribute selector that loses to it — so setting the property in
   JavaScript would do nothing visually and the button would sit there over an
   empty globe. `#seasons-status-pill[hidden]` above carries the same note,
   from glass on 2026-08-28. Every `display` an id or class rule declares needs
   this beside it. */
#btn-season-clock[hidden] { display: none; }

/* Engaged. ==> THE FILL ONLY, AND THE INK STAYS WHERE THE REST OF THE COLUMN'S
   IS. <== This used to lift `color` to `--text-primary` as well, and on glass
   (2026-08-31) that made the engaged button the brightest thing in the cluster
   — a control shouting about its own state beside four that never do. The state
   is already said twice without it: the mark changes from a triangle to a
   square, and the scrubber pill is on screen. `--hover` is a published token,
   so it goes sepia with everything else rather than being a colour this file
   chose. */
#btn-season-clock[data-on] {
  background: var(--hover);
}

/* Both marks are stacked in the same grid cell and cross-faded, which is
   `.control-view`'s pattern and is there for a measured reason: swapping the
   SVG's contents would be a parse and a layer promotion on every press.
   Opacity on two static children costs nothing (§9).

   It is TWO whole `<svg>` elements rather than one holding two shapes, because
   the stacking is grid on the BUTTON and grid does not lay out the children of
   an `<svg>` — `grid-area` on a path is inert. `.control` is already
   `display: grid`, so this costs no extra box.

   ==> AND THIS RULE WAS SILENTLY DEAD FOR TWO COMMITS, WHICH IS WORTH MORE
   THAN THE RULE. <== An edit left the paragraph above split by a stray
   comment-close, so the prose after it became raw CSS. The parser does not
   stop at that: it enters error recovery and discards everything up to and
   including the NEXT rule block, which was this one. Nothing errored, nothing
   logged, and both marks fell into their own grid rows — play against the top
   edge of the button, stop against the bottom. Aaron on glass, 2026-08-31.
   `tools/css-structure-check.mjs` is the gate that exists because of it, and
   it caught a SECOND one written into this very paragraph while fixing the
   first. Do not spell the delimiter out in prose. */
#btn-season-clock > svg {
  grid-area: 1 / 1;
  transition: opacity var(--duration-instant) var(--ease-swap);
}

/* ==> THE MARK SWAPS ON `data-playing` AND THE FILL STAYS ON `data-on`, AND
   THOSE ARE TWO DIFFERENT FACTS. §57.67 slice D. <== In slice C there was one
   flag, because the clock was either on screen or not and there was no timer
   to be running. There is now, and engaged-while-paused is the ordinary state
   — it is what a reader is in the whole time they are dragging the scrubber.
   One attribute could not have described it: the button would have shown ⏸
   over a clock that was standing still. */
#btn-season-clock .clock-pause { opacity: 0; }
#btn-season-clock[data-playing] .clock-play { opacity: 0; }
#btn-season-clock[data-playing] .clock-pause { opacity: 1; }

/* ==> THE SCRUBBER IS A THIRD PILL AND IT TAKES THE CAPTION'S SLOT. <==
   `#seasons-status-pill` is a `<button>` — pressing it toggles the drawer — and
   a range input cannot live inside a button at all, so the scrubber could
   never have been that element wearing a slider. It sits on the same edge with
   the same glass, and the caption steps aside for it below.

   ==> IT IS WIDER THAN THE OTHER TWO AND IT STOPS SHORT OF THE CLUSTER, AND
   THOSE ARE THE SAME DECISION SEEN FROM TWO SIDES. <== They are lozenges sized
   to their words, centred, and `--pill-inset` (60px a side) keeps them off both
   screen edges. This is a CONTROL, and on the 390px phone this app is judged on
   that inset would leave a 270px pill holding a date and a slider — the slider
   would get barely a hundred pixels.

   ==> SO IT IS INSET ASYMMETRICALLY, AND THAT IS A GLASS CORRECTION. <== The
   first version took `--space-comfy` off both sides and centred itself, which
   put its right edge 40px INSIDE the control cluster. `#controls` is z-index 40
   against this pill's 20, so the FABs painted straight over it and 23 of the
   leave button's 44 pixels sat under the settings gear — a control the reader
   can see half of and cannot press. Aaron on a phone, 2026-08-31.

   Measured at 390px rather than reasoned about: the cluster's left edge is at
   x=334 and this pill's right edge was at 374. The right inset is the cluster's
   own geometry now — its distance from the screen edge, plus its width, plus a
   gap — so it follows a device whose safe area is wider than the 12px floor
   instead of trusting a number that happens to work on one phone.
   `--pill-inset`'s 60 is that same sum with `--safe-right` assumed to be at
   that floor.

   The pill goes from 358px to 310 and the slider track from 276 to 230, which
   is still more than twice what a centred `--pill-inset` would have left it.

   `margin-inline: auto` is what keeps it sane on a desktop: under 520px it
   fills the space between the two insets, and over that it centres inside them
   rather than growing into a metre of slider. */
#seasons-clock-pill {
  position: fixed;
  bottom: var(--safe-bottom);
  left: var(--space-comfy);
  right: calc(var(--safe-right) + var(--touch-target) + var(--space-snug));
  max-width: 520px;
  margin-inline: auto;
  /* Same rung as the caption it replaces: UNDER the drawer, so an open sheet
     covers it rather than being painted through. See the note on
     `#seasons-status-pill` — that was a glass fault, not a preference. */
  z-index: 20;
  padding: var(--space-snug) var(--space-comfy);
  display: flex;
  flex-direction: column;
  gap: var(--space-tight);
  background: var(--glass-raised);
  backdrop-filter: blur(var(--glass-blur-raised));
  -webkit-backdrop-filter: blur(var(--glass-blur-raised));
  border: 1px solid var(--glass-border);
  border-radius: var(--radius-large);
  box-shadow: 0 4px 20px var(--glass-shadow);
}

/* `display: flex` above beats the browser's own `[hidden]`, same as the FAB. */
#seasons-clock-pill[hidden] { display: none; }

/* The moment, in words. ==> TABULAR FIGURES, AND THAT IS NOT A DETAIL. <== The
   date changes on every step of a drag; with proportional digits a `1` is
   narrower than a `0` and the whole line shivers sideways under the thumb,
   which is the single thing that makes a scrubber feel cheap. `.slider-value`
   in `ui/panels.css` carries the same rule for the same reason. */
.seasons-clock-date {
  font-family: var(--font-numeric);
  font-variant-numeric: tabular-nums;
  font-size: var(--type-small);
  color: var(--text-secondary);
  text-align: center;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* ==> IT REUSES `.slider` AND CHANGES EXACTLY TWO THINGS. <== The track, the
   thumb, the coarse-pointer thumb size, the focus ring and the grab cursor are
   all already decided in `ui/panels.css` and this is the second use of that
   pattern, not a second version of it.

   The two changes are both about where this one lives:

   1. **The track IS a hit area here.** `.slider` sets `cursor: default`
      because `ui/slider-grab.js` refuses any press that misses the thumb —
      the right rule for a slider inside a sheet you scroll past, where a
      finger travelling over one would otherwise change a setting nobody
      touched. This pill is fixed over the globe with nothing to scroll past
      it, and a press on the track jumping to that moment is what a scrubber is
      FOR. So it is not armed, and the cursor says so.
   2. **`touch-action: none`.** `.slider` says `pan-y` so a sideways drag
      inside the drawer is not stolen by the scroll container. There is no
      scroll container here, and `none` is what stops the browser deciding
      mid-gesture that a slightly-off-axis drag was a page gesture instead. */
.seasons-clock-range {
  cursor: pointer;
  touch-action: none;
}

/* ==> THE WAY OUT SITS ON THE SCRUBBER'S ROW, AND THAT IS FREE HEIGHT.
   §57.67 slice D. <== `.slider` is already `--touch-target` tall, so a 44px
   button beside it costs the pill nothing. On the date's line above it would
   have turned a single line of small text into a 44px one and taken most of
   thirty pixels of globe for a control that is pressed once.

   The scrubber takes what is left, which on the 390px phone this app is judged
   on is about 274 of the 326 the pill has inside its padding — still wider
   than the 270 §57.67g rejected `--pill-inset` for, and that 270 had to hold
   the date as well. */
.seasons-clock-row {
  display: flex;
  align-items: center;
  gap: var(--space-tight);
}
.seasons-clock-row .seasons-clock-range { flex: 1 1 auto; min-width: 0; }

/* ==> AND THE CAPTION STEPS ASIDE, RATHER THAN THE TWO STACKING. <== They are
   the same slot on the same edge. `display: none` and not opacity, for the
   reason every other suppression in this file uses it: the caption is a button
   and it has to leave the TAB ORDER too, or a keyboard user tabs into a
   control they cannot see (§13).

   SPECIFICITY. `#seasons-status-pill` above is (1,0,0); this is (1,0,1) and
   wins regardless of source order. */
html[data-seasons-clock="on"] #seasons-status-pill { display: none; }

/* --- getting the live chrome out of the archive --------------------------------- */

/* ==> THE CLUSTER SITS WHERE IT ALWAYS DID NOW. <== It used to be pushed up by
   `--seasons-bar-h`; step 5 deleted the bar, so the override went with it and
   the archive's cluster is the live app's cluster in the same place. One less
   thing that can be in two positions depending on which world you are in. */

/* ==> TWO OF THE FIVE CLUSTER BUTTONS ARE GONE WHILE THE ARCHIVE IS OPEN,
 * AND THAT IS §5 RATHER THAN TIDINESS. <== Home and Layers both open a
 * live-app drawer over a sepia globe with nothing on it: a dashboard
 * describing a house against a storm that is not there, and a set of toggles
 * for layers with no data behind them. Two surfaces that would each be
 * confidently wrong.
 *
 * `display: none` rather than `visibility` or an opacity fade: this has to
 * take them out of the TAB ORDER too, or a keyboard user walks into both
 * (§13, the same trap the closed drawer hit).
 *
 * ==> AND STORMS CAME BACK AT STEP 5, DOING A DIFFERENT JOB. <== §57.37,
 * §57.38b. It was hidden for the same reason as the other two — it opened the
 * LIVE storm list. Inside the archive it now reopens the archive's own drawer
 * at the rung the reader left, so the surface it opens is about the world on
 * screen and the §5 objection is answered rather than dodged.
 *
 * ==> IT IS THE WIDE HALF OF ONE ACTION, NOT A SECOND CONTROL. <== The bottom
 * pill does this on a phone and is `display: none` above 720px, exactly as
 * `#storm-pill` is. Without this the archive would have no way back to its
 * drawer on a desktop at all. Both roads run through the same function in
 * `seasons/index.js`, so they cannot drift into two different ideas of where
 * the reader was.
 *
 * ==> RECENTER AND SETTINGS STAY, AND BOTH EARN IT. <== Recenter is the
 * camera, and the camera is the same camera. Settings is app-wide — units,
 * motion, theme — and its theme control is the one thing in this whole mode
 * that has an explicit answer for being used from in here: `setThemeMode`
 * keeps the new preference as the RESTORE target instead of applying it, so a
 * theme changed inside the archive is the theme you get back on the way out. */
html[data-seasons="on"] #btn-home,
html[data-seasons="on"] #btn-layers {
  display: none;
}

/* ==> AND TWO MORE LIVE SURFACES WERE STILL ON SCREEN IN HERE, WHICH IS THE
 * SAME §5 FAULT AS THE THREE BUTTONS ABOVE. <== Found by audit 2026-08-28, not
 * by glass — both sit under the drawer at its 66vh and only appear once the
 * reader minimises the sheet, which is exactly the state the archive's own
 * chrome is designed for.
 *
 * `#storm-pill` is the narrow-width entry to the LIVE storm list. It goes on
 * counting today's storms over a sepia globe, and a tap opens a list naming
 * storms that are not drawn.
 *
 * ==> IT STAYS HIDDEN AND THE ARCHIVE BUILDS ITS OWN, WHICH IS A CORRECTION TO
 * §57.38 RATHER THAN AGREEMENT WITH IT. <== That section said the existing
 * pill should stay and change what it says. It cannot: `ui/view-storms.js`
 * rewrites this element's text, its busy state and its error tone on EVERY
 * POLL, open drawer or not, so the archive would inherit a red pill because
 * GDACS is down — a fact about today, under 1935's tracks. `#seasons-status-pill`
 * is a second element for that reason (§57.38b).
 *
 * `#status` is the source-health strip: stale flags, "GDACS is not responding".
 * Aaron's call, 2026-08-26 (§57.37): it is suppressed in here because nothing
 * in the archive reads a live feed, so every word it could say is about a
 * world the reader is not looking at. It is NOT §5 silence — a season that
 * will not load still speaks, through the board's own three states
 * (`ui/seasons-board-loading.js`). It also occupies exactly the slot the
 * archive's own pill sits in, so leaving it would be two things at the top
 * edge arguing about a globe only one of them is describing.
 *
 * `display: none` rather than `visibility` or opacity, for the reason the
 * buttons above use it: this has to take them out of the TAB ORDER too, or a
 * keyboard user walks into both (§13).
 *
 * SPECIFICITY IS WHY BOTH ARE WRITTEN AS DESCENDANTS OF `html[data-seasons]`.
 * `#storm-pill[data-hidden="true"]` in ui/panels.css is (1,1,0); this is
 * (1,1,1) and wins regardless of which stylesheet loads first. A bare
 * `#storm-pill` here would tie and hand the answer to source order. */
html[data-seasons="on"] #storm-pill,
html[data-seasons="on"] #status {
  display: none;
}

/* Narrow: the drawer is a bottom sheet and sits ON the bar. Same breakpoint as
   panels.css, restated because a media query adds no specificity. */
@media (max-width: 719.9px) {
  /* ==> THE ARCHIVE'S SHEET IS A FIXED HEIGHT, NOT A CONTENT HEIGHT.
     §57.21b item 3, corrected by push 2 item 1. <==

     ==> THE ONE LINE TO CHANGE IS `--seasons-sheet-h` BELOW. <== Everything
     else here is derived from it, deliberately, so the number lives once.

     THE FAULT IT FIXES. `max-height` alone is a CEILING, and a year with four
     storms is shorter than a year with twenty-eight — so stepping the year
     resized the sheet, and the `+`/`−` buttons moved with it. On the one
     control a reader presses repeatedly, every press moved the target. Aaron
     on glass 2026-08-25. It is the same fault `ui/panels.css` fixed on the
     home dashboard, and it takes the same shape: `height` as well as
     `max-height`, both reading the SAME `min()` expression, so the two can
     never disagree about the keyboard. The roster's own scroller absorbs the
     variation instead of the sheet doing it.

     ==> AND IT STEADIES THE CAMERA, WHICH IS THE LARGER WIN. <== The same
     argument `ui/panels.css` makes about the dashboard applies here and is not
     a repetition of it: `main.js`'s `archiveOffset()` measures this drawer at
     call time to decide where a flight should centre, so a sheet whose height
     depends on which year is open means the same storm frames differently
     depending on where the reader came from. A constant height makes that
     offset a constant too.

     WHERE 66vh CAME FROM, AND IT IS A GLASS DIAL RATHER THAN A LAW. Aaron's
     number is "about four storm names". The furniture above the roster is not
     a fixed height, so no `vh` figure yields exactly four rows on every year —
     but this one is measured rather than guessed. At 390x844 against real 2005
     data, `tools/seasons-height-measure.mjs` reports 379.0px of furniture
     (a 60px drawer header, a 94px picker, a 94px scorecard, 42px of filters
     and 89px of gaps and padding) over a 44.9px row pitch, so four names need
     558.4px, which is 66.2vh. The old 75vh showed 5.7. Re-run that tool after
     anything changes the furniture — step 9 adds a filter and a near-home
     slider to this same view, and both land above the roster.

     THE COST, ACCEPTED RATHER THAN OVERLOOKED: a quiet year with two storms
     now leaves empty sheet below its roster where it used to shrink. That is
     the trade the dashboard already made — a reader notices a moving control
     far more than a short list with room under it. */
  html[data-seasons="on"] #drawer[data-view="seasons-board"] {
    --seasons-sheet-h: 66vh;

    max-height: var(--seasons-sheet-h);
    max-height: min(var(--seasons-sheet-h),
                    calc(100dvh - var(--keyboard-inset) - var(--space-comfy)));
    /* Two declarations for each, not one: a browser without `dvh` keeps the
       plain `vh` rather than dropping the property altogether. */
    height: var(--seasons-sheet-h);
    height: min(var(--seasons-sheet-h),
                calc(100dvh - var(--keyboard-inset) - var(--space-comfy)));
  }
}

/* --- the doors ----------------------------------------------------------- */

/* A pinned footer inside the view, not the last row of the scroller. §57.16
   calls it a PERMANENT row: a door that scrolls out of sight during a busy
   season is a door that is hardest to find exactly when the list is longest.
   `.drawer-view` is a flex column and `.drawer-body` takes the free space, so
   a sibling after it is pinned with no positioning at all. */
.seasons-door {
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  gap: var(--space-base);
  width: 100%;
  box-sizing: border-box;
  min-height: var(--touch-target);
  padding: var(--space-snug) var(--drawer-inset);
  background: transparent;
  border: 0;
  border-top: 1px solid var(--glass-border);
  color: var(--text-primary);
  text-align: left;
  cursor: pointer;
  transition: transform var(--duration-instant) var(--ease-swap),
              opacity var(--duration-instant) var(--ease-swap);
}
.seasons-door:active { transform: scale(0.99); }
.seasons-door:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: -2px; }

@media (hover: hover) {
  .seasons-door:hover { background: var(--hover); }
}

.seasons-door-text {
  flex: 1 1 auto;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: var(--space-tight);
}

.seasons-door-label {
  font-family: var(--font-ui);
  font-size: var(--type-body);
  font-weight: 600;
}

.seasons-door-note {
  font-family: var(--font-ui);
  font-size: var(--type-small);
  color: var(--text-muted);
}

/* 18px is the drawer's own chevron size (panels.css, `.drawer-back svg`), not
   a new number. There is no icon token — icons in this app are sized at their
   call site — so matching the one the reader has already seen a hundred times
   is the closest thing to a token available. */
.seasons-door-chevron {
  flex: 0 0 auto;
  width: 18px;
  height: 18px;
  color: var(--text-muted);
}

/* ---------------------------------------------------------------------------
 * THE SEASON BOARD. §57.18, §57.30 step 5.
 *
 * ==> IT REUSES `.seg-group`, `.seg` AND `.drawer-body` FROM panels.css RATHER
 * THAN RESTATING THEM. <== The board is a view inside the one drawer, so the
 * basin and filter controls are the SAME segmented control the Layers panel
 * uses — same height, same focus ring, same disabled treatment. Only what is
 * genuinely new to this screen is below.
 *
 * NOT ONE HARDCODED COLOUR AND NOT ONE RAW PIXEL LITERAL, same rule as the bar
 * above. The one exception is `--swatch`, which every row sets inline from a
 * Saffir-Simpson token — those colours are fixed by §6 and are deliberately not
 * themeable, so they come from the storm rather than from the palette. It is the
 * same custom property name `.row-swatch` in the live storm list already uses.
 * ------------------------------------------------------------------------- */

/* ==> THE YEAR STEPPER IS A PINNED ROW UNDER THE HEADER, SHAPED LIKE THE LIVE
   ONE. <== §57.39a. It used to be a full-width block at the top of the
   SCROLLER: a bordered `−` box against the sheet's left edge, `+` against the
   right, and the year between them — one line under a header that already said
   the same four digits. Aaron on glass 2026-08-28, pointing at the live storm
   drawer and asking for that shape instead.

   THREE THINGS ARE LOAD-BEARING HERE.

   PINNED. `flex: 0 0 auto` outside `.drawer-body`, so it holds still while the
   roster scrolls under it. This is the one control on the screen a reader
   presses repeatedly and it must not have to be hunted for between presses —
   the same complaint §57.21b item 1 answered when the sheet was resizing.

   TIGHT AND CENTRED. `ui/panels.css`'s `.storm-step` block records why: buttons
   on the outer edges land under the Back chevron and the close X, and a
   mis-aimed press there dismisses the panel instead of stepping. Clustered in
   the middle they are clear of both.

   BORDERLESS. In the old block each button was a framed box, which is why they
   read as the loudest thing on the sheet. Bare glyphs in muted ink, exactly
   like the live chevrons.

   TWO SMALL BLOCKS RATHER THAN ONE SHARED ONE, deliberately: `.storm-step` is
   a live-drawer control on 30 lines across 9 files and confirmed on glass, and
   renaming it to share eleven lines of shape with this would be churn on
   something working. Each block points at the other; if a third stepper ever
   appears, extract then. */
.seasons-year {
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-tight);
  border-bottom: 1px solid var(--glass-border);
}

/* Square at the full touch target on both axes (§17). */
.seasons-step {
  display: flex;
  align-items: center;
  justify-content: center;
  width: var(--touch-target);
  height: var(--touch-target);
  flex: 0 0 auto;
  padding: 0;
  border: 0;
  background: none;
  color: var(--text-muted);
  font-size: var(--type-title);
  line-height: 1;
  cursor: pointer;
}

/* Disabled at the ends of the record rather than hidden (§7) — one end always
   is, since 1851 has no earlier Atlantic season. */
.seasons-step:disabled { opacity: 0.5; cursor: default; }

@media (hover: hover) {
  .seasons-step:not(:disabled):hover { color: var(--text-primary); }
}

/* The year the stepper is sitting on. A step down from the header's title so
   the two lines read as basin-then-year rather than as two titles arguing —
   the same relationship `KARINA` and `1 of 7` have in the live drawer — but in
   primary ink rather than muted, because unlike a position the year is the
   thing the reader came for.

   `tabular-nums` and a fixed floor so the pair does not shift as the digits
   change: a stepper whose target moves as you step is one you re-aim at every
   press. `min-width` rather than a width, so the "No season" fallback is not
   clipped. */
.seasons-year-now {
  min-width: 5.5em;
  text-align: center;
  font-size: var(--type-lead);
  font-variant-numeric: tabular-nums;
  color: var(--text-primary);
}

/* The native box is out of sight but still the thing that takes focus, so the
   ring is drawn on the box beside it. Never `outline: none` without a
   replacement (§10). */
.seasons-step:focus-visible,
.seasons-retry:focus-visible,
.seasons-check input:focus-visible + .check-box {
  outline: 2px solid var(--focus-ring);
  outline-offset: 2px;
}

/* --- the scorecard ------------------------------------------------------- */

.seasons-score {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: var(--space-snug);
  margin-bottom: var(--space-base);
}

.seasons-stat {
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: var(--space-tight) var(--space-tight);
  border-radius: var(--radius);
  background: var(--glass);
}

.seasons-stat-n {
  font-size: var(--type-title);
  color: var(--text-primary);
  font-variant-numeric: tabular-nums;
}

.seasons-stat-k {
  font-size: var(--type-small);
  color: var(--text-secondary);
}

/* --- notes -------------------------------------------------------------- */

.seasons-note {
  margin: var(--space-snug) 0;
  font-size: var(--type-small);
  color: var(--text-secondary);
  line-height: 1.35;
}

.seasons-bad { color: var(--text-primary); }

/* --- the radius slider (§57.19) ------------------------------------------- */

/* ==> THE CONTROL ITSELF IS SETTINGS', NOT A SECOND ONE THAT LOOKS LIKE IT.
   <== `.slider`, `.slider-row`, `.slider-label` and `.slider-value` all live in
   `ui/panels.css` and carry the thumb, the track, the focus ring, the coarse-
   pointer sizing and the hover state. What this rule adds is the ONE thing that
   is about where the control sits in this drawer rather than about what it is:
   the gap between it and the filter segments above it. A second set of slider
   styles here would be a second thing to keep in step with the visual contract
   (§10), and the first divergence nobody would notice is the 28px touch thumb.

   ==> IT APPEARS AND DISAPPEARS WITH THE FILTER, SO IT NEEDS NO HIDDEN STATE.
   <== `ui/view-seasons-board.js` puts it in the markup only under Near home. A
   `display: none` variant would be a rule for a case that cannot happen. */
.seasons-radius {
  margin-top: var(--space-snug);
}

/* ==> A WRAPPER WITH NO BOX, AND THAT IS THE WHOLE RULE. <== The div exists so
   a radius drag can swap the roster's contents without replacing the range
   input the reader's thumb is on. It must not change a single thing about the
   layout it wraps — the footprint slot, the master checkbox and the roster were
   siblings of the filters before this and still are. `display: contents` is the
   one declaration that says "I am plumbing", and it is load-bearing: as an
   ordinary block it would introduce a margin-collapsing boundary and the
   spacing above the master checkbox would change. */
.seasons-roster-slot {
  display: contents;
}

.seasons-retry {
  min-height: var(--touch-target);
  padding: 0 var(--space-base);
  border: 1px solid var(--glass-border);
  border-radius: var(--radius);
  background: var(--glass-raised);
  color: var(--text-primary);
  font: inherit;
  cursor: pointer;
}

/* --- the roster ---------------------------------------------------------- */

/* ==> THE ROSTER IS THE CONTAINER, AND THE ROWS ASK IT — NOT THE WINDOW — HOW
   MUCH ROOM THEY HAVE. §57.21b item 2. <== The drawer is a bottom sheet the
   full width of a phone and a ~340px side rail on desktop, so the window is
   the wrong thing to measure: a viewport media query would hand that rail
   one-line rows it cannot fit and truncate every name in it. A container query
   asks the box the rows are actually in. It also keeps this off any device
   branch (§13) — a narrow window on a desktop gets two lines because it is
   narrow, not because of what it is. */
.seasons-roster {
  list-style: none;
  margin: var(--space-snug) 0 0;
  padding: 0;
  container-type: inline-size;
  container-name: roster;
}

/* ==> THE ROWS RUN TO THE DRAWER'S OWN EDGE, NOT THE SCROLLER'S. <== Aaron on
   glass, 2026-08-25: the open storm's highlight stopped short on both sides
   and read as a floating box rather than as the row being lit.

   `--drawer-body-pad` is the scroller's own padding and is deliberately
   smaller than `--drawer-inset` (index.html) precisely so a full-bleed press
   target can run wider than the text inside it. The storm list spends that
   allowance the other way round — it lays a row out at the scroller's width
   and pushes its TEXT in. This list bleeds past it instead, then puts the same
   amount back as padding, so nothing inside the row moves and only the
   background and the separator get wider.

   ==> THE SEPARATORS COME WITH IT, AND THAT IS WHY THE MASTER BOX IS IN THIS
   RULE. <== It carries the line that reads as the roster's heading. Bleeding
   the rows and not it would leave one short line above a column of long ones,
   which is the ragged edge this was meant to remove. */
.seasons-row,
.seasons-check-all {
  margin-inline: calc(-1 * var(--drawer-body-pad));
  padding-inline: var(--drawer-body-pad);
}

/* ==> A FLEX LINE, BECAUSE THE ROW HOLDS A CONTROL BESIDE ITS LABEL. §57.22b.
   <== The label takes the space and the chevron that opens the storm's panel
   sits at the end. Without this the button wraps under the name on a narrow
   phone, which puts a 44px target in the middle of the next row's text.

   The master box is deliberately NOT in this rule. It has no chevron — there
   is no panel about "all of 2005" — and giving it one would be a control that
   looks like the forty below it and does something else. */
.seasons-row {
  display: flex;
  align-items: center;
  gap: var(--space-tight);
}

/* ==> THE LABEL IS NOW JUST THE BOX. §57.22b. <== It used to be the whole row
   and the whole tap target; the row opens the storm now, so what is left here
   is the tick, at the touch minimum and no wider. `flex: 0 0 auto` rather than
   a width, so the box keeps its own padding and the button beside it takes
   every remaining pixel.

   Scoped to `.seasons-row`, because the MASTER box above the roster is also a
   `.seasons-check` and it is still a full-width label with its own text — it
   has no panel to open, so there is nothing to take the row from it. */
.seasons-row .seasons-check {
  flex: 0 0 auto;
  min-width: var(--touch-target);
  justify-content: center;
}

.seasons-row + .seasons-row { border-top: 1px solid var(--glass-border); }

/* ==> THE ROW MATCHING THE BRIGHT TRACK ON THE GLOBE. §57.21 item 2. <==
   A LIFT, and nothing else. Tinting the row would put a second colour beside
   the storm's own Saffir-Simpson dot, which is a fixed colour meaning strength
   (§6), so the row is raised rather than coloured.

   ==> IT CARRIED A LEFT BAR IN THE FOCUS-RING AMBER AND THAT IS GONE. <==
   Aaron on glass, 2026-08-25. The bar was drawn at the row's left edge, which
   is exactly where the tick box sits, so the two shared a two-pixel column and
   the bar read as part of the checkbox rather than as a separate signal. The
   argument for it was that the lift alone might be too quiet; on glass it is
   not, and a second mark competing with the control beside it costs more than
   the emphasis was worth. If the lift ever does read as too quiet, the lever
   is `--hover`, not a mark in the checkbox's column. */
.seasons-row-focus {
  background: var(--hover);
}

/* Why the focused storm has no wind footprint (§57.26a). The slot is always in
   the markup and empty most of the time — the view writes into it rather than
   rebuilding the roster, because focus moves on every tap on a track.

   `:empty` rather than a `hidden` attribute, so the view sets one property
   (`innerHTML`) instead of two that could disagree. An empty box would still
   take its margin and push the roster down by a line every time nothing needed
   saying, which reads as the list twitching under the reader's thumb. */
.seasons-footprint:empty { display: none; }

/* The note inside it carries the roster's spacing above and none below — it
   sits directly on top of the list it is about, and a gap there would read as
   a note about the whole season rather than about the one bright storm. */
.seasons-footprint .seasons-note { margin-bottom: 0; }

/* The whole row is the label, so the tap target is the row rather than the
   checkbox — a 44px box in a 320px row is a target most thumbs miss (§13). */
.seasons-check {
  display: flex;
  align-items: center;
  gap: var(--space-snug);
  min-height: var(--touch-target);
  cursor: pointer;
}

/* The native box carries the state, the keyboard behaviour and the screen
   reader's announcement; it is moved out of sight rather than removed, because
   `display: none` would take it out of the tab order entirely (§13). What the
   reader SEES is `.check-box` beside it — the app's own drawn tick, shared
   with the Layers panel (panels.css). */
.seasons-check input {
  position: absolute;
  width: 1px;
  height: 1px;
  opacity: 0;
  pointer-events: none;
}

/* `.row-swatch` carries `margin-top: 5px` to pin itself to the NAME's line in
   the live storm list, whose swatch sits beside a two-line block. Here the
   whole leading cluster — box, dot — is centred on the row instead, because
   the box next to it is centred and two leading marks a few pixels apart on
   different baselines reads as a mistake. Same correction `.detail-ww` makes. */
.seasons-open .row-swatch,
.seasons-check .row-swatch { margin-top: 0; }

/* ==> THE ROSTER'S MARKS MOVED ONTO THE DOT, THE WAY THE WALL'S ALREADY WERE.
   <== §57.53, Aaron on glass 2026-08-30. The landfall mark was a `▲` glyph
   sitting in the right-hand meta line beside the dates; it is a `::after` on
   the swatch now, and the retirement bar is the `::before` above it. Two
   screens, one grammar: a reader who learns what a triangle under a dot means
   on the Wall of Years reads the same mark the same way one tap later.

   ==> THE SWATCH IS SHARED WITH THE LIVE STORM LIST, SO THIS IS SCOPED. <==
   `.row-swatch` is `ui/panels.css`'s and the live list uses it too. Scoping to
   `.seasons-open` keeps the archive's marks off a live storm, which has no
   settled landfall record and no retired name to carry.

   ==> THE GEOMETRY IS A PERCENTAGE RATHER THAN A COPY OF THE WALL'S NUMBERS.
   <== The wall's dot is measured after layout and this one is a fixed 12px, so
   a shared pixel token would be wrong on one of them. A percentage resolves
   against the dot it is drawn on, so both surfaces read the same ratios off
   `--mark-*` above and neither needs to know the other's size. It also means
   this cannot drift if `.row-swatch` is ever retuned.

   The floors are the wall's, for the wall's reason — they never bite at 12px
   and are written the same way so the two rules stay comparable at a glance. */
.seasons-open .row-swatch { position: relative; }

.seasons-open .row-swatch[data-lf]::after {
  content: '';
  position: absolute;
  top: calc(100% + var(--mark-gap));
  left: 50%;
  width: max(3px, calc(var(--mark-w-ratio) * 100%));
  height: max(2.5px, calc(var(--mark-w-ratio) * var(--mark-lf-h-ratio) * 100%));
  transform: translateX(-50%);
  background: var(--text-primary);
  opacity: 0.8;
  clip-path: polygon(50% 0, 100% 100%, 0 100%);
}

.seasons-open .row-swatch[data-ret]::before {
  content: '';
  position: absolute;
  bottom: calc(100% + var(--mark-gap));
  left: 50%;
  width: max(3px, calc(var(--mark-w-ratio) * 100%));
  height: max(1px, calc(var(--mark-ret-h-ratio) * 100%));
  transform: translateX(-50%);
  background: var(--text-primary);
  opacity: 0.8;
}

/* --- the master box ------------------------------------------------------ */

/* ==> THE MASTER BOX SITS IN THE SAME COLUMN THE ROWS CENTRE THEIRS IN. <==
   Aaron on glass, 2026-08-28: it did not line up with the boxes below it.
   Measured, both boxes are the same 14px and they sat 15px apart — a row
   centres its box inside a `--touch-target` column (`.seasons-row
   .seasons-check` above), and this label is not a `.seasons-row`, so its box
   sat flush against the left edge while every box under it sat inset.

   ==> THE MARGIN IS THE ROWS' OWN CENTRING, WRITTEN AS ARITHMETIC RATHER THAN
   AS 15px. <== Both halves are read from the tokens the other rule uses, so a
   retune of either the touch target or the box size carries this with it. A
   literal would be correct today and quietly wrong the first time either
   moved, which is the failure that produced the misalignment in the first
   place.

   ==> BOTH SIDES, NOT JUST THE LEFT. <== The box then OCCUPIES the full
   column, so the label after it starts exactly where a row's dot starts. Only
   the left margin would line the boxes up and leave the two lines of text
   still 15px apart, which is half a fix that looks like a different bug. */
.seasons-check-all .check-box {
  margin-inline: calc((var(--touch-target) - var(--check-box)) / 2);
}

/* ==> AND THE GAP AFTER THE BOX IS THE ROW'S GAP, NOT THE LABEL'S. <== With
   the box occupying its column the label was still 4px out, because
   `.seasons-check` separates its parts by `--space-snug` while `.seasons-row`
   separates the box column from the storm's button by `--space-tight`. Two
   different gaps doing the same job in the same column is what the 4px was. */
.seasons-check-all {
  gap: var(--space-tight);
}

/* ==> THE SPREADSHEET'S FILTER HEADER. §57.21b item 4. <== It is a row like
   the storms below it and deliberately reads as one: same box, same height,
   same target. What sets it apart is the rule under it, which is the same
   line the roster uses between rows — so it looks like a heading for the list
   rather than the first item in it. */
.seasons-check-all {
  border-bottom: 1px solid var(--glass-border);
}

.seasons-check-all .seasons-name {
  color: var(--text-secondary);
  font-size: var(--type-small);
}

/* --- one row ------------------------------------------------------------- */

/* ==> THE ROW IS A GRID SO THE BADGE CAN BE A COLUMN. <== Aaron on glass,
   2026-08-25: right-aligned beside the dates it read as glued to them —
   `CAT 4  ▲ Jul 4 – Jul 18` is one run of small text, and the strength is the
   thing the eye is scanning for. A flex row cannot fix that, because the
   badge's position then depends on how long the name is.

   ==> AND THE TWO RIGHT TRACKS ARE FIXED, WHICH IS THE SECOND HALF OF THE FIX
   AND THE PART THAT WAS MISSING. <== `auto` tracks are sized per ROW, and each
   row here is its own grid — so a row reading `Jul 4 – Jul 7` gave its date
   column 68px and one reading `Sep 17 – Sep 28` gave it 101px, which pushed
   the badge 33px sideways between one line and the next. The dates still ended
   flush right, so the only visibly wrong thing was the badge, and it looked
   jagged all the way down the list.

   ==> THE NUMBERS ARE MEASURED IN A BROWSER, AND THE FIRST SET WAS WRONG.
   <== `tools/seasons-row-check.mjs` renders these rows against this stylesheet
   and reports the geometry. Arithmetic off the type scale said the widest
   badge, `CAT 5`, needed 44px; rendered, it needs 51 — letter-spacing after
   the last glyph and the font's own side bearings are not in the arithmetic.
   The widest date range is `May 28 – May 30` at 105px, plus the landfall mark
   at 10px and its gap, so the meta column needs 119.

   ==> AND THEY ARE MULTIPLES OF `--type-small` RATHER THAN OF THE GRID'S OWN
   `ch`. <== A `ch` track resolves against the CONTAINER's font size, which is
   the row's, while the things being measured are all set at `--type-small`.
   Tying the track to the size of the text inside it means a move on the type
   scale carries the columns with it; tying it to `ch` meant the two could
   drift apart silently, and a track a few pixels short does not ellipse — it
   widens, and the jaggedness comes straight back. The multipliers carry ~15%
   headroom on purpose, because these were measured in one browser on one
   platform and the app runs on several: SF on iOS is not the same metric.

   Narrow: name and badge on the first line, the dates under both.
   Wide: all three across, same three columns.

   `min-width: 0` on the whole block is load-bearing inside a flex child —
   without it a long unbroken name blows the row out instead of ellipsing. */
.seasons-roster {
  --badge-col: calc(var(--type-small) * 4.7);
  --meta-col: calc(var(--type-small) * 10.6);
}

/* ==> THE `near` ROW IS NO LONGER DECLARED, AND THAT IS A CENTRING FIX RATHER
   THAN A TIDY-UP. <== Aaron on glass, 2026-08-28: the box, the dot and the text
   were not on one line. Measured, the marks sat dead centre and the TEXT sat
   6px high on the one-line layout.

   The cause is a grid rule that is easy to read straight past: **an empty row
   still takes its gap.** `near` was declared on every row in the app, and
   `.seasons-approach` only exists under the Near home filter — so on every
   other row the grid was a 19px line of text inside a 31px block with a dead
   `--space-base` hanging off the bottom. `align-items: center` on the button
   then centred the BLOCK, which put the text half a gap high.

   ==> SO THE ROW IS CREATED BY THE ITEM RATHER THAN BY THE TEMPLATE. <== The
   note carries `grid-column: 1 / -1` and no `grid-area`, so it auto-places onto
   an IMPLICIT row. An implicit row does not exist when nothing is in it, and a
   gap that does not exist cannot be taken. Nothing about the note's own
   appearance changes when it is there.

   `align-items: baseline` still does the work it always did — name, badge and
   dates share one baseline across the columns. */
.seasons-row-text {
  flex: 1 1 auto;
  min-width: 0;
  display: grid;
  grid-template-columns: minmax(0, 1fr) var(--badge-col);
  grid-template-areas:
    "name  badge"
    "meta  meta";
  align-items: baseline;
  gap: var(--space-tight) var(--space-snug);
}

/* ==> HOW CLOSE THIS STORM CAME, ON A LINE OF ITS OWN. §57.19. <== It only
   exists under the Near home filter, so the track is empty on every other row
   in the app and costs nothing there — a `grid-template-areas` row with no
   element in it collapses to zero height.

   ==> IT SPANS THE WHOLE WIDTH RATHER THAN SHARING THE META TRACK. <== The
   dates already live there, right-aligned, and *"Passed 31 mi WSW as a Cat 2"*
   beside *"Aug 26 – Sep 1"* is two facts of different kinds competing for one
   line at small type. Given its own row it reads as what it is: the answer to
   the question the reader asked the slider. */
.seasons-approach {
  /* `grid-column`, NOT `grid-area` — see `.seasons-row-text`. Naming an area
     means declaring it in the template, and a declared row takes its gap even
     with nothing in it. Spanning every column instead puts the note on an
     implicit row that only exists when the note does. */
  grid-column: 1 / -1;
  min-width: 0;
  font-size: var(--type-small);
  color: var(--text-secondary);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* Line 2 in the narrow layout, the third column in the wide one. The landfall
   mark sits to the LEFT of the dates — §57.21a took the pin off the globe, so
   this row is the only place a landfall now surfaces. Pushed to the end of its
   track so the dates end flush down the list whatever month they name. */
.seasons-row-meta {
  grid-area: meta;
  display: flex;
  align-items: baseline;
  justify-content: flex-end;
  gap: var(--space-tight);
  min-width: 0;
}

/* ELLIPSIS RATHER THAN A WRAP, and only because the row has a fixed shape to
   keep. The live storm list wraps instead, on the argument that a truncated
   name cannot be identified — but that list has one column and this one has
   two more beside it. An archive name is a single word. */
.seasons-name {
  grid-area: name;
  min-width: 0;
  color: var(--text-primary);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* CENTRED IN ITS TRACK. Aaron's call. Left-aligned, `TS` and `CAT 5` share a
   left edge and nothing else, so the column reads as ragged on its right;
   centred, a two-character badge and a five-character one both sit on the same
   axis, which is what a column of labels of different lengths wants. */
.seasons-row-text .row-badge {
  grid-area: badge;
  text-align: center;
}

.seasons-when {
  flex: 0 0 auto;
  font-size: var(--type-small);
  color: var(--text-secondary);
  font-variant-numeric: tabular-nums;
}

/* ==> WIDE ENOUGH FOR ONE LINE, SO ONE LINE. <== The number is where a name,
   a badge and a date range stop fighting each other measured in this drawer's
   own type — not a device, not a breakpoint anything else in the app uses. On
   a phone the roster is ~330px and stays two-line; on a desktop rail it is
   ~300px and also stays two-line, which is the case the viewport would have
   got wrong. The wide window and the tablet get one, and the three columns are
   the same three columns either way. */
@container roster (min-width: 400px) {
  .seasons-row-text {
    grid-template-columns: minmax(0, 1fr) var(--badge-col) var(--meta-col);
    grid-template-areas: "name badge meta";
    gap: var(--space-base);
  }
}

/* --- the ghosts ---------------------------------------------------------- */

/* A name this season has not reached. Dimmed rather than greyed to a fixed
   value, so it stays legible in sepia, dark and light alike — and it carries
   no dot, because there is no storm to grade. */
.seasons-row-ghost { opacity: 0.55; }

.seasons-ghost-name {
  display: flex;
  align-items: center;
  min-height: var(--touch-target);
  color: var(--text-secondary);
  font-style: italic;
}

/* ==========================================================================
   THE STORM DETAIL PANEL — §57.22, §57.22a, step 7.

   It lives in this stylesheet rather than in ui/panels.css because it ships
   only to a reader who opens the archive: seasons.css is loaded on first
   entry alongside seasons/index.js, and none of it taxes a boot that never
   goes near 1851. The panel deliberately REUSES `.detail-section` and
   `.detail-vitals` from panels.css — same shell, same chevrons, same pair
   layout as the live storm panel (§57.22) — so only what is genuinely new to
   the archive is defined here.
   ========================================================================== */

.season-detail-head {
  padding: var(--space-base) var(--drawer-inset) var(--space-snug);
}

.season-detail-name {
  display: flex;
  align-items: center;
  gap: var(--space-snug);
  margin: 0;
  font-size: var(--type-title);
  color: var(--text-primary);
}

/* The same swatch the roster row wears, so a storm is the same colour in the
   list and in its own panel. Filled rather than outlined — the roster's is an
   unchecked/checked control and this is not a control at all. */
.season-detail-dot {
  flex: 0 0 auto;
  width: 12px;
  height: 12px;
  border-radius: 50%;
  background: var(--swatch, var(--text-secondary));
}

.season-detail-strength {
  margin: var(--space-tight) 0 0;
  font-size: var(--type-small);
  color: var(--text-secondary);
}

/* ==> THE HONESTY LINE IS THE ONE PIECE OF TEXT ON THIS PANEL THAT IS NOT
   ALLOWED TO RECEDE. <== §57.22. Every other note here is secondary colour
   because it is a caveat about one figure; this one qualifies the ENTIRE
   panel, and a reader who skims past it can mistake cleaned-up hindsight for
   what forecasters knew on the night. The rule beside it does the work a
   heavier weight would, without shouting. */
.season-honesty {
  margin-top: var(--space-snug);
  padding-left: var(--space-snug);
  border-left: 2px solid var(--glass-border);
  color: var(--text-primary);
}

/* --- the storm's life, in a paragraph (§57.41) ---------------------------- */

/* ==> IT IS SET AS PROSE, NOT AS A NOTE. <== Every other free-text line on this
   panel is a `.detail-note` — a caveat about the figure beside it, secondary
   colour, deliberately quieter than the numbers. This one is the opposite: it
   is the thing to READ, and the figures below it are the supporting detail. So
   it takes primary colour and a comfortable measure, and it is the only block
   on the panel with real line spacing.

   ==> AND IT IS OUTSIDE `.detail-section`, WHICH IS WHY IT NEEDS ITS OWN
   INSET. <== It cannot be a collapsible section (a reader must not be able to
   fold away the summary and then read the figures without it), so it does not
   inherit the section padding and states its own. */
.season-story {
  margin: 0;
  padding: var(--space-snug) var(--drawer-inset) var(--space-base);
  font-size: var(--type-body);
  line-height: 1.55;
  color: var(--text-primary);
  border-bottom: 1px solid var(--glass-border);
}

/* --- landfalls ----------------------------------------------------------- */

.season-landfalls {
  margin: 0;
  padding: 0;
  list-style: none;
}

/* ==> THE ROW IS A NUMBER AND A BLOCK OF TEXT, AND THE NUMBER IS THE SAME ONE
   ON THE CHART ABOVE. <== §57.60. The list sits directly under the life chart
   so the two can be read together; a numbered mark whose list is four sections
   away is a puzzle rather than a reference (§57.54d).

   The badge is its own grid column rather than sitting inside the flow of the
   text, so the three text lines all start on one left edge whatever number is
   beside them — a `10` indenting its own row further than a `9` reads as a
   fault. `align-items: start` keeps the badge level with the place name rather
   than centred against a row whose height varies with how much the record
   holds. */
.season-landfall {
  /* ==> THE SIZE IS DECLARED ON THE ROW, NOT ON THE BADGE, AND THAT IS A BUG
     FIX RATHER THAN A TIDY-UP. <== §57.60g. Declared on `.season-landfall-n` it
     was out of scope for this rule, so `grid-template-columns` held an
     unresolved `var()`, which is invalid at computed-value time and computes to
     `none` — one implicit column, with the text stacked UNDER the badge instead
     of beside it. Nothing threw and nothing clipped. The row must be able to
     read it, so the row is where it lives. */
  --season-landfall-n: 20px;
  display: grid;
  grid-template-columns: var(--season-landfall-n) 1fr;
  /* The mockup's gap is 10px, which is not a step on this app's spacing scale
     (4 / 8 / 12 / 16). 12 is the nearest, and the same call §57.59c made about
     the mockup's palette: the geometry comes off the thing Aaron looked at, the
     scale stays the app's own. */
  column-gap: var(--space-base);
  align-items: start;
  padding: var(--space-snug) 0;
  border-bottom: 1px solid var(--glass-border);
}

.season-landfall-detail {
  display: flex;
  flex-direction: column;
  gap: var(--space-tight);
  min-width: 0;
}

/* ==> 20px, WHICH IS THE MOCKUP'S FIGURE AND NOT THE CHART'S DISC. <== §57.60c.
   The badge and the disc share a SHAPE and an INK — a filled circle in the
   panel's own text colour with the panel's background punched out of it — and
   that is what makes a reader carry a number down from the plot to the row.
   They deliberately do not share a diameter.

   ==> BECAUSE THE DISC DOES NOT HAVE ONE. <== Measured in a browser,
   2026-08-30: the chart is an SVG scaled to the panel, so its 16-unit disc
   renders at 12.07px at a 320px viewport, 15.19px at 390, 17.43px at 440 and
   29.9px at 719. There is no width at which a fixed 16px badge matches it, and
   it does not match even at the phone width this was first reasoned about.
   **The first version of this rule pinned the badge to `SEASONS.lifeChartDiscPx`
   and a suite assertion enforced it — a contract to a number that never
   described pixels on screen.** Do not restore it.

   Everything else here is read off the mockup Aaron accepted on 2026-08-30, in
   a column that is the same 358px wide as the app's own panel. */
.season-landfall-n {
  display: flex;
  align-items: center;
  justify-content: center;
  inline-size: var(--season-landfall-n);
  block-size: var(--season-landfall-n);
  border-radius: 50%;
  background: var(--text-primary);
  color: var(--text-inverse);
  font-family: var(--font-numeric);
  font-size: var(--type-micro);
  font-weight: 700;
  line-height: 1;
  /* An optical nudge, off the mockup. `align-items: start` puts the badge's top
     on the text block's top, and the first line's own leading sits the glyphs a
     shade lower than that, so the circle reads high without it. */
  margin-block-start: 1px;
}

.season-landfall:last-child { border-bottom: 0; }

/* ==> THE PLACE NAME IS THE HEADLINE OF A LANDFALL AND THE TIMESTAMP DEMOTES
   UNDER IT. <== Aaron on glass, 2026-08-29. "Port Sulphur, Louisiana, United
   States" is what a reader scans a landfall list for; the UTC stamp and the
   coordinates are the supporting detail that proves it. The name takes primary
   colour and the two lines under it stay as they were. Absent when there is no
   town inside `placeNearKm`, in which case the timestamp line leads exactly as
   it did before this existed.

   ==> AND THE TIMESTAMP DEMOTES UNCONDITIONALLY, INCLUDING ON THE STORMS THAT
   GET NO NAME. <== A rule that only applied when a name was present would mean
   two landfalls in the same list — one named, one 80 km down an empty coast —
   set at two different sizes, which reads as a fault rather than as a
   hierarchy. One shape for every row; the name is what varies. */
.season-landfall-where {
  font-size: var(--type-body);
  color: var(--text-primary);
}

.season-landfall-when {
  font-size: var(--type-small);
  color: var(--text-secondary);
}

.season-landfall-what {
  font-size: var(--type-small);
  color: var(--text-secondary);
}

/* ==> A §57.25 RULE 2 SENTENCE — THE RECORD IS SILENT AND HERE IS WHY. <==
   Three quarters of this archive is missing something, so these are not an
   edge case: on a 19th-century storm they are most of the panel.

   Styled as a note rather than as an error, deliberately. It is neither a
   failure nor a warning — "wind field size wasn't recorded before 2004" is a
   true fact about the record, and dressing it in amber would tell the reader
   something is wrong when nothing is. Secondary colour and body size: present,
   readable, subordinate to the figures it sits among. */
.detail-note {
  margin: var(--space-snug) 0 0;
  font-size: var(--type-small);
  line-height: 1.4;
  color: var(--text-secondary);
}

/* --- the link out to NOAA's written report — §57.22, §57.22a -------------- */

/* ==> IT IS THE ONLY THING IN THIS WHOLE FEATURE THAT LEAVES THE APP, SO IT
   HAS TO LOOK LIKE IT DOES. <== Everything else the archive draws is ours; this
   hands the reader to nhc.noaa.gov and a new tab. A link that looked like the
   rest of the panel would be a surprise, and on a phone a surprise new tab
   reads as the app having lost your place. The arrow says "out", the screen
   reader is told the same thing in words. */
.season-report-link {
  display: inline-flex;
  align-items: center;
  gap: var(--space-tight);
  min-height: var(--touch-target);
  margin-top: var(--space-snug);
  color: var(--text-primary);
  font-size: var(--type-body);
  text-decoration: underline;
  text-underline-offset: 3px;
}

.season-report-link:focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: 2px;
  border-radius: var(--radius);
}

/* The outward arrow, drawn in CSS rather than shipped as an icon: it is two
   borders and a rotation, and it inherits the link's colour for free — so it
   goes sepia with everything else the moment the palette is forced, with no
   second place to remember. */
.season-report-ext {
  width: 7px;
  height: 7px;
  border-top: 2px solid currentColor;
  border-right: 2px solid currentColor;
  transform: rotate(45deg);
}

@media (hover: hover) {
  .season-report-link:hover { color: var(--accent); }
}

/* --- the way into one storm's panel — §57.22, §57.22b, §13 ---------------- */

/* ==> THE ROW IS THE DOOR. <== Aaron on glass, 2026-08-25: tapping anywhere on
   the row should open the storm. So this button takes everything the tick box
   does not — the swatch, the name, the badge, the dates and the chevron —
   and the label beside it keeps the tick.

   A real <button>, so Tab reaches it after the checkbox on every row and Enter
   opens. That is §13's third input path, obtained as a side effect of using
   the right element rather than as a special case.

   ==> ITS BOX IS MEASURED RATHER THAN ASSUMED. <==
   `tools/seasons-row-check.mjs` asserts the rendered button is at least
   `--touch-target` tall, runs to the row's own edge, does not overlap the
   label beside it and does not wrap onto the next line. */
.seasons-open {
  flex: 1 1 auto;
  min-width: 0;
  display: flex;
  align-items: center;
  gap: var(--space-snug);
  min-height: var(--touch-target);
  padding: 0;
  border: 0;
  background: none;
  /* `font: inherit` and `text-align: left`, or the browser's own button
     defaults centre the name and shrink it to the platform's control size —
     which would be a different font on every device this app runs on, and a
     fixed visual contract (§10) exists to prevent exactly that. */
  font: inherit;
  text-align: left;
  color: var(--text-secondary);
  cursor: pointer;
}

.seasons-open:focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: -2px;
  border-radius: var(--radius);
}

@media (hover: hover) {
  .seasons-open:hover { color: var(--text-primary); }
}

/* Two borders and a rotation rather than a shipped icon: it inherits the
   button's colour, so it goes sepia with everything else the moment the
   palette is forced, with no second place to remember. Same construction as
   the report link's outward arrow, pointed a different way — one says "opens
   here", the other says "leaves the app". */
.seasons-open-chevron {
  flex: 0 0 auto;
  width: 8px;
  height: 8px;
  border-top: 2px solid currentColor;
  border-right: 2px solid currentColor;
  transform: rotate(45deg);
  /* It sits where a 44px button used to, so it keeps that much room around it
     even though it is no longer a target of its own — a glyph hard against
     the row's edge reads as clipped. */
  margin-inline: auto var(--space-tight);
}

/* ---------------------------------------------------------------------------
 * THE WALL OF YEARS (§57.29, §57.36)
 *
 * ==> ONE VERTICAL SCROLL AND NOTHING BREAKING IT INTO CHUNKS. <== Aaron on
 * glass, 2026-08-26: the decade heading was the first thing to go. The wall
 * reads as one unbroken run of years, and the only thing that interrupts it is
 * the satellite-era line, which is a claim about the data rather than a
 * navigation aid.
 *
 * ==> IT REUSES `.seg` AND `.drawer-body` FROM panels.css. <== Same reasoning
 * the board records: a second, near-identical set of segment styles is two
 * places one control drifts apart.
 * ------------------------------------------------------------------------- */

.wall-basins {
  display: flex;
  gap: var(--space-tight);
  margin-bottom: var(--space-snug);
}

.wall-tally {
  margin: 0 0 var(--space-snug);
  font-size: var(--type-small);
  color: var(--text-secondary);
}

.wall {
  display: flex;
  flex-direction: column;
}

/* ==> 44px, AND NO EXCEPTION WAS CARVED OUT FOR A DENSE WALL. <== §13's touch
   floor, judged on glass rather than assumed. The height is the constant so a
   row cannot drift below the target by picking up padding.

   `grid` rather than `flex` so the year column and the count column line up
   down the whole wall — ragged columns would make the counts unreadable as a
   series, which is half of what the screen is for. */
.wall-row {
  display: grid;
  grid-template-columns: 3.1em 1fr 2.2em;
  align-items: center;
  gap: var(--space-tight);
  width: 100%;
  height: var(--seasons-wall-row-h);
  /* ==> THE COLUMNS ARE TIGHT BECAUSE THE STRIP NEEDS THE ROOM. <== 31 storms
     is the widest season on record and it sets the dot size for all 175 rows.
     Every pixel taken by the year or the count is a pixel divided by 31. */
  padding: 0 2px;
  border: 0;
  background: none;
  color: var(--text-primary);
  text-align: left;
  cursor: pointer;
}

@media (hover: hover) {
  .wall-row:hover { background: var(--glass-raised); }
}

.wall-row:focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: -2px;
}

.wall-year {
  font-size: var(--type-small);
  font-variant-numeric: tabular-nums;
  color: var(--text-secondary);
}

/* The strip is clipped rather than allowed to push the row taller: step 4 adds
   a landfall mark under the dots, and a row that grew to fit one would break
   the 44px rhythm on exactly the years that matter most.

   ==> `overflow: visible` ON THE STRIP ITSELF, OR THE GLOW IS CLIPPED OFF ITS
   OWN DOTS. <== The slot clips horizontally; the glow needs to bleed a few
   pixels in every direction. Padding on the slot gives it that room without
   letting a long strip escape its column. */
.wall-strip-slot {
  min-width: 0;
  overflow: hidden;
  padding: var(--wall-strip-pad) 0;
  margin: calc(var(--wall-strip-pad) * -1) 0;
}

.wall-strip {
  display: flex;
  align-items: center;
  gap: var(--wall-dot-gap, 3px);
}

/* ==> THE SAME GLOW AS THE LIVE STORM LIST, OFF THE SAME TOKEN. <== Aaron on
   glass, 2026-08-26. `ui/panels.css` draws a live storm's swatch this way and
   there is exactly one definition of what a glowing dot looks like in this
   app; a second one here would drift the first time either moved. */
.wall-strip i {
  position: relative;
  flex: 0 0 auto;
  width: var(--wall-dot, 7px);
  height: var(--wall-dot, 7px);
  border-radius: 50%;
  background: var(--wall-swatch);
  box-shadow: 0 0 var(--dot-glow-blur) var(--wall-swatch);
}

/* ==> A TRIANGLE POINTING UP AT THE STORM THAT CAME ASHORE. <== §57.30 step 14
   sub-step 4. `clip-path` rather than the old border trick: the border version
   needs three widths kept in step with each other and computed from the same
   dot size, and it draws a box whose height and width argue with each other
   every time the dot resizes. A polygon is the shape stated once.

   ==> IT HANGS BELOW THE DOT AND MOVES NOTHING. <== The `::after` is out of
   flow, so a year with twelve landfalls is exactly as tall as a year with none
   and the 44px row rhythm holds. A reader scanning down the wall is comparing
   rows, and a row that grew to fit its own marks would break that on precisely
   the years worth comparing.

   Neutral ink at 0.8 rather than the storm's colour — see `stripHtml`. */
.wall-strip i[data-lf]::after {
  content: '';
  position: absolute;
  top: calc(100% + var(--wall-lf-gap));
  left: 50%;
  width: var(--wall-lf-w);
  height: var(--wall-lf-h);
  transform: translateX(-50%);
  background: var(--text-primary);
  opacity: 0.8;
  clip-path: polygon(50% 0, 100% 100%, 0 100%);
}

/* ==> A BAR ABOVE THE DOT WHOSE NAME WAS RETIRED. <== §57.52. The `::after`
   below the dot was already the landfall triangle, so this takes the `::before`
   that was free — a second attribute on one element rather than a second span
   per storm, which at 31 dots a row down a 175-row scroll is the difference
   between an attribute on 122 dots and 3,266 extra nodes. It cannot drift from
   its dot because it IS its dot: no second coordinate system and nothing to
   re-measure when `dotSizeFor` rescales the strip.

   ==> IT HANGS ABOVE AND MOVES NOTHING, THE SAME AS THE TRIANGLE. <== Out of
   flow, so a year with five retired storms is exactly as tall as a year with
   none and the 44px row rhythm holds. The wall is a comparison BETWEEN rows,
   and a row growing to fit its own marks would break that on the years most
   worth comparing.

   No `border-radius`: at 1px tall on the busiest basin a rounded cap eats the
   whole bar, and at 2px it turns a line into a lozenge, which is the dot shape
   this mark exists to avoid. */
.wall-strip i[data-ret]::before {
  content: '';
  position: absolute;
  bottom: calc(100% + var(--wall-ret-gap));
  left: 50%;
  width: var(--wall-ret-w);
  height: var(--wall-ret-h);
  transform: translateX(-50%);
  background: var(--text-primary);
  opacity: 0.8;
}

.wall-count {
  font-size: var(--type-small);
  font-variant-numeric: tabular-nums;
  text-align: right;
  color: var(--text-primary);
}

.wall-count small {
  font-size: var(--type-small);
  color: var(--text-secondary);
}

/* ==> A QUIET YEAR KEEPS ITS PLACE AND ITS YEAR. <== §57.36 — the gaps are the
   information. Short rather than 44px because it is not a destination: there
   is nothing to open, so it takes no touch target and no tab stop. */
.wall-hair {
  display: flex;
  align-items: center;
  height: var(--seasons-wall-hair-h);
  padding: 0 var(--space-tight);
  font-size: var(--type-small);
  font-variant-numeric: tabular-nums;
  color: var(--text-muted);
}

/* ==> THE LINE UNDER WHICH NOBODY WAS WATCHING FROM ORBIT. <== It carries a
   rule above it because the boundary is the point, and it is a SENTENCE rather
   than a shade because only words can say that a short strip up here is a gap
   in the record and not a quiet year (§5). */
.wall-era {
  margin: var(--space-snug) 0;
  padding-top: var(--space-snug);
  border-top: 1px solid var(--glass-border);
  font-size: var(--type-small);
  line-height: 1.4;
  color: var(--text-secondary);
}

/* ==> THE SHADED BAND IS GONE AND AN ASTERISK REPLACED IT. <== Aaron on glass,
   2026-08-26: at the contrast a sepia palette allows there was almost no
   difference between a shaded row and an unshaded one, so the one thing
   standing between a short 1890s strip and "the 1890s were quiet" was a tint
   most eyes would miss. A mark survives any palette and any sort order. */
.wall-star {
  font-weight: 700;
  color: var(--stale);
}

.wall-note {
  margin: var(--space-base) 0;
  font-size: var(--type-body);
  line-height: 1.5;
  color: var(--text-secondary);
}

.wall-note-bad { color: var(--text-primary); }

/* The reason the fetch failed, said out loud. §5 — "something went wrong"
   gives a reader nothing to act on and the next session nothing to read. */
.wall-why {
  display: block;
  margin-top: var(--space-tight);
  font-size: var(--type-small);
  color: var(--text-muted);
}

.wall-retry {
  min-height: var(--touch-target);
  padding: 0 var(--space-base);
  border: 1px solid var(--glass-border);
  border-radius: var(--radius);
  background: var(--glass-raised);
  color: var(--text-primary);
  font-size: var(--type-body);
  cursor: pointer;
}

.wall-retry:focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: 2px;
}

/* ==> THE SEASON IN PROGRESS, PINNED ABOVE THE RECORD. <== §57.36, §57.11. It
   is not in `wall.json` and cannot be until NOAA reviews it, so it is built in
   the browser and sits above the wall rather than inside it — a row in the run
   of years would say it was settled. The rule under it is the seam between
   what is being written and what has been.

   It keeps the same grid as an ordinary row so the year and the count stay in
   their columns; the note takes a fourth track of its own. */
/* ==> THE COUNT SITS ON THE RIGHT EDGE, IN THE SAME COLUMN AS EVERY OTHER
   ROW'S. <== Aaron on glass, 2026-08-26: it was reading as `10 2 still active
   …`, two numbers jammed together with the count adrift from the column it
   belongs to. The note takes a track of its own BEFORE the count, so the
   right-hand edge of the wall stays one straight line from 2026 to 1851. */
.wall-row-live {
  grid-template-columns: 3.1em 1fr auto 2.2em;
  border-bottom: 1px solid var(--glass-border);
}

/* The two states where there are no storms to draw yet — reading, and could
   not be read. Not a button: there is nothing to open, and a control that
   does nothing when pressed is worse than no control. */
.wall-row-flat {
  grid-template-columns: 3.1em 1fr;
  cursor: default;
}

.wall-live-note {
  font-size: var(--type-small);
  color: var(--text-secondary);
  white-space: nowrap;
}

.wall-live-note-wide { white-space: normal; }

/* ---------------------------------------------------------------------------
 * THE WALL'S FILTERS AND ITS SORT (§57.36, step 3)
 *
 * ==> EVERY ROW OF CONTROLS IS A ROW OF YEARS THE READER CANNOT SEE. <== The
 * sheet is 66vh and the wall is the whole point of the screen, so the chips,
 * the toggle and the sort share as little vertical space as three controls
 * can. That is why the thresholds are behind a disclosure and why the sort is
 * one dropdown rather than a row of five buttons.
 *
 * ==> AND IT REUSES `.switch-row`, `.slider-row` AND `.slider` FROM panels.css
 * RATHER THAN GROWING LOOKALIKES. <== §12. What is here is only what those
 * three do not already cover.
 * ------------------------------------------------------------------------- */

.wall-controls {
  display: flex;
  flex-direction: column;
  gap: var(--space-tight);
  margin-bottom: var(--space-snug);
}

/* ==> SEVEN CHIPS ACROSS A 390px PHONE, AND THEY SHARE THE WIDTH RATHER THAN
   CLAIMING IT. <== `flex: 1 1 0` gives each one about 47px inside the sheet's
   padding, so the 44px touch floor is cleared on the narrowest screen the app
   supports without the row ever scrolling sideways. A horizontal scroller here
   would hide the strongest categories behind a gesture. */
.wall-chips {
  display: flex;
  gap: 2px;
  padding: 2px;
  background: var(--glass-raised);
  border: 1px solid var(--glass-border);
  border-radius: var(--radius);
}

/* ==> ON IS THE CATEGORY'S OWN COLOUR AT FULL STRENGTH. <== Aaron on glass,
   2026-08-27, against the mockup. It shipped first as a 28% tint of the hue,
   which kept the chips quiet next to the wall — and made the row of them read
   as seven greyish pills rather than as the Saffir-Simpson scale itself. The
   scale IS the legend for every dot below, so the chips are worth the weight.

   The ink is `--on-bright` rather than the app's own pale text, because none
   of the seven can carry that at AA — Cat 1's yellow least of all. */
.wall-chip {
  flex: 1 1 0;
  min-width: 0;
  min-height: 40px;          /* the GROUP clears 44px including its padding */
  padding: 0 2px;
  background: none;
  border: 1px solid transparent;
  border-radius: calc(var(--radius) - 3px);
  color: var(--text-secondary);
  font: inherit;
  font-size: var(--type-small);
  font-variant-numeric: tabular-nums;
  cursor: pointer;
  transition: background var(--duration-base) var(--ease-settle),
              color var(--duration-base) var(--ease-settle),
              border-color var(--duration-base) var(--ease-settle);
}

.wall-chip[aria-pressed="true"] {
  background: var(--wall-chip-ink);
  border-color: var(--wall-chip-ink);
  color: var(--on-bright);
  font-weight: 600;
}

.wall-chip:focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: -2px;
}

@media (hover: hover) {
  .wall-chip:hover { background: color-mix(in srgb, var(--wall-chip-ink) 14%, transparent); }
  .wall-chip[aria-pressed="true"]:hover {
    background: color-mix(in srgb, var(--wall-chip-ink) 38%, transparent);
  }
}

/* The landfall toggle borrows `.switch-row` whole; this only trims the height
   it reserves, because the shared rule is written for a settings panel with
   room to breathe and this one sits in a stack of four. */
.wall-switch { min-height: var(--touch-target); }

/* ==> FIVE BUTTONS THAT WRAP, NOT A ROW THAT SCROLLS SIDEWAYS. <== Aaron on
   glass against the mockup. The mockup carries four across 390px; this has
   five, and the fifth does not fit at a legible size. Wrapping shows all of
   them; a horizontal scroller would hide ACE behind a gesture with nothing on
   screen saying it was there. */
.wall-sort {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-tight);
}

.wall-sort-btn {
  flex: 1 1 auto;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 2px;
  min-height: var(--touch-target);
  padding: 0 var(--space-snug);
  background: none;
  border: 1px solid var(--glass-border);
  border-radius: var(--radius);
  color: var(--text-secondary);
  font: inherit;
  font-size: var(--type-small);
  white-space: nowrap;
  cursor: pointer;
  transition: background var(--duration-base) var(--ease-settle),
              color var(--duration-base) var(--ease-settle),
              border-color var(--duration-base) var(--ease-settle);
}

/* The selected key reads as chosen the same way the drawer's segment control
   does — a lit edge and full-strength ink, never a darker patch on a dark
   panel (§57.21b, which this project learned the hard way). */
.wall-sort-btn[aria-pressed="true"] {
  background: var(--seg-active);
  border-color: var(--seg-active-edge);
  color: var(--text-primary);
  font-weight: 600;
}

/* ==> THE ARROW IS THE ONLY THING SAYING DIRECTION EXISTS. <== Press-again-to
   -reverse is a hidden state otherwise, which was the whole objection to this
   control. It appears on the selected button only, because an unselected key
   has no direction yet. */
.wall-sort-arrow { font-size: var(--type-small); }

.wall-sort-btn:focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: -2px;
}

@media (hover: hover) {
  .wall-sort-btn:hover { background: var(--hover); color: var(--text-primary); }
}

/* ==> THE SLOT MUST NOT BE A BOX. <== It exists so a threshold drag can
   replace everything below the controls without touching the controls
   themselves (`view-seasons-wall.js`). `display: contents` means its children
   lay out exactly as they did when they were siblings — the wrapper is a
   handle for `innerHTML` and nothing else, and a stray block here would put a
   margin between the honesty line and the wall that was never there. */
.wall-results-slot { display: contents; }

/* ==> IT LOOKS LIKE A CONTROL NOW, BECAUSE IT WAS NOT BEING FOUND. <== Aaron
   on glass, 2026-08-28: "the More filters button is inconspicuous, it's hard
   to notice". It was small secondary text on the panel with no edge, no
   background and no affordance — the three sliders behind it were effectively
   undiscoverable. It now carries the same raised fill and hairline as the sort
   buttons beside it, so it reads as one of the row of controls it belongs to
   rather than as a caption between them.

   `list-style: none` plus the WebKit pseudo-element, or Safari draws its own
   triangle next to ours. */
.wall-more > summary {
  min-height: var(--touch-target);
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-snug);
  padding: 0 var(--space-snug);
  border: 1px solid var(--glass-border);
  border-radius: var(--radius);
  background: var(--glass-raised);
  font-size: var(--type-small);
  color: var(--text-secondary);
  cursor: pointer;
  list-style: none;
}

.wall-more > summary::-webkit-details-marker { display: none; }

@media (hover: hover) {
  .wall-more > summary:hover {
    background: var(--hover);
    color: var(--text-primary);
  }
}

.wall-more > summary:focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: -2px;
}

/* Two borders and a rotation rather than a shipped icon, the same construction
   `.seasons-open-chevron` uses — it inherits the summary's colour, so it goes
   sepia with everything else with no second place to remember. Points DOWN
   when closed and UP when open. */
.wall-more-chevron {
  flex: 0 0 auto;
  width: 8px;
  height: 8px;
  border-right: 2px solid currentColor;
  border-bottom: 2px solid currentColor;
  transform: translateY(-2px) rotate(45deg);
  transition: transform var(--duration-base) var(--ease-settle);
}

.wall-more[open] > summary .wall-more-chevron {
  transform: translateY(2px) rotate(-135deg);
}

/* ==> THE SLIDE IS A GRID ROW GOING 0fr TO 1fr. <== A `<details>` flips its
   children between rendered and not, so there is nothing in between to ease
   and `height` cannot be transitioned from `auto` anyway. A single-row grid
   CAN be, and it costs no layout thrash of its own.

   ==> THE PANEL IS NOT HIDDEN WHILE CLOSED, AND THAT IS DELIBERATE. <== A
   closed `<details>` would otherwise hide it outright and there would be
   nothing to animate. `overflow: hidden` plus a zero row does the hiding, and
   `min-height: 0` on the inner element is load-bearing — without it a grid
   item refuses to shrink below its content and the panel never closes. */
.wall-more > .wall-more-panel {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows var(--duration-base) var(--ease-settle);
}

.wall-more[open] > .wall-more-panel { grid-template-rows: 1fr; }

.wall-more > .wall-more-panel > .wall-more-inner {
  min-height: 0;
  overflow: hidden;
}

/* ==> AND A CLOSED PANEL IS OUT OF THE TAB ORDER, WHICH THE GRID ALONE DOES
   NOT DO. <== `overflow: hidden` clips the sliders visually and leaves them
   focusable, so a keyboard reader would Tab into three controls they cannot
   see — §13 calls that a bug rather than a limitation. `visibility` is
   animatable in the sense that matters here: it flips at the END of the
   collapse and at the START of the expand, so the content is never invisible
   while it is moving. */
.wall-more:not([open]) > .wall-more-panel > .wall-more-inner {
  visibility: hidden;
  transition: visibility 0s linear var(--duration-base);
}

@media (prefers-reduced-motion: reduce) {
  .wall-more > .wall-more-panel,
  .wall-more-chevron { transition: none; }
  .wall-more:not([open]) > .wall-more-panel > .wall-more-inner {
    transition: none;
  }
}

/* ==> THE LINE THAT KEEPS A SORT CONTROL FROM MAKING A CLIMATE CLAIM. <==
   §57.36. It is a persistent block rather than a tooltip or a tint, because
   both of those are dismissable and this is not — and because the moment it
   matters most is the moment a non-year sort has scattered the old years
   through the list and the satellite-era line cannot be drawn at all. */
.wall-honesty {
  margin: 0 0 var(--space-snug);
  padding: var(--space-snug);
  background: var(--glass-raised);
  border-radius: var(--radius);
  font-size: var(--type-small);
  line-height: 1.45;
  color: var(--text-secondary);
}

/* ==> THE FIGURE COLUMN EXISTS ONLY WHILE ITS SORT IS ON. <== It borrows about
   2.6em from the strip, which on the busiest basin is a dot from roughly 7px
   to 6px — paid only by a reader who asked to compare that number, and
   returned the moment they sort by year or by count again. The row's own grid
   is redeclared rather than a column left empty, so nothing is reserved when
   nothing is drawn. */
.wall-row[data-figure] {
  grid-template-columns: 3.1em 1fr 2.6em 2.2em;
}

.wall-figure {
  font-size: var(--type-small);
  font-variant-numeric: tabular-nums;
  text-align: right;
  color: var(--text-secondary);
  overflow: hidden;
  white-space: nowrap;
}

/* ==> COLLAPSED, NEVER HIDDEN. <== §57.36. Off a timeline the empty rows are
   dead scroll, but deleting them would let an over-filtered wall pass for a
   complete one — the count in the summary is the thing standing between those
   two readings. */
.wall-tail > summary {
  min-height: var(--touch-target);
  display: flex;
  align-items: center;
  font-size: var(--type-small);
  color: var(--text-secondary);
  cursor: pointer;
}

.wall-tail > summary:focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: -2px;
}

.wall-tail-rows {
  display: flex;
  flex-direction: column;
}

/* ==> `2 of 13` IS ONE LINE OR IT IS BROKEN. <== Aaron on glass, 2026-08-27.
   The count column is sized for a bare `31`, so the moment a filter turned it
   into a ratio it wrapped onto two lines inside a 44px row — the number and its
   denominator stacked, which reads as two separate figures rather than as one.
   The column widens only while the ratio is showing, so an unfiltered wall
   keeps every pixel of its strip. */
.wall-count { white-space: nowrap; }

.wall-row[data-ratio] { grid-template-columns: 3.1em 1fr 4.6em; }
.wall-row[data-figure][data-ratio] { grid-template-columns: 3.1em 1fr 2.6em 4.6em; }

/* --- the distribution bar under a ranked figure (§57.56) ------------------ */

/* ==> THE VALUE CELL CARRYING A RANK TAKES THE WHOLE ROW, AND THE LABEL SITS
   ABOVE IT. <== `.detail-vitals` is `grid-template-columns: auto 1fr`, which is
   right for `173 mph (150 kt)` and wrong for a rank sentence that wraps to
   three lines and a distribution across 3,266 storms: both would be drawn in
   whatever the longest label left over, and on `Fastest strengthening` at 390px
   that is under half the panel. Spanning both columns gives every bar the same
   width on every row, which is the only way the seven of them can be read down
   a column as a profile.

   ==> THE CLASS IS `has-rank` RATHER THAN `has-spine` SINCE §57.57b. <== The
   cell spans whenever it carries a MARK, and a mark is a rank sentence and
   usually — but not always — a bar. A class named after one of its two reasons
   is the kind that goes stale.

   ==> IT IS `grid-column: 1 / -1` RATHER THAN A SECOND GRID. <== The rows
   without a mark keep the exact two-column shape they always had, which is most
   rows on the panel: dates, coordinates, `As a tropical cyclone`. */
.detail-vitals dd.has-rank {
  grid-column: 1 / -1;
}

/* ==> A FIGURE BLOCK IS DIVIDED FROM WHAT COMES BEFORE IT, BECAUSE WITHOUT ONE
   THE PANEL HAS NO GROUPING AT ALL. <== §57.66. Aaron on glass, 2026-08-30:
   *"all the data runs together and I can't tell what belongs with what."*

   ==> THE CAUSE IS ONE NUMBER AND IT IS THE GRID'S OWN `gap`. <== `.detail-vitals`
   sets `gap: var(--space-tight)`, 4px, between every row. A row carrying no
   mark is one line, so 4px reads correctly as the space between two facts. A
   row carrying a mark is FIVE stacked lines — label, value, rank sentence, bar,
   axis — and the 4px after it is identical to the 4px between its own lines. So
   the axis of one figure sat exactly as close to the next figure's label as
   `First seen` sits to `Last seen`, and nothing on screen said which side of it
   anything belonged to.

   ==> THE RULE GOES ABOVE THE LABEL, NOT UNDER THE BAR. <== Below-the-block was
   tried on paper and is wrong: `Power and stamina score` is followed by its own
   gloss (§57.58) and `Fastest strengthening` by the rapid-intensification
   sentence (§57.55a), and a rule under the bar would cut each of those away
   from the figure it explains. Above the label the boundary is always in the
   one place a boundary belongs.

   ==> `--glass-border` RATHER THAN A NEW COLOUR. <== It is what every rule in
   this panel is already drawn in, including `.season-detail-footnote` twenty
   lines up, and a second opinion about what a dividing line looks like is the
   drift `tools/text-role-check.mjs` exists to stop one category up.

   ==> AND THE LABEL SPANS BOTH COLUMNS, WHICH IS A FIX RIDING ALONG. <== Its
   `<dd>` already spans, but the `<dt>` sat in the `auto` column and so a long
   label like `Power and stamina score` was widening that column for EVERY
   unmarked row on the panel — pushing `Aug 23, 2005` right to make room for a
   label on a different line. Spanning takes it out of the measurement, which is
   the same reason §57.56 spans the value cell. A full-width rule needs it
   anyway. */
.detail-vitals dt.has-rank {
  grid-column: 1 / -1;
  margin-top: var(--space-snug);
  padding-top: var(--space-base);
  border-top: 1px solid var(--glass-border);
}

/* ==> THE FIRST FIGURE IN A SECTION TAKES NO RULE. <== The section heading is
   already the boundary, and a hairline immediately under it reads as the
   heading being underlined rather than as a division between two things. */
.detail-vitals:first-child dt.has-rank:first-child {
  margin-top: 0;
  padding-top: 0;
  border-top: 0;
}

/* Room under the axis, so the block is bounded at both ends rather than only
   at the top. Smaller than the space above, because the rule is doing most of
   the work up there and this only has to stop the last line touching. */
.detail-vitals dd.has-rank {
  padding-bottom: var(--space-snug);
}

/* ==> WHERE THE FIGURE STANDS, DIRECTLY UNDER THE FIGURE. <== §57.54b. This is
   the sentence `Where it ranks` used to carry three to five sections away from
   the number it was about, and moving it here is the whole of step 3. It is
   quieter than the value above it and louder than the axis below it, which is
   the order the reader should read them in: what it was, where that puts it,
   what the spread looks like. */
.detail-figure-rank {
  display: block;
  margin-top: var(--space-tight);
  font-size: var(--type-small);
  line-height: 1.4;
  color: var(--text-secondary);
}

/* ==> AND THE BAR STAYS ON ITS OWN LINE UNDER BOTH. <== The value is still the
   thing being read; the rank places it and the bar is the context under that. */
.season-spine {
  margin-top: var(--space-tight);
}

/* ==> THE ONE FOOTNOTE THAT GOVERNS EVERY RANK ON THE PANEL. <== §57.54b,
   §57.57c. It replaces the scope sentence that used to sit inside
   `Where it ranks`, and it is outside every section so it cannot be folded
   away. The rule above it is what separates it from `NOOA's report` — without
   one it reads as another paragraph of that section rather than as a note
   about the whole panel.

   ==> `--glass-border` RATHER THAN A NEW RULE COLOUR. <== It is what every
   other horizontal rule in this stylesheet is drawn in — the roster's row
   separators, the drawer's own edges — and a second opinion about what a
   dividing line looks like is exactly the drift `tools/text-role-check.mjs`
   exists to stop one category up. The first draft of this rule reached for a
   `--hairline` token that does not exist anywhere in the repo, which would
   have rendered as no border at all with nothing throwing. */
.season-detail-footnote {
  display: block;
  margin-top: var(--space-comfy);
  padding-top: var(--space-base);
  border-top: 1px solid var(--glass-border);
}

/* The first note in the footnote has the rule above it doing the separating,
   so it does not need its own top margin as well. */
.season-detail-footnote .detail-note:first-child {
  margin-top: 0;
}

.season-spine-plot {
  display: block;
  width: 100%;
  /* Roughly three times the first mockup's height, which is what made the axis
     legible rather than decorative. §57.54c. */
  height: var(--spine-height);
}

.season-spine-fill {
  fill: var(--spine-fill);
}

/* ==> THE FLOOR THE COLUMNS STAND ON, AND A TICK AT EACH END OF THE RANGE.
   <== §57.64. Without them the bar has no ends: the reader has to work out
   where the range starts and stops from where the ink happens to run out, and
   on the coarse ladders the last few columns are one pixel tall, so it is not
   workable-out at all. Quieter than the mark and quieter than the fill's
   tallest columns — this is furniture, not data. */
.season-spine-rule,
.season-spine-tick {
  stroke: var(--spine-fill);
  stroke-width: 1;
}

/* ==> THE MARK IS THE ONE THING ON THE BAR THE READER IS LOOKING FOR. <== Two
   device pixels, held at that width by `vector-effect="non-scaling-stroke"` on
   the element, because the plot is stretched by `preserveAspectRatio="none"`
   and a scaled stroke would be a different thickness on every screen width. */
.season-spine-mark {
  stroke: var(--spine-mark);
  stroke-width: 2;
}

/* The two real extremes, one at each end. `space-between` rather than a grid:
   there are exactly two and they are pinned to the ends of the bar above. */
.season-spine-axis {
  display: flex;
  justify-content: space-between;
  gap: var(--space-base);
  margin: 2px 0 0;
  /* `--type-small`, not `--type-micro`: the token block reserves micro for
     uppercase section headings and kickers, and these are neither. */
  font-size: var(--type-small);
  font-family: var(--font-numeric);
  color: var(--text-muted);
}

/* ==> THE RIGHT-HAND END IS RIGHT-ALIGNED SO IT ENDS WHERE THE BAR ENDS. <==
   `32 days, 18 hours` is the longest label either end ever carries — the
   lifespan ladder's top rung, `Storm 3 1899` — and left-aligned it would sit
   under the middle of the bar and read as a mark. */
.season-spine-axis span:last-child {
  text-align: right;
}

/* ==> THIS STORM'S OWN FIGURE, PRINTED ON THE BAR UNDER ITS MARK. <== §57.64.
   Without it the mark is a position with no value — the reader can see the
   storm sits four fifths along and has to look back up to the row to find out
   what four fifths means. It is the brightest thing in this block after the
   mark itself, because it is the one number here that is about THIS storm
   rather than about the archive. */
.season-spine-figure {
  position: relative;
  margin: 3px 0 0;
  /* The absolutely positioned span is out of flow, so the paragraph would
     collapse to nothing and the axis line would sit on top of it. One line's
     worth of height, held open. */
  height: 1.3em;
  font-size: var(--type-small);
  font-family: var(--font-numeric);
  color: var(--text-primary);
}

.season-spine-figure span {
  position: absolute;
  top: 0;
  white-space: nowrap;
}

/* ==> THREE ANCHORS, NOT ONE, AND THE RECORD-HOLDER IS WHY. <== A label
   centred on a mark at 2% hangs half of itself off the left edge of the panel.
   Near either end it pins to that end instead. `--spine-at` is only set on the
   middle case, so the two pinned cases cannot be moved by a stale value. */
.season-spine-figure-start { left: 0; }
.season-spine-figure-end { right: 0; }
.season-spine-figure-mid {
  left: var(--spine-at);
  transform: translateX(-50%);
}

/* --- the life chart (§57.54d, §57.59) -------------------------------------
   ==> WIND AT EVERY RECORDED FIX, AGAINST TIME, AND IT IS NOT A SECTION. <==
   It sits directly under the paragraph, above the first fold, because step 6
   numbers the landfall list against the marks on it — a reader who could fold
   the chart away would then meet a numbered list with nothing to match.

   ==> ONLY COLOURS AND STROKES ARE HERE. <== Every position, size and font
   size lives in `ui/season-life-chart.js` in the SVG's own user units, so the
   whole drawing scales with its container the way `ui/chart-home.js` does. A
   rem-based type token would hold a label still while the tick under it moved.
   ------------------------------------------------------------------------ */
.lifec {
  margin: 0;
  padding: var(--space-base) var(--drawer-inset) var(--space-snug);
  border-bottom: 1px solid var(--glass-border);
}

.lifec-plot {
  display: block;
  width: 100%;
  height: auto;
}

/* ==> THE BANDS ARE THE REAL SAFFIR-SIMPSON COLOURS AT A LOW OPACITY, NOT A
   RE-THEMED SET. <== SPEC §6: category colours are fixed and are not
   themeable, so a Cat 4 stripe here is the same ink as the Cat 4 dot on the
   roster and on the globe. Measured 2026-08-30, it costs nothing on the sepia
   panel either — composited at this opacity the real colours land within 15 of
   a warmed set on a 442-point scale. The `fill` is set per rect in the
   renderer; only the opacity is a style decision. */
.lifec-band { opacity: 0.11; }

.lifec-band-label {
  opacity: 0.85;
  font-family: var(--font-numeric);
}

/* The floor and the day ticks are furniture: present enough to bound the plot,
   quiet enough that the line is what the eye lands on. `--spine-fill` is the
   distribution bar's own furniture colour and this is the same job one panel
   further up, so the two charts' baselines match rather than being two
   opinions about what a quiet rule looks like. */
.lifec-baseline,
.lifec-tick {
  stroke: var(--spine-fill);
  stroke-width: 1;
  opacity: 0.7;
}

.lifec-axis {
  fill: var(--text-muted);
  font-family: var(--font-numeric);
}

/* ==> THE LINE IS 2.6 UNITS AND ROUND-CAPPED SO A ONE-FIX HOLD STILL DRAWS.
   <== A storm that held one wind for six hours is a zero-length segment, and a
   butt cap would render it as nothing at all. */
.lifec-line {
  stroke-width: 2.6;
  stroke-linecap: round;
  fill: none;
}

/* ==> A SYSTEM WITH NO GRADE TO CLAIM IS GREY, WHICH IS §57.7g's DECISION
   ARRIVING ON A SECOND SURFACE. <== A wave before genesis and an extratropical
   system after it are both real measured winds and neither is a category.
   Sandy crossed New Jersey at 80 kt as an `EX`: the line still climbs to 80
   and only the ink changes, so the height keeps saying the wind while the
   colour stops making a claim the agency withdrew. `stormEnded` is the token
   that already means exactly this, in all three palettes. */
.lifec-line-ungraded { stroke: var(--storm-ended); }

/* The peak is already the highest point on the line. The dot is not new
   information — it says WHICH hump the `Peak winds` row above is about. Its
   fill is the grade it reached; the ring lifts it off a band of the same hue. */
.lifec-peak {
  stroke: var(--text-primary);
  stroke-width: 1.5;
}

/* ==> THE DROP LINE IS DASHED AND QUIET BECAUSE IT IS A POINTER, NOT A
   READING. <== It carries the eye from a numbered disc up to the moment on the
   line, and nothing about the storm is measured along it. */
.lifec-drop {
  stroke: var(--text-primary);
  stroke-width: 1;
  stroke-dasharray: 2 3;
  opacity: 0.45;
}

/* ==> THE DISC IS THE PANEL'S OWN INK WITH THE PANEL'S OWN BACKGROUND PUNCHED
   OUT OF IT. <== `textInverse` rather than a literal dark value: the number
   has to stay legible if a theme ever moves, and a hardcoded hex is how a
   filled badge ends up dark-on-dark. §57.63 is the same mistake one file over. */
.lifec-disc { fill: var(--text-primary); }

.lifec-disc-n {
  fill: var(--text-inverse);
  font-family: var(--font-numeric);
  font-weight: 700;
}

.lifec figcaption { margin-top: var(--space-tight); }
