/* newz.audio-palette.com — dense terminal reader.
   Optimised for headlines-per-screen and fast scanning, not for lingering. */

:root {
  --bg:        #0c0d10;
  --bg-alt:    #131519;
  --line:      #23262d;
  --fg:        #dfe3ea;
  --fg-dim:    #8b93a3;
  --fg-faint:  #5b6272;
  --accent:    #e8b34a;
  --accent-2:  #5fd3a6;
  --link:      #cfd6e3;
  --link-hover:#ffffff;
  --hot:       #ff6b5e;

  --mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas,
          "DejaVu Sans Mono", "Liberation Mono", monospace;

  /* Indirection so a theme can leave monospace behind without lying about
     what --mono means. The punk theme is the only one that does. */
  --body-font: var(--mono);

  --row-gap: 2px;
  --pad: 14px;

  /* The page's reading measure — the width the centred furniture shares.
     A CEILING, not a layout: everything that uses it is fluid below this width
     and stops growing above it, so the slack on a wide screen goes to the
     margins rather than to the line length. That is the whole of the page's
     responsiveness for width, and it needs no resize listener — `100%` and
     `max()` are re-evaluated by the browser on every resize for free. JS
     measuring is reserved for what CSS genuinely cannot express, which here is
     only --head-h (a wrapping bar's height).

     1440 is derived rather than chosen: at that width the lead's 0.92fr art
     track computes to 677px, which is the picture's own natural width, so the
     hole between the photograph and its headline closes. If .lead-art's
     `max-height: 380px` ever moves, this moves with it — see `.story.lead`.

     Three things now share it (the lead, the section bar, and the lockbar pair
     by centring on the same midline). They are deliberately CONCENTRIC rather
     than edge-matched: forcing the lockbar to exactly 1440 would mean stretching
     it, and stretching a centred group is the bug that caused every alignment
     complaint this file records. Sharing a centre is what reads as aligned. */
  --measure: 1440px;

  /* Height of the sticky masthead. Everything that sticks *under* it — the
     rail, and every section heading — offsets by this, so a theme that changes
     the header's size has one number to move instead of five. Themes that grow
     it should pin it with min-height rather than trusting content to measure. */
  --head-h: 41px;

  /* Height of the page bar that sticks under the masthead. Pinned, not
     measured — which is exactly what forces .secnav to be a single row that
     scrolls sideways instead of wrapping. See the rule. */
  --nav-h: 32px;

  /* Height of the LOCKED BAR — brief and weather together — in its compact
     form, which is the only form that is ever sticky. At full height it is
     ~144px, a quarter of a 900px laptop screen to hold open permanently, so it
     shrinks to one line the moment it starts sticking (body.wx-stuck) and this
     is what it shrinks to. Both halves obey it: the brief clamps to one line
     under the same class, or the bar is 34px on the right and five lines tall
     on the left.

     That is what lets --stick-h stay a constant. By the time the page has
     scrolled far enough for anything else to stick, the weather is already
     compact, so this number is the one actually on screen. The alternative is
     measuring a bar whose height changes, on scroll, and writing it back into a
     custom property — which is a resize loop waiting to happen. */
  --wx-h: 34px;

  /* What anything sticking under ALL THREE answers to: the scroll-margin a
     page turn has to clear, and anything a future page pins. A theme that
     resizes any of the bars moves this on its own. */
  --stick-h: calc(var(--head-h) + var(--nav-h) + var(--wx-h));
}

@media (prefers-reduced-motion: no-preference) {
  /* Jumping between sections is a lot less disorienting when you can see which
     direction you went. */
  html { scroll-behavior: smooth; }
}

@media (prefers-color-scheme: light) {
  :root {
    --bg:        #f7f7f4;
    --bg-alt:    #ffffff;
    --line:      #dcdcd4;
    --fg:        #1a1c20;
    --fg-dim:    #5c6270;
    --fg-faint:  #8b909c;
    --accent:    #9a6a00;
    --accent-2:  #10745a;
    --link:      #1a1c20;
    --link-hover:#000000;
    --hot:       #b3251a;
    /* Light-mode accent is dark enough that button labels need white. */
    --btn-fg:    #ffffff;
  }
}

/* ---------- the palettes ----------

   `[data-theme=…]`, NOT `:root[data-theme=…]` — dropped 2026-08-12 so a palette
   can be applied to a nested element as well as to <html>. That is the whole
   mechanism behind the theme picker: a tile carrying `data-theme="newzpaper"`
   gets the entire palette, the fonts and `color-scheme`, and its subtree
   inherits them, so a preview is the real thing rather than a hand-painted
   swatch that can drift. Nothing else needed changing to make that work.

   **These must still beat the `prefers-color-scheme` media query above, and
   what carries that is now SOURCE ORDER rather than specificity.** `:root[…]`
   was (0,2,0) against the media query's (0,1,0); a bare `[data-theme=…]` is
   (0,1,0), i.e. a tie, broken by these blocks being written below it. So the
   media query has to stay above the palettes. Moving it below would leave a
   reader whose OS is in light mode seeing light colours under every theme —
   which looks like a broken palette, not like a rule-ordering mistake.

   One consequence to know when using a palette on a nested element: it inherits
   any variable the page's own theme sets that the applied one doesn't. That is
   harmless for the core contract, because every theme defines all of it — but
   it is another reason the punk-only keys (--ink, --outline, --shadow, --paper,
   --dot, --pop*) must stay inside punk-scoped rules, where a non-punk tile
   cannot match them however they were inherited. */
[data-theme="dark"] {
  /* Also pins the UA's own chrome — the search input and scrollbars would
     otherwise follow the OS and render light behind a dark page. */
  color-scheme: dark;
  --bg: #0c0d10; --bg-alt: #131519; --line: #23262d;
  --fg: #dfe3ea; --fg-dim: #8b93a3; --fg-faint: #5b6272;
  --accent: #e8b34a; --accent-2: #5fd3a6;
  --link: #cfd6e3; --link-hover: #ffffff; --hot: #ff6b5e;
  /* Must be reset explicitly: a light-mode OS with the dark toggle on would
     otherwise inherit white from the media query and wash out on amber. */
  --btn-fg: #0c0d10;
  /* The two terminal themes are the ones that keep the :root default, so these
     restate it rather than change it — and they have to, now that a palette can
     be applied to a nested element. An unset variable INHERITS, so the `dark`
     tile in the picker was picking up whatever the page around it was wearing:
     Times inside a newzpaper reader, Arial Black inside a punk one. It looked
     like a font bug and was a scoping one. Anything a palette leaves unset is a
     hole for the page's own theme to fall through, which is why these two are
     part of the contract now and not an indirection with a default. */
  --body-font: var(--mono);
  --display: var(--body-font);
}
[data-theme="light"] {
  color-scheme: light;
  --bg: #f7f7f4; --bg-alt: #ffffff; --line: #dcdcd4;
  --fg: #1a1c20; --fg-dim: #5c6270; --fg-faint: #8b909c;
  --accent: #9a6a00; --accent-2: #10745a;
  --link: #1a1c20; --link-hover: #000000; --hot: #b3251a;
  --btn-fg: #ffffff;
  /* Same reason as dark, above. */
  --body-font: var(--mono);
  --display: var(--body-font);
}

/* Pop-punk, in two. Both keep the same variable contract as the terminal
   themes, so every rule below works untouched, and both share one decoration
   layer at the foot of the file selected with [data-theme^="punk"] — only the
   palette below differs. Adding a third variant means adding a palette block
   here and nothing else.

   Everything that lands on text clears 4.5:1 against its own --bg. That is why
   bubblegum's --accent is #d01565 rather than the brighter #ff2d87 it wants to
   be: that pink is 3.2:1 on pink and unreadable at 11px. The bright one lives
   on as --pop, restricted to fills, borders and shadows where contrast is not
   a question. Keep that split if you retune these.

   Pink on pink is unforgiving and eyeballing it does not work — #e0186f looks
   plenty dark and lands at 4.20. `node test-contrast.mjs` checks every pair in
   both variants; run it rather than trusting the swatch. */

/* Shared: type, the three shout colours, and the geometry. */
[data-theme^="punk"] {
  --body-font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
               "Helvetica Neue", Arial, sans-serif;
  /* No webfont: the CSP has no font-src, so it falls back to default-src
     'self' and anything hosted elsewhere is blocked. These are all installed
     by default on Windows and macOS, and a heavy condensed grotesque is the
     right face for this look anyway. */
  --display: "Arial Black", "Arial Bold", Impact, Haettenschweiler,
             "Franklin Gothic Heavy", var(--body-font);

  --pop:   #ff2d87;   /* decorative only — see the note above */
  --pop-2: #23e0d8;
  --pop-3: #ffd93d;
  /* The colour of text printed *on* a --pop-2 or --pop-3 fill. Those fills are
     bright in both variants, so this stays dark in both — it is not "the dark
     colour", it's the ink. Sticker outlines use --outline instead, which does
     flip. */
  --ink:   #1b0f1a;
}

/* Bubblegum — daylight. */
[data-theme="punk"] {
  color-scheme: light;
  --bg: #fff0f6; --bg-alt: #ffffff; --line: #1b0f1a;
  --fg: #1b0f1a; --fg-dim: #6d4763; --fg-faint: #8a6382;
  --accent: #d01565; --accent-2: #0a7d72;
  --link: #1b0f1a; --link-hover: #d01565; --hot: #d90b3c;
  --btn-fg: #ffffff;

  --outline: var(--ink);
  --shadow: 2px 2px 0 var(--ink);
  --paper: #fffbe6;
  --warn-bg: #fff3b0;
  --dot: rgba(255, 45, 135, 0.16);
  --row-hover: rgba(255, 45, 135, 0.12);
}

/* Blacklight — the same stickers under a UV lamp.

   Two flips matter. The sticker outline goes near-white, because a black
   outline on a dark page is just a missing edge; and --btn-fg goes dark,
   because the pinks bright enough to read on black are too bright to carry
   white text — #ff5fa8 under white is 2.8:1, under ink it's 6.8:1. Anything
   filled with --accent or --accent-2 gets ink, not paper.

   The hard shadow goes cyan rather than black: an offset black shadow is
   invisible here, and the pink/cyan misregistration is the look anyway. */
[data-theme="punk-dark"] {
  color-scheme: dark;
  --bg: #140d1a; --bg-alt: #1f1528; --line: #4a2f57;
  --fg: #f6ecfa; --fg-dim: #b99ec8; --fg-faint: #8f75a0;
  --accent: #ff5fa8; --accent-2: #34e0d4;
  --link: #f6ecfa; --link-hover: #ff5fa8; --hot: #ff4d6d;
  --btn-fg: #140d1a;

  --outline: #f7e8ff;
  --shadow: 2px 2px 0 var(--pop-2);
  --paper: #241733;
  --warn-bg: #2e2113;
  --dot: rgba(255, 45, 135, 0.22);
  --row-hover: rgba(255, 45, 135, 0.20);
}

/* ---------- newzpaper ----------

   Black ink on aged newsprint, set in Times. The fifth theme, and the one that
   tests whether the "a theme is a palette block" claim above is true: it needed
   a palette, two font variables, and a decoration layer for the things a
   newspaper does that no other theme here does — double rules, reverse-type
   section flags, a drop cap.

   **It is monochrome on purpose, and that is safe here for a documented
   reason.** Severity in the weather bar is carried by the event's own NAME and
   by the ⚠ glyph as well as by colour — the alerts code was written that way so
   it survives a colour-blind reader, and a one-ink theme is the same problem
   from the other side. The single exception is --hot, which is an aged red
   rather than black: a spot colour for what is urgent is period-correct (papers
   printed one), and it keeps "fresh", the hazard chips and the today marker
   legible as a class rather than as more black type.

   --accent is pure ink and --accent-2 a sepia, so section flags and the events
   ones are told apart by hue as well as by position, the way a two-plate press
   would have done it. Both are dark enough to carry paper-coloured text.

   The paper is #e9e2d0 rather than white. Newsprint yellows, and a pure white
   ground under a Times face reads as a word processor rather than as a page. */
[data-theme="newzpaper"] {
  color-scheme: light;

  /* Times first, then the metric-compatible clones so Linux gets the same
     measure rather than falling back to a wider default serif. No webfont: the
     CSP has no font-src, so it falls back to default-src 'self' and anything
     hosted elsewhere is blocked — same constraint the punk display face works
     under. */
  --body-font: "Times New Roman", Times, "Liberation Serif", "Nimbus Roman",
               "DejaVu Serif", Georgia, serif;
  --display: var(--body-font);

  /* Two newzpaper-only faces, used only inside newzpaper-scoped rules — the
     same arrangement as punk's --ink and --outline, and the reason a new theme
     can still skip them.

     --nameplate is the blackletter for the masthead, which is the single thing
     that says "newspaper" before a word is read. Every entry is a face that
     ships with an OS or with Office; there is no webfont because the CSP has no
     font-src and falls back to default-src 'self'. We could serve one from our
     own origin under that rule, and chose not to: it would be the first binary
     in the repo and a request on every first load, to fix a nameplate that
     already degrades to the ruled serif caps this theme shipped with. A reader
     without a blackletter sees the old masthead, not a broken one.

     --woodtype is the heavy condensed face for the lead headline — the poster
     wood type a front page shouts in. Impact is the one that carries it on
     macOS as well as Windows; Haettenschweiler is narrower and better where it
     exists, so it goes first. */
  --nameplate: "Old English Text MT", "Cloister Black", "UnifrakturMaguntia",
               "Blackadder ITC", var(--display);
  --woodtype: "Haettenschweiler", "Impact", "Franklin Gothic Medium Cond",
              "Arial Narrow", "Liberation Sans Narrow", var(--display);

  --bg: #e9e2d0; --bg-alt: #f2ecdd; --line: #2a251c;
  --fg: #14110b; --fg-dim: #4a4336; --fg-faint: #6b6355;
  --accent: #14110b; --accent-2: #46392a;
  --link: #14110b; --link-hover: #8c2016; --hot: #8c2016;
  --btn-fg: #f2ecdd;

  /* The one number a theme may want that isn't colour: Times runs smaller than
     the monospace and sans faces at the same px, so the whole page is set a
     touch larger to keep the same reading size. */
  --paper-size: 14.5px;
}

* { box-sizing: border-box; }

/* Any class rule setting `display` outsmarts the UA stylesheet's
   [hidden] { display: none }, so state it once, loudly. */
[hidden] { display: none !important; }

body {
  margin: 0;
  background: var(--bg);
  color: var(--fg);
  font-family: var(--body-font);
  font-size: 13px;
  line-height: 1.45;
  -webkit-font-smoothing: antialiased;
}

