/* =====================================================================
   Homestead Valley Wildfire StoryMap - stylesheet

   TWO VISUAL LANGUAGES, one page. A scroll-driven map story asks the
   reader to tell two kinds of box apart at a glance:

     VOICE  (--voice-*)   narrative. The chapter card, the pull-quote,
                          the footer. Paper: soft, generous measure,
                          hung on an accent edge.
     INSTRUMENT (--panel-*) controls. Map buttons, legend, popups, the
                          basemap bed. Crisp: hairline ring, tight
                          radius, near-flat.

   Everything themeable is a custom property. :root below carries the
   full set, which IS Serpentine - the one theme the page ships with.
   applyTheme() in js/story.js inline-styles the five map-facing
   --color-* values from data/chapters.json, which is why those five are
   the only ones duplicated between the two files. Inline styles win
   over this block, so chapters.json stays authoritative for them.

   The switching machinery is still in place: applyTheme() sets
   html[data-theme], so a second theme is added by registering it in
   chapters.json "themes" and adding an html[data-theme="..."] block
   here that overrides only what differs from Serpentine.
===================================================================== */

:root {
  /* --- Brand five. Also inline-styled from chapters.json at runtime, and
     read back by themeColor() for MapLibre paint properties, which cannot
     consume custom properties. --color-dark must stay dark enough to label
     the light CARTO Positron basemap and --color-light light enough to halo
     it - note that holds even though the page chrome itself is dark. */
  --color-primary: #4ca08c;
  --color-secondary: #dd8a3e;
  --color-accent: #5f9ec9;
  --color-dark: #0d1917;
  --color-light: #e9f1ee;

  --font-heading: "Chivo", "Segoe UI", sans-serif;
  --font-body: "Literata", Georgia, serif;

  /* One number for the whole page's density, applied to html's font-size
     below. Nearly every length in this file is in rem, so this scales the
     type, the padding, the gaps, the map frame, the controls and the legend
     together - 1 is what a reader sees at 100% browser zoom. A page cannot
     set the browser's own zoom (it is a user preference, with no API), so
     this is the honest equivalent.

     0.8 IS THE SHIPPING VALUE AND IS DELIBERATE - do not raise it.

     It was briefly set to 1, on the reasoning that the audience is a
     residential community whose Safety Committee reads this at public
     meetings, that a good share of readers are over 60, and that 0.8 puts
     body copy at 14.3px - close to the ~16px floor where small-print
     fatigue starts. That reasoning was not wrong about the floor, but it
     was the wrong lever. Raising this inflates every rem on the page at
     once, including the ones that are chrome rather than content: the
     fixed header, the sticky section bar, the map controls, the legend.
     The nav alone went from ~106px to 192px of permanent furniture on a
     900px screen - about a fifth of the viewport gone before any story is
     told. The page became more legible per word while showing much less
     of itself at a time, and on review the second effect lost.

     So the legibility floor is raised where it belongs: in the
     per-element sizes below (--text-body, --text-caption and the heading
     clamps), which lift the CONTENT without dragging the furniture up
     with it. This number stays at 0.8.

     Viewport-based lengths deliberately do NOT scale with it: --gutter,
     --map-stage-h and the 100vh floors stay put, which is exactly how they
     behave under real browser zoom too, since a vh is a share of the window
     at any zoom level. Nor do 1px hairlines, which stay crisp. Neither, in
     practice, do the clamp() vw terms on the major headings - at 0.8 those
     all sit on their rem ceilings at desktop widths, so the vw middle
     terms are inert there and the ceilings are what you actually tune. */
  --ui-scale: 0.8;

  /* Serif body copy wants more leading than a sans, and light-on-dark text
     wants a little more again. */
  --body-leading: 1.74;

  /* --- Type scale. Only the sizes that more than one rule has to agree on
     live here; everything else states its own, next to the thing it sizes.

     --text-body is a token because FOUR rules are supposed to render at
     the same size and three of them were not: .chapter-content p set 1.01rem
     while .chapter-body li, .footer-text and the Explorer's intro set
     nothing at all and inherited the 1rem root, so a bulleted line sat a
     hair under the paragraph above it. One token means that cannot come
     back. --keyline-size is bounded against this value, so raising it
     without re-reading that comment will squeeze the pull-quote.

     --text-caption is the step DOWN - figcaptions, in-card captions, the
     legend's prose. It is a step and not a whisper: at 1 scale it renders
     at 14.7px, which is about the floor this audience can take (see
     --ui-scale for who they are). Nothing on the page should set a caption
     size smaller than this by hand. */
  --text-body: 1.12rem;
  --text-caption: 0.92rem;
  --display-tracking: -0.018em;
  --display-weight: 600;

  /* Combined rendered height of #header + #toolbar, kept in sync at runtime
     by setupNavOffset(). The top of every map chapter's stage and every
     chapter's scroll-stop point read off this instead of a hand-tuned
     guess, so they stay correct even though the bar's height varies with
     its font size and with labels wrapping at narrow widths. Falls back to
     a sane default before JS runs. */
  --nav-offset: 5.5rem;
  --gutter: 6vw;

  /* Height of the Block Explorer's map. (Story map chapters no longer use
     a band - their map is pinned to the viewport.) */
  --map-stage-h: 85vh;

  /* A map chapter's three beats (see .chapter-stage-text): how much map-
     only scrolling comes before the card rises over the map, and after it
     has left. Kept short so the card arrives soon and the media takes
     over soon after it goes. Both have to be at least about half a screen,
     because regions switch at the half-way mark: a shorter lead-out hands
     over while the card is still on screen, and a shorter lead-in lets
     the card peek up over the PREVIOUS region - over a media takeover's
     image, at 40vh. */
  --map-lead-in: 50vh;
  --map-lead-out: 60vh;

  /* Cross-fade between wind scenarios when a stepped chapter changes step. */
  --wind-fade: 400ms;

  /* The map <-> media handover is a crossfade: one fades out while the
     other fades in, on the same duration and curve so they meet in the
     middle instead of one popping before the other has gone. A sine
     ease-in-out starts and lands gently - softer than `ease`, which
     front-loads the change. MEDIA_FADE_MS in js/story.js must match
     --media-fade. */
  --media-fade: 0.8s;
  --media-ease: cubic-bezier(0.37, 0, 0.63, 1);

  /* The map is inset from the band rather than bleeding to its edges, and
     wears a hairline + radius from the Instrument palette, so it reads as
     a framed exhibit on the page instead of a hole cut through it. Applied
     to #map and .explorer-map-wrap. */
  --map-frame-pad: 1.15rem;
  --map-frame-radius: 6px;

  /* Rendered height of a one-line .map-title plate: 1.3rem of type at
     1.18 leading, plus 0.5rem padding top and bottom, plus its hairline.
     Hand-computed because the two things that have to sit clear of the
     plate - the Explorer's legend, which shares the top-left corner, and
     the wind key once it drops below the plate on a phone - are both
     absolutely positioned and so cannot be laid out after it. They read
     the offset from here rather than each carrying its own copy of the
     number, which is what kept them in step when the title's font size
     doubled. Re-measure this if the font size, leading or padding below
     changes; the two breakpoints at the end of the file override it
     where the type is smaller and where the plate wraps to two lines. */
  --map-title-h: 2.7rem;

  /* --- Page. Serpentine: Marin's serpentinite bedrock. Dark, because the
     Safety Committee presents this at community meetings with the lights
     down. Dark chrome over the light CARTO Positron basemap is the
     conventional pairing, so map legibility is unaffected. */
  --page-bg: linear-gradient(180deg, #132220 0%, #0c1716 60%, #091211 100%);
  --page-bg-color: #0c1716;
  --map-bed: #0f1b1a;
  --sunk-bg: #101d1b;

  /* --- Ink */
  --ink: #dde8e5;
  --ink-soft: #9bb0aa;
  --ink-faint: #768c86;
  --ink-on-fill: #08110f;
  --rule: rgba(214, 234, 228, 0.16);

  /* --- Voice */
  --voice-bg: rgba(19, 34, 32, 0.93);
  --voice-radius: 3px;
  --voice-edge: var(--color-primary);
  --voice-edge-w: 3px;
  /* The hairline ring is primary at 18%, so it tracks whatever
     chapters.json sets rather than restating the hex. */
  --voice-shadow: 0 0 0 1px color-mix(in srgb, var(--color-primary) 18%, transparent), 0 28px 52px -26px rgba(0, 0, 0, 0.8);
  /* Two reading measures for the text card, picked by how much of the
     screen the card has to itself.

     --voice-measure is the docked case: the media panel owns 58% of the
     viewport, so the card has to fit in the other 42% without touching it.
     The free strip is (42vw - --gutter); the 1.5rem is the gap between the
     card and the panel's edge, so the card never butts right up against
     the photograph.

     It is a min() rather than the flat 32rem it used to be because that
     arithmetic was only checked at 1440px. A fixed rem measure crosses over
     the panel as the window narrows - at 32rem/0.8 scale that happened
     around 1150px, which the 820px stacking rules below did not catch, so
     between those two widths the card painted on top of the photograph.
     Raising --ui-scale would have moved the crossover up to ~1280px and
     made a latent bug a routine one. Under min() the card simply gives up
     width instead, at any scale and any width.

     The rem term is the ceiling: 34rem is ~55 characters of the new body
     size, which is inside the 45-75 comfortable range. The vw term wins
     below ~1430px.

     --voice-measure-wide is the undocked case ("mediaPosition": "none"),
     where the card is centred and nothing is competing with it. */
  --voice-measure: min(34rem, calc(42vw - var(--gutter) - 1.5rem));
  --voice-measure-wide: 38rem;

  /* --- Instrument */
  --panel-bg: rgba(14, 26, 25, 0.93);
  --panel-ink: #dfeae7;
  --panel-rule: rgba(214, 234, 228, 0.20);
  --panel-radius: 3px;
  --panel-shadow: 0 10px 26px -14px rgba(0, 0, 0, 0.85);
  --panel-hover: rgba(214, 234, 228, 0.10);

  /* --- Nav */
  --nav-bg: #081312;
  --nav-ink: #e4efec;
  --nav-ink-soft: rgba(228, 239, 236, 0.68);
  --nav-sub-bg: #0f1d1b;
  --nav-rule: rgba(228, 239, 236, 0.14);
  --nav-marker: var(--color-primary);
  /* Ink for text sitting ON --nav-marker (the Block Explorer call-to-action
     in the toolbar), which is the brand teal rather than a page surface, so
     it cannot just use --ink. Near-black is the legible choice against it:
     #08110f on #4ca08c = 6.12:1, where the near-white this used to carry
     managed only 2.77:1. */
  --nav-cta-ink: #08110f;
  /* Fill for the call-to-action once the reader is IN the Block Explorer,
     where the label flips to --nav-ink. White cannot sit on --nav-marker
     (2.77:1), so the active state darkens the pill toward the bar instead.
     65% resolves to #346f61, which carries --nav-ink at 4.97:1 - going any
     darker buys separation the pill does not need and any lighter drops
     the label under 4.5:1.

     Darkening does cost the pill its own edge: #346f61 on the row it sits
     in (.toolbar is --nav-sub-bg) is 2.96:1, just shy of the 3:1 wanted
     for a component boundary. The active ring carries that instead, at
     14.73:1 - which is why the outline on .toolbar-section-standalone.active
     is load-bearing in this state and not just decoration. */
  --nav-cta-active-bg: color-mix(in srgb, var(--nav-marker) 65%, var(--nav-bg));

  /* --- Hero.
     The scrim has to be genuinely dark from ~40% downward: the hero copy is
     anchored bottom-left and runs from roughly 30% to 90% of the hero's
     height, all of it set in near-white. A scrim that only darkens in the
     last few percent looks fine as a gradient and leaves the lede sitting
     on whatever the photograph happens to be doing. */
  --hero-scrim: radial-gradient(135% 100% at 50% 8%, rgba(8, 19, 18, 0.12) 0%, rgba(8, 19, 18, 0.58) 45%, rgba(5, 13, 12, 0.88) 78%, rgba(4, 10, 9, 0.95) 100%);
  --hero-ink: #ecf4f1;

  /* --- key-line: the story's pull-quote. 25 instances across the 44
     chapters in data/chapters.json - the most repeated structural device
     on the page, and it has to win the page at a glance because it is the
     one line a reader skimming a chapter is meant to come away with.

     It was losing. At the old 1.14rem against 1.01rem body copy it was
     only 1.13x the text around it - a difference you measure rather than
     see - and the panel was a 10% wash that all but vanished over the
     card. The three levers move together now: a tint with enough body to
     read as a panel, and a size that is a clear step up rather than a
     nudge. --keyline-ink on the resulting surface is 11.4:1.

     The size is bounded on BOTH sides, which is why it is a token rather
     than a number in the rule. Floor: it has to clear the 1.12rem body
     copy by enough to see (this sits at 1.25x). Ceiling: it has to stay
     visibly under the chapter h2, which is the smaller of the two heading
     scales at clamp(1.55rem, 2.4vw, 1.95rem) - i.e. 1.95rem on any
     desktop - because a pull-quote that approaches its own heading stops
     reading as a quote and starts competing with it. 1.4rem is 0.72x that
     heading, which is the same ratio the 1.26rem/1.72rem pair had before
     the type scale went up; an earlier pass at 0.80x overwhelmed it.
     Check any change against the DOCKED .chapter-heading h2 rule, not the
     undocked [data-media-position="none"] one - the 2.45rem heading there
     is the permissive case.

     Not a filled pill, though: a dark solid block made every takeaway
     look like a button you could press. The hung edge stays, and the
     right side stays open. */
  --keyline-tint: color-mix(in srgb, var(--color-primary) 16%, transparent);
  --keyline-edge: var(--color-primary);
  --keyline-ink: #eaf3f0;
  --keyline-size: 1.4rem;

  --footer-bg: #081312;
  --footer-ink: #dbe8e4;

  /* --- Signal. --risk is held in reserve for alerts and review
     scaffolding; it is deliberately not used for chrome. */
  --risk: #e2604a;
  --focus: #7fc9b6;
  --scrim: rgba(5, 13, 12, 0.62);
  --backdrop: rgba(4, 11, 10, 0.95);

  /* Tells the UA to render scrollbars and form controls dark too, so the
     window frame does not stay light around a dark page. */
  color-scheme: dark;
}

/* =====================================================================
   Base
===================================================================== */
/* Its own rule, NOT folded into the html, body block below: a percentage
   font-size resolves against the parent, so setting it on both would apply
   --ui-scale twice and land the page at 64%. A percentage rather than a px
   value so a reader who has raised their browser's default font size still
   gets that preference, scaled, instead of having it overridden. */
html {
  font-size: calc(var(--ui-scale) * 100%);
}

html, body {
  margin: 0;
  padding: 0;
  height: 100%;
  font-family: var(--font-body);
  color: var(--ink);
  background: var(--page-bg-color);
  -webkit-font-smoothing: antialiased;
}

body {
  background: var(--page-bg);
  background-attachment: fixed;
}

h1, h2, h3, h4 {
  font-family: var(--font-heading);
  font-weight: var(--display-weight);
  letter-spacing: var(--display-tracking);
  line-height: 1.18;
}

/* Measured values read as measurements when the digits line up: elevation
   bands, acreages, the 20 ft contour interval, the bivariate matrix ticks. */
#legend,
.explorer-legend,
.legend-matrix,
.explorer-popup {
  font-variant-numeric: tabular-nums;
}

:where(a):focus-visible,
:where(button):focus-visible,
[role="switch"]:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

/* =====================================================================
   Map - a single persistent MapLibre instance, pinned to the viewport
   below the header + toolbar, full width. A map chapter's card floats over
   it in #story (z-index 1) and scrolls past while the map stays put and
   stays interactive. Shown only while a map chapter owns the screen
   (body.map-on-screen, set by updateMediaForScroll() in js/story.js) - so
   it fades out when a media-only chapter takes over, and for every
   chapter without a map.

   The --map-frame-pad inset is what produces the frame: the map is held
   off the edges of the screen so the page shows through around it, and
   the hairline + radius + shadow below are the same Instrument tokens the
   legend and the map buttons use. Edge-to-edge, the map read as a hole
   punched through the page; inset and framed, it reads as a figure ON the
   page, in the theme's own materials.
===================================================================== */
#map {
  position: fixed;
  top: calc(var(--nav-offset, 0px) + var(--map-frame-pad));
  right: var(--map-frame-pad);
  bottom: var(--map-frame-pad);
  left: var(--map-frame-pad);
  z-index: 0;
  background: var(--map-bed);
  border: 1px solid var(--panel-rule);
  border-radius: var(--map-frame-radius);
  box-shadow: var(--panel-shadow);
  /* Clips the MapLibre canvas to the rounded corners, and stops the legend
     or a popup painting out over the frame. */
  overflow: hidden;
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--media-fade) var(--media-ease);
}

body.map-on-screen #map {
  opacity: 1;
  pointer-events: auto;
}