a { color: var(--link); text-decoration: none; }
a:hover, a:focus-visible { color: var(--link-hover); text-decoration: underline; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* ---------- header ---------- */

.masthead {
  position: sticky;
  top: 0;
  z-index: 20;
  display: flex;
  align-items: baseline;
  gap: 14px;
  flex-wrap: wrap;
  padding: 10px var(--pad);
  background: var(--bg);
  border-bottom: 1px solid var(--line);
}

.brand {
  font-weight: 700;
  letter-spacing: 0.14em;
  color: var(--accent);
  white-space: nowrap;
  /* The source string is natural case ("The Valley Bulletin") and the caps are
     put on here, so newzpaper can have the mixed-case form a blackletter
     nameplate needs without either theme fighting the markup. */
  text-transform: uppercase;
}
.brand span { color: var(--fg-faint); font-weight: 400; }

/* The dateline, in the masthead where a paper puts it (moved out of the weather
   bar on 2026-08-11 — see renderBrandDate in app.js for why).

   It needs no rule drawn beside it up here. In the weather bar it had to have
   one or it read as a fourth weather field; against the brand it is obviously
   the paper's own date, and the "//" the brand already used as a separator does
   the whole job.

   Deliberately NOT `--fg-faint` like the old "// TREASURE VALLEY" it replaces.
   That was a subtitle and could recede; this is information a reader might
   actually come here for, and at 0.14em tracking in 11px faint grey it was
   unreadable at arm's length. */
.brand-date {
  font-variant-numeric: tabular-nums;
  /* The name is uppercased, the date is not — it inherits otherwise, and
     "THURSDAY AUGUST 13TH" shouts a fact nobody is reading the masthead for. */
  text-transform: none;
}
.brand .bd-ord {
  font-size: 0.6em;
  vertical-align: 0.45em;
  letter-spacing: 0.03em;
  color: var(--fg-faint);
}

/* Both forms live in the DOM and CSS picks one — see renderBrandDate. The short
   one is hidden by default so the full date is what a wide screen gets without
   a media query having to fire. */
.bd-short { display: none; }

.masthead .spacer { flex: 1 1 auto; }

/* The filter and the three buttons, as one object.
   Its whole job in the four terminal/punk themes is to be invisible: the same
   14px gap the masthead uses and `align-items: baseline` reproduce exactly what
   the four loose children did, so nothing about those mastheads changed when
   the wrapper went in. It exists for the themes that need to place the controls
   against a centred nameplate — see newzpaper. */
.masthead .tools {
  display: flex;
  align-items: baseline;
  gap: 14px;
  flex-wrap: wrap;
}

.meta {
  color: var(--fg-faint);
  font-size: 11px;
  white-space: nowrap;
}
.meta b { color: var(--fg-dim); font-weight: 400; }

button, .btn {
  font: inherit;
  font-size: 11px;
  color: var(--fg-dim);
  background: var(--bg-alt);
  border: 1px solid var(--line);
  border-radius: 3px;
  padding: 3px 9px;
  cursor: pointer;
  white-space: nowrap;
}
button:hover:not(:disabled) { color: var(--fg); border-color: var(--fg-faint); }
button:disabled { opacity: 0.5; cursor: default; }

button.primary {
  color: var(--btn-fg, var(--bg));
  background: var(--accent);
  border-color: var(--accent);
  font-weight: 700;
}
button.primary:hover:not(:disabled) { color: var(--btn-fg, var(--bg)); filter: brightness(1.12); }

#filter {
  font: inherit;
  font-size: 11px;
  color: var(--fg);
  background: var(--bg-alt);
  border: 1px solid var(--line);
  border-radius: 3px;
  padding: 3px 8px;
  width: 150px;
}
#filter::placeholder { color: var(--fg-faint); }

/* ---------- section jump bar ---------- */

/* Real anchors to real section ids, so this works with the keyboard, survives
   middle-click, and needs no JS to do the scrolling.

   The one rule that matters: it never wraps. Every sticky heading on the page
   offsets by --stick-h, and a bar that grew to two lines on a narrow screen
   would leave story rows scrolling up through the gap — the same trap --head-h
   already carries a warning about. Out of room it scrolls sideways instead,
   and the half-cut chip at the edge is what says so.

   The bar is full-bleed and its CONTENT sits in --measure, which is the same
   relationship `.page` has with `.story.lead`. It was ranged to the screen edge
   until 2026-08-12 — the last thing on the page that was, once the lockbar and
   the lead were centred. On a 2560px screen that put TREASURE VALLEY at x=14
   while everything under it centred on x=1280.

   The padding is the gutter and the centring at once: `max()` picks whichever
   is larger, so above --measure it is the leftover margin and below it collapses
   to the page gutter and the bar goes fluid edge to edge. Nothing here is
   pinned to a viewport size and there is no breakpoint — the browser
   re-evaluates `100%` on every resize. */
.secnav {
  position: sticky;
  top: var(--head-h);
  /* Under the masthead (20), over the section headings (5). */
  z-index: 19;
  display: flex;
  flex-wrap: nowrap;
  align-items: center;
  gap: 6px;
  /* `safe`, because this is a scroll container: unqualified `center` pushes the
     first chip off the scrollable origin when the chips overflow, and there is
     no way to scroll back to it. `safe` falls back to flex-start exactly then. */
  justify-content: safe center;
  min-height: var(--nav-h);
  padding: 0 max(var(--pad), calc((100% - var(--measure)) / 2));
  background: var(--bg);
  border-bottom: 1px solid var(--line);
  overflow-x: auto;
  overscroll-behavior-x: contain;
  /* Hidden rather than thin: a scrollbar appearing on some platforms and not
     others would make --nav-h true on some platforms and not others. */
  scrollbar-width: none;
}
.secnav::-webkit-scrollbar { display: none; }
/* Nothing has rendered yet — don't reserve a bar for it. */
.secnav:empty { display: none; }

/* Spread across the measure, so the bar reads as a newspaper's section index:
   TREASURE VALLEY on the left rim, EVENTS on the right, the rest evenly spaced
   between. Set by `renderNav` and only when there are enough chips to carry it
   — see NAV_SPREAD_MIN.

   **The count has to come from JS, because CSS cannot spread with a ceiling.**
   Filling a fixed measure needs the free space divided by (chips − 1), and
   `space-between` is the only thing that does that division — it has no maximum.
   The obvious guard, a large `gap` capped by `width: fit-content`, does not
   work: `gap` is a hard MINIMUM in flexbox, so a gap wide enough to fill 1440px
   simply overflows it (measured: 6 chips ran 40px past the group) rather than
   compressing. So the spacing is all-or-nothing, and the thing that decides is
   a count — which CSS has no way to ask about and JS has for free. Same split
   as `.drop-ok` in the newzpaper theme: JS states a fact about the content, CSS
   decides what to do with it.

   Left ungated it degrades badly, and /admin can get you there — NATIONAL and
   WORLD are already unticked, which is why there are 6 chips and not 8. Gaps at
   2560px:

     8 chips  101px      6 chips  172px  ← today, reads as a bar
     7 chips  131px      5 chips  241px  ← loose, still reads as a bar
                         4 chips  347px  ← two words and a lot of paper
                         2 chips 1190px  ← marooned

   Below the threshold the bar falls back to the `safe center` above: a tight
   centred group, still concentric with everything else, just not touching the
   rims.

   No narrow-screen override is needed, and that is a property of
   `space-between` rather than an oversight. Once the chips overflow, the free
   space is negative and there is none to distribute, so it behaves exactly like
   flex-start and the gaps sit at the 6px floor — which is the phone bar this
   file already describes, scrolling sideways with a half-cut chip at the edge.
   Verified at 390px: first chip at the gutter, scrollable, unchanged. */
.secnav.spread { justify-content: space-between; }

.chip {
  display: flex;
  flex: 0 0 auto;
  align-items: baseline;
  gap: 5px;
  padding: 2px 8px;
  border: 1px solid transparent;
  border-radius: 3px;
  font-size: 10.5px;
  letter-spacing: 0.1em;
  color: var(--fg-dim);
  white-space: nowrap;
}
.chip:hover, .chip:focus-visible {
  color: var(--fg);
  border-color: var(--line);
  text-decoration: none;
}
.chip-count {
  color: var(--fg-faint);
  font-size: 10px;
  letter-spacing: 0;
  font-variant-numeric: tabular-nums;
}
/* The page you are on. Exactly one at a time now — the bar is the router, and
   this is the only "you are here" on the screen, which is why the page's own
   heading doesn't need to be sticky. It used to be `.inview` and could be true
   of several chips at once, back when every section was on one scrolling page.

   Underlined as well as coloured: current-page is navigation state, and it has
   to survive a colour-blind reader the same way the ♪ on an event row does. */
.chip.current, .chip.current .chip-count { color: var(--accent); }
.chip.current {
  border-color: var(--line);
  box-shadow: inset 0 -2px 0 var(--accent);
}
/* The events page. Coloured like its own headings, so the bar reads as
   sections-then-what's-on rather than as one undifferentiated list. */
.chip-events, .chip-events .chip-count { color: var(--accent-2); }
.chip-events.current, .chip-events.current .chip-count { color: var(--accent-2); }
.chip-events.current { box-shadow: inset 0 -2px 0 var(--accent-2); }

/* No matches on that page under the current filter. Dimmed rather than hidden:
   a bar that loses chips as you type reflows under the pointer, and "IDAHO 0"
   is a more useful answer than a gap. */
.chip.nomatch { opacity: 0.38; }

/* ---------- brief ---------- */

/* ---------- weather ---------- */

/* Three blocks side by side: now-and-the-week, the forecaster's paragraph, and
   the hazards stacked beside it.

   `order` rather than DOM order, because the two disagree on purpose — the
   hazards are written first (see renderWeather) and read last.

   Nothing here grows. An earlier pass let the paragraph take all the slack so
   the hazards sat flush right, which on a 2500px screen put them a clear
   1500px from anything they related to — reading as three unconnected widgets
   on one rule rather than as a weather bar. The paragraph is capped instead so
   the three blocks stay a group, and the group is centred on the bar.

   Centring is only safe BECAUSE nothing grows: with an elastic member the
   three would spread to the full width and `justify-content` would have nothing
   left to centre. The two rules are one decision, not two.

   Reading order is now conditions → hazards → prose, which is also severity
   order: the temperature is what you came for, an advisory is what might change
   your afternoon, and the forecaster's paragraph is the elaboration. The
   hazards sit in the middle rather than at either end so they touch the block
   they qualify. */
/* ---------- the locked bar ----------

   Sticky under the masthead and the page bar, and the only thing on the page
   that never scrolls away: the brief ranged left, the weather ranged right.
   What changes is how much of it there is — see --wx-h and the compact rules
   below. z-index above the page content and below the two bars it sits under.

   The brief is the elastic member and the weather is rigid, which is what puts
   one at each end. The old bar centred the weather and had a rule that NOTHING
   in it could grow, because a single `flex: 1` would spread the blocks to the
   full width and leave the hazard chips a clear 1500px from the temperature
   they qualify. That rule is retired rather than broken: there is a deliberate
   left occupant now, so the weather group is pushed right as a group and its
   four blocks keep their own gap. Give anything INSIDE .weather a flex-grow and
   the old failure comes straight back. */
#lockbar {
  position: sticky;
  top: calc(var(--head-h) + var(--nav-h));
  z-index: 6;
  display: flex;
  /* Top-anchored, not vertically centred. The bar's height is set by whichever
     panel is taller — normally the brief, at six clamped lines — and centring
     left the shorter one (the weather, almost always) floating in the middle
     with air above it while the brief started hard at the top. Two panels side
     by side that begin at different heights read as two unrelated things, which
     is the same complaint centring the bar horizontally was fixing.

     `body.wx-stuck #lockbar` restates `center` for the compact line and must
     keep doing so: that bar is one fixed --wx-h row, where centring is the
     correct answer and top-anchoring would hang the text off the ceiling. */
  align-items: flex-start;
  /* Centred as a PAIR, not ranged to the two edges — changed 2026-08-11.
     `space-between` is only a layout while the two halves are wide enough to
     meet in the middle, and on a 2560px screen they are not: the weather slot
     grew to 1965px with its content ranged left inside it, the brief was capped
     at 78ch and pinned to the far rim, and ~1200px of nothing sat between them.
     The brief didn't read as the right-hand half of a bar, it read as marooned.
     Centred, the two travel together and the slack goes to the outside where
     nobody has to read across it. */
  justify-content: center;
  gap: 10px 34px;
  padding: 0 var(--pad);
  background: var(--bg);
  border-bottom: 1px solid var(--line);
}

/* A proportional split, and both halves have to be allowed to shrink.

   The first attempt made the weather rigid (`flex: 0 0 auto`) on the grounds
   that nothing in it may grow — which is a rule about the weather's own
   INTERNAL layout and does not mean the bar can't resize it. Rigid, it claimed
   its full natural width, which is about 1076px once the forecast paragraph is
   included, and left the brief **28 pixels** on a 1024px screen. Not broken,
   not an error: a single column of letters.

   40/60 rather than auto, so the split is the same shape at every width, and
   the weather absorbs it by wrapping — its four blocks already wrap, so a
   narrow bar puts the forecast paragraph on a second row instead of squeezing
   everything. The cap stops the brief becoming one 1900px line of 12px text on
   an ultrawide, which is a measure nobody can track across; the gap that leaves
   in the middle is also what makes "left" and "right" read as two ranged blocks
   rather than one stretched row.

   **The proportional split is gone (2026-08-11, same day it arrived).** `1 1 60%`
   and `1 1 40%` are grow factors, and growing is exactly what stranded the
   brief: the weather half claimed 1965px on an ultrawide and then ranged its
   content left inside it, so the "split" described two boxes and not two blocks
   of type. Both slots size to their content now and `#lockbar` centres the
   pair, which is the same anti-stretch rule `.weather` has always had one level
   down, finally applied to the level above it too.

   They keep shrink and `min-width: 0` because shrinking is still needed — a
   narrow screen has to be able to squeeze them before the bar wraps. It is only
   the growing that was wrong. The brief's cap survives unchanged: it is what
   stops a paragraph running to a measure nobody can track across.

   **The brief shrinks twice as fast, and that number is measured.** Flex
   distributes shrink in proportion to base size, and the brief's 78ch cap makes
   its base the SMALLER of the two — so at equal shrink factors the paragraph
   held its width and the weather was crushed to 191px at an 800px bar, which is
   the "single column of letters" failure this file already records happening to
   the brief, now pointed the other way. Measured across 800/900/1000/1200:

     brief-shrink   800px        900px        1000px       1200px
     1              191/547 h353  291/547 h292  391/547 h292  591/547 h192
     2              433/305 h198  441/397 h192  449/489 h192  591/547 h192
     3              553/185 h242  558/280 h198  564/375 h192  591/547 h192

   The column that decides it is `h`, the bar's HEIGHT, because this bar is
   locked to the top of every page and every pixel of it is charged to all of
   them. 2 is the only weighting that keeps the bar at its natural 192px all the
   way down to 900 and costs 6px at 800; 1 and 3 both crush one panel, and a
   crushed panel is tall — it wraps. So this is a height rule wearing a width
   rule's clothes, which is why it isn't obviously arbitrary. */
#weather-slot { flex: 0 1 auto; min-width: 0; }
#brief-slot {
  flex: 0 2 auto;
  min-width: 0;
  max-width: 78ch;
}

/* Either half can be missing, and neither may leave a hole.
   `renderBrief` leaves its slot childless when the payload carries no brief,
   which is a supported state rather than a fault — see Secrets in CLAUDE.md,
   "no Anthropic key hides the brief", and it is also what a fresh deploy shows
   until the home box posts its first one. `renderWeather` does the same when
   nothing at all came back. An empty flex item does not collapse: the bases
   already sum to 100%, so there is no positive free space for flex-grow to
   redistribute and the empty half sits there at ~546px wide. `:empty` matches
   because replaceChildren() leaves zero children and the markup has no
   whitespace inside the div — same prior art as `.secnav:empty`.

   **Hiding it is only half the fix**, which is worth stating because hiding it
   alone looks like it worked. With one slot gone the survivor grows to the full
   width and its own ranging then holds it against the outer edge — the
   identical void, now on the other side with nothing to blame it on. Alone in
   the bar each one centres, which is what the weather did before the brief
   moved in beside it.

   Both directions are stated because the weather one is no longer theoretical:
   the render can now produce an empty slot on a bad-payload day, which is the
   whole point of the change that allowed it.

   **Hiding is now the whole fix**, where it used to be half of one. Under
   `space-between` an emptied slot left the survivor ranged against an outer
   edge and the void simply moved; the `:has()` rules below existed to undo
   that. With the bar centred, one slot alone centres on its own and there is
   nothing left to correct. */
#brief-slot:empty,
#weather-slot:empty { display: none; }

/* 1px, invisible, and the only thing that knows when the bar started sticking.
   A stuck sticky element never stops intersecting the viewport, so it cannot
   observe itself; this sits immediately above it in normal flow and leaves the
   observed area at exactly that scroll position. */
#wx-sentinel { height: 1px; margin-bottom: -1px; }

/* Ranged LEFT, inside the locked bar — it was ranged right until 2026-08-11,
   when the two halves swapped ends. The three blocks keep their own 22px gap
   and stay a group; `flex-start` is what ranges the group rather than spreading
   it, and is the reason nothing in here may grow.

   The gutter moved to `#lockbar` when the bar became centred. While the halves
   were ranged to the edges each one carried `var(--pad)` on its outward side;
   centred, that padding is no longer a gutter at all — it is asymmetric space
   inside a centred group, which pushes the pair off centre by half of it. The
   bar takes the gutter now (it only matters at narrow widths anyway, as the
   floor before content touches the screen edge) and these two keep vertical
   padding only. `justify-content` here is about the group's own rows when it
   wraps, not about where the group sits. */
.weather {
  display: flex;
  /* Top-anchored like the bar itself, so the three blocks start on one line
     instead of centring against the tallest of them. Centred, `.wx-main` is the
     tallest (temperature over the four-day strip) and the forecast paragraph
     began 14px below the temperature it describes — a ragged top edge inside a
     panel whose neighbour starts flush. `body.wx-stuck .weather` restates
     `center`, which is right there: the compact bar is one row and has no top
     to anchor to. */
  align-items: flex-start;
  justify-content: flex-start;
  gap: 8px 22px;
  flex-wrap: wrap;
  padding: 8px 0;
}

/* ---------- the compact bar ----------

   Both forms are built on every render and CSS picks one, rather than the
   bar being rebuilt on scroll: there is then no state to keep in sync and no
   work on the scroll path at all. The class lives on <body> because the
   observer that sets it is watching a sentinel, not the bar.

   The height is PINNED, not left to content, for the same reason --nav-h is:
   --stick-h is a promise about how much of the viewport is covered, and a bar
   that wrapped to two lines on a narrow screen would quietly make it a lie.
   BOTH halves have to obey it — the brief is clamped to one line by the same
   class, or the bar is --wx-h on the right and five lines on the left. */
.wx-compact { display: none; }

body.wx-stuck #lockbar {
  min-height: var(--wx-h);
  height: var(--wx-h);
  align-items: center;
}

/* Give back, at the foot of the page, exactly the height the bar just gave up.

   The bar is `position: sticky` and therefore still in normal flow, so its
   height is part of the DOCUMENT's height — compacting it doesn't only free
   viewport, it makes the page ~120px shorter. Without this the browser has to
   claw back any scroll position that no longer exists, which reads as a lurch
   backwards mid-gesture, and on a page whose whole overflow was about that much
   it became a closed loop: the collapse destroyed the scroll that triggered it,
   the clamp brought the sentinel back into view, the bar reopened. Measured
   strobing 12 times in 130ms.

   --wx-collapse is written by syncBarCollapse, which is the only place that can
   measure it: the bar's full height is unknowable while it is compact. The 0px
   fallback is the honest answer before the first measurement, not a guess.

   This is only half the fix and the smaller half — see canCompact in app.js for
   why a page that CAN'T afford the collapse must not be given a reserve
   instead, and simply doesn't compact. */
body.wx-stuck { padding-bottom: var(--wx-collapse, 0px); }
body.wx-stuck .weather > :not(.wx-compact) { display: none; }
/* `justify-content` and `padding` both date from when the weather was the bar's
   RIGHT-hand panel: it ranged its compact line to the right and carried the page
   gutter on that side. It is the left panel of a centred pair now, so flex-end
   pushed the line away from the brief and the padding opened a second gap beside
   the bar's own 34px one — the compact twin of the leftover the brief had. */
body.wx-stuck .weather {
  display: flex;
  justify-content: flex-start;
  flex-wrap: nowrap;
  padding: 0;
  height: 100%;
  align-items: center;
}
body.wx-stuck .wx-compact {
  display: flex;
  align-items: center;
  gap: 7px;
  min-width: 0;
  overflow: hidden;
  white-space: nowrap;
  font-size: 12px;
}
.wx-c-sep   { color: var(--fg-faint); }
.wx-c-temp  { font-size: 15px; font-weight: 700; font-variant-numeric: tabular-nums; }
.wx-c-cond  { color: var(--fg-dim); }
.wx-c-range { display: flex; gap: 5px; font-size: 11px; font-variant-numeric: tabular-nums; }
/* The forecast paragraph is the first thing to go and the hazard the last:
   on a line this short, what could change your afternoon outranks the prose
   describing it. Both are still one scroll up. */
.wx-compact .wx-alert { padding: 1px 6px; }
.wx-compact .wx-aqi { padding: 1px 6px; }
/* Orders start at 1 because the dateline used to be 0 — left as they are
   rather than renumbered, so the hazards-before-prose intent stays legible
   against the note above and in git. */
.wx-main    { order: 1; display: flex; flex-direction: column; gap: 7px; }
.wx-hazards { order: 2; display: flex; flex-direction: column; align-items: flex-start; gap: 5px; }
.wx-today   { order: 3; flex: 0 1 auto; min-width: 0; }

.wx-now { display: flex; align-items: center; gap: 9px; }
.wx-temp {
  font-size: 26px;
  line-height: 1;
  color: var(--fg);
  font-variant-numeric: tabular-nums;
}
.wx-head { display: flex; align-items: baseline; gap: 9px; }
.wx-cond { color: var(--fg-dim); font-size: 12px; }
.wx-sub { color: var(--fg-faint); font-size: 10px; }

/* Today's high and low, beside the condition. Same two colours the day cells
   below use for the same two numbers, so the strip reads as one thing
   continued rather than as a second opinion. */
.wx-range { display: flex; gap: 7px; font-size: 11px; font-variant-numeric: tabular-nums; }
.wx-hi { color: var(--fg); }
.wx-lo { color: var(--fg-faint); }

/* The forecast paragraph, and on Fridays the weekend under it.

   62ch is two lines of a typical NWS paragraph and puts the block at about the
   height of the temperature and the day strip beside it, which is the whole
   reason for a cap rather than a clamp doing the work: the three blocks should
   read as one bar, not as a long sentence with things parked at each end.

   The clamp is a guard against a talkative forecaster and lives on the LINE
   rather than the block — `-webkit-box` only clamps its own text, so putting it
   on the container would collapse the two lines into one clamp between them.
   The full paragraph is on the title attribute either way. */
.wx-today {
  color: var(--fg-dim);
  font-size: 11px;
  max-width: 62ch;
  display: flex;
  flex-direction: column;
  gap: 4px;
}
.wx-line {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 3;
  line-clamp: 3;
  overflow: hidden;
}
/* Tomorrow is clamped a line tighter than today. 62ch × 2 is ~124 characters
   against a typical NWS paragraph of ~117, so this normally shows the whole
   thing — it is the talkative-forecaster guard, one notch earlier, because the
   second-order fact should not be able to out-type the first. */
.wx-next    { -webkit-line-clamp: 2; line-clamp: 2; }
.wx-weekend { -webkit-line-clamp: 2; line-clamp: 2; }
/* The day is what this line is scanned for — two conditions run together
   otherwise, and there's no punctuation strong enough at 11px to separate
   them. Same brightness the day strip gives its own names. */
.wx-wk-day { color: var(--fg); }
.wx-wk-sep { color: var(--fg-faint); }
.wx-today-label { color: var(--fg-faint); }

/* The AQI chip is the one element allowed to shout — in an Idaho August it's
   the number most likely to change what someone does with their afternoon. */
.wx-aqi {
  display: flex;
  align-items: baseline;
  gap: 6px;
  padding: 3px 8px;
  border: 1px solid currentColor;
  border-radius: 2px;
  color: var(--fg-dim);
}
.wx-aqi-value { font-size: 14px; font-variant-numeric: tabular-nums; }
.wx-aqi-label { font-size: 10px; letter-spacing: 0.04em; }
.wx-aqi.aqi-good           { color: var(--accent-2); }
.wx-aqi.aqi-moderate       { color: var(--accent); }
.wx-aqi.aqi-usg,
.wx-aqi.aqi-unhealthy,
.wx-aqi.aqi-very-unhealthy,
.wx-aqi.aqi-hazardous      { color: var(--hot); }
/* Past "unhealthy" a coloured outline is too easy to skim past. */
.wx-aqi.aqi-unhealthy,
.wx-aqi.aqi-very-unhealthy,
.wx-aqi.aqi-hazardous {
  background: color-mix(in srgb, var(--hot) 14%, transparent);
}

/* Active NWS alerts. Deliberately built from the same parts as the AQI chip —
   this bar now has two elements allowed to shout and they had better read as
   siblings rather than as two people's work.

   The colours are the themes' existing --accent / --hot rather than new
   palette entries, which is why adding this needed no work in any of the four
   themes and can't drift from them. */
.wx-alert {
  display: flex;
  align-items: baseline;
  gap: 6px;
  padding: 3px 8px;
  border: 1px solid currentColor;
  border-radius: 2px;
  font-size: 11px;
  color: var(--fg-dim);
}
.wx-alert-mark  { font-size: 11px; line-height: 1; }
.wx-alert-event { letter-spacing: 0.02em; }
.wx-alert-when  { font-size: 10px; opacity: 0.75; }

.wx-alert.alert-minor    { color: var(--fg-dim); }
.wx-alert.alert-moderate { color: var(--accent); }
.wx-alert.alert-severe,
.wx-alert.alert-extreme  { color: var(--hot); }
/* Past "advisory" a coloured outline is too easy to skim past — the same
   escalation the AQI chip makes at "unhealthy", for the same reason. */
.wx-alert.alert-severe,
.wx-alert.alert-extreme {
  background: color-mix(in srgb, var(--hot) 14%, transparent);
}
.wx-alert.alert-extreme { font-weight: 700; }
.wx-alert.alert-more    { color: var(--fg-faint); border-style: dashed; }

/* The chips link to the NWS point forecast, which lists every active hazard
   with a link to its full text.

   Two things the global `a` rules would otherwise do to them: repaint the chip
   `--link-hover` (the grade colour is the whole signal, so it has to survive
   the hover) and underline it, which on a pill with a glyph in it reads as
   damage rather than as a link. The grade colours are all two-class selectors
   so they already outrank `a:hover` on specificity; the underline needs saying
   out loud. The affordance is a tint of the chip's OWN colour via currentColor,
   which means it follows the grade for free. */
a.wx-alert { cursor: pointer; }
a.wx-alert:hover,
a.wx-alert:focus-visible {
  text-decoration: none;
  background: color-mix(in srgb, currentColor 16%, transparent);
}

.wx-days { display: flex; gap: 14px; }
.wx-day { text-align: center; min-width: 34px; font-variant-numeric: tabular-nums; }
.wx-day-name { color: var(--fg-faint); font-size: 10px; letter-spacing: 0.06em; }
.wx-day-hi { color: var(--fg); font-size: 12px; }
.wx-day-lo { color: var(--fg-faint); font-size: 10px; }
.wx-day-precip { color: var(--accent-2); font-size: 9px; }

/* Narrow screens: the narrative is the first thing to go — the numbers either
   side of it carry the same information in less room.

   With it gone there is no elastic member left to push the hazards right, and
   "ranged right" stops meaning anything on a column 380px wide anyway. They
   take the top instead, which is where the DOM already has them: an advisory is
   the one thing in this bar that might change what someone does in the next
   hour, and it should not be below the four-day strip on the screen with the
   least of it visible. */
@media (max-width: 700px) {
  .weather { gap: 10px 12px; }
  .wx-today { display: none; }
  .wx-days { gap: 10px; }
  .wx-hazards {
    /* First in the bar on a phone — the dateline that used to share this
       argument lives in the masthead now, so the only question left is whether
       an advisory outranks a temperature, and it does. */
    order: -2;
    width: 100%;
    flex-direction: row;
    flex-wrap: wrap;
    align-items: flex-start;
    /* Centred to match the bar. These chips take a full-width row of their own
       here, so left-aligning them put a flush-left row directly above a centred
       temperature — two axes in a bar 144px tall. */
    justify-content: center;
  }
  /* An alert's timing goes the same way, and for the same reason the narrative
     does: "⚠ Heat Advisory" is the fact, "until Sat 9pm" is the detail. Two
     alerts with their timings took two extra rows on a 380px screen (measured
     at +92px against a bar already 164px tall); without them the chips share a
     line and it costs nothing.

     Note this is the one place the timing is genuinely gone rather than moved
     — the full text lives on the title attribute, and a touch device has no
     hover. Judged worth it: a reader who can see there IS a heat advisory has
     the part that changes their afternoon. */
  .wx-alert-when { display: none; }
}

/* The brief, at the RIGHT end of the locked bar (it was the left end until
   2026-08-11). It has no background or border of its own — it is a half of that
   bar rather than a band under it, and a filled panel inside a filled bar reads
   as two bars.

   The text stays ranged left inside its own block: the BLOCK sits to the right
   of the weather, which is not the same thing as setting a paragraph rag-left —
   and centring the bar changed where the block sits, not how the type is set.
   A centred paragraph here would be six ragged lines of 12px text. */
.brief {
  margin: 0;
  padding: 8px 0;
  display: flex;
  gap: 12px;
  align-items: flex-start;
  min-width: 0;
}
/* flex: 0 0 auto, or it shrinks. `white-space: nowrap` stops the words
   wrapping but does nothing about the BOX, and a flex item's default
   `flex-shrink: 1` squeezed this to about 30px in a 40%-wide bar — "THE BRIEF"
   clipped to "TH" with the paragraph apparently sitting on top of it. */