/* Fullscreen has to beat the rules above: the map is normally an inset,
   rounded box below the nav and can be at opacity 0, and all of that
   would otherwise survive into fullscreen. `position` is restated because the UA's
   own `position: fixed` for the fullscreen element loses to #map's ID
   selector. Declared after the others so it wins on source order. */
#map:fullscreen {
  position: fixed;
  inset: 0;
  width: 100%;
  height: 100%;
  border: none;
  border-radius: 0;
  box-shadow: none;
  opacity: 1;
  pointer-events: auto;
}

/* =====================================================================
   Map controls - INSTRUMENT language. Fullscreen top-right, home + zoom
   stacked bottom-right. Replaces MapLibre's default NavigationControl
   (see initMapControls()).
===================================================================== */
.map-controls {
  position: absolute;
  inset: 0;
  z-index: 2;
  pointer-events: none;
}

/* 2.9rem, not the 2.25rem these were: a 36px square is at the bottom of
   what counts as a comfortable target, and these are the only controls on
   the page a reader has to hit while also tracking what the map does in
   response. The icon grows with the box (padding scales too, so the
   glyph goes from ~21px to ~27px) rather than the box growing around a
   small icon, which would just add dead margin. Anything depending on
   this width is listed at .map-title's max-width and the .map-north-arrow
   footprint note. */
.map-control-btn {
  pointer-events: auto;
  width: 2.9rem;
  height: 2.9rem;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--panel-bg);
  border: 1px solid var(--panel-rule);
  border-radius: var(--panel-radius);
  box-shadow: var(--panel-shadow);
  color: var(--panel-ink);
  cursor: pointer;
  padding: 0.55rem;
  transition: background 0.15s ease, color 0.15s ease;
}

.map-control-btn svg {
  width: 100%;
  height: 100%;
}

.map-control-btn:hover {
  background: var(--panel-hover);
  color: var(--panel-bg);
}

.map-control-fullscreen {
  position: absolute;
  top: 1rem;
  right: 1rem;
}

.map-control-stack {
  position: absolute;
  bottom: 1.5rem;
  right: 1rem;
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 0.6rem;
}

/* NOT overflow: hidden, which is how this used to round its two buttons
   into a single capsule. Both buttons now carry a [data-tip] tooltip,
   drawn as an ::after outside their own box, and a clipping parent would
   swallow it whole. The two outer corners are rounded on the first and
   last child instead - visually identical at --panel-radius's 3px, and it
   clips nothing. */
.map-control-zoom-group {
  display: flex;
  flex-direction: column;
  border-radius: var(--panel-radius);
  box-shadow: var(--panel-shadow);
  border: 1px solid var(--panel-rule);
}

.map-control-zoom-group .map-control-btn {
  box-shadow: none;
  border: none;
  border-radius: 0;
}

.map-control-zoom-group .map-control-btn:first-child {
  border-bottom: 1px solid var(--panel-rule);
  border-radius: var(--panel-radius) var(--panel-radius) 0 0;
}

.map-control-zoom-group .map-control-btn:last-child {
  border-radius: 0 0 var(--panel-radius) var(--panel-radius);
}

/* =====================================================================
   North arrow - the first item in each map's bottom-right control stack,
   so it reads as part of the same instrument cluster as the home/zoom
   buttons and sits clear of the title (top-left) and legend (bottom-left
   on the story map, top-left in the Explorer).

   The plate stays put and the rose inside it turns: initNorthArrow()
   writes `rotate(-bearing)` onto .map-north-rose. The "N" turns with the
   needle because it labels the needle's tip, not the corner of the map.

   Square, and the same width as the buttons above it, so a rose rotated
   to any bearing stays inside it - the rose's own box is 1.3rem x
   ~1.96rem (a 0.64rem label, a 0.02rem gap and a 1.3rem needle), whose
   worst-case (45 degrees) footprint is (1.3 + 1.96) / root 2 = about
   2.3rem against 2.775rem of usable plate. Both numbers moved together
   when the controls were enlarged; check this sum before growing either.
===================================================================== */
.map-north-arrow {
  /* Inherits none inside #map's .map-controls, but .explorer-map-controls
     has no such wrapper - and a 2.9rem square that swallows drags would
     be a dead spot on the Explorer's map, where dragging is how a reader
     gets around. Stated here so both maps behave the same. This is also
     why the arrow is the one control with no tooltip: a [data-tip] needs
     hover, and hover needs pointer events. */
  pointer-events: none;
  width: 2.9rem;
  height: 2.9rem;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--panel-bg);
  border: 1px solid var(--panel-rule);
  border-radius: var(--panel-radius);
  box-shadow: var(--panel-shadow);
  color: var(--panel-ink);
}

.map-north-rose {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.02rem;
  /* Inline-styled to rotate(-bearing) by initNorthArrow(), which is why
     this is a wrapper rather than a transform on the plate itself. */
  transition: transform 0.2s ease;
}

.map-north-label {
  font-family: var(--font-heading);
  font-size: 0.64rem;
  font-weight: 700;
  line-height: 1;
  letter-spacing: 0.04em;
}

.map-north-rose svg {
  display: block;
  width: 1.3rem;
  height: 1.3rem;
}

/* =====================================================================
   Map title - names what the map is showing, top-left, in the same
   Instrument materials as the other on-map furniture. Both maps use this
   class; the story map's plate is filled per chapter by updateMapTitle()
   (one MapLibre instance serves six chapters), the Explorer's is static
   markup, which is the only difference .map-title-static records.

   Top-LEFT rather than centred because the story map's wind key is
   centred and the Explorer's legend is top-left - see the 820px block
   for the one width where the wind key has to step down past this.
===================================================================== */
.map-title {
  position: absolute;
  top: 1rem;
  left: 1rem;
  z-index: 2;
  /* Keeps a long title clear of the fullscreen button in the opposite
     corner (2.9rem wide, 1rem in, plus a gap). At this type size that
     only bites on a phone, where it is also what makes the plate wrap to
     the two lines --map-title-h accounts for at the 560px breakpoint. */
  max-width: calc(100% - 7rem);
  background: var(--panel-bg);
  color: var(--panel-ink);
  border: 1px solid var(--panel-rule);
  border-radius: var(--panel-radius);
  box-shadow: var(--panel-shadow);
  padding: 0.5rem 1rem;
  font-family: var(--font-heading);
  /* Display-scale, not control-scale: this is the one piece of on-map
     furniture that is a heading rather than an instrument, so it takes
     the page's display leading and tracking (see the h1/h2 rule) instead
     of the legend's. --display-weight is the same 600 it was.
     --map-title-h is hand-derived from this size; move both together. */
  font-size: 1.3rem;
  font-weight: var(--display-weight);
  line-height: 1.18;
  letter-spacing: var(--display-tracking);
  pointer-events: none;
}

/* Empty until the first map chapter is activated, and an empty plate is
   still a visible --map-title-h box over the basemap. */
.map-title:empty {
  display: none;
}


/* =====================================================================
   Wind direction - a fan of arrows on the NE and SW flanks of the
   boundary (offshore Diablo NE->SW, onshore reverse SW->NE). The arrows
   are MapLibre markers pinned to real coordinates, so they pan/zoom/
   rotate with the map; only their styling lives here. This block is the
   small top-centre key naming the two colours. Hidden by default; a
   chapter opts in via chapter.layers.wind.
===================================================================== */
.wind-indicator {
  position: absolute;
  top: 1rem;
  left: 50%;
  transform: translateX(-50%);
  z-index: 2;
  display: none;
  flex-direction: column;
  gap: 0.25rem;
  background: var(--panel-bg);
  border: 1px solid var(--panel-rule);
  border-radius: var(--panel-radius);
  padding: 0.45rem 0.8rem;
  box-shadow: var(--panel-shadow);
  pointer-events: none;
}

.wind-indicator.visible {
  display: flex;
}

.wind-key-item {
  display: flex;
  align-items: baseline;
  gap: 0.45rem;
  font-family: var(--font-heading);
  font-size: 0.84rem;
  font-weight: 600;
  color: var(--panel-ink);
  white-space: nowrap;
}

.wind-key-item::before {
  content: "";
  width: 0.5rem;
  height: 0.5rem;
  border-radius: 50%;
  flex: none;
  transform: translateY(-0.08em);
}

/* A step showing one scenario names only that one. A class, not [hidden]:
   .wind-key-item's display:flex would beat the hidden attribute. */
.wind-key-item.wind-key-off {
  display: none;
}

.wind-key-diablo::before { background: var(--color-secondary); }
.wind-key-reverse::before { background: var(--color-accent); }

.wind-key-item small {
  font-size: 0.76rem;
  font-weight: 400;
  color: var(--ink-soft);
}

/* MapLibre owns the marker element's `transform` (position + bearing), so
   never set one here - size, colour and visibility only. The drift
   animation below gets around that by moving the inner <svg> instead: it
   inherits the marker's rotation from its parent, so a plain translateY()
   in the svg's own coordinate space slides the arrow along whichever
   bearing MapLibre has rotated it to. Negative Y is the direction the
   arrowhead points, which is downwind. */
/* Shown and hidden as a cross-fade rather than display:none, so a stepped
   chapter's switch from Diablo to the reverse wind (see WIND_MODES in
   js/story.js) dissolves one set into the other. The opacity runs on the
   .wind-arrow-fade wrapper, never here: MapLibre writes an inline opacity
   on the marker root that would override it. visibility is delayed by the
   fade's length on the way out so hidden arrows stop painting only once
   they have faded. */
.wind-arrow {
  width: 2.6rem;
  height: 4.74rem;
  display: block;
  visibility: hidden;
  pointer-events: none;
  filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.35));
  transition: visibility 0s linear var(--wind-fade);
}

.wind-arrow.visible {
  visibility: visible;
  transition-delay: 0s;
}

.wind-arrow-fade {
  width: 100%;
  height: 100%;
  opacity: 0;
  transition: opacity var(--wind-fade) ease;
}

.wind-arrow.visible .wind-arrow-fade {
  opacity: 1;
}

.wind-arrow svg {
  display: block;
  width: 100%;
  height: 100%;
}

/* Each arrow slides downwind and fades, on a loop, so the two fans read as
   moving air rather than two static legends. buildWindArrows() gives every
   arrow its own --wind-duration and a NEGATIVE --wind-delay, which starts it
   part-way through the cycle - otherwise all eight would launch in lockstep
   the moment the chapter scrolled in.

   The prefers-reduced-motion guard here is load-bearing, not belt-and-braces
   like the one on .toolbar-section-standalone. The global `reduce` block at
   the end of this file neutralises animations by forcing
   animation-iteration-count: 1, which for this keyframe set would run the
   drift exactly once and leave every arrow parked on the 100% frame - i.e.
   opacity: 0. The arrows would disappear entirely. Attaching the animation
   only under `no-preference` means reduced-motion readers get them static
   and fully opaque, which is the correct fallback. */
/* On every arrow, not just .visible ones: an arrow fading out has already
   lost .visible, and taking the drift off it then would snap it back to
   its resting frame mid-fade. Hidden arrows are visibility:hidden, so this
   costs no paint. */
@media (prefers-reduced-motion: no-preference) {
  .wind-arrow svg {
    animation: wind-drift var(--wind-duration, 3.6s) linear infinite;
    animation-delay: var(--wind-delay, 0s);
  }
}

/* translateY moves the arrow along the heading MapLibre rotated its parent
   to; the rotate() on top of it banks the arrow a few degrees through the
   cycle so it follows the bend of its streamline instead of sliding down a
   straight diagonal. Order matters: `translateY() rotate()` turns the arrow
   about its own centre and THEN displaces it, which is what we want. The
   reverse order would swing the travel direction too and the fan would
   splay apart.

   --wind-curl-in / --wind-curl-out are set per arrow by buildWindArrows()
   and are signed, so each fan banks the way its own curve actually turns.
   Only opacity is keyed at 22%/78% - transform is left to interpolate
   straight through from 0% to 100%, which is what keeps the travel linear
   while the fade happens at the ends. */
@keyframes wind-drift {
  0%   { transform: translateY(42%) rotate(var(--wind-curl-in, 0deg));   opacity: 0; }
  22%  { opacity: 1; }
  78%  { opacity: 1; }
  100% { transform: translateY(-58%) rotate(var(--wind-curl-out, 0deg)); opacity: 0; }
}

.wind-arrow-diablo { color: var(--color-secondary); }
.wind-arrow-reverse { color: var(--color-accent); }

#story {
  position: relative;
  z-index: 1;
  pointer-events: none;
}

/* =====================================================================
   Media sidecar - the one place a chapter's images and video appear. A
   panel pinned to the viewport, docked to the left or right 58% of it (or
   spanning the whole width), filled and faded by placeMediaForChapter()
   as the reader scrolls from chapter to chapter.

   Driven by body classes that function sets, rather than by anything on
   the chapter itself, because the panel is a sibling of #story - it has
   to be, since it must not scroll with the narrative. Two independent
   classes, on purpose:

     body.media-pos-left|right|full   the panel's SHAPE
     body.media-hidden                whether it is SHOWN

   They used to be one: "none" was a fourth shape that only set opacity,
   so hiding the panel also dropped it back to the base full-width box,
   and it visibly stretched to full width while fading out (and shrank
   from full width while fading in). Now the shape is left alone while the
   panel hides, and placeMediaForChapter() only changes it while the panel
   is fully transparent - which is also why left/width are not
   transitioned at all: a shape change is never meant to be seen.

   #map is pinned too; the two never share the screen - whichever scroll
   region owns it decides which is shown (see setupMediaRegions()).
===================================================================== */
#media-sidecar {
  position: fixed;
  top: var(--nav-offset, 0px);
  height: calc(100vh - var(--nav-offset, 0px));
  z-index: 0;
  background: var(--sunk-bg);
  opacity: 0;
  pointer-events: none;
  left: 0;
  width: 100%;
  /* Hard guarantee that nothing inside the panel can paint past its edges
     and push the page into horizontal scroll. */
  overflow: hidden;
  transition: opacity var(--media-fade) var(--media-ease);
}

body.media-pos-left #media-sidecar {
  left: 0;
  width: 58%;
  opacity: 1;
  pointer-events: auto;
}

body.media-pos-right #media-sidecar {
  left: 42%;
  width: 58%;
  opacity: 1;
  pointer-events: auto;
}

/* Full-bleed variant: a map chapter's media-only takeover, and any
   chapter whose media is much wider than a half-viewport panel can
   usefully show (e.g. a 3:1 panorama) - the media takes the whole width
   rather than shrinking to a thin letterboxed band inside a 58% panel. */
body.media-pos-full #media-sidecar {
  left: 0;
  width: 100%;
  opacity: 1;
  pointer-events: auto;
}

/* After the shape rules, at the same specificity, so it wins and the
   panel keeps its shape while it fades out. */
body.media-hidden #media-sidecar {
  opacity: 0;
  pointer-events: none;
}

/* Momentary dip to transparent while placeMediaForChapter() swaps the
   sidecar's content - and, if it changes, its shape - with the panel
   visible before and after. Needs !important to override the shape rules
   above, which share the same specificity plus a body class. The dip is
   shorter than --media-fade so a swap does not linger on an empty panel;
   the fade back in runs at the full --media-fade once the class comes
   off. 0.34s must match MEDIA_SWAP_MS in js/story.js. */
#media-sidecar.media-sidecar-swapping {
  transition: opacity 0.34s var(--media-ease) !important;
  opacity: 0 !important;
}

#media-sidecar .media-carousel {
  height: 100%;
  /* The base rule gives the carousel a top margin for an inline context it
     no longer has; in the panel it fills the whole thing. */
  margin-top: 0;
}

#media-sidecar .media-carousel-track,
#media-sidecar .media-carousel-item {
  height: 100%;
}

/* Each slide is a padded box centring its media, so the image floats with
   breathing room instead of running edge-to-edge into the map, the text
   card and the window frame. Asymmetric at the bottom to leave room for
   the caption strip. */
#media-sidecar .media-carousel-item {
  display: flex;
  align-items: center;
  justify-content: center;
  box-sizing: border-box;
  padding: 2rem 2rem 3.25rem;
}

/* max-width/max-height with auto sizing (rather than 100%/100% +
   object-fit) means the ELEMENT box shrinks to the image's real aspect
   ratio, not just the pixels inside it - so the frame hugs the artwork
   instead of framing empty letterbox bands, and the image can never exceed
   its slide. These are informational graphics (posters, diagrams,
   before/after panels): cropping them to fill the panel, as
   object-fit:cover did, hid content and forced the reader into the
   lightbox to see the whole thing. */
#media-sidecar .media-carousel-item img,
#media-sidecar .media-carousel-item video {
  width: auto;
  height: auto;
  max-width: 100%;
  max-height: 100%;
  object-fit: contain;
  border-radius: var(--panel-radius);
  box-shadow: 0 18px 40px -20px var(--scrim);
  margin-top: 0;
}

/* Video embeds cannot be sized like the images above: an <iframe> has no
   intrinsic aspect ratio, so width/height:auto collapses it to the HTML
   default 300x150 box. It gets an explicit 16:9 ratio instead - matching
   .video-pending, so a slot that was a placeholder does not change size
   once the real URL lands.

   Which dimension binds depends on the panel's shape, and that flips
   between mediaPosition values and viewport sizes: "full" is wide-and-
   short (height binds), "left"/"right" on a tall window is narrow-and-tall
   (width binds). Pinning EITHER dimension to 100% breaks the other case -
   a browser clamps the second dimension against its max-* without
   recomputing the first from the ratio, so the embed stretches. Leaving
   both auto and letting the two max-* constraints do the work is the only
   form that fits correctly either way. */
#media-sidecar .media-carousel-item iframe {
  width: auto;
  height: auto;
  max-width: 100%;
  max-height: 100%;
  aspect-ratio: 16 / 9;
  flex: 0 1 auto;
  border: 0;
  border-radius: var(--panel-radius);
  box-shadow: 0 18px 40px -20px var(--scrim);
  margin-top: 0;
}

/* Height-driven, exactly like the real embed above, so a reserved slot and
   the YouTube iframe that eventually replaces it occupy the same box. */
#media-sidecar .video-pending {
  width: auto;
  height: auto;
  max-width: 100%;
  max-height: 100%;
  flex: 0 1 auto;
  border-radius: var(--panel-radius);
  box-shadow: 0 18px 40px -20px var(--scrim);
}

/* Caption sits in the padded strip BELOW the image, on the panel's own
   background - the old dark gradient was there to stay readable when the
   image ran edge-to-edge underneath it, which it no longer does. */
#media-sidecar figcaption {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  margin: 0;
  padding: 0.6rem 2rem 0.9rem;
  box-sizing: border-box;
  text-align: center;
  font-size: var(--text-caption);
  line-height: 1.45;
  color: var(--ink-soft);
}

/* A carousel with more than one item also renders a row of dots, and the
   base rule pins those 0.75rem off the bottom of the panel - which is
   exactly where the caption strip above sits, so the two print over each
   other. Lift the caption clear and give the slide the extra bottom
   padding the pair now needs.

   Scoped with :has() so the single-item slides - most chapters - keep the
   tighter original spacing rather than carrying a reserved gap for a
   control they never show.

   Both numbers are a hand-stacked sum, not round figures, and they moved
   when the dots and the caption type grew. Bottom up: 0.75rem inset +
   0.85rem of dot = 1.6rem, so the caption clears at 1.9rem. The caption
   box is 0.92rem x 1.45 of text plus 0.6/0.9rem of padding = 2.83rem,
   putting its top edge at 4.73rem - which is what the image must not be
   allowed to reach. Re-add these if any of the four inputs change. */
#media-sidecar .media-carousel:has(.media-carousel-dots) .media-carousel-item {
  padding-bottom: 4.8rem;
}

#media-sidecar .media-carousel:has(.media-carousel-dots) figcaption {
  bottom: 1.9rem;
}

/* =====================================================================
   Chapter - VOICE language
===================================================================== */
.chapter {
  min-height: 100vh;
  display: flex;
  /* Top-aligned (not vertically centred) so a chapter's card always lands
     in the same predictable spot - right below the nav bar, with a
     comfortable reading gap - whether the reader scrolls into it naturally
     or jumps straight to it from a nav tab. */
  align-items: flex-start;
  justify-content: center;
  padding: calc(var(--nav-offset, 0px) + 2.5rem) var(--gutter) 6vh;
  box-sizing: border-box;
  /* Anchor point used when a nav tab jumps the reader straight to a
     chapter (scrollIntoView) - without this the chapter's top few rows
     would land underneath the fixed header + sticky toolbar. */
  scroll-margin-top: var(--nav-offset, 0px);
}

/* The card sits opposite whichever side the media panel is docked to, so
   the two never overlap. Config-driven: renderChapter() writes the
   resolved mediaPosition onto the section, and the arithmetic that makes
   this safe is in the --voice-measure comment at the top of the file.

   Keyed on mediaPosition and not mapPosition: the map is a full-width band
   inside the chapter now and takes no side, so the panel is the only thing
   the card has to get out of the way of. */
.chapter[data-media-position="left"] { justify-content: flex-end; }
.chapter[data-media-position="right"] { justify-content: flex-start; }
.chapter[data-media-position="none"] { justify-content: center; }

.chapter-content {
  pointer-events: auto;
  position: relative;
  min-width: 0;
  max-width: var(--voice-measure);
  background: var(--voice-bg);
  padding: 1.6rem 1.9rem 1.75rem;
  border-radius: var(--voice-radius);
  box-shadow: var(--voice-shadow);
  /* The accent edge is what marks this box as narrative rather than a
     control - the one structural difference between Voice and Instrument
     that survives at a glance. */
  border-left: var(--voice-edge-w) solid var(--voice-edge);
}

/* Statement chapters: no media panel, so the card is centred and owns the
   screen, and can afford the wider measure. It stays left-aligned inside
   that - these run to several paragraphs plus a pull-quote, and a centred
   pull-quote with a left rule reads as broken. The heading steps up
   instead, since these are section openers and transitions. */
.chapter[data-media-position="none"] .chapter-content {
  max-width: var(--voice-measure-wide);
}

.chapter[data-media-position="none"] .chapter-heading h2 {
  font-size: clamp(1.8rem, 3vw, 2.45rem);
}

/* "mediaPosition": "full" - the media spans the whole viewport behind the
   card rather than docking to one side, so there is no opposite side to
   sit in and the card overlays it at reading width, pulled to the start of
   the line. Must come after the [data-media-position="none"] rules: equal
   specificity, later wins. */
.chapter[data-media-full="true"] {
  justify-content: flex-start;
}

.chapter[data-media-full="true"] .chapter-content {
  max-width: var(--voice-measure);
  /* Reads against a photograph rather than the page, so it needs a harder
     edge than --voice-shadow gives it. */
  box-shadow: 0 1px 0 var(--rule), 0 26px 50px -22px var(--scrim);
}

/* Picture-only chapter (no title / text - see renderChapter()): hide the
   empty card but keep its box, so the section keeps its scroll length. */
.chapter[data-no-card="true"] .chapter-content {
  visibility: hidden;
  pointer-events: none;
}

/* =====================================================================
   Map chapters - the card floats over the viewport-pinned #map. The
   section is a tall, transparent stretch of page in three beats:

     1. --map-lead-in of map on its own, so the reader meets the map
        before any words cover it;
     2. the card, scrolling up over the map;
     3. --map-lead-out of map on its own again once the card has gone,
        before the next region - a media-only chapter, the next map
        chapter or a plain one - takes the screen.

   The map is interactive in all three: this section and #story are
   pointer-events:none, only the card opts back in.

   The card sits on the left, at the same gutter as every other chapter's
   card, and passes over the map title and the legend on its way up.
===================================================================== */
.chapter.chapter-stacked {
  display: block;
  padding: 0;
  /* A toolbar jump should land on beat 1 - the map alone - so no nav
     clearance is reserved here; the lead-in already provides it. */
  scroll-margin-top: 0;
}

.chapter-stage-text {
  display: flex;
  align-items: flex-start;
  justify-content: flex-start;
  padding: var(--map-lead-in) var(--gutter) var(--map-lead-out);
  box-sizing: border-box;
}

/* Reads against the map rather than the page, so it takes the reading
   measure (not the wide one a "none" chapter gets) and the harder shadow
   the full-bleed media card uses. Must come after the
   [data-media-position="none"] rules: equal specificity, later wins. */
.chapter.chapter-stacked .chapter-content {
  max-width: var(--voice-measure);
  box-shadow: 0 1px 0 var(--rule), 0 26px 50px -22px var(--scrim);
}

/* And the docked heading scale, not the section-opener one. */
.chapter.chapter-stacked .chapter-heading h2 {
  font-size: clamp(1.55rem, 2.4vw, 1.95rem);
}

/* A map chapter's photographs, as a chapter of their own: an empty
   spacer. While it sits under the middle of the screen the map fades out
   and #media-sidecar opens full width (body.media-pos-full). Its height
   is how much scrolling the takeover holds for - the panel is pinned, so
   that scrolling moves nothing on screen and a full screen of it read as
   dead scroll. Transparent and click-through so the carousel underneath
   gets every click. */
.chapter-media-only {
  height: 65vh;
  pointer-events: none;
}

/* The full-width takeover carousel. At this width the arrows would sit
   far out at the screen edges, a long way from the image, and a little
   small for a panel this size - so the slide gets side gutters the arrows
   live in, and the arrows step up a size. */
body.media-pos-full #media-sidecar .media-carousel-item {
  padding-left: 6rem;
  padding-right: 6rem;
}

body.media-pos-full #media-sidecar .media-carousel-arrow {
  width: 3.75rem;
  height: 3.75rem;
  font-size: 2.3rem;
}

body.media-pos-full #media-sidecar .media-carousel-prev { left: 1.25rem; }
body.media-pos-full #media-sidecar .media-carousel-next { right: 1.25rem; }

.chapter-heading {
  display: flex;
  align-items: flex-start;
  gap: 0.65rem;
  margin-bottom: 0.9rem;
}

/* The docked case, and so the tighter of the two heading scales - the card
   only has the free 42% to work in. This is the ceiling --keyline-size is
   checked against; see that token before changing it. */
.chapter-heading h2 {
  margin: 0;
  font-size: clamp(1.55rem, 2.4vw, 1.95rem);
}

.chapter-content .icon {
  display: inline-flex;
  width: 1.4rem;
  height: 1.4rem;
  color: var(--color-primary);
  flex: none;
  margin-top: 0.12em;
}

.chapter-content .icon svg {
  width: 100%;
  height: 100%;
}

.chapter-content p {
  line-height: var(--body-leading);
  font-size: var(--text-body);
  margin: 0 0 0.9rem;
}

.chapter-content p:last-child {
  margin-bottom: 0;
}

.chapter-body ul {
  margin: 0 0 0.9rem;
  padding-left: 1.1rem;
}

/* Stated, not inherited: a bare <li> takes the 1rem root, which is a shade
   under the paragraphs it sits between. */
.chapter-body li {
  line-height: var(--body-leading);
  font-size: var(--text-body);
  margin-bottom: 0.35rem;
}

.chapter-body strong {
  font-weight: 600;
  color: var(--ink);
}

/* Chapter copy carries real outbound links (CAL FIRE, HVLT, the Firewise
   PDF). Unstyled they render as default browser blue-and-underline, which
   is the one colour on the page no theme controls. */
.chapter-body a,
.footer-text a {
  color: var(--color-primary);
  text-decoration: none;
  border-bottom: 1px solid color-mix(in srgb, var(--color-primary) 45%, transparent);
  transition: border-color 0.15s ease, color 0.15s ease;
}

.chapter-body a:hover,
.footer-text a:hover {
  border-bottom-color: var(--color-primary);
}

/* Review scaffolding, not design: flags a chapter whose copy is still
   placeholder. Deliberately quarantined as a dashed off-palette chip in
   the reserve signal colour, so it never reads as part of the page. */
.chapter-content .dummy-tag {
  display: inline-block;
  font-family: var(--font-heading);
  font-size: 0.74rem;
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--risk);
  background: transparent;
  border: 1px dashed color-mix(in srgb, var(--risk) 50%, transparent);
  padding: 0.125rem 0.4375rem;
  border-radius: 2px;
  margin-bottom: 0.9rem;
}

/* --- The pro-tip: an aside with something actionable in it. It was the
   quietest thing in the card - 0.93rem against 1.01rem body copy, so
   literally smaller than the prose it was meant to interrupt, with no
   fill at all behind it. It now carries a panel of its own and steps
   just above the body text.

   Set in the display SANS, which is the opposite swap to .key-line below,
   and the pairing is the point: the two devices sit in the same card and
   have to stay apart at a glance. Quote = serif on a primary-teal panel,
   aside = sans on a secondary-amber one. Same structural idea, two
   unmistakably different objects. --ink on this surface is 10.7:1. */
.chapter-content .pro-tip {
  margin: 1.3rem 0 0;
  padding: 0.8rem 1.05rem 0.85rem 1.1rem;
  border-left: 3px solid color-mix(in srgb, var(--color-secondary) 70%, transparent);
  border-radius: 0 var(--voice-radius) var(--voice-radius) 0;
  background: color-mix(in srgb, var(--color-secondary) 14%, transparent);
  font-family: var(--font-heading);
  font-size: 1.16rem;
  line-height: 1.55;
}

/* Sentence case and a weight/colour shift rather than a tracked-out
   all-caps label - the label is doing the same job with less noise. Now
   that the body of the aside is in the same face, weight is carrying the
   separation the face used to: 700 against the paragraph's 400. */
.chapter-content .pro-tip strong {
  display: block;
  font-size: 1.05rem;
  font-weight: 700;
  letter-spacing: 0;
  text-transform: none;
  color: color-mix(in srgb, var(--color-secondary) 78%, var(--ink));
  margin-bottom: 0.25rem;
}

.chapter-content .map-placeholder-note {
  margin-top: 0.9rem;
  font-size: 0.88rem;
  font-style: italic;
  color: var(--ink-faint);
  line-height: 1.5;
}

/* --- The pull-quote. The story's takeaway device, and the thing a reader
   skimming a chapter should still catch. Hung on the signal rule rather
   than boxed in a filled pill, which made every takeaway look like a
   button.

   Set in the BODY serif, not the display sans. That looks backwards until
   you see it next to the heading: h2 is Chivo, so a Chivo pull-quote
   underneath it read as a second, smaller heading - a hierarchy problem
   dressed up as emphasis. In Literata it reads as the narrative's own
   voice raised, which is what a pull-quote is, and the scale jump plus the
   panel carry the emphasis instead of the face. The serif is also what
   makes the larger size sit comfortably: a sans at this size in a 38rem
   measure starts to feel like signage.

   Size lives in --keyline-size because it is pinned between the body copy
   below it and the chapter h2 above it - that reasoning is with the token. */
.chapter-content .key-line {
  margin: 1.5rem 0 0.9rem;
  /* Real padding on all four sides now that the tint is heavy enough to
     read as a panel - at the old 0.1rem the fill hugged the text and
     looked like a highlighter stroke rather than a block. */
  padding: 0.85rem 1.15rem 0.9rem 1.2rem;
  border-left: 4px solid var(--keyline-edge);
  border-radius: 0 var(--voice-radius) var(--voice-radius) 0;
  background: var(--keyline-tint);
  color: var(--keyline-ink);
  font-family: var(--font-body);
  font-size: var(--keyline-size);
  font-weight: 600;
  letter-spacing: var(--display-tracking);
  line-height: 1.4;
  text-wrap: pretty;
}

.chapter-content img {
  width: 100%;
  border-radius: var(--panel-radius);
  display: block;
  margin-top: 0.75rem;
}

/* Acknowledgements: the only chapter with pictures inside its card rather
   than in the media panel - the client wants the portrait above the bio
   and the two credentials beneath it, as part of the text. */
.chapter-content .ack-portrait {
  width: 10rem;
  aspect-ratio: 3 / 4;
  object-fit: cover;
  object-position: 50% 15%;
  margin: 0 0 1.1rem;
}

.chapter-content .ack-badges {
  display: flex;
  align-items: center;
  gap: 1.5rem;
  margin-top: 1.3rem;
}

/* Undo the text-link underline - these links are pictures. */
.chapter-content .ack-badges a,
.chapter-content .ack-badges a:hover {
  border-bottom: 0;
  line-height: 0;
}

.chapter-content .ack-badges img {
  width: auto;
  height: 6.5rem;
  margin: 0;
  border-radius: 0;
  transition: transform 0.15s ease;
}

.chapter-content .ack-badges a:hover img {
  transform: scale(1.04);
}

.chapter-content figcaption {
  font-size: var(--text-caption);
  color: var(--ink-soft);
  margin-top: 0.3rem;
  line-height: 1.45;
}

.chapter-content video,
.chapter-content iframe {
  width: 100%;
  border-radius: var(--panel-radius);
  margin-top: 0.75rem;
  border: none;
  aspect-ratio: 16 / 9;
}

.video-block {
  margin-top: 1.1rem;
}

.video-block h3 {
  font-size: 1rem;
  margin-bottom: 0.3rem;
}

/* =====================================================================
   Media carousel - the one gallery component. Every chapter's images and
   video go through it, into the docked #media-sidecar panel (see above).
   The rules here are the shared skeleton; the #media-sidecar block
   overrides the handful of things that differ once it is in the panel -
   sizing, the caption, the top margin.
===================================================================== */
.media-carousel {
  position: relative;
  margin-top: 0.85rem;
  overflow: hidden;
  border-radius: var(--panel-radius);
}

.media-carousel-track {
  display: flex;
  transition: transform 0.4s ease;
}

.media-carousel-item {
  position: relative;
  flex: none;
  width: 100%;
  /* <figure> has a browser-default margin (e.g. 1em 40px) - left in place,
     it inflates each slide's outer flex width past 100%, so N * 100%
     translateX no longer lines up with slide boundaries and you see a
     sliver of the neighbouring slide instead of a clean cut. */
  margin: 0;
}