.brief-label {
  flex: 0 0 auto;
  color: var(--accent-2);
  font-size: 10px;
  letter-spacing: 0.12em;
  white-space: nowrap;
  padding-top: 2px;
}
.brief > div { min-width: 0; }
/* Clamped, not merely capped. The brief runs 500–900 characters depending on
   the day, and the locked bar has to be a predictable height whichever it is —
   otherwise the page under it steps up and down between rebuilds. The full text
   stays on the title attribute, the same guard the forecast paragraph uses.

   **Sixteen lines, and it is a guard rather than a measure.** This shipped at
   six on the reasoning that six is about as tall as the weather beside it, so
   neither half drove the bar alone — a good rule about the BAR that was never
   checked against the brief's own budget. `config.brief.maxWords` is 130 and has
   been since the first commit, so the clamp was set to a little over half of
   what the model is asked for, and every brief that came in near budget was
   silently cut mid-sentence — reported 2026-08-11 on a 119-word one, five days
   after the bar was built.

   Size it from a measurement, not from the character count, because the brief's
   box is 40% of the bar capped at 78ch and therefore NARROWER on a smaller
   screen — which is the direction that needs more lines, not fewer:

     131 words / 859 chars    @ 2560px wide → 69 ch/line → 13 lines
                              @ 1362px wide → 62 ch/line → 14 lines

   Twelve was tried first, from an estimate rather than a measurement, and still
   clipped both. Sixteen covers a full 130-word brief down to about a 1000px
   viewport with margin for a wordy one.

   **So there are two clamps, and the reader picks.** Sixteen lines is the right
   ceiling for "never cut a sentence" and the wrong default for a bar that is
   locked to the top of every page — measured at 293px, 37% of a 788px laptop.
   Six is the paragraph's shape and what the bar was designed around. Collapsed
   is the default and `body.brief-open` is the other one, remembered in
   localStorage; `.brief-more` only exists when the text actually overflows the
   collapsed clamp, because a control that opens nothing is worse than none.

   That is what makes truncation legitimate here. A paragraph cut with no
   affordance is indistinguishable from a broken one — which is exactly how this
   was reported — and the same cut with a control beside it is a reader's
   choice.

   It only looked fine at six for five days because a short brief fits either
   number, and because the bar compacted away the moment anyone scrolled. It does
   not compact on a short page any more (see canCompact in app.js), so a
   truncated brief now stays truncated on screen.

   The ceiling and maxWords have to be changed together. If maxWords moves, the
   16 moves with it. */
.brief-text {
  margin: 0;
  color: var(--fg);
  font-size: 12px;
  line-height: 1.45;
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 6;
  line-clamp: 6;
  overflow: hidden;
}
/* `:not(.wx-stuck)` rather than trusting source order. The compact bar's
   one-line clamp lives above this, but the narrow-screen override lives in a
   media query BELOW it at equal specificity — so an open brief on a phone would
   have won and made the compact bar twelve lines tall, which is exactly the
   promise --wx-h exists to keep. Stating it here means compact wins from
   wherever the other rule is written. */
body.brief-open:not(.wx-stuck) .brief-text { -webkit-line-clamp: 16; line-clamp: 16; }

/* Deliberately a plain <button>, so punk's `button { border: 2px solid }`
   shorthand dresses it like every other control there rather than inventing a
   second idea of what a button looks like — the lesson the release tabs already
   learned. Everything here is size and rhythm; colour comes from the palette. */
.brief-more {
  margin-top: 4px;
  padding: 0 5px;
  font: inherit;
  font-size: 10px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--link);
  background: none;
  border: 1px solid var(--line);
  border-radius: 3px;
  cursor: pointer;
}
.brief-more:hover, .brief-more:focus-visible { color: var(--link-hover); }
/* The compact bar is one line by contract, and --wx-h is a promise the rest of
   the sticky offsets are calculated from. A control that could add a second row
   to it has no business being there. */
body.wx-stuck .brief-more { display: none; }
.brief-note { color: var(--fg-faint); font-size: 10px; margin-top: 4px; }

/* Compact: one line, and the note goes entirely. At 34px the brief has room
   for its opening clause and nothing else — which is still the most useful
   sentence it has, since it leads with the day's biggest local story. */
/* `padding: 0` — the `0 0 0 var(--pad)` here was a leftover from when the brief
   was the bar's LEFT-hand panel and needed the page gutter on that side. It is
   the right-hand panel of a centred pair now, so that value was a gutter in the
   middle of the bar, nudging the compact line off centre by half of it. */
body.wx-stuck .brief { padding: 0; align-items: center; height: 100%; }
body.wx-stuck .brief-note { display: none; }
body.wx-stuck .brief-text {
  -webkit-line-clamp: 1;
  line-clamp: 1;
  font-size: 11.5px;
}

/* ---------- layout ---------- */

/* ---------- one page at a time ----------

   The reader used to lay every section side by side in an auto-fill grid and
   let you scroll the lot. That grid had a documented trap — it held exactly as
   many columns as there were live sections, so anything that left fewer columns
   than sections (a `span 2`, or merely widening the minmax) pushed the last
   section into a second row that began below the tallest item in the first, and
   FILM & TV landed thousands of pixels down behind a screen of nothing.

   None of that exists now. One section is rendered at a time and it has the
   whole width, so there is no inter-section grid to get wrong and no reason for
   a release strip to fight for room — the page simply gives it a band. What
   replaced the trap is the note on .briefs below.

   The paged version is also DENSER, which is the part worth saying out loud
   before anyone worries about the big lead: every section used to get one
   ~370px column however wide the screen was, so Treasure Valley's thirty
   stories ran off the bottom while four other columns sat half empty. */
main { display: block; }

.page { padding: 0 var(--pad) 26px; }

/* Where a page turn lands, clear of all three sticky bars. */
.page, .page-head { scroll-margin-top: calc(var(--stick-h) + 4px); }

/* The page's masthead rule: name, rule, count. Not sticky, deliberately — the
   chip in the bar above is already lit and is a permanent answer to "which page
   is this", so a fourth pinned strip would spend ~28px of every screen
   restating it. */
/* Three tracks, so the title is centred on the PAGE rather than on whatever is
   left over beside the count.

   Grid rather than the flex row this was: under flex the title could only be
   centred by balancing it against a spacer of exactly the count's width, which
   is a number that changes with "30 stories" vs "9 stories". Two `1fr` side
   tracks do it geometrically instead — the count lives in the right one and
   rangesto its end, and the empty left one is the same width whatever the
   count says, so the title cannot drift when the story count changes. */
.page-head {
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: baseline;
  gap: 12px;
  padding: 14px 0 10px;
  border-bottom: 2px solid var(--fg);
  margin-bottom: 14px;
}
.page-title {
  grid-column: 2;
  text-align: center;
  margin: 0;
  font-size: 18px;
  letter-spacing: 0.14em;
  color: var(--accent);
  font-weight: 700;
}
/* Was the flex spacer that pushed the count right. The grid's own tracks do
   that now, and an empty div in a three-track grid would land in a cell and
   take one of them. */
.page-rule { display: none; }
.page-count {
  grid-column: 3;
  justify-self: end;
  color: var(--fg-faint);
  font-size: 11px;
  letter-spacing: 0.06em;
  white-space: nowrap;
}

/* The lead, across the full width — the picture is what anchors the page, and
   everything else is a column under it. */
.page-top {
  padding-bottom: 16px;
  border-bottom: 2px solid var(--line);
  margin-bottom: 16px;
}

/* Release strips get a band across the page, between the hero and the columns.

   Across the width rather than inside a column: FILM & TV has three strips now,
   and one narrow column carrying all three would tower over everything beside
   it. Here they take a third each and the page stays level. They stay ABOVE the
   briefs because they are a different kind of thing from a headline, and
   burying them under thirty rows is the crowding this layout exists to fix. */
.strips-band {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(290px, 1fr));
  gap: 0 22px;
  margin-bottom: 4px;
}

/* CSS multi-column, not grid, and this is the one place in this file where
   that's right. A grid fills left-to-right, so a list ordered by rank would
   read 1-2-3 across and 4-5-6 underneath; multi-column fills top-to-bottom, so
   column one is the first N stories in order — which is how a newspaper's
   column of briefs is actually read.

   `columns: <width> <count>` makes the width a target and the count a CEILING,
   so this is auto-fill's equivalent: as many columns as fit, never more than
   six. Without the cap a 2545px screen makes eight columns of four rows each,
   which stops looking like a page and starts looking like a spreadsheet. */
.briefs {
  columns: 300px 6;
  column-gap: 26px;
  column-rule: 1px solid var(--line);
}
/* A row split across a column break loses its headline from its source line. */
.briefs .story { break-inside: avoid; }

.filter-empty {
  padding: 22px var(--pad);
  color: var(--fg-dim);
  font-size: 13px;
  border-top: 1px solid var(--line);
}

/* Kept for the release strips' own headings, which are still h2s inside a
   page. No longer sticky: with one section on a page there is nothing to lose
   track of, and the lit chip in the bar says which page you are on. */
.events h2 {
  margin: 0 0 8px;
  font-size: 11px;
  letter-spacing: 0.16em;
  color: var(--accent);
  font-weight: 700;
  display: flex;
  align-items: baseline;
  gap: 8px;
}
.events h2 .count { color: var(--fg-faint); font-weight: 400; letter-spacing: 0; }

/* ---------- story rows ---------- */

.story {
  display: grid;
  grid-template-columns: 44px 1fr;
  gap: 0 8px;
  padding: 3px 0;
  border-bottom: 1px solid transparent;
  /* A URL has no break opportunity in it, so a feed that puts one in a summary
     renders a single token wider than the column and it runs straight through
     the column rule into the next one. Hacker News does exactly that — its
     description is "Article URL: https://…" — and TECH was bleeding across two
     columns because of it. `anywhere` rather than `break-word`: only `anywhere`
     also lowers the min-content width, which is what a multi-column and grid
     layout measures the track against. Inherited, so it covers the headline
     too, and it changes nothing for text that already fits. */
  overflow-wrap: anywhere;
}
/* Same guard for the events page: a venue or a note can carry a raw link. */
.event { overflow-wrap: anywhere; }
.story:hover { border-bottom-color: var(--line); }

.story .age {
  color: var(--fg-faint);
  font-size: 11px;
  text-align: right;
  padding-top: 1px;
  font-variant-numeric: tabular-nums;
}
.story.fresh .age { color: var(--hot); }

.story .headline { min-width: 0; }
.story .headline a { display: inline; }

.story .src {
  color: var(--fg-dim);
  font-size: 11px;
}
.story .also { color: var(--fg-faint); font-size: 10px; }

.story .summary {
  grid-column: 2;
  color: var(--fg-dim);
  font-size: 11.5px;
  margin-top: 1px;
  display: none;
}
body.show-summaries .story .summary { display: block; }

/* ---------- lead story ---------- */

/* The section's top story, given the room a lead deserves.

   It keeps the .story class deliberately: the filter and the once-a-minute age
   re-stamp both walk `.story` in payload order, and a lead that opted out would
   desynchronise every row beneath it. The cost is that the dense row's grid has
   to be restated rather than inherited. */
/* The anchor: picture left, headline and deck right, across the full page.

   Art beside the text rather than above it, and this is the one number the
   layout turns on. Stacked art across a 1400px page is 1400 ÷ 16 × 9 = 790px of
   photograph before a single headline — the whole viewport, on a laptop, for
   one story. Beside the text it is ~46% of the width, so the same 16/9 crop is
   ~360px and the columns underneath start above the fold. `max-height` is the
   belt to that: on an ultrawide 46% is still 1100px, and a hero has a size
   past which it stops being a hero and starts being a wallpaper. */
/* Capped and centred (2026-08-11), which also closes a hole nobody had named.

   The proportions below are fractions of whatever width they are given, and on
   a 2560px screen that made the art track 1213px — while the picture inside it
   is only ~676px wide, because `aspect-ratio: 16/9` against `max-height: 380px`
   sizes it from its HEIGHT, not from its cell. So ~537px of empty track sat
   between the photograph and the headline it belongs to, inside a "grid" that
   looked perfectly reasonable in the CSS.

   1440px is not a taste number: at that width the art track comes out at
   0.92/1.92 x (1440 - 26) = 677px, which is the picture's own natural width. So
   the cap is chosen to make the track and the image the same size — the gap
   closes because the geometry stops disagreeing, not because it was tuned shut.
   Below 1440 nothing changes; this is a ceiling, not a layout. If max-height
   ever moves off 380px, this moves with it.

   It lives in --measure now (2026-08-12) rather than as a literal here, because
   the section bar came to share it and two copies of a derived number is one
   copy too many — the derivation above is the reason the value is what it is,
   so it stays written here where it can be checked. */
.story.lead {
  display: grid;
  grid-template-columns: minmax(0, 0.92fr) minmax(0, 1fr);
  gap: 0 26px;
  align-items: center;
  padding: 0;
  max-width: var(--measure);
  margin-inline: auto;
}
/* No picture is not a smaller lead — the text takes the width. :has() rather
   than a class because the art can leave *after* render: a publisher CDN that
   404s deletes the wrapper, and the column has to close behind it. */
.story.lead:not(:has(.lead-art)) { grid-template-columns: minmax(0, 1fr); }

.story.lead .lead-art {
  display: block;
  overflow: hidden;
  border-radius: 3px;
  background: var(--bg-alt);
  aspect-ratio: 16 / 9;
  max-height: 380px;
}
/* Cropped from the upper third, not the middle.
   `object-fit: cover` into a 16/9 box has to throw away most of a PORTRAIT
   photograph, and at the default `50% 50%` it throws away the half with the
   face in it: Pitchfork's 3886×5830 press shot scaled to 676 wide is 1014px
   tall in a 380px box, so centring showed 31%–69% of the frame, which was a
   chest. Reported 2026-08-12 as "it isn't centered correctly" — it was centred,
   and that was the fault.
   28% is safe for the landscape photographs that are most of the feed rather
   than a trade against them: a 750×422 news picture scales to 380.4px in the
   same box, so there is under a pixel of crop to position and this is a no-op.
   It only bites where there is a lot to throw away, which is exactly the case
   where the subject is up top. */
.story.lead .lead-art img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: 50% 28%;
}

.story.lead .lead-body { min-width: 0; }
.story.lead .headline a {
  font-size: clamp(21px, 2.4vw, 34px);
  line-height: 1.1;
  font-weight: 700;
  letter-spacing: -0.015em;
}

.story.lead .lead-meta {
  display: flex;
  align-items: baseline;
  gap: 8px;
  margin-top: 8px;
}
.story.lead .lead-meta .age { text-align: left; }

/* A lead shows its summary whether or not the summaries toggle is on — a big
   headline with nothing under it is just a big row. Single column: it sits in
   half the page now rather than across it, which is already a standfirst
   measure without being split again. */
.story.lead .summary {
  display: block;
  margin-top: 9px;
  font-size: 13.5px;
  line-height: 1.5;
  max-width: 62ch;
}

/* There was a `.story.second` weight here — two or three headlines in a column
   beside the lead. It went on 2026-08-10: a middle weight between a hero and a
   brief costs a whole column of width to say what the ranking already says by
   order, and it pushed the columns further down the page. */

/* ---------- new releases ---------- */

/* A strip of things that just came out, above a section's headlines. Not a
   story list and styled so it doesn't read as one: no ages, no source chips,
   and in the music case the sleeve carries the row. See lib/releases.js. */