.media-carousel-item img,
.media-carousel-item video,
.media-carousel-item iframe {
  margin-top: 0;
  border-radius: 0;
}

/* A video slot the client has reserved but not yet supplied a URL for.
   Sized to the same 16:9 box a YouTube embed will occupy, so dropping the
   real URL in later does not reflow anything around it. */
.video-pending {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 0.65rem;
  aspect-ratio: 16 / 9;
  padding: 1.5rem;
  box-sizing: border-box;
  text-align: center;
  background:
    repeating-linear-gradient(
      -45deg,
      color-mix(in srgb, var(--color-accent) 5%, transparent) 0 12px,
      color-mix(in srgb, var(--color-accent) 10%, transparent) 12px 24px
    ),
    var(--sunk-bg);
  border: 1px dashed color-mix(in srgb, var(--color-accent) 45%, transparent);
}

.video-pending-badge {
  font-family: var(--font-heading);
  font-size: 0.84rem;
  font-weight: 600;
  letter-spacing: 0;
  text-transform: none;
  color: var(--ink-on-fill);
  background: var(--color-accent);
  border-radius: 2px;
  padding: 0.28rem 0.7rem;
}

.video-pending p {
  margin: 0;
  max-width: 34ch;
  font-size: 1.05rem;
  line-height: 1.5;
  color: var(--ink);
}

.video-pending p.video-pending-note {
  font-size: var(--text-caption);
  color: var(--ink-soft);
}

/* 3.25rem with a 2rem chevron. These were 2.25rem holding a 1.1rem glyph,
   which read as a small grey dot on a photograph rather than a control -
   the thing a reader has to find twice per slide. The chevron grows faster
   than the circle on purpose: at the old ratio the glyph only filled about
   half the button, so most of the enlargement would have gone into empty
   scrim. */
.media-carousel-arrow {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  width: 3.25rem;
  height: 3.25rem;
  border-radius: 50%;
  border: none;
  background: var(--scrim);
  color: #fff;
  font-size: 2rem;
  line-height: 1;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 1;
  transition: background 0.2s ease;
}

.media-carousel-arrow:hover {
  background: var(--color-primary);
}

.media-carousel-prev { left: 0.75rem; }
.media-carousel-next { right: 0.75rem; }

.media-carousel-dots {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0.75rem;
  display: flex;
  justify-content: center;
  gap: 0.6rem;
  z-index: 1;
}

/* 0.85rem, up from 0.5rem. These are both a position readout and a target,
   and an 8px circle is hard to hit deliberately and hard to count at a
   glance. The gap grows with them so a row of five still reads as five
   separate dots rather than a dashed line. Anything moving here has to be
   reconciled with the slide's padding-bottom and the caption's `bottom`
   in the #media-sidecar :has(.media-carousel-dots) rules, which are
   hand-stacked against this height. */
.media-carousel-dot {
  width: 0.85rem;
  height: 0.85rem;
  border-radius: 50%;
  border: none;
  background: rgba(255, 255, 255, 0.55);
  cursor: pointer;
  padding: 0;
}

.media-carousel-dot.active {
  background: #fff;
}

/* Per-image expand button - same affordance language as the map's own
   fullscreen control, recoloured to sit on top of a photo. */
.media-expand-btn {
  position: absolute;
  top: 0.75rem;
  right: 0.75rem;
  width: 2.5rem;
  height: 2.5rem;
  padding: 0.5rem;
  display: flex;
  align-items: center;
  justify-content: center;
  border: none;
  border-radius: var(--panel-radius);
  background: var(--scrim);
  color: #fff;
  cursor: pointer;
  z-index: 1;
  transition: background 0.2s ease;
}

.media-expand-btn svg {
  width: 100%;
  height: 100%;
}

.media-expand-btn:hover {
  background: var(--color-primary);
}

/* =====================================================================
   Image lightbox - one reusable modal (ensureImageModal()/
   openImageModal()) triggered by any .media-expand-btn. object-fit:
   contain, so the real aspect ratio is always preserved however tall or
   wide the source photo is.
===================================================================== */
.image-modal {
  position: fixed;
  inset: 0;
  z-index: 50;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--backdrop);
  opacity: 0;
  visibility: hidden;
  transition: opacity 0.2s ease;
  padding: 3rem 1.5rem;
}

.image-modal.open {
  opacity: 1;
  visibility: visible;
}

body.image-modal-open {
  overflow: hidden;
}

.image-modal-figure {
  margin: 0;
  max-width: 100%;
  max-height: 100%;
  display: flex;
  flex-direction: column;
  align-items: center;
}

.image-modal-img {
  display: block;
  max-width: 100%;
  max-height: calc(100vh - 8rem);
  width: auto;
  height: auto;
  object-fit: contain;
  border-radius: var(--panel-radius);
  box-shadow: 0 8px 40px rgba(0, 0, 0, 0.5);
}

.image-modal-caption {
  margin-top: 0.85rem;
  color: rgba(255, 255, 255, 0.92);
  font-size: 1rem;
  line-height: 1.5;
  text-align: center;
  max-width: 60ch;
}

.image-modal-close {
  position: absolute;
  top: 1.25rem;
  right: 1.25rem;
  width: 3rem;
  height: 3rem;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0.6rem;
  border: none;
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.12);
  color: #fff;
  cursor: pointer;
  transition: background 0.2s ease;
}

.image-modal-close svg {
  width: 100%;
  height: 100%;
}

.image-modal-close:hover {
  background: rgba(255, 255, 255, 0.25);
}

/* Video variant (ensureVideoModal()) - a 16:9 frame as large as the
   viewport allows, and a bigger, higher-contrast close button: the frame
   swallows clicks and keys once the reader is in the player, so the button
   is the obvious way out and should look it. */
.video-modal-frame {
  width: min(100%, calc((100vh - 8rem) * 16 / 9), 80rem);
  aspect-ratio: 16 / 9;
  border-radius: var(--panel-radius);
  overflow: hidden;
  background: #000;
  box-shadow: 0 8px 40px rgba(0, 0, 0, 0.5);
}

.video-modal-frame iframe {
  display: block;
  width: 100%;
  height: 100%;
  border: 0;
}

/* Semi-transparent, so the page stays visible behind the video: the
   lightbox's near-opaque --backdrop is right for studying a diagram, but
   this is a short detour from the title screen. */
.image-modal.video-modal {
  background: var(--scrim);
}

/* Same look and hover as the lightbox close (.image-modal-close), just
   bigger. The shadow keeps the translucent disc readable now that the
   page shows through behind it. */
.image-modal-close.video-modal-close {
  width: 4rem;
  height: 4rem;
  padding: 0.85rem;
  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.35);
}

/* The hero's "Click here" - reads as a link inside the lede. */
.hero-lede a[data-video-modal] {
  color: inherit;
  font-weight: 600;
  text-decoration: underline;
  text-decoration-thickness: 2px;
  text-underline-offset: 3px;
  text-decoration-color: var(--color-secondary);
}

.hero-lede a[data-video-modal]:hover {
  color: var(--color-secondary);
}

/* =====================================================================
   Hero - full-bleed cover. The content block is anchored bottom-left on a
   reading measure rather than centred: it leaves the subject of the
   panorama (the valley, Mount Tam) visible instead of covering it, it
   gives the page a single left edge shared with the header and the
   chapters, and it reads as a specific place rather than a product
   launch.
===================================================================== */
.chapter.hero {
  position: relative;
  min-height: 100vh;
  align-items: flex-end;
  justify-content: flex-start;
  padding: 0;
  overflow: hidden;
}

.hero-bg {
  position: absolute;
  inset: 0;
  background-size: cover;
  background-position: center;
  z-index: 0;
}

/* Autoplaying background-video variant of .hero-bg (renderHeroChapter()) -
   falls back to the plain background-image .hero-bg when the chapter has no
   backgroundVideo or the reader has prefers-reduced-motion set. */
.hero-bg-video {
  background: #000;
}

.hero-bg-media {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center;
}

/* Rendered in both the video and the static-image path, so the scrim is
   themeable in both (it used to be an inlined gradient in story.js for the
   image path, which no theme could reach). */
.hero-bg-overlay {
  position: absolute;
  inset: 0;
  background: var(--hero-scrim);
}

/* Play/pause for the hero background video. Sits above the scrim but stays
   understated - it is a control for an ambient background, not a media
   player, so it keeps out of the title's way until hovered. */
.hero-video-toggle {
  position: absolute;
  bottom: 1.25rem;
  right: 1.25rem;
  z-index: 2;
  /* #story is pointer-events:none so scroll/drag passes through to the map
     underneath; interactive bits inside it have to opt back in (same as
     .hero-content and .map-control-btn do). Without this the button
     renders but swallows nothing - every click goes straight past it. */
  pointer-events: auto;
  width: 3rem;
  height: 3rem;
  padding: 0.6rem;
  display: flex;
  align-items: center;
  justify-content: center;
  border: 1px solid rgba(255, 255, 255, 0.45);
  border-radius: 50%;
  background: rgba(0, 0, 0, 0.4);
  color: #fff;
  cursor: pointer;
  opacity: 0.6;
  transition: opacity 0.2s ease, background 0.2s ease;
}

.hero-video-toggle svg {
  width: 100%;
  height: 100%;
  fill: currentColor;
}

.hero-video-toggle:hover,
.hero-video-toggle:focus-visible {
  opacity: 1;
  background: rgba(0, 0, 0, 0.65);
}

/* Sized in rem, NOT in ch. `ch` resolves against this container's own
   ~16px font, so a reading measure here (54ch = ~490px) is far too narrow
   for the 4.1rem title inside it and snaps the h1 into four stacked lines.
   The container is sized for the display type; the lede sets its own
   reading measure below, where `ch` does mean what it says. */
.hero-content {
  position: relative;
  z-index: 1;
  pointer-events: auto;
  /* Content-box (this stylesheet sets no global box-sizing), so 46rem is the
     text measure and the gutter padding sits outside it. */
  max-width: min(46rem, 100%);
  text-align: left;
  padding: 0 var(--gutter) clamp(3.5rem, 9vh, 6.5rem);
  color: var(--hero-ink);
}

/* A mark on the same baseline as the dateline, not an illustration above
   the title. */
.hero-content .icon {
  display: none;
}

/* The eyebrow is a dateline: where this is about. Sentence case, no
   tracking, tied to the hero's left edge by a short rule. */
.hero-content .eyebrow {
  display: flex;
  align-items: center;
  gap: 0.7rem;
  text-transform: none;
  letter-spacing: 0;
  font-size: 1.05rem;
  font-family: var(--font-heading);
  font-weight: 500;
  color: var(--hero-ink);
  opacity: 0.88;
  margin: 0 0 1rem;
}

.hero-content .eyebrow::before {
  content: "";
  width: 2.4rem;
  height: 2px;
  flex: none;
  background: var(--color-secondary);
}

.hero-content h1 {
  font-size: clamp(2.3rem, 6vw, 4.1rem);
  font-weight: 700;
  line-height: 1.03;
  letter-spacing: -0.025em;
  margin: 0 0 1.1rem;
  text-wrap: balance;
}

.hero-lede {
  font-size: clamp(1.18rem, 1.6vw, 1.38rem);
  line-height: 1.6;
  max-width: 46ch;
  margin: 0;
  opacity: 0.94;
}

/* The page's one piece of non-user-triggered motion, and it is functional:
   it tells a first-time reader that this page is driven by scrolling. A
   travelling line rather than a bouncing arrow glyph. */
.scroll-cue {
  display: flex;
  align-items: center;
  gap: 0.7rem;
  margin-top: 2.2rem;
  font-family: var(--font-heading);
  font-size: 0.95rem;
  font-weight: 500;
  letter-spacing: 0;
  text-transform: none;
  opacity: 0.82;
}

.scroll-cue-arrow {
  position: relative;
  display: block;
  width: 1px;
  height: 2.1rem;
  overflow: hidden;
  background: rgba(255, 255, 255, 0.28);
}

.scroll-cue-arrow::after {
  content: "";
  position: absolute;
  left: 0;
  top: 0;
  width: 1px;
  height: 0.9rem;
  background: var(--color-secondary);
  animation: scroll-cue-travel 2.4s cubic-bezier(0.65, 0, 0.35, 1) infinite;
}

@keyframes scroll-cue-travel {
  0% { transform: translateY(-100%); }
  60%, 100% { transform: translateY(2.1rem); }
}

/* =====================================================================
   Header - slim persistent brand bar (not the hero, which carries the
   full title treatment). Left-aligned on the same --gutter as the hero
   and the chapters, so the page has one left edge rather than a centred
   bar floating over left-aligned content.
===================================================================== */
#header {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  z-index: 3;
  box-sizing: border-box;
  text-align: left;
  padding: 0.8rem var(--gutter);
  /* Opaque, so this and the sticky toolbar docked under it read as a
     permanent piece of chrome rather than a translucent overlay letting
     page content show through. */
  background: var(--nav-bg);
  color: var(--nav-ink);
  border-bottom: 1px solid var(--nav-rule);
  pointer-events: none;
  font-family: var(--font-heading);
}

/* #header is full-bleed and has no max-width - only --gutter padding - so
   this can grow without being crushed into a two-line wrap. It is the only
   place the project is named once the hero has scrolled away, so it is set
   as a title rather than as a caption. The header's height is measured at
   runtime into --nav-offset (setupNavOffset()), so growing it here moves
   the sticky toolbar and every scroll-margin with it automatically.

   The ceiling was 1.65rem and that was too timid: at --ui-scale 0.8 it
   rendered 21.1px, BELOW every h2 on the page including a docked chapter
   heading at 24.96px, so the one piece of permanent branding read as the
   smallest title in the document. 2.1rem puts it at 26.9px - clearly above
   the docked h2, still well under a full-width chapter h2 (31.4px) and far
   under the hero (52.5px), so the hierarchy still runs hero > chapter >
   brand bar. The cost is about 7px of header height, which --nav-offset
   passes on to every chapter's top padding; that is the trade and it is
   deliberate, unlike raising --ui-scale, which would have inflated the
   chips, the map controls and the legend along with it.

   Only the max term moved. At desktop widths the 2vw middle term is above
   the ceiling anyway (28.8px at 1440px), so the ceiling is the only knob
   that does anything here - see the --ui-scale comment. */
#header h1 {
  margin: 0;
  font-size: clamp(1.3rem, 2vw, 2.1rem);
  font-weight: 700;
  letter-spacing: -0.01em;
  line-height: 1.2;
}

/* =====================================================================
   Toolbar - persistent section nav below the brand header.

   It used to be two tiers: SECTION tabs on top and that section's CHAPTER
   pills underneath. The sub-row is gone. With 46 chapters it was a tag
   cloud nobody aimed at, and because only the active section's sub-row
   was displayed, every section change was a potential height change in a
   sticky element - i.e. the page jumping under the reader mid-scroll. One
   tier cannot do that, so the "reserved" invisible-but-laid-out
   workaround went with it.

   The bar is now two parts, not two tiers: .toolbar-sections (the eight
   narrative tabs, which may wrap) and .toolbar-tail (the Block Explorer
   call-to-action and the theme picker, which never do). The tail is
   separate because the eight labels, the Explorer and four theme swatches
   do not fit on one line at any ordinary desktop width - sharing a row,
   the Explorer ended up wherever the wrap happened to leave it.

   #toolbar's own "top" is set inline from JS (setupNavOffset()) to sit
   flush under the actual rendered header height, and its total height
   feeds --nav-offset, which every scroll target offsets against. Wrapping
   therefore costs nothing structurally: --nav-offset is re-measured by a
   ResizeObserver, and the wrap count only changes on resize, never
   mid-scroll.
===================================================================== */
#toolbar {
  position: sticky;
  top: 2.5rem;
  left: 0;
  width: 100%;
  z-index: 4;
  box-sizing: border-box;
  display: flex;
  align-items: center;
  gap: 1.25rem;
  padding: 0 var(--gutter);
  background: var(--nav-sub-bg);
  pointer-events: auto;
}

/* A fixed 4-column grid, NOT flex-wrap - the same argument as #legend.
   Eight labels of wildly different lengths ("Whither Homestead" against
   "Defensible Space & Hardening") wrapped wherever they happened to run
   out of room, so the row landed 5 / 3 at ragged x positions and the bar
   read as an accident. Four equal tracks put them 4 / 4, each chip the
   same width as the one above or below it, with a straight left edge down
   the whole block.

   The chips are allowed to wrap their own text (see white-space below),
   which is what makes this hold up as the window narrows: a grid row is
   as tall as its tallest cell and align-items: stretch passes that height
   to all four, so a row where one label has taken two lines is four
   equal-height two-line chips rather than one tall chip and three short
   ones. Nothing has to be measured or rebalanced at a breakpoint.

   grid-auto-rows: 1fr carries that one step further, across rows rather
   than within them. stretch alone left row 1 at 38px of one-line chips
   and row 2 at 58px, because only "Defensible Space & Hardening" wraps
   and only its own row grew - two neat rows of four that did not match
   each other. The rows are implicit (auto-flow puts them there; the
   template only names columns), so grid-auto-rows is what sizes them,
   and in an auto-height container fr tracks resolve to the largest
   content contribution among them - which is to say all eight chips end
   up as tall as the tallest one, whichever label happens to wrap at the
   current width.

   minmax(0, 1fr) rather than 1fr so a long label wraps inside its track
   instead of forcing the track wider than its quarter share and shoving
   .toolbar-tail off the end.

   min-width: 0 so this can actually shrink below its content width -
   without it the flex item's auto minimum would push .toolbar-tail off
   the right edge.

   The padding and row gap are tighter than they look like they want to
   be. Equal rows cost real height - this bar is sticky for the entire
   read and --nav-offset is subtracted from every chapter's usable screen
   - so the space goes to the chips themselves rather than the air around
   them. */