.releases {
  margin: 0 0 12px;
  padding: 8px 10px 10px;
  background: var(--bg-alt);
  border: 1px solid var(--line);
  border-radius: 4px;
}
.releases-head {
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: 4px 8px;
  margin-bottom: 7px;
}
.releases-label {
  font-size: 10px;
  letter-spacing: 0.14em;
  font-weight: 700;
  color: var(--accent-2);
}
.releases-note { font-size: 10px; color: var(--fg-faint); }
.releases-more { display: inline-block; margin-top: 8px; font-size: 10.5px; color: var(--fg-dim); }
.releases-empty { font-size: 10.5px; color: var(--fg-faint); padding: 6px 0 2px; }

/* The strip's tabs — Vinyl · CD · Preorders · Picks.

   These are real <button>s and they deliberately keep the shared button styling
   rather than inventing their own: the selected one is `.primary`, which is the
   same class the refresh button wears. That is what makes them free across the
   themes. An underline-tab treatment was tried first and lost to punk's
   `[data-theme^="punk"] button { border: 2px solid var(--outline) }` shorthand,
   which is exactly the "if you're writing a rule scoped to one theme's name,
   you want a variable instead" trap — except here the answer wasn't a variable,
   it was to stop having a second idea about what a button looks like.

   This row WRAPS, unlike the jump bar it otherwise resembles. That bar can't,
   because --nav-h is a pinned number every sticky offset on the page answers to
   and a second line would make it a lie. Nothing measures this strip, so a
   fifth tab falling onto a second line inside a ~430px column costs a few
   pixels of card height and no correctness. */
.release-tabs {
  display: flex;
  flex-wrap: wrap;
  gap: 4px;
  margin: 0 0 9px;
}
.release-tab {
  display: flex;
  align-items: baseline;
  gap: 5px;
  font-size: 10px;
  letter-spacing: 0.1em;
  padding: 2px 8px;
}
.release-tab-count {
  letter-spacing: 0;
  color: var(--fg-faint);
  font-variant-numeric: tabular-nums;
}
/* On the filled chip the count has to come off --fg-faint, which is a
   background-relative colour and disappears into the accent. */
.release-tab.primary .release-tab-count { color: inherit; opacity: 0.7; }

.release-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(84px, 1fr));
  gap: 10px 8px;
}
/* Two columns as soon as two readable ones fit, and one when they don't.
   auto-fill rather than a literal 2 because the strip must not need to know how
   wide its section came out — the news grid is itself auto-fill, so that width
   moves with the viewport and with whether the rail is showing.

   The floor is 175px and the arithmetic is tight enough to be worth writing
   down. A news column is ~430px at 2545px wide; the section spends 2×--pad on
   it and the strip another 2×10 of its own padding plus its border, leaving
   ~380. Two columns and a 12px gutter need 362 of that. It fitted at 185px in
   every mockup and not on the page, which is where the first attempt went: the
   rows silently stayed one-up and looked like the CSS hadn't deployed.

   This is what lets the strip carry a whole slate rather than the first eight
   and a link to the rest. Twenty films are ten rows tall here. */
.release-list {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(175px, 1fr));
  gap: 6px 12px;
}

.release { display: block; color: var(--fg); }
.release:hover { text-decoration: none; }
.release:hover .release-title { color: var(--link-hover); text-decoration: underline; }

/* Both are spans inside an <a> — they carry block children, so they have to
   become blocks themselves rather than staying inline. */
.release-body { display: block; min-width: 0; }
.release-art { display: block; overflow: hidden; border-radius: 2px; background: var(--bg); }
.release-art img { display: block; width: 100%; height: 100%; object-fit: cover; }

.release-byline {
  display: block;
  font-size: 10.5px;
  line-height: 1.3;
  color: var(--accent);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
/* Two lines then ellipsis: "JINX [Indie Exclusive Sky Blue/Aqua]" is a normal
   length for a record and would otherwise set the height of the whole row. */
.release-title {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  overflow: hidden;
  font-size: 11.5px;
  line-height: 1.3;
}
.release-meta {
  display: block;
  font-size: 10px;
  color: var(--fg-faint);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.release-grid .release-art { aspect-ratio: 1; margin-bottom: 4px; }

.release-list .release { display: grid; grid-template-columns: 34px minmax(0, 1fr); gap: 0 8px; }
/* Pinned to the second column rather than merely following the art, so a
   thumbnail that fails to load leaves a gap instead of sliding its row left
   out of line with the others. */
.release-list:not(.flat) .release-body { grid-column: 2; }
/* Nothing in this block has art at all — take the column back. */
.release-list.flat .release { grid-template-columns: minmax(0, 1fr); }
.release-list .release-art { aspect-ratio: 1; }
.release-list .release-title { font-size: 12px; }

/* ---------- events ---------- */

/* Today down the left, the rest of the week in columns beside it.

   This was a 340px rail pinned next to the news with eight rows in it, and the
   other six days behind a <dialog>. Both were workarounds for not having room.
   Given a page there is no cap and no overlay — everything the payload holds is
   on screen at once. */
.events { min-width: 0; }
.events h2 { color: var(--accent-2); }

.events-page {
  display: grid;
  grid-template-columns: minmax(0, 1.15fr) minmax(0, 3fr);
  gap: 0 26px;
  align-items: start;
}
/* Today is the column a reader came for, so it gets the rule and the weight. */
.today-col {
  border-right: 1px solid var(--line);
  padding-right: 22px;
}
.week-cols {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
  gap: 0 18px;
  align-items: start;
}
/* Said quietly and in place, rather than spending eight rows on things nobody
   can go to any more — but still there, because they did happen. */
.events-earlier {
  color: var(--fg-faint);
  font-size: 10.5px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  border-top: 1px solid var(--line);
  margin: 10px 0 4px;
  padding-top: 5px;
}

.day-label {
  color: var(--fg-dim);
  font-size: 11px;
  letter-spacing: 0.08em;
  border-bottom: 1px solid var(--line);
  padding-bottom: 2px;
  margin-bottom: 3px;
}
.day-label.today { color: var(--hot); }

.event { display: grid; grid-template-columns: 62px 1fr; gap: 0 8px; padding: 2px 0; }
.event .time { color: var(--fg-faint); font-size: 11px; text-align: right; font-variant-numeric: tabular-nums; }
.event .venue { color: var(--fg-dim); font-size: 11px; }
.event .price { color: var(--accent-2); font-size: 11px; }
.event .support { color: var(--fg-faint); font-size: 10.5px; }
.event .repeats { color: var(--fg-faint); font-size: 10.5px; white-space: nowrap; }
/* One list now holds both kinds, so the row carries the distinction the two
   panel headings used to. A glyph rather than colour alone — it has to survive
   a colour-blind reader and a greyscale print. */
.event .kind { color: var(--accent); font-size: 11px; }
/* Already started. Today's list runs from midnight, so by evening most of it
   has happened; dimming is the difference between a count and a promise. */
.event.past { opacity: 0.45; }

/* ---------- the week's day columns ---------- */

.events-empty { color: var(--fg-faint); font-size: 11.5px; padding: 4px 0; }

.weekday { min-width: 0; padding-bottom: 14px; }
.weekday-label {
  display: flex;
  align-items: baseline;
  gap: 6px;
  padding-bottom: 3px;
  margin-bottom: 4px;
  border-bottom: 1px solid var(--line);
}
.weekday-name { font-size: 11px; letter-spacing: 0.08em; color: var(--fg-dim); }
.weekday-num { font-size: 10.5px; color: var(--fg-faint); }
.weekday-count { font-size: 10.5px; color: var(--fg-faint); margin-left: auto; }
.weekday-empty { color: var(--fg-faint); font-size: 11px; padding-top: 4px; }
/* Today's rows are 62px of right-aligned clock against a wide column; in a
   180px week column that would be a third of the width. */
.week-cols .event { grid-template-columns: 46px 1fr; }

/* ---------- states ---------- */

.empty, .loading { color: var(--fg-faint); padding: 20px var(--pad); }

.errbar {
  padding: 6px var(--pad);
  background: var(--bg-alt);
  border-bottom: 1px solid var(--line);
  color: var(--fg-faint);
  font-size: 11px;
}
.errbar b { color: var(--hot); font-weight: 400; }

footer {
  padding: 14px var(--pad) 28px;
  color: var(--fg-faint);
  font-size: 11px;
  line-height: 1.7;
}
footer a { color: var(--fg-dim); }

/* ---------- the theme picker ----------

   Five tiles, each a miniature front page wearing its own palette.

   **The tiles are the real thing, not painted swatches.** `data-theme` on
   `.tp-paper` pulls in that theme's entire palette block — every colour, both
   font stacks and `color-scheme` — because the palettes are selected with a
   bare `[data-theme=…]` rather than `:root[data-theme=…]`. So a tile cannot
   drift from what it advertises, and everything below is written once against
   the variables rather than five times against five sets of hex codes. The same
   property is why `test-contrast.mjs` parses the palettes out of this file
   instead of restating them.

   Everything inside a tile is `tp-`-prefixed and that is load-bearing. The
   decoration blocks are descendant selectors on the themed element —
   `[data-theme^="punk"] .chip`, `.story`, `.masthead` — so a tile that reused a
   real class name would pull the real page's layout into a 190px box. It is the
   `chip-events` collision one level further in. */
#theme-picker {
  border: 1px solid var(--line);
  border-radius: 4px;
  background: var(--bg);
  color: var(--fg);
  padding: 16px;
  /* `width`, not `max-width`. A <dialog> is `width: fit-content`, and a
     fit-content box around `repeat(auto-fit, minmax(190px, 1fr))` resolves to a
     SINGLE column — the grid asks for one 190px track, the dialog shrinks to
     it, and auto-fit then has no room to fit anything more. The result is five
     tiles stacked in a 288px-wide, 941px-tall ribbon, which looks like a broken
     grid and is really a circular measurement. Stating the width breaks the
     circle: the grid is handed a width and fills it.
     1040 = 5 × 190 tiles + 4 × 12 gaps + 2 × 16 padding, so the five sit on one
     row at full size. A sixth theme wraps rather than squeezing, which is
     auto-fit doing its job. */
  width: min(92vw, 1040px);
  /* The dialog is centred by the UA; this only stops a short viewport clipping
     the last row of tiles rather than scrolling it. */
  max-height: 86vh;
  overflow: auto;
}
/* Tinted rather than opaque, because hovering a tile repaints the LIVE page
   behind this and the reader has to be able to see it happen. Plain black at a
   low alpha works over every palette here — a themed backdrop would need a
   variable that only this element would ever set. */
#theme-picker::backdrop { background: rgba(0, 0, 0, 0.45); }

.tp-head {
  display: flex;
  align-items: baseline;
  gap: 12px;
  margin-bottom: 14px;
}
.tp-title {
  flex: 1 1 auto;
  margin: 0;
  font-size: 13px;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--fg-dim);
}
.tp-close {
  flex: 0 0 auto;
  font-size: 13px;
  line-height: 1;
}

/* auto-fit, so the row count follows the width rather than being declared: five
   across on a wide dialog, two or three on a laptop, one on a phone. 190px is
   the floor at which the miniature still reads as a page — below it the chip
   row and the rules stop being distinguishable from noise. */
.tp-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
  gap: 12px;
}

.tp-tile {
  display: block;
  padding: 0;
  border: 1px solid var(--line);
  border-radius: 3px;
  background: transparent;
  overflow: hidden;
  text-align: left;
  cursor: pointer;
  /* The base `button, .btn` rule sets `white-space: nowrap`, which is right for
     a control whose label must not break and wrong for a card containing a
     headline: inherited, it ran the miniature's lead off the side of the tile
     as one clipped line instead of wrapping to two. Restated here rather than
     excluded up there, because nowrap is correct for every actual button and
     this is the exception. Same trade as the punk pill radius, from the other
     end — there the shared rule took the `:not()`, because a lozenge is wrong
     for a card in a way no local override could undo. */
  white-space: normal;
}
.tp-tile:hover, .tp-tile:focus-visible { border-color: var(--accent); }
/* The theme in force. Ringed rather than merely tinted, and the plate says the
   word too — this is selection state, so it has to survive a colour-blind
   reader the same way `.chip.current` carries an underline as well as a hue. */
.tp-tile.tp-on { border-color: var(--accent); box-shadow: inset 0 0 0 2px var(--accent); }
.tp-tile.tp-on .tp-name::after { content: " ✓"; }

/* ----- the miniature ----- */

/* Everything from here down resolves against the TILE's palette, not the
   page's. `--bg` here is newzpaper's cream inside the newzpaper tile and punk's
   pink inside the punk one, with no rule naming either. */
.tp-paper {
  display: block;
  padding: 9px 10px 11px;
  background: var(--bg);
  color: var(--fg);
  font-family: var(--body-font);
  /* A fixed height so five tiles are one row of equal cards whatever their type
     sizes do — Times and Arial Black do not set the same number of lines. */
  height: 132px;
  overflow: hidden;
}
.tp-brand {
  display: block;
  font-family: var(--display, var(--body-font));
  /* 13px suited a four-letter name. Nineteen characters at that size is wider
     than the 190px tile, and the shared `button` rule's nowrap would run it off
     the edge as one clipped line rather than wrapping — the same trap the
     miniature's headline hit. A nameplate that takes two lines is what a real
     one does, so it is allowed to. */
  font-size: 11px;
  white-space: normal;
  text-transform: uppercase;
  font-weight: 700;
  letter-spacing: 0.06em;
  color: var(--fg);
  line-height: 1.1;
}
.tp-brand-date {
  margin-left: 4px;
  font-family: var(--body-font);
  font-size: 7px;
  font-weight: 400;
  text-transform: none;   /* the miniature's date, like the real one, isn't shouted */
  letter-spacing: 0;
  color: var(--fg-dim);
}
.tp-nav {
  display: flex;
  gap: 6px;
  margin-top: 6px;
  padding-bottom: 4px;
  border-bottom: 1px solid var(--line);
  font-size: 5.5px;
  letter-spacing: 0.1em;
  color: var(--fg-dim);
  white-space: nowrap;
  overflow: hidden;
}
.tp-current { color: var(--accent); }
.tp-chip-ev { color: var(--accent-2); }
.tp-bar {
  display: flex;
  align-items: center;
  gap: 6px;
  margin-top: 6px;
}
.tp-temp {
  font-size: 15px;
  line-height: 1;
  color: var(--fg);
}
/* The one element in the miniature that has to shout, for the same reason it
   does at full size: an alert is the thing most likely to change an afternoon,
   and it is also the only place a palette's --hot is on show. */
.tp-alert {
  padding: 1px 4px;
  border: 1px solid var(--hot);
  border-radius: 2px;
  font-size: 5.5px;
  color: var(--hot);
  white-space: nowrap;
}
/* Clamped at two lines, because `.tp-paper` is a fixed 132px and the tiles have
   to be one row of equal cards. The five typefaces set this same sentence in
   between 175px and 232px of track, so it is one line in Times and two in Arial
   Black — a third line would push the ruled column out of the paper in some
   themes and not others, which is the ragged-cards look the fixed height exists
   to prevent. */
.tp-lead {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  overflow: hidden;
  margin-top: 7px;
  font-family: var(--display, var(--body-font));
  font-size: 9px;
  font-weight: 700;
  line-height: 1.15;
  color: var(--fg);
}
/* Ruled lines rather than dummy text: at 5px real words are noise, and what is
   being judged here is colour, rule weight and the headline's typeface. The
   ragged last line is what makes them read as a column of type rather than as a
   bar chart. */
.tp-rows {
  display: block;
  margin-top: 7px;
}
.tp-row {
  display: block;
  height: 2px;
  margin-bottom: 3px;
  background: var(--fg-dim);
  opacity: 0.32;
}
.tp-row:last-child { width: 62%; }

/* ----- the name plate -----
   In the PAGE's theme, not the tile's. Labelled in its own colours each tile
   reads as an unrelated card; labelled in the reader's, the five read as one
   picker offering five looks. */
.tp-plate {
  display: flex;
  align-items: baseline;
  gap: 6px;
  padding: 7px 9px;
  border-top: 1px solid var(--line);
  background: var(--bg-alt);
}
.tp-name {
  font-size: 11px;
  letter-spacing: 0.06em;
  color: var(--fg);
}
.tp-note {
  font-size: 9.5px;
  color: var(--fg-faint);
}

/* ---------- the place picker ----------

   Which town the weather bar reports on. Shares the theme picker's dialog
   chrome (`.tp-head`, `.tp-title`, `.tp-close`) because it is the same object —
   a modal list of alternatives with one in force — and having two ideas about
   what that looks like is the trap `.release-tabs` already documents. */
#place-picker {
  border: 1px solid var(--line);
  border-radius: 4px;
  background: var(--bg);
  color: var(--fg);
  padding: 16px;
  width: min(92vw, 320px);
  max-height: 86vh;
  overflow: auto;
}
#place-picker::backdrop { background: rgba(0, 0, 0, 0.45); }

.pp-list { display: grid; gap: 4px; }
.pp-row {
  display: block;
  width: 100%;
  text-align: left;
  padding: 6px 10px;
  font-size: 12px;
  letter-spacing: 0.04em;
  cursor: pointer;
}
.pp-row:hover, .pp-row:focus-visible { border-color: var(--accent); color: var(--fg); }
.pp-on {
  border-color: var(--accent);
  color: var(--accent);
  box-shadow: inset 0 0 0 2px var(--accent);
}
.pp-on .pp-name::after { content: " ✓"; }

.pp-foot {
  margin-top: 12px;
  padding-top: 10px;
  border-top: 1px solid var(--line);
}
.pp-locate { font-size: 11px; display: inline-flex; align-items: baseline; gap: 6px; }
.pp-locate-mark { font-size: 12px; }
/* Whatever the geolocation attempt has to say — how far the nearest town came
   out, or why it failed. Empty until something happens, and cleared on open, so
   it is never a stale answer to a question the reader hasn't asked yet. */
.pp-note {
  margin-top: 7px;
  font-size: 10.5px;
  line-height: 1.5;
  color: var(--fg-dim);
  min-height: 1em;
}
.pp-note:empty { display: none; }

/* ----- the town, in the weather bar -----
   First thing in `.wx-main`, above the temperature. `flex: 0 0 auto` and a
   nowrap name, because `.weather` is the one component on the page where
   nothing may grow — see the note on that rule. */
.wx-place {
  display: inline-flex;
  align-items: baseline;
  gap: 4px;
  flex: 0 0 auto;
  align-self: flex-start;
  margin-bottom: 2px;
  padding: 1px 6px;
  font-size: 9.5px;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--fg-dim);
  background: transparent;
  border-color: transparent;
  white-space: nowrap;
  cursor: pointer;
}
.wx-place:hover, .wx-place:focus-visible { color: var(--fg); border-color: var(--line); }
.wx-place-caret { font-size: 8px; }

/* In the compact line it is text, not a control — see compactWeather. */
.wx-c-place {
  font-size: 10px;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--fg-dim);
  white-space: nowrap;
}

/* ---------- responsive ---------- */

/* Below this the lead's picture and its headline would each be too narrow to
   be either, so the hero stacks: art above, text under it. The art keeps its
   max-height, so a stacked hero is capped rather than filling the viewport the
   way an uncapped 16/9 across the page would.

   The events page goes the same way — today stops being a column beside the
   week and becomes the thing above it. */
@media (max-width: 1000px) {
  .story.lead, .events-page { grid-template-columns: minmax(0, 1fr); }
  .story.lead .lead-art { margin-bottom: 10px; max-height: 300px; }
  .today-col {
    border-right: 0;
    padding-right: 0;
    border-bottom: 1px solid var(--line);
    padding-bottom: 14px;
    margin-bottom: 14px;
  }
}

/* The locked bar stops being two ranged ends and becomes two stacked rows: at
   this width there is not enough room for a readable brief AND the weather
   beside it, and squeezing both gives a two-word brief next to a squashed
   forecast. Stacked, the brief is clamped harder to keep the bar's full height
   sane on a small screen.

   **Both clamps are lower here and neither tracks the wide bar's.** Collapsed is
   3 rather than 6, and open is 12 rather than 16: a phone's brief runs the full
   width so a line holds more, and sixteen lines of 12px text is most of a screen
   before the reader reaches a headline. Truncation is the intended default at
   this width rather than the oversight it was at full width — and since
   `.brief-more` is rendered on the same rule everywhere, it is a choice here
   too. That is what makes the harder default defensible: the rest is one tap
   away rather than gone. */
@media (max-width: 760px) {
  #lockbar {
    display: block;
  }
  #brief-slot { max-width: none; }
  .brief { padding-right: var(--pad); }
  .brief-text { -webkit-line-clamp: 3; line-clamp: 3; }
  body.brief-open:not(.wx-stuck) .brief-text { -webkit-line-clamp: 12; line-clamp: 12; }
  .weather { justify-content: center; padding: 8px var(--pad); border-top: 1px solid var(--line); }
  /* Compact is still one line, so the stack has to collapse back to a row or
     the two halves would be 2 × --wx-h tall. */
  body.wx-stuck #lockbar { display: flex; }
  body.wx-stuck .weather { border-top: 0; }
}

@media (max-width: 760px) {
  /* 2px less masthead padding, so --head-h drops with it and everything sticky
     follows without repeating the number. */
  :root { --pad: 11px; --head-h: 39px; }
  body { font-size: 13.5px; }
  .masthead { gap: 8px; padding: 8px var(--pad); }
  .meta.wide { display: none; }
  /* "Tue Aug 11" instead of "Tuesday August 11th, 2026". The brand is nowrap
     inside a wrapping masthead, so the full date would push the filter and the
     three buttons onto a second row — and --head-h, which every sticky offset
     on the page is derived from, grows by a whole row on every page. The year
     is the part nobody needed on a phone anyway. */
  .bd-full { display: none; }
  .bd-short { display: inline; }
  #filter { width: 110px; }
  .brief { flex-direction: column; gap: 4px; }
  .story { grid-template-columns: 40px 1fr; }
  /* One column of briefs, and no rule between columns that no longer exist. */
  .briefs { columns: auto; column-rule: none; }
  .page-title { font-size: 15px; }
}

/* The brief is the first thing to go from the compact line. A phone has room
   for the weather or for a three-word fragment of a paragraph, not both, and
   the temperature and the hazard are what the bar is locked for. */
@media (max-width: 620px) {
  body.wx-stuck #brief-slot { display: none; }
}

/* The compact bar is a single non-wrapping line, and a phone is where it is
   most likely to run out of room. Rather than let it wrap — which would make
   --wx-h a lie and leave a gap under it — the least load-bearing pieces drop
   in order: the range first, then the condition, then the date. What survives
   at any width is the temperature and the hazard. */
@media (max-width: 700px) {
  body.wx-stuck .wx-c-range { display: none; }
}
@media (max-width: 540px) {
  body.wx-stuck .wx-c-cond { display: none; }
}
@media (max-width: 420px) {
  /* The date used to be dropped here too, as the compact line's third
     casualty. It is in the masthead now — which is sticky directly above this
     line and so always on screen — and never entered the compact bar at all. */
  body.wx-stuck .wx-compact .wx-alert-when { display: none; }
}

@media (prefers-reduced-motion: no-preference) {
  .refreshing { animation: pulse 1s ease-in-out infinite; }
  @keyframes pulse { 50% { opacity: 0.45; } }
}

/* ---------- pop-punk decoration (both variants) ---------- */

/* Everything here is scoped to the theme and lives after the media queries, so
   the two terminal themes never see it and it wins where it needs to.

   Borders are overridden as `border-*-width` rather than the shorthand,
   deliberately. The responsive rules above turn borders off with
   `border-right: none` / `border-left: none`, which sets *style*; setting only
   width here means those switch-offs still hold and the chunky borders don't
   come back on a phone. */

[data-theme^="punk"] {
  /* Pinned rather than measured: the brand becomes a bordered sticker, which
     is taller than the buttons it sits beside, and every sticky heading
     offsets by this. min-height on .masthead below makes it true. */
  --head-h: 50px;
  /* Same again for the page bar — the chips gain a 2px outline and a pill
     radius here, so they don't fit the terminal themes' 32px. --stick-h is
     derived from all three and follows on its own. */
  --nav-h: 38px;
  /* And for the compact weather line, whose hazard and AQI chips wear the same
     2px outline. Pinned by height on .weather below, like the other two. */
  --wx-h: 38px;
}

/* min-height still pins the SINGLE-row case, which is what the sticker brand
   needs; syncHeadHeight in app.js measures whatever the bar actually comes out
   at and writes --head-h from it, so the two agree at every width. Note it
   clears the property before measuring precisely so this floor can't ratchet
   the measurement upwards — see the comment there before changing either. */
[data-theme^="punk"] .masthead {
  min-height: 50px;
  align-items: center;
  border-bottom-width: 2px;
}

/* Hazard tape. Inside the padding box rather than below the border, so it
   costs no height and --head-h stays honest. */
[data-theme^="punk"] .masthead::after {
  content: '';
  position: absolute;
  left: 0; right: 0; bottom: 0;
  height: 5px;
  background: repeating-linear-gradient(
    -45deg, var(--pop) 0 9px, var(--pop-2) 9px 18px);
}

[data-theme^="punk"] .brand {
  font-family: var(--display);
  font-size: 15px;
  letter-spacing: 0.04em;
  color: var(--btn-fg);
  background: var(--accent);
  border: 2px solid var(--outline);
  border-radius: 4px;
  padding: 3px 10px;
  box-shadow: var(--shadow);
  /* Transforms don't affect layout, so the tilt can't push the masthead
     around or upset the flex baseline. */
  transform: rotate(-2deg);
}
/* The tagline wants to be --pop-3 yellow and can't be: yellow on either
   accent pink is 2–3.4:1. It reads as decoration but it's still words. */
[data-theme^="punk"] .brand span { color: var(--btn-fg); font-weight: 700; }

/* Numbers keep the monospace: the age and time columns are fixed-width tracks
   and the tabular figures are what keep them looking like a printed schedule. */
[data-theme^="punk"] .story .age,
[data-theme^="punk"] .event .time,
[data-theme^="punk"] .meta { font-family: var(--mono); }

/* ----- controls: chunky, and they press down ----- */

/* `:not(.tp-tile)` throughout: a theme-picker tile is a <button> for its
   semantics — click, Enter, Space and focus all for free — but it is a card,
   not a control. Left in, the pill radius and the 12px side padding wrap a
   190px miniature front page in a lozenge, and the yellow hover fill paints
   over the very palette the tile exists to show. The tile takes punk's card
   treatment instead, further down, which is the same hard outline and shadow
   `.releases` wears. Same shape of exclusion as `.story:not(.lead)`. */
[data-theme^="punk"] button:not(.tp-tile),
[data-theme^="punk"] .btn,
[data-theme^="punk"] #filter {
  border: 2px solid var(--outline);
  border-radius: 999px;
  padding: 3px 12px;
  font-weight: 700;
}
[data-theme^="punk"] button:not(.tp-tile) { box-shadow: var(--shadow); }
/* Yellow fill in both variants, so the label is ink in both. */
[data-theme^="punk"] button:not(.tp-tile):hover:not(:disabled) {
  background: var(--pop-3);
  border-color: var(--outline);
  color: var(--ink);
}
/* --pop is bright enough that white sits on it at 3.5:1; ink gets 5.3:1. */
[data-theme^="punk"] button.primary:hover:not(:disabled) {
  background: var(--pop);
  color: var(--ink);
}
[data-theme^="punk"] button:not(.tp-tile):active:not(:disabled) {
  transform: translate(2px, 2px);
  box-shadow: none;
}

/* ----- the jump bar ----- */

[data-theme^="punk"] .secnav { border-bottom-width: 2px; gap: 7px; }

[data-theme^="punk"] .chip {
  font-family: var(--display);
  font-size: 10px;
  letter-spacing: 0.06em;
  border: 2px solid var(--outline);
  border-radius: 999px;
  padding: 2px 10px;
  background: var(--bg-alt);
  color: var(--fg);
}
[data-theme^="punk"] .chip .chip-count { color: var(--fg-dim); }
[data-theme^="punk"] .chip:hover,
[data-theme^="punk"] .chip:focus-visible {
  background: var(--pop-3);
  border-color: var(--outline);
}
[data-theme^="punk"] .chip:hover,
[data-theme^="punk"] .chip:hover .chip-count,
[data-theme^="punk"] .chip:focus-visible,
[data-theme^="punk"] .chip:focus-visible .chip-count { color: var(--ink); }

/* Filled instead of merely recoloured — at 10px in a heavy face, a colour
   swap alone reads as a different font rather than a different state. The
   inset underline the terminal themes use is redundant against a solid fill,
   so it comes off rather than drawing a second line inside the sticker. */
[data-theme^="punk"] .chip.current,
[data-theme^="punk"] .chip.current .chip-count {
  background: var(--accent);
  color: var(--btn-fg);
  box-shadow: var(--shadow);
}
[data-theme^="punk"] .chip-events,
[data-theme^="punk"] .chip-events .chip-count { color: var(--accent-2); }
[data-theme^="punk"] .chip-events.current,
[data-theme^="punk"] .chip-events.current .chip-count {
  background: var(--accent-2);
  color: var(--btn-fg);
}

/* ----- page mastheads as stickers ----- */

/* The page title is the paper's section rule, so it gets the sticker the
   section headings used to wear. It is no longer sticky, which means it can be
   tilted directly rather than needing a square opaque parent to hold a
   background true while rows scroll under it. */
[data-theme^="punk"] .page-head {
  border-bottom-width: 3px;
  align-items: center;
}
[data-theme^="punk"] .page-title {
  font-family: var(--display);
  font-size: 17px;
  letter-spacing: 0.05em;
  color: var(--btn-fg);
  background: var(--accent);
  border: 2px solid var(--outline);
  border-radius: 4px;
  padding: 3px 12px;
  box-shadow: var(--shadow);
  transform: rotate(-1.2deg);
}
[data-theme^="punk"] .page-events .page-title { background: var(--accent-2); }
[data-theme^="punk"] .page-count { font-weight: 700; color: var(--fg-dim); }