.toolbar-sections {
  flex: 1 1 auto;
  min-width: 0;
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  grid-auto-rows: 1fr;
  align-items: stretch;
  gap: 0.3rem 0.5rem;
  padding: 0.4rem 0;
}

/* The stacked-layout section menu button (renderToolbar()) - only the
   stacked @media block shows it. */
.toolbar-menu-toggle {
  display: none;
}

/* Explore button's phone label - shown only by the 480px rule. */
.toolbar-standalone-label-short {
  display: none;
}

@media (max-width: 480px) {
  .toolbar-standalone-label {
    display: none;
  }

  .toolbar-standalone-label-short {
    display: inline;
  }
}

/* Never shrinks and never wraps: the whole point of pulling these out of
   the tab row is that they hold their position at every width. */
.toolbar-tail {
  flex: none;
  display: flex;
  align-items: center;
  margin-left: auto;
}

/* --- Section tabs */
.toolbar-section-item {
  flex: none;
  font-family: var(--font-heading);
  font-size: 1rem;
  font-weight: 500;
  letter-spacing: 0;
  color: var(--nav-ink-soft);
  background: transparent;
  border: none;
  border-bottom: 2px solid transparent;
  border-radius: 0;
  padding: 0.55rem 0 0.5rem;
  cursor: pointer;
  white-space: nowrap;
  transition: color 0.2s ease, border-color 0.2s ease;
}

.toolbar-section-item:hover {
  color: var(--nav-ink);
  border-bottom-color: var(--nav-rule);
}

.toolbar-section-item.active {
  background: transparent;
  color: var(--nav-ink);
  font-weight: 600;
  border-bottom-color: var(--nav-marker);
}

/* The chip treatment: the .key-line / .pro-tip device from the narrative
   (hung left edge, right corners only) borrowed for the tab row, so a
   section header reads as a labelled thing rather than a word floating on
   the bar.

   GREEN MEANS ACTIVE AND NOTHING ELSE. An earlier version tinted all
   eight chips with --color-primary and gave the active one a stronger
   pour of the same green. It failed in the obvious way: a row of eight
   green blocks reads as eight selected tabs, the current section is a
   difference of degree inside a field of the same hue, and at a glance
   you cannot tell which one you are in - which is the only question the
   bar exists to answer. So the tint comes off the inactive chips
   entirely. They are now the bar's own near-black, outlined, and the
   single green fill in the row is where the reader is.

   Inactive chips sit on --nav-bg (#081312), one step DARKER than the
   toolbar's --nav-sub-bg (#0f1d1b) rather than transparent. The grid
   wants eight discrete objects - equal columns, straight edges, the
   symmetry the layout was built for - and transparent chips would
   dissolve that back into eight floating words. Darker rather than
   lighter because a lighter fill is the universal "this one" signal and
   that job belongs to the active chip alone.

   Scoped to `.toolbar-sections .toolbar-section-item` rather than the bare
   class because the Block Explorer pill also carries
   .toolbar-section-item - it has to, so showToolbarSection() and the click
   handler find it with everything else - but it lives in .toolbar-tail and
   is drawn as a filled CTA. A descendant selector leaves it alone by
   position instead of by a counter-override, so neither treatment has to
   out-specify the other.

   border-color on .active is load-bearing, not decoration:
   .toolbar-section-item.active sets border-bottom-color: var(--nav-marker)
   and nothing else, so without it an active chip paints three green edges
   and one teal one.

   Contrast, computed against the composited colours (the inactive fills
   are opaque now, so these are arithmetic on known values rather than
   guesses about alpha):
     - inactive label (--nav-ink-soft, 68% over #081312) on its own chip:
       7.80:1
     - active label (--nav-ink, opaque) on the active fill #346f61: 4.97:1,
       AA for normal text and the label is larger and bolder than that
     - active chip SURFACE against the bar: 2.97:1, a hair under the 3:1
       a non-text boundary wants, which is why it is never the only
       signal - the hung 3px rule runs at full --nav-marker for 5.54:1
       against the bar, and the type steps up in both size and weight
   The active fill is color-mix(--nav-marker 65%, --nav-bg) - the same mix
   --nav-cta-active-bg uses - rather than neat --nav-marker, which no ink
   in the palette can sit on at 4.5:1, and which would also have made the
   chip indistinguishable from the Block Explorer CTA two inches to its
   right.

   Selection is therefore signalled four ways at once: fill goes from
   near-black to green, the hung edge goes from a quiet white rule to full
   marker, weight goes 500 to 700, and size goes 1rem to 1.1rem. */
.toolbar-sections .toolbar-section-item {
  display: flex;
  align-items: center;
  justify-content: center;
  text-align: center;
  /* Wrapping is what keeps the grid symmetrical as the window narrows -
     see the .toolbar-sections comment. balance splits a two-line label
     evenly instead of leaving one orphaned word. */
  white-space: normal;
  text-wrap: balance;
  /* A LENGTH, not a multiplier, and that is the whole trick that lets the
     active chip be bigger without moving the furniture. A line box is as
     tall as its line-height, not its font-size, so a fixed 1.3rem box
     holds the 1rem inactive label and the 1.1rem active one at exactly
     the same height. Were this still the unitless 1.25, promoting a chip
     would grow its line box, and since .toolbar-sections equalises rows
     with grid-auto-rows: 1fr, BOTH rows would grow - a sticky bar that
     changes height as the reader scrolls from one section to the next,
     which is the precise failure the two-tier toolbar was deleted for.
     1.3rem also leaves 1.1rem enough room (ratio 1.18) that two-line
     labels do not touch.

     One residual case this does not cover: at a width where the longest
     label fits on one line at 1rem but not at 1.1rem, activating that
     chip adds a line and the rows grow together. At 1440px / 0.8 scale
     the longest ("Defensible Space & Hardening") needs roughly 227px
     active against a ~260px track, so there is headroom; the exposure is
     a narrow band of window widths just above the 820px breakpoint. */
  line-height: 1.3rem;
  padding: 0.5rem 0.7rem;
  background: var(--nav-bg);
  border: 1px solid var(--nav-rule);
  border-left: 3px solid color-mix(in srgb, var(--nav-ink) 22%, transparent);
  border-radius: 0 var(--panel-radius) var(--panel-radius) 0;
  transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
}

/* Hover lifts the chip toward the bar's own lightness and warms the hung
   edge to a partial marker - a preview of the active state, not a copy of
   it. No fill tint, so a pointer resting on the row still cannot be
   mistaken for the selection. */
.toolbar-sections .toolbar-section-item:not(.active):hover {
  color: var(--nav-ink);
  background: color-mix(in srgb, var(--nav-ink) 12%, var(--nav-bg));
  border-color: color-mix(in srgb, var(--nav-ink) 28%, transparent);
  border-left-color: color-mix(in srgb, var(--nav-marker) 70%, transparent);
}

.toolbar-sections .toolbar-section-item.active {
  color: var(--nav-ink);
  font-size: 1.1rem;
  font-weight: 700;
  background: color-mix(in srgb, var(--nav-marker) 65%, var(--nav-bg));
  border-color: color-mix(in srgb, var(--nav-marker) 72%, transparent);
  border-left-color: var(--nav-marker);
}

/* ---------------------------------------------------------------------
   The Block Explorer tab.

   It is not another chapter group - it is the one place the reader can
   drive the map themselves, and the point of the whole prototype. The
   underline treatment the other tabs use made it look like a sibling of
   "What Wildfire Means Here", so it is drawn as a filled call-to-action
   instead: its own pill, in the theme's marker colour, living in
   .toolbar-tail so it is on screen at every width.

   It keeps the .toolbar-section-item class, so showToolbarSection() and
   the click handler still find it with everything else and it still
   tracks the reader's position. Only its appearance is different.

   --nav-cta-ink exists because --nav-marker is orange in two themes and
   red in the other two, and no single ink is legible on both. Each theme
   pairs its own; all four clear 4.5:1 against their marker.
--------------------------------------------------------------------- */
.toolbar-section-standalone {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.42rem 0.95rem;
  border: 1px solid transparent;
  border-bottom: 1px solid transparent;
  border-radius: 999px;
  background: var(--nav-marker);
  color: var(--nav-cta-ink);
  font-weight: 600;
  box-shadow: 0 0 0 0 color-mix(in srgb, var(--nav-marker) 55%, transparent);
  /* color is in the list because this rule replaces the base tab's
     transition wholesale. Leave it out and the label snaps to its active
     near-white while the fill is still part-way through darkening, which
     parks white-on-teal (2.77:1) on screen for the length of the fade. */
  transition: background 0.2s ease, color 0.2s ease, box-shadow 0.2s ease,
    transform 0.2s ease;
}

/* A slow halo rather than a bounce: the bar is pinned to the top of the
   viewport for the whole read, so anything that actually moved would be
   an irritant within a minute. Only box-shadow animates - it is painted
   outside the border box and takes no part in layout, so this cannot
   reflow the sticky bar no matter how far the ring spreads. The global
   prefers-reduced-motion:reduce block near the end of this file would
   neutralise it anyway; the no-preference guard here just means the
   animation is never attached in the first place. */
@media (prefers-reduced-motion: no-preference) {
  .toolbar-section-standalone {
    animation: toolbar-cta-halo 3.2s ease-out infinite;
  }
}

@keyframes toolbar-cta-halo {
  0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--nav-marker) 55%, transparent); }
  70% { box-shadow: 0 0 0 0.6rem color-mix(in srgb, var(--nav-marker) 0%, transparent); }
  100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--nav-marker) 0%, transparent); }
}

/* Being in the Explorer inverts the pill: it darkens toward the bar and
   the label flips to the nav's pale ink. That is the opposite move
   from the other tabs, which brighten when active - but this one starts
   filled, so brightening had nowhere to go.

   The outline is doing real work here, not just marking state: the
   darkened fill sits only 2.96:1 off the row behind it, so the ring is
   what gives the pill an edge. See --nav-cta-active-bg.

   The background declaration is load-bearing, not a restatement of the
   base rule: .toolbar-section-item.active sets background: transparent at
   equal specificity earlier in the file, so without an explicit fill here
   the pill goes bare and the label is near-white on the dark bar. */
.toolbar-section-standalone.active {
  background: var(--nav-cta-active-bg);
  color: var(--nav-ink);
  border-bottom-color: transparent;
  outline: 2px solid var(--nav-ink);
  outline-offset: 2px;
}

/* The dot is drawn in --nav-cta-ink, which is near-black for the teal
   fill and so disappears into the darkened active one. It follows the
   label. */
.toolbar-section-standalone.active .toolbar-standalone-dot {
  background: var(--nav-ink);
}

/* :not(.active):hover so hovering while already in the Explorer does not
   restate a weaker treatment - the two rules are the same specificity and
   this one is later in the file. */
.toolbar-section-standalone:not(.active):hover {
  background: color-mix(in srgb, var(--nav-marker) 82%, #ffffff);
  color: var(--nav-cta-ink);
  border-bottom-color: transparent;
  transform: translateY(-1px);
}

/* The dot in front of the label. Decorative (aria-hidden in the markup);
   it reads as a live indicator next to "Explore Your Block". */
.toolbar-standalone-dot {
  flex: none;
  width: 0.42rem;
  height: 0.42rem;
  border-radius: 50%;
  background: var(--nav-cta-ink);
  opacity: 0.85;
}

/* =====================================================================
   Theme picker - last item in .toolbar-tail, after the Explorer button
   and separated from it by a rule, so it reads as page chrome rather
   than a second call to action. Built by renderThemePicker().
===================================================================== */
.theme-picker {
  display: flex;
  align-items: center;
  gap: 0.35rem;
  padding-left: 1.1rem;
  margin-left: 0.9rem;
  border-left: 1px solid var(--nav-rule);
}

.theme-option {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  flex: none;
  font-family: var(--font-heading);
  font-size: 0.8rem;
  font-weight: 500;
  letter-spacing: 0;
  color: var(--nav-ink-soft);
  background: transparent;
  border: 1px solid transparent;
  border-radius: 999px;
  padding: 0.22rem 0.55rem 0.22rem 0.28rem;
  cursor: pointer;
  white-space: nowrap;
  transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease;
}

.theme-option:hover {
  border-color: var(--nav-rule);
  color: var(--nav-ink);
}

.theme-option.active {
  background: color-mix(in srgb, var(--nav-ink) 14%, transparent);
  border-color: color-mix(in srgb, var(--nav-ink) 45%, transparent);
  color: var(--nav-ink);
}

/* Three vertical bands previewing primary / secondary / accent. */
.theme-swatch {
  display: flex;
  width: 1.3rem;
  height: 1.3rem;
  border-radius: 50%;
  overflow: hidden;
  flex: none;
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--nav-ink) 35%, transparent);
}

.theme-swatch span {
  flex: 1;
}

/* The labels are the first thing to go when the nav gets tight - the
   swatches alone still communicate the choice, and the accessible name
   stays on the button via aria-label. */
@media (max-width: 1100px) {
  .theme-option-label {
    display: none;
  }

  .theme-option {
    padding: 0.22rem 0.28rem;
  }
}

/* =====================================================================
   Legend - INSTRUMENT language. Lives inside #map (see index.html) and is
   anchored to the map's own box rather than the viewport: the map is a
   band at the top of its chapter now, so a viewport-fixed legend would
   float over the narrative text below it, describing a map that is no
   longer on screen.
===================================================================== */
#legend {
  position: absolute;
  bottom: 1.5rem;
  left: 1.5rem;
  z-index: 2;
  background: var(--panel-bg);
  color: var(--panel-ink);
  border: 1px solid var(--panel-rule);
  border-radius: var(--panel-radius);
  box-shadow: var(--panel-shadow);
  /* The right pad keeps the first row clear of the 2rem close button. */
  padding: 0.75rem 2.9rem 0.75rem 1rem;
  /* Sized to the MAP, not the viewport - it lives inside #map. Width leaves
     the right-hand control stack (fullscreen / home / zoom) uncovered;
     height stops below the title plate, so the legend can never run off
     the map or over its title. If a short window still cannot fit it, the
     box scrolls inside itself rather than shrinking the type. rem caps for
     the same reason as before: everything inside scales with --ui-scale. */
  box-sizing: border-box;
  width: min(calc(100% - 3rem - 3.5rem), 64rem);
  max-height: calc(100% - 3rem - var(--map-title-h));
  overflow-x: hidden;
  overflow-y: auto;
  overscroll-behavior: contain;
  /* 1rem, up from 0.88rem: the item labels were the smallest running text
     on the map and hard to read at a glance over a busy basemap. */
  font-size: 1rem;
  transition: opacity 0.2s ease, transform 0.2s ease;
}

#legend.legend-closed {
  display: none;
}

/* Balanced columns, not the fixed 3-track grid this replaces. A grid row is
   as tall as its tallest section, so one long key (six fire districts, the
   eight-step elevation ramp) left holes beside every short one and the box
   grew tall and lopsided. Columns pack each section straight under the one
   above and balance the three to equal height - symmetric by construction,
   and far shorter for the same type size. The sections are ordered in
   data/chapters.json "legend" so related keys read down a column together:
   boundaries, fire, terrain, vegetation, roads. */
#legend .legend-columns {
  column-count: 3;
  column-gap: 1.6rem;
  column-rule: 1px solid var(--rule);
  column-fill: balance;
}

#legend .legend-section,
#legend .legend-section:nth-of-type(3n),
#legend .legend-section:last-child {
  /* A section is never split across two columns. */
  break-inside: avoid;
  border-right: none;
  padding-right: 0.3rem;
  margin-bottom: 0.55rem;
}

/* 2rem with a 1.9rem glyph, up from 1.5rem / 1.4rem - it was a faint
   small cross that was easy to miss and fiddly to hit, on touch above all. */
.legend-close {
  position: absolute;
  top: 0.3rem;
  right: 0.4rem;
  width: 2rem;
  height: 2rem;
  border: none;
  border-radius: var(--panel-radius);
  background: transparent;
  color: var(--ink-soft);
  font-size: 1.9rem;
  line-height: 1;
  cursor: pointer;
  padding: 0;
}

.legend-close:hover {
  color: var(--risk);
}

/* Reads as one of the map's controls rather than as a tag: same hairline,
   same --panel-radius corner and same hover fill as .map-control-btn and
   the title plate, instead of the 999px capsule it used to wear. It now
   carries a whole instruction ("Open Legend to select your layers") and
   is the only thing standing between the reader and the key, so it is
   also set a size up from the legend's own 1rem body. */