/* The release strips' own headings, which are still h2s inside a page. */
[data-theme^="punk"] .events h2 {
  padding: 7px 0 9px;
  align-items: center;
}
[data-theme^="punk"] .events h2 .label {
  font-family: var(--display);
  font-size: 13px;
  letter-spacing: 0.07em;
  color: var(--btn-fg);
  background: var(--accent-2);
  border: 2px solid var(--outline);
  border-radius: 4px;
  padding: 2px 10px;
  box-shadow: var(--shadow);
  transform: rotate(-1.2deg);
}
[data-theme^="punk"] .events h2 .count { font-weight: 700; color: var(--fg-dim); }

[data-theme^="punk"] .page-top { border-bottom-width: 3px; }
[data-theme^="punk"] .today-col { border-right-width: 3px; }
[data-theme^="punk"] .briefs { column-rule-width: 2px; }

/* ----- story rows ----- */

/* `:not(.lead)` because this negative margin is about DENSE ROWS: it pulls a
   row 4px outboard so its rounded hover background bleeds to the gutter instead
   of stopping short of it. The lead is not a row in a column — it is a centred
   block with `margin-inline: auto`, and this rule outranks that ( (0,2,1) over
   (0,2,0) ) and is written later, so unexcluded it silently un-centres the hero
   on the two punk themes and nowhere else. That is a nasty one to see: the
   layout is correct in newzpaper and off by half the page in punk. */
[data-theme^="punk"] .story:not(.lead) {
  border-radius: 4px;
  padding: 3px 4px;
  margin: 0 -4px;
}
[data-theme^="punk"] .story:hover {
  background: var(--row-hover);
  border-bottom-color: transparent;
}
[data-theme^="punk"] .story .headline a { font-weight: 600; }

/* Highlighter pen over the last 90 minutes. The band sits under the baseline
   rather than through the glyphs, which is what lets one rule serve both
   variants: struck across the text it would need dark type to stay readable,
   and blacklight's headlines are near-white. box-decoration-break so a headline
   that wraps gets the stripe on both lines instead of one stretched box. */
[data-theme^="punk"] .story.fresh .headline a {
  background-image: linear-gradient(
    transparent 82%, var(--pop-3) 82%, var(--pop-3) 96%, transparent 96%);
  -webkit-box-decoration-break: clone;
  box-decoration-break: clone;
}
[data-theme^="punk"] .story.fresh .age { font-weight: 700; }

/* ----- lead story + releases ----- */

/* The lead is the one headline with room for the display face. Kept a size
   below the section stickers so it leads its column without competing with
   the label above it. */
[data-theme^="punk"] .story.lead { border-bottom: 2px dashed var(--line); }
[data-theme^="punk"] .story.lead .headline a {
  font-family: var(--display);
  font-size: 16px;
  font-weight: 400;
  letter-spacing: 0.005em;
}
[data-theme^="punk"] .story.lead .lead-art {
  border: 2px solid var(--outline);
  box-shadow: var(--shadow);
}
/* The highlighter would sit under a display face twice its usual size and
   read as a printing error rather than a mark. The hot timestamp already
   says "just in" on a lead. */
[data-theme^="punk"] .story.lead.fresh .headline a { background-image: none; }

[data-theme^="punk"] .releases {
  background: var(--paper);
  border: 2px solid var(--outline);
  box-shadow: var(--shadow);
  background-image: radial-gradient(var(--dot) 1.5px, transparent 1.5px);
  background-size: 14px 14px;
}
[data-theme^="punk"] .releases-label {
  font-family: var(--display);
  font-size: 11px;
  letter-spacing: 0.05em;
  color: var(--btn-fg);
  background: var(--accent-2);
  border-radius: 3px;
  padding: 2px 8px;
  transform: rotate(-1.2deg);
}
[data-theme^="punk"] .release-art { border: 2px solid var(--outline); }
[data-theme^="punk"] .release-byline { font-weight: 700; }
[data-theme^="punk"] .release-title { font-weight: 600; }

/* ----- weather + brief ----- */

[data-theme^="punk"] .weather { border-bottom-width: 2px; }
[data-theme^="punk"] .wx-temp {
  font-family: var(--display);
  font-size: 30px;
  color: var(--accent);
}
[data-theme^="punk"] .wx-aqi,
[data-theme^="punk"] .wx-alert {
  border-width: 2px;
  border-radius: 999px;
  box-shadow: var(--shadow);
  font-weight: 700;
}
[data-theme^="punk"] .wx-day-name { font-weight: 700; color: var(--fg-dim); }

/* The newsprint goes on the whole locked bar, not on the brief inside it.
   The brief used to be a band of its own and carried the paper and the dots
   itself; now that it is one half of the bar, a filled panel sitting inside a
   filled bar reads as two bars stacked. Same treatment, one level up. */
[data-theme^="punk"] #lockbar {
  background: var(--paper);
  border-bottom-width: 2px;
  background-image: radial-gradient(var(--dot) 1.5px, transparent 1.5px);
  background-size: 14px 14px;
}
[data-theme^="punk"] .weather { background: transparent; }
[data-theme^="punk"] .brief-label {
  font-family: var(--display);
  font-size: 11px;
  color: var(--btn-fg);
  background: var(--accent-2);
  border-radius: 3px;
  padding: 3px 8px;
  transform: rotate(-1.5deg);
  align-self: flex-start;
}
[data-theme^="punk"] .brief-text { color: var(--fg); }

/* ----- events ----- */

[data-theme^="punk"] .day-label {
  font-family: var(--display);
  font-size: 12px;
  color: var(--fg);
  border-bottom: 2px dashed var(--line);
  padding-bottom: 3px;
}
[data-theme^="punk"] .day-label.today { color: var(--hot); }

[data-theme^="punk"] .weekday-label { border-bottom-width: 2px; }
[data-theme^="punk"] .weekday-name { font-family: var(--display); color: var(--fg); }
[data-theme^="punk"] .events-earlier { font-weight: 700; border-top-width: 2px; }

[data-theme^="punk"] .event .price {
  background: var(--pop-2);
  color: var(--ink);
  border: 1.5px solid var(--outline);
  border-radius: 999px;
  padding: 0 7px;
  font-weight: 700;
  white-space: nowrap;
}

/* ----- states + footer ----- */

/* ----- the theme picker -----

   Two different scopes here and mixing them up is the trap.

   `[data-theme^="punk"] .tp-tile` is picker CHROME: it fires when the reader's
   page is punk and dresses all five tiles, which is right — the picker should
   look like the paper it is part of.

   `.tp-paper[data-theme^="punk"]` is the punk TILE's own miniature, and it has
   to name `.tp-paper` explicitly. Written the usual way as
   `[data-theme^="punk"] .tp-brand` it would also match every tile's brand
   whenever the PAGE is punk — because <html> carries a data-theme too — so all
   five miniatures would wear punk's sticker and the picker would advertise the
   same look five times. */
[data-theme^="punk"] .tp-tile { box-shadow: var(--shadow); }
[data-theme^="punk"] .tp-tile:active { transform: translate(2px, 2px); box-shadow: none; }

.tp-paper[data-theme^="punk"] .tp-brand {
  color: var(--btn-fg);
  background: var(--accent);
  border: 2px solid var(--outline);
  border-radius: 3px;
  padding: 2px 6px;
  box-shadow: var(--shadow);
  /* Inline-block so the sticker is the width of the word rather than the width
     of the tile, and so the tilt has a box to turn. */
  display: inline-block;
  transform: rotate(-2deg);
}
.tp-paper[data-theme^="punk"] .tp-brand-date { color: var(--btn-fg); opacity: 0.85; }
.tp-paper[data-theme^="punk"] .tp-current {
  background: var(--accent);
  color: var(--btn-fg);
  padding: 0 3px;
}
.tp-paper[data-theme^="punk"] .tp-nav { border-bottom-width: 2px; }
.tp-paper[data-theme^="punk"] .tp-alert { border-width: 2px; border-radius: 3px; font-weight: 700; }

[data-theme^="punk"] .errbar {
  background: var(--warn-bg);
  color: var(--fg);
  border-bottom-width: 2px;
  font-weight: 600;
}
[data-theme^="punk"] .errbar b { color: var(--hot); font-weight: 800; }

[data-theme^="punk"] footer {
  position: relative;
  padding-top: 20px;
  color: var(--fg-dim);
}
[data-theme^="punk"] footer::before {
  content: '';
  position: absolute;
  left: 0; right: 0; top: 0;
  height: 5px;
  background: repeating-linear-gradient(
    -45deg, var(--pop) 0 9px, var(--pop-2) 9px 18px);
}

@media (max-width: 760px) {
  /* The brand is the widest thing in the header and the tallest. Dropping the
     tagline keeps it on one line — a wrapped masthead would be taller than the
     --head-h every sticky heading is offsetting by. */
  [data-theme^="punk"] .brand span { display: none; }
  [data-theme^="punk"] .brand { font-size: 14px; }
}

/* ---------- newzpaper decoration ----------

   Everything here is scoped to the theme and lives after the media queries, so
   the other four never see it and it wins where it needs to.

   The rule this file works under is "a theme should be a palette block and
   nothing else — if you are scoping a rule to a theme's name, you probably want
   a variable". This block is the honest exception, and the test for it is
   whether a rule expresses something only THIS theme has an opinion about.
   Double rules, reverse-type section flags, small caps and a drop cap are
   newspaper grammar; there is no sensible `--rule-style` variable that punk or
   the terminal themes would ever set. Colour, type and geometry all stayed in
   the palette, which is where the claim actually matters. */

/* Times is a small face. Everything is set in px against the monospace and
   sans defaults, so the whole page scales up once here rather than each rule
   restating a size. */
/* ----- the stock -----

   Old newsprint: fibre, blotching, and darker at the edges where a sheet has
   been handled. All of it is layered CSS gradients — no image files, because
   the CSP has no img-src problem but a noise tile big enough to read would be a
   data: URI big enough to notice, and this costs nothing at all.

   `background-attachment: fixed` on the mottling is what stops the blotches
   swimming up the screen against the text as you scroll. It also means the
   texture is anchored to the VIEWPORT, which is why the sticky bars can sit on
   flat `--bg` without a visible seam — at these alphas the two are within a
   couple of values of each other anywhere on screen. */
[data-theme="newzpaper"] body {
  font-size: var(--paper-size);
  background-image:
    /* Blotches: five soft stains at low alpha, placed rather than random,
       because a repeating pattern at this size reads as a texture map and
       these read as paper. */
    radial-gradient(ellipse 42% 30% at 12% 16%, rgba(122, 96, 48, 0.085), transparent 62%),
    radial-gradient(ellipse 34% 26% at 84% 33%, rgba(122, 96, 48, 0.072), transparent 64%),
    radial-gradient(ellipse 46% 22% at 38% 71%, rgba(122, 96, 48, 0.078), transparent 62%),
    radial-gradient(ellipse 26% 30% at 70% 86%, rgba(104, 80, 38, 0.066), transparent 62%),
    radial-gradient(ellipse 30% 20% at 26% 47%, rgba(104, 80, 38, 0.055), transparent 66%),
    /* Fibre: two crossed hairline gratings, as before. */
    repeating-linear-gradient(0deg,  rgba(20, 17, 11, 0.030) 0 1px, transparent 1px 3px),
    repeating-linear-gradient(90deg, rgba(20, 17, 11, 0.022) 0 1px, transparent 1px 4px);
  background-attachment: fixed;
}

/* The vignette, and it is deliberately ABOVE the sticky bars rather than on the
   page behind them.
   The masthead, the section index and the locked bar are all opaque `--bg` and
   all three touch the top edge — exactly where a vignette painted underneath
   them would be hidden. Over the top, the whole window reads as one sheet with
   handled edges instead of three flat panels on a stained page. z-index 60
   clears the highest of them (the masthead at 20); the theme picker is a modal
   <dialog> and renders in the top layer, so it is unaffected by any of this. */
[data-theme="newzpaper"] body::after {
  content: "";
  position: fixed;
  inset: 0;
  /* Or it would eat every click on the page. */
  pointer-events: none;
  z-index: 60;
  background: radial-gradient(ellipse 80% 74% at 50% 46%,
                              transparent 52%, rgba(52, 40, 18, 0.17) 100%);
}

/* ----- the masthead ----- */

/* A blackletter nameplate centred on the page, the date ruled underneath it as
   a folio line, the edition time under that, and the controls ranged right on
   the nameplate's own row. That is the shape of every front page ever printed.

   It took the brand a full row (`flex: 1 0 100%`) for its first day, which put
   the controls on a third line of their own — correct, and one row taller than
   it needed to be. The grid below does it in two.

   **This works only because --head-h is MEASURED.** The masthead is nearly
   three times the height of the terminal themes' under this theme, and every
   sticky offset on the page is derived from it — the section index, the locked
   bar, the page top. A pinned number would have put the index behind the
   masthead, which is the exact bug the ResizeObserver was added for. Do not
   give this theme a --head-h. */
/* A three-column grid, not a flex row — the same move `.page-head` made and for
   the same reason. A centred nameplate with the controls ranged right cannot be
   done with flex without balancing the brand against a spacer exactly as wide
   as the controls, and "filter + three buttons" is not a fixed width: it changes
   with the theme's own button padding and with the filter's width at the 760px
   breakpoint. Give the outer columns equal `1fr` and the nameplate is centred on
   the page by construction, whatever is sitting beside it.

   `minmax(0, 1fr)` rather than a bare `1fr`: an `fr` track floors at its
   content's min-content width, so a wide toolbar would push the middle column
   off centre — exactly the failure this grid is here to prevent. The floor of 0
   lets the columns stay equal and the toolbar shrink instead.

   `align-items: start` puts the controls level with the top of the nameplate,
   which is what "aligned with the logo" means here. The brand cell is much
   taller than they are (nameplate + folio), so centring would drop them to the
   middle of the dateline. */
[data-theme="newzpaper"] .masthead {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
  align-items: start;
  border-bottom: 3px double var(--line);
  row-gap: 4px;
  padding-top: 7px;
}
/* The spacer's only job was pushing the controls right in a flex row; a grid
   column does that now, and an empty cell would eat the first column. */
[data-theme="newzpaper"] .masthead .spacer { display: none; }
[data-theme="newzpaper"] .masthead .tools {
  /* Rows are stated rather than left to auto-placement. The status spans all
     three columns, and once it is placed the auto-placement cursor has moved
     past row 1 — so the toolbar, which only pins a COLUMN, lands underneath it
     instead of beside the nameplate. Three explicit rows, no ambiguity. */
  grid-column: 3;
  grid-row: 1;
  justify-self: end;
  /* Nudged down so the row of 11px controls sits against the nameplate's
     x-height rather than its ascender — optically level, which is what reads as
     aligned, rather than mathematically level, which does not. */
  padding-top: 6px;
}
[data-theme="newzpaper"] .brand {
  grid-column: 2;
  grid-row: 1;
  text-align: center;
  font-family: var(--nameplate);
  /* Blackletter is drawn with its own weight and reads as a smear when a face
     without a bold is synthesised into one — so 400, not 700, unlike every
     other display face in this theme. */
  font-weight: 400;
  /* Roughly double the 30px it launched at, but scaled rather than pinned.
     The nameplate is the best thing on this theme and a wide screen should get
     it big — while every pixel of the masthead is charged to --head-h, and so
     to the section index, the locked bar and the top of every page below them.
     A flat 62px is 8% of a 768px laptop viewport spent on the nameplate before
     a headline. The vw term hands the size to the screen that can afford it:
     62 at 1830px and up, 46 on a 1366 laptop, the 32px floor below ~940. */
  font-size: clamp(32px, 3.4vw, 62px);
  line-height: 1.04;
  letter-spacing: 0.02em;
  color: var(--fg);
  /* **One capital per word — the difference between a nameplate and a puzzle.**
     Blackletter capitals are the fully decorated forms, drawn to be used once
     at the head of a word; a run of them is close to unreadable, which is why
     every real blackletter nameplate is mixed case — The New York Times, The
     Daily Telegraph.

     This used to be `text-transform: lowercase` plus a `::first-letter`
     uppercase, because the source string was "NEWZ" and CSS has no way to fold
     caps down and put initials back in one declaration (`capitalize` raises
     initials and lowers nothing, so on all-caps input it is a no-op). With the
     source in natural case — see index.html — the right rendering is simply the
     markup, and both rules are gone. **Don't re-uppercase the source**: it
     would take this theme back to needing a transform it cannot express. */
  text-transform: none;
}
/* The folio line: what a masthead prints under its own name, in spaced caps
   between hairlines. Real data rather than "VOL. L...No. 1" — it is the date
   the paper already knew. */
[data-theme="newzpaper"] .brand .brand-date {
  display: block;
  margin: 5px auto 0;
  padding: 3px 0 2px;
  border-top: 1px solid var(--line);
  border-bottom: 1px solid var(--line);
  font-family: var(--display);
  font-size: 9.5px;
  font-style: normal;
  font-weight: 400;
  letter-spacing: 0.26em;
  /* The tracking is applied to the right of every letter including the last,
     which pushes a centred line half a space off centre. */
  text-indent: 0.26em;
  text-transform: uppercase;
  color: var(--fg-dim);
}
/* The "//" is a separator between two things sitting side by side, and here
   they are stacked. See renderBrandDate — it is wrapped for exactly this. */
[data-theme="newzpaper"] .bd-sep { display: none; }

/* The edition time, centred on its own line directly under the dateline.
   Nameplate, then date, then "updated 5:28 PM" — the masthead stack a paper
   actually prints, rather than the tally ranged off to one side that this was
   first (and which read as a status bar wearing a serif).

   `order: -1` ties with the brand's, and a tie is broken by DOM order — the
   brand is written first in the markup, so it keeps row one and this takes row
   two. It spans all three grid columns, which is what makes it a row of its own
   and gives it the full page to centre against — placed in a single column it
   would centre on that column instead, which is a different midline.

   It is `.meta.wide`, which is `display: none` below 760px, so a phone still
   gets the two-line masthead and pays nothing for this. */
[data-theme="newzpaper"] .masthead .meta.wide {
  grid-column: 1 / -1;
  grid-row: 2;
  text-align: center;
  margin-top: 4px;
  font-style: normal;
  text-transform: uppercase;
  letter-spacing: 0.18em;
  /* The tracking is applied to the right of the last letter too, which pushes
     a centred line half a space off centre — same correction as .brand-date. */
  text-indent: 0.18em;
  font-size: 9px;
  color: var(--fg-faint);
}
/* The superscript ordinal is a nicety at 26px and a speck at 9.5px in spaced
   caps, where it collides with the tracking rather than riding above it. */
[data-theme="newzpaper"] .bd-ord { display: none; }
[data-theme="newzpaper"] .meta { font-style: italic; }

[data-theme="newzpaper"] button,
[data-theme="newzpaper"] .btn {
  font-family: var(--display);
  border-radius: 0;
  border-color: var(--line);
  letter-spacing: 0.04em;
}
[data-theme="newzpaper"] button.primary {
  background: var(--fg);
  color: var(--btn-fg);
  border-color: var(--fg);
}
[data-theme="newzpaper"] #filter { border-radius: 0; font-style: italic; }

/* ----- the section index ----- */

/* Reverse type for the page you are on — a black slug with paper-coloured
   letters, which is how a printed index marks its own section, and it reads at
   a glance without needing a second colour. The base theme's inset underline is
   redundant against a solid fill, so it comes off. */
[data-theme="newzpaper"] .secnav { border-bottom: 3px double var(--line); }
[data-theme="newzpaper"] .chip {
  font-family: var(--display);
  border-radius: 0;
  font-size: 11.5px;
  letter-spacing: 0.14em;
  text-transform: uppercase;
}
[data-theme="newzpaper"] .chip.current,
[data-theme="newzpaper"] .chip.current .chip-count {
  background: var(--fg);
  color: var(--btn-fg);
  box-shadow: none;
  border-color: var(--fg);
}
[data-theme="newzpaper"] .chip-events.current,
[data-theme="newzpaper"] .chip-events.current .chip-count {
  background: var(--accent-2);
  border-color: var(--accent-2);
  color: var(--btn-fg);
}

/* ----- the locked bar ----- */

[data-theme="newzpaper"] #lockbar { border-bottom: 3px double var(--line); }
[data-theme="newzpaper"] .brief-label,
[data-theme="newzpaper"] .releases-label,
[data-theme="newzpaper"] .events h2 .label {
  font-family: var(--display);
  font-weight: 700;
  letter-spacing: 0.16em;
  text-transform: uppercase;
}
[data-theme="newzpaper"] .brief-text { font-size: 1em; }
[data-theme="newzpaper"] .brief-note,
[data-theme="newzpaper"] .wx-sub,
[data-theme="newzpaper"] .wx-today-label { font-style: italic; }
/* The dateline used to be set in italic here, on the reasoning that a
   letterpress paper italicises its dateline under the nameplate. It is a ruled
   folio line in spaced caps now — see the masthead block above, which sets
   `font-style: normal` and wins on specificity. Kept as a note rather than a
   rule: italic is right for a dateline that sits BESIDE the name, and this one
   no longer does. */
/* Boxed rather than pill-shaped: a rounded chip is the one shape a letterpress
   page never produced. */
[data-theme="newzpaper"] .wx-alert,
[data-theme="newzpaper"] .wx-aqi { border-radius: 0; }

/* ----- the page ----- */

/* The section flag: serif caps over a heavy rule, which is how a printed
   section front announces itself. A page turn should land on something that
   looks like the top of a page. */
[data-theme="newzpaper"] .page-head {
  border-bottom: 4px double var(--line);
  align-items: baseline;
}
[data-theme="newzpaper"] .page-title {
  font-family: var(--display);
  font-size: 30px;
  font-weight: 700;
  letter-spacing: 0.16em;
  color: var(--fg);
  text-transform: uppercase;
}
[data-theme="newzpaper"] .page-count { font-style: italic; letter-spacing: 0; }

[data-theme="newzpaper"] .page-top {
  border-bottom-width: 3px;
  border-bottom-style: double;
}

/* The lead is wood type: heavy condensed caps, set tight, the way a front page
   shouts. The dense rows stay serif — a page where everything shouts has no
   lead — so this is the one place the condensed face appears.

   `clamp()` rather than a size, because unlike the flyer this is imitating, the
   headline is real and its length is not ours to choose. A 40-character
   headline at a fixed 58px is three lines of poster before the reader reaches a
   second story; the vw term lets it fill a wide page and stand down on a narrow
   one. `text-wrap: balance` keeps the last line from being one orphaned word,
   which is the failure that makes big type look accidental.

   Condensed faces are drawn heavy and have no true bold, so 400 — asking for
   700 gets a synthesised smear that fills in the counters at this size. */
[data-theme="newzpaper"] .story.lead .headline a {
  font-family: var(--woodtype);
  font-weight: 400;
  font-size: clamp(30px, 3.9vw, 56px);
  letter-spacing: 0.004em;
  line-height: 0.95;
  text-transform: uppercase;
  text-wrap: balance;
}
[data-theme="newzpaper"] .story .headline a { font-weight: 700; }

/* The deck, ruled top and bottom — the band a broadsheet puts its standfirst
   in, under the headline and above everything else. Justified with hyphenation
   because that is how the reference sets its columns, and because a justified
   measure is the only place `hyphens: auto` earns its keep: it is what stops
   justification opening rivers of white space between words. */
[data-theme="newzpaper"] .story.lead .summary {
  border-top: 1px solid var(--line);
  border-bottom: 1px solid var(--line);
  padding: 7px 0 8px;
  margin-top: 7px;
  text-align: justify;
  hyphens: auto;
}
/* Justified prose in the locked bar too, for the same reason — it is the only
   other block of continuous text on the page. */
[data-theme="newzpaper"] .brief-text { text-align: justify; hyphens: auto; }

/* The byline. A printed one is spaced caps under a rule, not italic — the
   italic source line is right for a dense brief row, where 9px letterspaced
   caps would be a smear, so only the lead's changes. */
[data-theme="newzpaper"] .story .src { font-style: italic; }
[data-theme="newzpaper"] .story.lead .src {
  font-style: normal;
  text-transform: uppercase;
  letter-spacing: 0.16em;
  font-size: 9.5px;
}

/* Pictures print in COLOUR, and deliberately so (2026-08-12).
   They were `grayscale(1) contrast(1.08) sepia(0.16)` for about an hour —
   photogravure, to match the reference flyer, which is a 1920s front page and
   monochrome because that is what the press could do. This is not that press.
   A colour photograph on aged newsprint reads as a modern paper's centre
   spread rather than as a mistake, and it is the one place the reader gets to
   see what the valley actually looked like today. The rest of the theme —
   blackletter, wood type, ruled columns, the stock itself — carries the period
   without asking the photographs to lie about their own.

   Nothing replaces the rule rather than a neutral `filter: none`: an empty
   declaration is a thing to wonder about later, and the themes that want a
   filter can add one. */

/* The lead deck gets a drop cap, three lines deep, which is what a broadsheet
   standfirst actually uses. `::first-letter` needs a block, which the deck is —
   and it is deliberately NOT on the dense rows, where a dropped capital in a
   300px column would eat a third of the row.

   `.drop-ok` is set by leadStory when the summary starts with a letter, and it
   is required. CSS cannot ask what the first character is, and `::first-letter`
   will cheerfully drop a digit: a deck opening "2011: Gene Bleymaier went
   through…" rendered as a three-line **2** above "011:", which reads as a
   broken number rather than a decorated one. Leading quotes go the same way. */
[data-theme="newzpaper"] .story.lead.drop-ok .summary::first-letter {
  float: left;
  font-family: var(--display);
  font-size: 3.1em;
  line-height: 0.82;
  font-weight: 700;
  padding: 0.06em 0.09em 0 0;
  color: var(--fg);
}

/* A newspaper separates its columns with hairlines and its bands with heavier
   ones. The briefs already carry a `column-rule`, so this is only the weight. */
[data-theme="newzpaper"] .briefs { column-rule-width: 1px; }
[data-theme="newzpaper"] .strips-band {
  border-bottom: 1px solid var(--line);
  padding-bottom: 10px;
}

[data-theme="newzpaper"] .releases,
[data-theme="newzpaper"] .release-tab,
[data-theme="newzpaper"] .weekday-label { border-radius: 0; }
[data-theme="newzpaper"] .release-tab {
  font-family: var(--display);
  text-transform: uppercase;
  letter-spacing: 0.08em;
}
[data-theme="newzpaper"] .release-tab.primary {
  background: var(--fg);
  color: var(--btn-fg);
  border-color: var(--fg);
}

/* ----- events ----- */

[data-theme="newzpaper"] .day-label,
[data-theme="newzpaper"] .weekday-name {
  font-family: var(--display);
  font-weight: 700;
  letter-spacing: 0.12em;
  text-transform: uppercase;
}
[data-theme="newzpaper"] .weekday-num,
[data-theme="newzpaper"] .event .venue { font-style: italic; }
[data-theme="newzpaper"] .event .price { font-style: normal; font-weight: 700; }

/* ----- states + footer ----- */

/* ----- the theme picker -----
   `.tp-paper[data-theme=…]`, for the reason spelled out in the punk block
   above: the plain form would dress all five miniatures whenever the reader's
   page is newzpaper. */
.tp-paper[data-theme="newzpaper"] .tp-nav { border-bottom: 3px double var(--line); }
/* Reverse type for the section in force, which is how a printed index marks
   its own page — the same slug `.chip.current` wears at full size. */
.tp-paper[data-theme="newzpaper"] .tp-current {
  background: var(--fg);
  color: var(--btn-fg);
  padding: 0 3px;
}
/* Mixed case, like the real nameplate — this tile is the theme, not a picture
   of it, so it has to make the same typographic choice. */
.tp-paper[data-theme="newzpaper"] .tp-brand { letter-spacing: 0.02em; text-transform: none; }
.tp-paper[data-theme="newzpaper"] .tp-alert { border-radius: 0; }
[data-theme="newzpaper"] .tp-title,
[data-theme="newzpaper"] .tp-name { font-family: var(--display); }

[data-theme="newzpaper"] .errbar {
  border-bottom: 1px solid var(--line);
  font-style: italic;
}
[data-theme="newzpaper"] footer {
  border-top: 3px double var(--line);
  font-style: italic;
}

@media (max-width: 760px) {
  /* Back to the wrapping flex row. The three-column grid exists to hold the
     controls beside a centred nameplate, and there is no width here to do that
     in: a 390px screen would give the toolbar a ~130px column and wrap the
     four controls into three lines. Flex puts them back on a row of their own
     under the nameplate, which is what a phone masthead was already doing. */
  [data-theme="newzpaper"] .masthead {
    display: flex;
    align-items: baseline;
  }
  [data-theme="newzpaper"] .masthead .spacer { display: block; }
  [data-theme="newzpaper"] .masthead .tools { padding-top: 0; }
  [data-theme="newzpaper"] .brand { flex: 1 0 100%; }
  /* The nameplate stands down but keeps its line — it is already on a row of
     its own at every width now, so shrinking it is the only lever needed. */
  [data-theme="newzpaper"] .brand { font-size: 20px; }
  /* **The folio line stays on a phone**, where it used to be dropped with
     `.brand span { display: none }`. That was sound when the date sat beside a
     26px nameplate and pushed the controls onto a second row — but it also left
     a phone with no date anywhere on the page, because the compact weather line
     deliberately stopped printing one on the grounds that the masthead above it
     always does. Two rules, each locally reasonable, that between them lost the
     fact. It is a ruled line under the name now and costs no extra row.
     `.bd-short` ("Tue Aug 12") is what actually renders here — see the shared
     760px block. */
  [data-theme="newzpaper"] .brand .brand-date {
    font-size: 8px;
    letter-spacing: 0.16em;
    text-indent: 0.16em;
    margin-top: 3px;
  }
  [data-theme="newzpaper"] .page-title { font-size: 19px; }
  /* Wood type has to stand down harder than the body does: `clamp()`'s vw term
     already tracks the viewport, but its 30px floor is sized for a page three
     columns wide. */
  [data-theme="newzpaper"] .story.lead .headline a { font-size: 24px; }
}