.legend-reopen {
  position: absolute;
  bottom: 1.5rem;
  left: 1.5rem;
  z-index: 2;
  display: none;
  font-family: var(--font-heading);
  font-size: 1.1rem;
  font-weight: 500;
  color: var(--panel-ink);
  background: var(--panel-bg);
  border: 1px solid var(--panel-rule);
  border-radius: var(--panel-radius);
  box-shadow: var(--panel-shadow);
  padding: 0.5rem 1rem;
  cursor: pointer;
}

.legend-reopen.visible {
  display: inline-block;
}

.legend-reopen.legend-right {
  left: auto;
  right: 1.5rem;
}

/* Every section is always visible and clickable (role="switch") so a
   reader can flip any layer on/off, not just the ones the current chapter
   turned on - opacity distinguishes active from inactive (previously
   inactive sections collapsed to max-height:0, which hid them entirely and
   made them impossible to switch back on).

   The dimming is on the title and the symbology, NOT on the section box:
   opacity on the box would also fade everything inside it, including the
   switch's tooltip, which would then show at 45% over the basemap. The
   switch needs no dimming anyway - its grey track already says "off". */
.legend-section h4,
.legend-section > :not(.legend-header) {
  opacity: 0.45;
  transition: opacity 0.2s ease;
}

.legend-section:hover h4,
.legend-section:hover > :not(.legend-header) {
  opacity: 0.75;
}

.legend-section.active h4,
.legend-section.active > :not(.legend-header) {
  opacity: 1;
}

.legend-section {
  /* min-width:0 lets a grid item shrink below its content's intrinsic
     width, which is what keeps all three tracks exactly 1fr wide - the
     default (auto) would let the widest section push its column out and
     break the alignment the grid is there to provide. */
  min-width: 0;
  margin: 0;
  padding: 0.15rem 0.9rem 0.15rem 0.3rem;
  border-right: 1px solid var(--rule);
  border-radius: 3px;
  cursor: pointer;
  transition: background 0.15s ease;
}

/* Column separators: drop the rule on the right-hand column only, so it
   reads as two dividers BETWEEN three columns rather than a trailing line
   down the outside edge. nth-of-type (not nth-child) because #legend's
   first child is the close button, which would otherwise shift the count
   by one. */
.legend-section:nth-of-type(3n),
.legend-section:last-child {
  border-right: none;
  padding-right: 0.3rem;
}

.legend-section:hover {
  background: var(--panel-hover);
}

.legend-section:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 1px;
}

.legend-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.5rem;
  margin: 0 0 0.4rem;
}

.legend-section h4 {
  margin: 0;
  font-size: 1.08rem;
  font-weight: 600;
  letter-spacing: 0;
  font-family: var(--font-heading);
}

/* Pill switch. Not its own click target (the whole .legend-section div is
   the click/keyboard target) - mirrors state via .active on the parent so
   it never needs its own listener. It does carry a tooltip saying what a
   click will do, and the copy flips with the state: data-tip-on /
   data-tip-off are set in buildLegendSectionHtml() and swapped in here by
   the .active selector, so no JS has to keep the text in step.

   Sized in rem, like every symbol in this legend, so the switch tracks
   --ui-scale with the labels beside them. 2.25 x 1.25rem, up from
   1.625 x 0.94rem, which read as a small decoration rather than a
   control. */
.legend-toggle {
  position: relative;
  flex: none;
  width: 2.25rem;
  height: 1.25rem;
  border-radius: 999px;
  background: color-mix(in srgb, var(--panel-ink) 24%, transparent);
  transition: background 0.2s ease;
}

.legend-toggle[data-tip]::after {
  content: attr(data-tip-on);
}

.legend-section.active .legend-toggle[data-tip]::after {
  content: attr(data-tip-off);
}

.legend-toggle-thumb {
  position: absolute;
  top: 0.15625rem;
  left: 0.15625rem;
  width: 0.9375rem;
  height: 0.9375rem;
  border-radius: 50%;
  background: var(--panel-bg);
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
  transition: transform 0.2s ease;
}

.legend-section.active .legend-toggle {
  background: var(--color-primary);
}

/* Travel = track - thumb - both insets (2.25 - 0.9375 - 2x0.15625), so the
   thumb lands flush against the far inset. Keep it in step if any of those
   three change. */
.legend-section.active .legend-toggle-thumb {
  transform: translateX(1rem);
}

.legend-item {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  margin-bottom: 0.25rem;
}

.legend-swatch {
  width: 0.9rem;
  height: 0.9rem;
  border-radius: 2px;
  flex: none;
  display: inline-block;
}

/* Line-based swatch variants (renderLegendShell()) - a short rule instead
   of a filled block, so legend items for line layers (marin /
   homesteadHighlight boundary, contours, streets) look like the line they
   represent instead of a flat colour chip. Colour comes from item.color
   via the inline border-top-color set in JS. */
.legend-swatch-line-solid,
.legend-swatch-line-dashed {
  width: 1.35rem;
  height: 0;
  border-radius: 0;
  background: transparent;
  border-top: 3px solid;
}

/* Dotted, not dashed, to match the round-capped dot pattern the trail
   layer actually draws on the map (see TRAIL_DASHARRAY). Same width and
   colour as the solid variant - the pattern is the only difference, on the
   map and here. */
.legend-swatch-line-dashed {
  border-top-style: dotted;
}

/* Road Network rows get the same white halo the roads have on the map
   (LINE_HALO_PAD), or the near-black road swatch vanishes into the dark
   legend panel. Four unblurred drop-shadows rather than one blurred one:
   that traces a crisp 1px outline, and as a filter it follows the shape,
   so each dot of the dotted trail swatch gets its own halo too. Both
   legends - the story map's and the Explorer's - share this markup. */
.legend-section[data-layer="streets"] .legend-swatch-line-solid,
.legend-section[data-layer="streets"] .legend-swatch-line-dashed {
  filter:
    drop-shadow(1px 0 0 #ffffff)
    drop-shadow(-1px 0 0 #ffffff)
    drop-shadow(0 1px 0 #ffffff)
    drop-shadow(0 -1px 0 #ffffff);
}

/* Teardrop swatch for point landmarks (the Community Center pin) - the
   same three-corner-round / one-corner-square trick rotated 45deg that
   gives the map pin its silhouette, so the legend row reads as the symbol
   it stands for rather than as another colour chip. The inner dot is the
   ::after, matching the white centre createPinImage() draws.

   Colour is set from the live theme in JS
   (updateCommunityCenterLegendSwatches), not from chapters.json, because
   this is a themed landmark rather than a data encoding. */
.legend-swatch-pin {
  width: 0.6875rem;
  height: 0.6875rem;
  border-radius: 50% 50% 50% 0;
  transform: rotate(-45deg);
  position: relative;
  margin-left: 0.125rem;
  margin-right: 0.1875rem;
}

.legend-swatch-pin::after {
  content: "";
  position: absolute;
  top: 50%;
  left: 50%;
  width: 0.25rem;
  height: 0.25rem;
  margin: -0.125rem 0 0 -0.125rem;
  border-radius: 50%;
  background: #fff;
}

/* Bivariate matrix symbology (renderMatrixSymbology()) - a 2D grid of
   colour cells with one axis per variable, so a reader decodes a map
   colour back into a PAIR of class values instead of matching it against
   16 unordered chips. Sized to sit in the same horizontal legend strip as
   the list-style sections without making the whole legend taller. */
.legend-matrix {
  display: flex;
  align-items: stretch;
  gap: 0.25rem;
}

/* Rotated so the axis arrow points UP alongside the grid, matching the
   fact that rows are emitted highest-class-first. */
/* The five rules below are the smallest type on the page, and at the old
   sizes the x-axis edge numbers were 0.52rem - about 8px - which is below
   what this readership can decode at all. Raised about 15% each, keeping
   their order (ticks > axis labels > edge numbers) so the matrix still
   reads as a chart rather than five competing captions. Held back from the
   legend body's 1rem on purpose: these have to fit alongside a
   4-column cell band inside one of three legend tracks. */
.legend-matrix-ylabel {
  writing-mode: vertical-rl;
  transform: rotate(180deg);
  font-size: 0.72rem;
  line-height: 1;
  letter-spacing: 0.02em;
  text-align: center;
  color: var(--ink-soft);
  white-space: nowrap;
}

/* One grid holds the y tick column, the cells, the x edge row and the x
   axis label, which is what keeps the edge numbers aligned to the cell
   boundaries regardless of how wide the tick labels are. */
.legend-matrix-grid {
  display: grid;
  grid-template-columns: auto repeat(var(--legend-matrix-cols, 4), 1.3rem);
  gap: 0.125rem;
  align-items: center;
}

.legend-matrix-cell {
  width: 1.3rem;
  height: 1.3rem;
  border-radius: 2px;
  display: inline-block;
  /* Hairline separators: without them, adjacent cells of similar lightness
     (the middle of the matrix) visually merge into a blob and the grid
     stops reading as discrete classes. */
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.55);
}

.legend-matrix-ytick {
  font-size: 0.78rem;
  line-height: 1;
  text-align: right;
  padding-right: 0.15rem;
  white-space: nowrap;
  color: var(--panel-ink);
}

/* space-between puts the first and last number flush with the two ends of
   the cell band and the inner ones on the boundaries between cells. */
.legend-matrix-xedges {
  display: flex;
  justify-content: space-between;
  font-size: 0.7rem;
  line-height: 1;
  color: var(--ink-soft);
  /* Half a cell of bleed each side so the end numbers can centre on the
     outer boundaries rather than being clipped inside the band. */
  margin: 0.1rem -0.5rem 0;
}

.legend-matrix-xlabel {
  font-size: 0.72rem;
  line-height: 1;
  text-align: center;
  letter-spacing: 0.02em;
  color: var(--ink-soft);
  margin-top: 0.05rem;
}

.legend-matrix-note {
  margin: 0.35rem 0 0;
  max-width: 15rem;
  font-size: 0.74rem;
  line-height: 1.4;
  color: var(--ink-faint);
}

/* Gradient-bar symbology (renderGradientSymbology()) - a single continuous
   vertical ramp, high at the top, ruled at every class break, with a label
   per class (or per break) and a short tick joining each label to the bar.
   One fixed height for every gradient section, so the three of them sit in
   the legend's middle row as a matched set. The vertical padding is room for
   break labels centred on the very top and bottom of the bar. */
.legend-gradient {
  display: flex;
  align-items: stretch;
  gap: 0.5rem;
  height: 9rem;
  padding: 0.45rem 0;
}

.legend-gradient-bar {
  position: relative;
  display: block;
  flex: none;
  width: 1.1rem;
  height: 100%;
  border-radius: 2px;
  box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.18);
}

/* White rule with a dark underline, so a break reads on the pale yellow /
   off-white ends of a ramp and on its darkest end alike. */
.legend-gradient-break {
  position: absolute;
  left: 0;
  right: 0;
  border-top: 1px solid rgba(255, 255, 255, 0.85);
  box-shadow: 0 1px 0 rgba(0, 0, 0, 0.35);
}

.legend-gradient-labels {
  position: relative;
  flex: 1;
  min-width: 0;
  font-size: 0.78rem;
  line-height: 1;
  color: var(--panel-ink);
}

.legend-gradient-label {
  position: absolute;
  left: 0;
  transform: translateY(50%);
  white-space: nowrap;
}

/* Tick from the bar to each label. */
.legend-gradient-label::before {
  content: "";
  position: absolute;
  left: -0.5rem;
  top: 50%;
  width: 0.32rem;
  border-top: 1px solid currentColor;
  opacity: 0.6;
}

/* Legend position + empty state, both driven by chapters.json "settings" */
#legend.legend-right {
  left: auto;
  right: 1.5rem;
}

#legend.legend-hidden {
  display: none;
}

/* =====================================================================
   Footer - VOICE language. Two columns on wide screens: the closing
   statement on the left, the resource list on the right. The ten links
   are a reference list people scan for one specific thing (their Block
   Captain, the CAL FIRE zones, the Firewise PDF), so they are set as a
   ruled list rather than a cloud of centred pills, which gave ten
   unrelated destinations identical visual weight.
===================================================================== */
#footer {
  position: relative;
  z-index: 1;
  background: var(--footer-bg);
  color: var(--footer-ink);
  padding: 5rem var(--gutter) 3rem;
  border-top: 1px solid var(--nav-rule);
}

.footer-inner {
  max-width: 68rem;
  margin: 0 auto;
  text-align: left;
  display: grid;
  grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
  gap: 2.5rem 4rem;
  align-items: start;
}

#footer h2 {
  grid-column: 1;
  margin: 0;
  font-size: clamp(1.8rem, 3.2vw, 2.5rem);
  max-width: 22ch;
  text-wrap: balance;
}

.footer-text {
  grid-column: 1;
  line-height: var(--body-leading);
  font-size: var(--text-body);
  opacity: 0.82;
  margin: 0;
  max-width: 42ch;
}

.footer-links {
  grid-column: 2;
  grid-row: 1 / span 2;
  display: flex;
  flex-direction: column;
  gap: 0;
  margin: 0;
  border-top: 1px solid var(--nav-rule);
}

.footer-link {
  color: var(--footer-ink);
  text-decoration: none;
  border: none;
  border-bottom: 1px solid var(--nav-rule);
  border-radius: 0;
  padding: 0.7rem 0;
  font-family: var(--font-heading);
  font-size: 1rem;
  font-weight: 500;
  line-height: 1.4;
  opacity: 0.85;
  transition: color 0.2s ease, opacity 0.2s ease, padding-left 0.2s ease;
}

.footer-link:hover {
  opacity: 1;
  color: var(--color-secondary);
  padding-left: 0.4rem;
}

.footer-credit {
  grid-column: 1 / -1;
  font-size: 0.88rem;
  line-height: 1.5;
  opacity: 0.55;
  margin: 1rem 0 0;
  padding-top: 1.5rem;
  border-top: 1px solid var(--nav-rule);
  max-width: 70ch;
}

/* =====================================================================
   Block Explorer - standalone in-flow section with its own MapLibre
   instance, lazily initialized on scroll-into-view
   (initExplorerObserver()/initExplorerMap()). Sits right after the
   "Analyze My Own Block" chapter. Mirrors the general .chapter rhythm
   (same top padding math) without using the .chapter class itself, since
   it is not part of the scroll-triggered map-handoff system.
===================================================================== */
.explorer-section {
  position: relative;
  z-index: 1;
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  justify-content: center;
  /* Only --map-frame-pad horizontally: the map is near-full-width, held
     off the window edge by the same margin as a story map is held off the
     edge of its band. The header carries the full --gutter itself. */
  padding: calc(var(--nav-offset, 0px) + 3rem) var(--map-frame-pad) 4rem;
  box-sizing: border-box;
  background: var(--page-bg-color);
  text-align: left;
  /* #story is pointer-events:none so a map can stay draggable through the
     gaps between chapter cards - every interactive descendant has to opt
     back in. Without this the Explorer's map swallows no mouse events at
     all: no clicks, no hover, no cursor change. */
  pointer-events: auto;
}

/* The section's own horizontal padding is only --map-frame-pad (so the map
   can run close to the edge), so the header makes up the difference to a
   full --gutter itself and stays on the page's one left edge. */
.explorer-header {
  max-width: 58ch;
  margin: 0 calc(var(--gutter) - var(--map-frame-pad)) 1.5rem;
}

/* Not a tag: a short rule plus a sentence-case label, same dateline device
   as the hero eyebrow, so "this is a tool" is said once in one voice. */
.explorer-badge {
  display: flex;
  align-items: center;
  gap: 0.7rem;
  font-size: 1rem;
  font-weight: 500;
  letter-spacing: 0;
  text-transform: none;
  background: transparent;
  color: var(--ink-soft);
  padding: 0;
  border-radius: 0;
  margin-bottom: 0.7rem;
  font-family: var(--font-heading);
}

.explorer-badge::before {
  content: "";
  width: 2.4rem;
  height: 2px;
  flex: none;
  background: var(--color-secondary);
}

.explorer-header h2 {
  margin: 0 0 0.6rem;
  font-size: clamp(1.8rem, 3vw, 2.45rem);
}

.explorer-header p {
  margin: 0;
  line-height: var(--body-leading);
  font-size: var(--text-body);
  color: var(--ink-soft);
}

/* Near-full-width and as tall as a story map's band - this is the one map
   on the page the reader is meant to work in rather than read, so it gets
   at least as much room as the narrative ones.

   Framed the same way #map is, with the same tokens, so the two maps in
   the story read as the same kind of object. align-self: stretch is
   needed because .explorer-section is a flex column with
   align-items: flex-start, which would otherwise shrink this to the
   width of its contents. */
.explorer-map-wrap {
  position: relative;
  align-self: stretch;
  /* The cap is in rem so it tracks --ui-scale, which is also what real
     browser zoom does to it - the vh term is already zoom-invariant, so
     leaving the cap in px would be the one half of this min() that didn't
     move. 62.5rem is the old 1000px at a 16px basis. */
  height: min(var(--map-stage-h), 62.5rem);
  overflow: hidden;
  border: 1px solid var(--panel-rule);
  border-radius: var(--map-frame-radius);
  box-shadow: var(--panel-shadow);
  background: var(--map-bed);
  /* The "Explore Your Block" tab scrolls to this element rather than to
     the top of the section, so the map itself lands in view.
     --nav-offset covers the header + toolbar heights but NOT the 2.5rem
     the sticky toolbar is offset from the top (see #toolbar), so that has
     to be added back or the map's top edge tucks under the bar. The last
     1rem is breathing room. */
  scroll-margin-top: calc(var(--nav-offset, 5.5rem) + 2.5rem + 1rem);
}

#explorer-map {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
}

/* Blocks are the only thing on this map worth clicking, and clicking one
   is the whole point of the section - so the pointer cursor is the resting
   state here, instead of MapLibre's default grab/grabbing pan affordance. */
#explorer-map .maplibregl-canvas-container.maplibregl-interactive,
#explorer-map .maplibregl-canvas-container.maplibregl-interactive:active,
#explorer-map .maplibregl-canvas-container.maplibregl-interactive .maplibregl-canvas {
  cursor: pointer;
}

/* Mirrors #map-fullscreen-btn's top-right placement, but expands the
   Explorer's own wrap - see initExplorerMap() for why this map needs a
   separate button rather than sharing the story map's. */
.explorer-map-fullscreen {
  position: absolute;
  top: 1rem;
  right: 1rem;
  z-index: 2;
}

/* The wrap is height-capped and framed for its inline slot in the page; in
   fullscreen it owns the whole screen, so the cap has to come off or the
   map renders as a letterboxed strip on a black backdrop, and the frame
   has to come off with it - a hairline and a radius make no sense on an
   edge that is the edge of the display. */
.explorer-map-wrap:fullscreen {
  height: 100%;
  width: 100%;
  border-radius: 0;
  border: none;
  box-shadow: none;
}

/* In-page fullscreen, for browsers without the Fullscreen API on anything
   but <video> - i.e. every iPhone browser. toggleMapFullscreen() in
   js/story.js puts this class on the map (or the Explorer's wrap) instead
   of calling requestFullscreen(). Pinned over everything, including the
   sticky nav, and sized to the dynamic viewport so the browser's own
   toolbars do not hide the map's bottom edge. !important because #map's
   ID rules set its inset, frame and opacity. */
.map-pseudo-fullscreen {
  position: fixed !important;
  inset: 0 !important;
  width: 100% !important;
  height: 100vh !important;
  height: 100dvh !important;
  max-height: none !important;
  z-index: 1000 !important;
  border: none !important;
  border-radius: 0 !important;
  box-shadow: none !important;
  opacity: 1 !important;
  pointer-events: auto !important;
}

body.map-pseudo-fullscreen-open {
  overflow: hidden;
}

/* The Explorer's wrap lives inside #story, whose z-index: 1 makes it its
   own stacking context - so the wrap's z-index cannot climb past the fixed
   header (z-index 3) on its own. Lift #story itself, only while it holds
   the in-page fullscreen map (the story map's #map is outside #story, and
   raising #story then would cover it with the chapter cards). */
#story:has(.map-pseudo-fullscreen) {
  z-index: 1000;
}

/* =====================================================================
   Touch tip - a small bubble hung under the map's fullscreen button, its
   arrow pointing up at it. On touch devices the maps are view-only in the
   page (setupTouchMapGestures() in js/story.js); this appears for a few
   seconds when a map comes into view or the reader taps or pinches it,
   and the fullscreen button pulses (.map-control-attention) while it
   shows. Kept to the corner the button already occupies so it covers as
   little of the map as possible. Only ever created on touch devices.

   Position: both maps put their fullscreen button 1rem in from the
   top-right corner at 2.9rem square (.map-control-btn), so the bubble
   starts 0.6rem under it and the arrow sits under the button's centre.
===================================================================== */
.map-touch-hint {
  position: absolute;
  top: calc(1rem + 2.9rem + 0.6rem);
  right: 1rem;
  z-index: 3;
  display: flex;
  align-items: center;
  gap: 0.5rem;
  max-width: min(13.5rem, calc(100% - 2rem));
  box-sizing: border-box;
  padding: 0.5rem 0.7rem;
  font-family: var(--font-heading);
  font-size: 0.86rem;
  font-weight: 600;
  line-height: 1.3;
  text-align: left;
  color: var(--ink-on-fill);
  background: var(--color-primary);
  border: none;
  border-radius: var(--panel-radius);
  box-shadow: var(--panel-shadow);
  cursor: pointer;
  opacity: 0;
  visibility: hidden;
  transform: translateY(-0.3rem);
  transform-origin: top right;
  transition: opacity 0.2s ease, transform 0.2s ease, visibility 0s linear 0.2s;
}

/* The arrow: a small rotated square under the button's centre (1.45rem
   in from the right edge, half the button). */
.map-touch-hint::before {
  content: "";
  position: absolute;
  top: -0.3rem;
  right: calc(1.45rem - 0.3rem);
  width: 0.6rem;
  height: 0.6rem;
  background: inherit;
  transform: rotate(45deg);
}

.map-touch-hint svg {
  flex: none;
  width: 1rem;
  height: 1rem;
}

.map-touch-hint.visible {
  opacity: 1;
  visibility: visible;
  transform: translateY(0);
  transition: opacity 0.2s ease, transform 0.2s ease;
}

/* The fullscreen button, while the bubble is pointing at it: a soft ring
   pulsing out from it, in the bubble's colour. */
.map-control-btn.map-control-attention {
  border-color: var(--color-primary);
  animation: map-control-pulse 1.4s ease-out infinite;
}

@keyframes map-control-pulse {
  0%   { box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-primary) 70%, transparent), var(--panel-shadow); }
  100% { box-shadow: 0 0 0 0.7rem color-mix(in srgb, var(--color-primary) 0%, transparent), var(--panel-shadow); }
}

@media (prefers-reduced-motion: reduce) {
  .map-control-btn.map-control-attention {
    animation: none;
    box-shadow: 0 0 0 3px var(--color-primary), var(--panel-shadow);
  }
}

.map-pseudo-fullscreen .map-touch-hint,
:fullscreen .map-touch-hint {
  display: none;
}

/* Same bottom-right stack as the main map's home/zoom controls, scoped to
   this wrap instead of #map's own box. */
.explorer-map-controls {
  position: absolute;
  right: 1rem;
  bottom: 1rem;
  z-index: 2;
  display: flex;
  flex-direction: column;
  /* Matched to .map-control-stack's gap so the two maps' control clusters
     are the same object - at the enlarged button size a 0.1rem difference
     was visible as the plates nearly touching on one map only. */
  gap: 0.6rem;
  align-items: flex-end;
}

/* The Explorer's own legend - same .legend-section switches as the story
   legend (so a row is both a key and an on/off control), but anchored
   inside this section's map instead of the viewport, and listing only the
   layers this map actually carries. Column layout + scroll because the
   elevation key alone is 8 classes tall.

   Starts below the title plate rather than at 1rem: the plate owns the
   top-left corner on both maps, and this is the one piece of furniture
   that would otherwise sit under it. Both numbers are derived from
   --map-title-h (the plate's 1rem inset, the plate itself, then a 0.5rem
   gap), and the max-height gives back the usual 1rem at the bottom too,
   or the scroll area would run past the bottom of the map. */
.explorer-legend {
  position: absolute;
  top: calc(1.5rem + var(--map-title-h));
  left: 1rem;
  z-index: 2;
  /* 17.5rem, up from 15.625rem, because the labels inside grew with the
     type scale - at the old width "Vegetation Density (Fuel Load)" wrapped
     to three lines and the 8-class elevation key stopped fitting without a
     scroll. */
  width: 19.5rem;
  max-height: calc(100% - 2.5rem - var(--map-title-h));
  overflow-y: auto;
  background: var(--panel-bg);
  color: var(--panel-ink);
  border: 1px solid var(--panel-rule);
  border-radius: var(--panel-radius);
  box-shadow: var(--panel-shadow);
  padding: 0.7rem 1rem 0.7rem 0.9rem;
  box-sizing: border-box;
  text-align: left;
  font-size: 1rem;
}

.explorer-legend.legend-closed {
  display: none;
}

.explorer-legend-hint {
  margin: 0 1.2rem 0.6rem 0;
  font-size: 0.84rem;
  font-style: italic;
  color: var(--ink-faint);
  line-height: 1.4;
}

.explorer-legend-hint.hidden {
  display: none;
}

.explorer-legend-sections {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}

.explorer-legend .legend-section {
  min-width: 0;
  border-right: none;
  padding: 0.3rem 0.4rem;
  border-bottom: 1px solid var(--rule);
}

.explorer-legend .legend-section:last-child {
  border-bottom: none;
}

/* A per-block layer with no block selected has nothing to draw, so its row
   is dimmed and its click handler short-circuits (see
   setExplorerBlockLayers) - otherwise the switch looks live but would only
   ever produce an empty map. */
.explorer-legend .legend-section-unavailable {
  opacity: 0.45;
  cursor: default;
}

.explorer-legend-reopen {
  position: absolute;
  /* Takes the legend's place, so it clears the title the same way. */
  top: calc(1.5rem + var(--map-title-h));
  left: 1rem;
  z-index: 2;
  display: none;
  font-family: var(--font-heading);
  /* Matched to .legend-reopen on the story map: same rectangular
     --panel-radius corner, same label size and same hover fill, so the two
     maps don't disagree about what a control looks like. */
  font-size: 1.1rem;
  font-weight: 500;
  color: var(--panel-ink);
  background: var(--panel-bg);
  border: 1px solid var(--panel-rule);
  border-radius: var(--panel-radius);
  box-shadow: var(--panel-shadow);
  padding: 0.5rem 1rem;
  cursor: pointer;
}

.explorer-legend-reopen.visible {
  display: inline-block;
}

/* Block detail popup - anchored on the clicked block (openExplorerPopup()),
   overriding MapLibre's default white-box chrome to match the Instrument
   language. */
.explorer-popup .maplibregl-popup-content {
  background: var(--panel-bg);
  color: var(--panel-ink);
  border: 1px solid var(--panel-rule);
  border-radius: var(--panel-radius);
  box-shadow: var(--panel-shadow);
  padding: 0.9rem 1.15rem 1rem;
  text-align: left;
  font-family: var(--font-body);
}

/* The tip is a separate element with its own border colour, so a themed
   popup body with an unthemed arrow reads as a glitch. */
.explorer-popup .maplibregl-popup-anchor-top .maplibregl-popup-tip { border-bottom-color: var(--panel-bg); }
.explorer-popup .maplibregl-popup-anchor-bottom .maplibregl-popup-tip { border-top-color: var(--panel-bg); }
.explorer-popup .maplibregl-popup-anchor-left .maplibregl-popup-tip { border-right-color: var(--panel-bg); }
.explorer-popup .maplibregl-popup-anchor-right .maplibregl-popup-tip { border-left-color: var(--panel-bg); }

.explorer-popup .maplibregl-popup-close-button {
  width: 1.8rem;
  height: 1.8rem;
  font-size: 1.45rem;
  color: var(--ink-faint);
  border-radius: 0;
}

.explorer-popup .maplibregl-popup-close-button:hover {
  background: transparent;
  color: var(--risk);
}

.explorer-popup-eyebrow {
  margin: 0 1.2rem 0.25rem 0;
  font-family: var(--font-heading);
  font-size: 0.88rem;
  font-weight: 500;
  text-transform: none;
  letter-spacing: 0;
  color: var(--ink-soft);
}

.explorer-popup h3 {
  margin: 0 0 0.55rem;
  font-size: 1.18rem;
  line-height: 1.3;
  padding-right: 0.8rem;
}

.explorer-popup-captain {
  margin: 0;
  font-size: 0.98rem;
  line-height: 1.5;
  padding-top: 0.5rem;
  border-top: 1px solid var(--rule);
}

.explorer-popup-captain strong {
  display: block;
  font-family: var(--font-heading);
  font-size: 0.86rem;
  font-weight: 600;
  text-transform: none;
  letter-spacing: 0;
  color: var(--ink-soft);
  margin-bottom: 0.1rem;
}

/* =====================================================================
   Tooltips.

   Every icon-only control on the page used to rely on the native title=
   attribute, which has three problems for this readership: the browser
   decides when to show it (roughly a second of stillness, with no way to
   ask for sooner), it renders in the OS's own small system font with no
   regard for --ui-scale, and it never appears for a keyboard user at all.
   So they are drawn here instead, as an ::after on the control, in the
   Instrument materials the rest of the map furniture uses.

   The copy is in the markup, not here: data-tip on the element in
   index.html and in the renderers in js/story.js. It is written as a full
   instruction ("Click to return to the starting view") rather than a
   label ("Reset view"), because on an icon-only button the reader's
   question is what pressing it will do, not what it is called.

   title= is removed wherever data-tip is added. Leaving both would show
   the native bubble underneath this one a second later.

   aria-label is NOT removed - it is what assistive tech reads, and
   content on a ::after is not reliably announced. The two carry the same
   meaning deliberately.

   ::after, not ::before, because .wind-key-item and .legend-swatch-pin
   already use ::before and sharing would make reuse here a trap.

   POSITIONING. Three of the four clipping ancestors on this page sit
   between a tooltip and the air it needs: #map, .explorer-map-wrap and
   .media-carousel are all overflow: hidden, and every control carrying a
   tooltip is pinned to one of their edges. So the default (below,
   centred) is only right for a control with room under it, and anything
   on an edge takes a data-tip-pos pointing INWARD. Four map controls
   point left, the two carousel arrows point at each other. There is no
   auto-flip - this is CSS with no measuring step - so a new tooltip needs
   its direction chosen by hand against the box it lives in.
===================================================================== */
/* :where() so this weighs nothing - and it has to weigh nothing.
   A tooltip is an ::after on the control, so the control must be a
   containing block; but several of these controls are ALREADY positioned
   by their own rule, and half of those are position: absolute pinned to a
   corner. A bare [data-tip] is one attribute selector, exactly the same
   0-1-0 as .map-control-fullscreen, and this block sits ~2500 lines
   later - so it won. The fullscreen button lost its `position: absolute`
   and fell back into flow at the map's top-left corner, taking the
   carousel arrows, the expand button and the modal close with it.

   :where() drops the specificity to zero, so any element's own position
   declaration beats it no matter where in the file it sits. Static
   controls still pick up `relative` and anchor their bubble; absolute,
   fixed and sticky ones keep their placement and are containing blocks
   already, so the ::after anchors correctly either way. */
:where([data-tip]) {
  position: relative;
}

[data-tip]::after {
  content: attr(data-tip);
  position: absolute;
  z-index: 10;
  /* Default: centred below. */
  top: calc(100% + 0.5rem);
  left: 50%;
  transform: translateX(-50%);
  /* max-content so a short tip is a short bubble rather than stretching
     to the button's width, with a measure on it so a long one wraps
     instead of running off the map. */
  width: max-content;
  max-width: 14rem;
  padding: 0.4rem 0.65rem;
  box-sizing: border-box;
  background: var(--panel-bg);
  color: var(--panel-ink);
  border: 1px solid var(--panel-rule);
  border-radius: var(--panel-radius);
  box-shadow: var(--panel-shadow);
  font-family: var(--font-heading);
  font-size: 0.88rem;
  font-weight: 500;
  font-style: normal;
  letter-spacing: 0;
  line-height: 1.35;
  text-align: center;
  text-transform: none;
  /* The carousel arrows set nowrap and a 2rem chevron on themselves, and
     an ::after inherits both. */
  white-space: normal;
  pointer-events: none;
  opacity: 0;
  visibility: hidden;
  /* This is the transition used on the way OUT: a quick fade with no
     delay, and visibility only flips once the fade is done, so the hidden
     bubble is not a hit target between hovers. */
  transition: opacity 0.1s ease, visibility 0s linear 0.1s;
}

/* Shown: on hover (real hover only - see below), on keyboard focus, and
   for a touch press (.tip-touch, set by setupTouchTooltips() in
   js/story.js). The way IN is nearly immediate: a 0.05s delay and a 0.1s
   fade. The old 0.25s delay + 0.14s fade made every bubble feel slow to
   arrive; 0.05s still stops a cursor sweeping across the control stack
   from flashing a bubble on each button it passes. */
[data-tip]:focus-visible::after,
[data-tip].tip-touch::after {
  opacity: 1;
  visibility: visible;
  transition: opacity 0.1s ease 0.05s, visibility 0s linear 0.05s;
}

/* :hover only where the device can really hover. On a touchscreen the
   browser fakes :hover on tap and then leaves it stuck until something
   else is tapped, which is why the bubbles used to linger on phones.
   Touch gets .tip-touch instead: shown while the finger is down, gone
   shortly after it lifts. */
@media (hover: hover) {
  [data-tip]:hover::after {
    opacity: 1;
    visibility: visible;
    transition: opacity 0.1s ease 0.05s, visibility 0s linear 0.05s;
  }
}

/* Modal close buttons take focus as soon as their modal opens (so Escape
   and Tab work straight away), and that focus alone would pin the bubble
   on screen for as long as the modal is up. Hover-only for these: the
   button's aria-label already tells keyboard and screen-reader users what
   it does. :not(:hover) keeps the hover bubble working on a focused one. */
.image-modal-close[data-tip]:focus-visible:not(:hover)::after {
  opacity: 0;
  visibility: hidden;
}

[data-tip][data-tip-pos="above"]::after {
  top: auto;
  bottom: calc(100% + 0.5rem);
}

[data-tip][data-tip-pos="left"]::after {
  top: 50%;
  bottom: auto;
  left: auto;
  right: calc(100% + 0.5rem);
  transform: translateY(-50%);
}

[data-tip][data-tip-pos="right"]::after {
  top: 50%;
  bottom: auto;
  left: calc(100% + 0.5rem);
  right: auto;
  transform: translateY(-50%);
}

/* =====================================================================
   Narrow widths - phones and small tablets.

   The 58%/42% dock split has no useful form here: a 32rem card squeezed
   into the right 42% of a 375px viewport is ~157px wide. So below 820 the
   docked panel takes the full width and sits in the TOP 46vh, and the
   card is pushed down to clear it - the panel above, the words below,
   which is how this genre degrades.

   This is also where the gutters tighten, the map band shortens so more
   of the text below shows with it, and the nav type comes back down a
   notch (it is sticky, so its height is permanently off the screen).
===================================================================== */
/* Stacked layout. Phones, AND portrait tablets: an iPad held upright is
   744-1024px wide but taller than it is wide, and the side-by-side layout
   gave it a squeezed media band with the text floating over the photos.
   This query must stay identical to STACKED_LAYOUT_QUERY in js/story.js,
   which moves each chapter's media into the chapter on the same screens. */
@media (max-width: 820px), (max-width: 1100px) and (orientation: portrait) {
  :root {
    --gutter: 1.25rem;
    --voice-measure: 100%;
    --voice-measure-wide: 100%;
    --map-stage-h: 58vh;
    --map-frame-pad: 0.6rem;
    /* Tracks the 1.25rem .map-title below: 1.25 x 1.18 + 1rem of padding
       + the hairline. */
    --map-title-h: 2.6rem;

    /* How much of the screen a chapter's photo or video may take while it
       is pinned above the scrolling text (.chapter-inline-media below):
       just over half, so a useful strip of text always shows under it.
       svh - the small viewport, with the browser's toolbars showing - so
       that strip is never hidden under the phone's own toolbar. */
    --inline-media-max-h: calc((100vh - var(--nav-offset, 0px)) * 0.52);
    --inline-media-max-h: calc((100svh - var(--nav-offset, 0px)) * 0.52);
  }

  /* The pinned media panel is not used on stacked layouts - every
     chapter's media is inline (.chapter-inline-media below) - so it is
     kept out of the way even if a class lingers on <body>. */
  #media-sidecar {
    display: none;
  }

  /* Article flow: a plain chapter is its media, full width, then its
     card underneath - one column, no floating, no pinned panel. The
     100vh minimum was there to hold a pinned photo on screen while the
     card scrolled past; with the photo in the flow it only made blank
     screen. */
  .chapter:not(.chapter-stacked):not(.hero) {
    display: block;
    min-height: 0;
    /* Plain spacing, not nav clearance: a nav jump already clears the
       sticky bar through scroll-margin-top, and padding every chapter by
       the nav's height left a screen-high gap between chapters in flow. */
    padding-top: 2rem;
    padding-bottom: 2.5rem;
  }

  .chapter:not(.chapter-stacked):not(.hero) .chapter-content {
    width: 100%;
    max-width: none;
    box-sizing: border-box;
  }

  /* A picture-only chapter has no card to show under its picture. */
  .chapter[data-no-card="true"] .chapter-content {
    display: none;
  }

  /* A map chapter's photographs: the section holds them now instead of
     being an empty spacer over the pinned panel, so it sizes to them and
     takes clicks again. */
  .chapter-media-only {
    height: auto;
    pointer-events: auto;
    padding: 1.5rem 0 2rem;
    background: var(--sunk-bg);
    scroll-margin-top: var(--nav-offset, 0px);
  }

  /* Edge to edge: the gutters are cancelled so photos and videos get the
     whole screen width.

     And pinned, Esri-mobile style: while the reader is in a chapter its
     photo or video sticks under the nav and the text scrolls up beneath
     it, passing behind it, rather than the picture scrolling away with the
     first line of text. Sticky, not fixed, so it is the chapter that
     decides how long it holds: when the chapter's end comes up, the media
     goes with it and the next chapter's arrives. Opaque and above the card
     (z-index within #story) so the text really does pass behind it. */
  .chapter-inline-media {
    position: sticky;
    top: var(--nav-offset, 0px);
    z-index: 2;
    margin: 0 calc(-1 * var(--gutter)) 1.25rem;
    background: var(--sunk-bg);
    box-shadow: 0 12px 18px -14px rgba(0, 0, 0, 0.7);
    pointer-events: auto;
  }

  /* A map chapter's photographs have a section of their own and nothing
     scrolling under them, so they stay in the flow. */
  .chapter-media-only .chapter-inline-media {
    position: static;
    box-shadow: none;
  }

  .chapter-media-only .chapter-inline-media {
    margin: 0;
  }

  .chapter-inline-media .media-carousel {
    margin: 0;
    border-radius: 0;
    background: var(--sunk-bg);
  }

  .chapter-inline-media .media-carousel-item {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: flex-start;
  }

  /* Full width, natural height, capped so the pinned media leaves room for
     the text scrolling under it - the expand button still opens a tall
     poster full size. */
  .chapter-inline-media .media-carousel-item img,
  .chapter-inline-media .media-carousel-item video {
    display: block;
    width: 100%;
    height: auto;
    max-height: var(--inline-media-max-h);
    object-fit: contain;
  }

  /* A 16:9 box as wide as the screen allows - the full width on a phone,
     where that is well inside the cap. On a portrait tablet the full width
     would make it most of the screen tall, so it is held to the cap and
     centred instead. */
  .chapter-inline-media .media-carousel-item iframe,
  .chapter-inline-media .video-pending {
    display: block;
    width: min(100%, calc(var(--inline-media-max-h) * 16 / 9));
    height: auto;
    aspect-ratio: 16 / 9;
    margin: 0 auto;
    border: 0;
  }

  .chapter-inline-media figcaption {
    position: static;
    width: 100%;
    box-sizing: border-box;
    padding: 0.55rem var(--gutter) 0.7rem;
    text-align: center;
    font-size: 0.9rem;
    line-height: 1.45;
    color: var(--ink-soft);
  }

  /* Carousel controls below the media instead of over it. Over a photo the
     side arrows were fine; over a video they sat on top of the player's
     own controls - most of all its fullscreen button in the corner - and a
     tap meant for the video changed slide instead. So on stacked layouts
     the carousel is a two-row grid: the media across the top, and a
     control bar underneath with the arrows at its ends and the dots in
     between. Nothing is drawn on the media except the photo expand button. */
  .chapter-inline-media .media-carousel {
    display: grid;
    grid-template-columns: auto minmax(0, 1fr) auto;
    grid-template-areas:
      "track track track"
      "prev  dots  next";
    align-items: center;
  }

  /* Slides keep their own heights (flex-start, not stretch) and the track
     takes the active slide's height from renderMediaCarousel()'s
     fitHeight(), so a short video is not padded out to a tall photo. */
  .chapter-inline-media .media-carousel-track {
    grid-area: track;
    min-width: 0;
    align-items: flex-start;
    transition: transform 0.4s ease, height 0.3s ease;
  }

  .chapter-inline-media .media-carousel-arrow {
    position: static;
    transform: none;
    width: 2.75rem;
    height: 2.75rem;
    margin: 0.35rem var(--gutter) 0.6rem;
    font-size: 1.7rem;
    color: var(--ink);
    background: transparent;
    border: 1px solid var(--rule);
  }

  .chapter-inline-media .media-carousel-prev {
    grid-area: prev;
  }

  .chapter-inline-media .media-carousel-next {
    grid-area: next;
  }

  .chapter-inline-media .media-carousel-dots {
    grid-area: dots;
    position: static;
    padding: 0.35rem 0 0.6rem;
  }

  .chapter-inline-media .media-carousel-dot {
    background: color-mix(in srgb, var(--ink) 28%, transparent);
  }

  .chapter-inline-media .media-carousel-dot.active {
    background: var(--color-primary);
  }

  /* No room to keep a column clear for the map controls on a phone: the
     card spans the width between the gutters and simply scrolls over the
     controls like it does over the rest of the map. */
  .chapter-stage-text {
    justify-content: center;
    padding-left: var(--gutter);
    padding-right: var(--gutter);
  }

  .chapter.chapter-stacked .chapter-content {
    width: 100%;
  }

  /* The takeover carousel's arrow gutters are a desktop luxury. */
  body.media-pos-full #media-sidecar .media-carousel-item {
    padding-left: 1rem;
    padding-right: 1rem;
  }

  /* The nav is the biggest consumer of vertical space on a phone and it is
     pinned there for the whole read, so the desktop sizes come back down a
     notch. Still larger than the 0.84rem/0.95rem they were before. */
  #header h1 {
    font-size: 1.25rem;
  }

  /* The nav becomes one row: a button naming the current section on the
     left, the Explore button on the right. The sideways-scrolling row of
     eight chips it replaces showed about one chip at a phone's width -
     usually not the active one - so a reader could not see where they
     were. The full list is still one tap away: the button opens it as a
     drop-down under the bar (.toolbar-menu-open). The list is absolutely
     positioned, so opening it never changes the bar's height or
     --nav-offset and nothing on the page moves. */
  #toolbar {
    position: sticky;
    gap: 0.6rem;
    padding-top: 0.45rem;
    padding-bottom: 0.45rem;
  }

  .toolbar-menu-toggle {
    display: flex;
    flex: 1 1 auto;
    min-width: 0;
    align-items: center;
    gap: 0.55rem;
    padding: 0.55rem 0.75rem;
    font-family: var(--font-heading);
    font-size: 1rem;
    font-weight: 600;
    color: var(--nav-ink);
    background: transparent;
    border: 1px solid var(--nav-rule);
    border-left: 3px solid var(--nav-marker);
    border-radius: 0 var(--panel-radius) var(--panel-radius) 0;
    cursor: pointer;
    text-align: left;
  }

  .toolbar-menu-count {
    flex: none;
    font-size: 0.8rem;
    font-weight: 500;
    color: var(--nav-ink-soft);
    font-variant-numeric: tabular-nums;
  }

  .toolbar-menu-label {
    flex: 1 1 auto;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  .toolbar-menu-chevron {
    flex: none;
    width: 1.1rem;
    height: 1.1rem;
    transition: transform 0.2s ease;
  }

  .toolbar-menu-open .toolbar-menu-chevron {
    transform: rotate(180deg);
  }

  .toolbar-sections {
    display: none;
    position: absolute;
    top: 100%;
    left: 0;
    right: 0;
    grid-template-columns: minmax(0, 1fr);
    grid-auto-rows: auto;
    gap: 0;
    padding: 0.35rem var(--gutter) 0.75rem;
    background: var(--nav-sub-bg);
    border-bottom: 1px solid var(--nav-rule);
    box-shadow: var(--panel-shadow);
    max-height: 70vh;
    overflow-y: auto;
  }

  .toolbar-menu-open .toolbar-sections {
    display: grid;
  }

  /* Full-width rows with a comfortable touch height, the current one
     marked by the same left rule as the button. Specificity matches the
     desktop chip rules so these win over them. */
  .toolbar-sections .toolbar-section-item,
  .toolbar-sections .toolbar-section-item.active {
    font-size: 1rem;
    text-align: left;
    white-space: normal;
    padding: 0.75rem 0.85rem;
    min-height: 2.75rem;
  }

  .toolbar-section-standalone {
    flex: none;
    padding: 0.5rem 0.75rem;
  }

  /* The map band is only 58vh tall now, so controls pinned to its own
     bottom edge sit much closer to the text below it. */
  /* Clears MapLibre's collapsed attribution (i) in the bottom-right
     corner, which is sized in px - a 24px button 10px in - not rem. */
  .map-control-stack {
    bottom: calc(34px + 0.5rem);
  }

  .map-title {
    font-size: 1.25rem;
  }

  /* The wind key is centred and its two rows are ~200px of nowrap text,
     which at phone widths reaches back into the title plate's corner. It
     drops below the plate rather than beside it. The wind chapter's own
     title ("Wind Exposure") is short enough to stay on one line at every
     width, so this only ever has to clear a single-line plate - which is
     also why the two-line --map-title-h at 560px below simply leaves it
     a wider gap rather than being wrong. */
  .wind-indicator {
    top: calc(1.5rem + var(--map-title-h));
  }

  /* Spans the width, so it starts above MapLibre's attribution (i) in the
     bottom-right corner rather than under it - same clearance as
     .map-control-stack. */
  /* Height in % of the map, not vh: the map is only the top 55% of the
     screen now, so 40vh would run off its top. Clears the title plate. */
  #legend {
    bottom: calc(34px + 0.5rem);
    left: 0.75rem;
    right: 0.75rem;
    width: auto;
    max-width: none;
    max-height: calc(100% - 34px - 2rem - var(--map-title-h));
    overflow-y: auto;
  }

  /* The Block Explorer's legend, matched to the story map's: bottom-left,
     closed behind its button by default (renderExplorerLegend()), and a
     tap outside it closes it. Open at the top-left under the title it
     covered nearly the whole map. Scoped through the wrap so it beats the
     860px rule further down. */
  .explorer-map-wrap .explorer-legend {
    top: auto;
    bottom: calc(34px + 0.5rem);
    left: 0.75rem;
    right: 0.75rem;
    width: auto;
    max-height: calc(100% - 34px - 2rem - var(--map-title-h));
  }

  .explorer-map-wrap .explorer-legend-reopen {
    top: auto;
    bottom: 0.75rem;
    left: 0.75rem;
  }

  /* Centred in the screen, not pinned to the bottom. On a tall portrait
     screen (an upright iPad above all) bottom-anchoring left most of the
     cover photo empty above the text. The top padding clears the fixed
     header. */
  .chapter.hero {
    align-items: center;
  }

  .chapter.hero .hero-content {
    padding-top: 5rem;
    padding-bottom: 3rem;
  }

  .legend-reopen,
  .legend-reopen.legend-right {
    bottom: 0.75rem;
    left: 0.75rem;
    right: auto;
  }

  .hero-content {
    max-width: none;
  }

  .footer-inner {
    grid-template-columns: minmax(0, 1fr);
    gap: 1.5rem;
  }

  #footer h2,
  .footer-text,
  .footer-links {
    grid-column: 1;
  }

  .footer-links {
    grid-row: auto;
  }
}

@media (max-width: 860px) {
  .explorer-map-wrap {
    height: min(var(--map-stage-h), 43.75rem);
  }

  .explorer-legend {
    width: calc(100% - 2rem);
    max-height: 45%;
  }

  /* Three 1fr tracks inside a ~320px-wide box would give each column about
     100px - narrower than the longest legend labels and the bivariate
     matrix. Drop to two columns; the alignment the grid provides is the
     point, not the specific count. The separator moves to the 2nd column
     accordingly (see the base rule for why nth-of-type). */
  #legend .legend-columns {
    column-count: 2;
  }

  .legend-section:nth-of-type(3n) {
    border-right: 1px solid var(--rule);
    padding-right: 0.9rem;
  }

  .legend-section:nth-of-type(2n),
  .legend-section:last-child {
    border-right: none;
    padding-right: 0.3rem;
  }
}

@media (max-width: 560px) {
  :root {
    /* Two lines, not one: by phone widths the plate's max-width is down
       to roughly 260px and the longest titles ("Vegetation Density (Fuel
       Load)", "Homestead Valley Block Map") no longer fit on one line.
       At this type size the crossover is nearer 400px than 560px, so for
       the widths in between the legend below gets about 1.4rem more gap
       than it needs. That is the right way round to be wrong - the
       alternative is the legend sitting on top of a second line - and it
       is not worth a fourth breakpoint to tighten. */
    --map-title-h: 4.1rem;
  }

  #legend .legend-columns {
    column-count: 1;
  }

  .legend-section,
  .legend-section:nth-of-type(3n) {
    border-right: none;
    padding-right: 0.3rem;
  }
}

/* =====================================================================
   Reduced motion. There was no such block before, so the scroll cue
   animated and every panel dock/fade ran regardless of the OS setting.
   The hero background video is handled separately in renderHeroChapter(),
   which checks the same query in JS and falls back to a still frame.
===================================================================== */
@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.001ms !important;
    scroll-behavior: auto !important;
  }

  /* With the travelling line stilled, the cue needs to read as a static
     mark rather than an empty 1px sliver. */
  .scroll-cue-arrow::after {
    height: 2.1rem;
  }
}
