/* ============================================================
   scene.css — Shell for every scene in the scroll spine.
   Templates that need more layout (map, rankings) bring their
   own files.
   ============================================================ */

/* min-height, not height: a window too short for the composition grows
   the section and scrolls rather than clipping it.

   100svh minus the chrome, not 100vh, and both halves of that matter.
   svh is the small viewport — the one that survives a mobile URL bar
   expanding. The subtraction is the fix for a defect that had two
   faces and one cause: a slide sized to the WHOLE viewport when only
   part of the viewport is free. Standalone that put the top ~76px of
   every scene under our own fixed header, so the composition read high
   and a tall one clipped at the top — off the page, not merely above
   it, which is exactly what the snap contract in js/snap.js says must
   never happen. Vendored into a host with its own masthead the same
   sum ran the bottom of every slide below the fold instead.

   js/snap.js lands a stop at the chrome's lower edge rather than at
   y = 0, so the box below is exactly this tall. The two have to be
   read from the same token or they drift apart again. */
.scene {
  min-height: calc(100svh - var(--chrome-h));
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--space-7) var(--space-5);
  position: relative;
}

.scene-inner {
  width: 100%;
  max-width: var(--content-width);
  display: flex;
  flex-direction: column;
  gap: var(--space-4);
}

.scene-inner.center { align-items: center; text-align: center; }

.scene > .eyebrow { margin-bottom: var(--space-3); }

/* ── Hero ──────────────────────────────────────────────── */
/* ── THE HERO IS A SLIDE, SO IT HAS TO FIT ONE VIEWPORT ────
   js/snap.js states the contract at the head of its SNAP_IDS list:
   a scene on that list is a fixed frame, so anything past the fold
   in one is not merely below — it is unreachable. This section was
   breaking it. At 1440x900 the stack measured about 1300px against
   roughly 820px of usable height, which put the report CTA and the
   scroll cue off the bottom of the page and pushed every stop below
   it down by the overflow.

   TWO CAUSES, and the padding was the smaller one. `22vh + 16vh` is
   342px of a 900px window spent on air. It is now the header plus a
   gutter at the top and one --space-7 at the bottom, which is what
   the composition actually needs: the section starts at document
   y = 0 UNDER the fixed header (base.css), so the top pad has to
   clear --header-h explicitly. 22vh only ever cleared it by
   coincidence, and stopped clearing it at all below a ~450px window.

   The larger cause is in the type, and the block on .hh-figure says
   what it was.

   THE HERO IS THE ONE SCENE THAT CANNOT BE SCROLLED CLEAR OF THE
   CHROME, because it is the first thing in the document — there is
   nothing above it to scroll away. Every other scene gives up
   --chrome-h, the chrome that stays pinned over the viewport. The
   hero cannot use that number, because at rest its top edge does not
   sit at y = 0: it sits at --chrome-flow-h, wherever the chrome above
   it is IN FLOW (a host's masthead, a sticky nav that has not moved
   yet). So it gives up that instead, and pads past only the pinned
   chrome the in-flow chrome does not already account for
   (--chrome-overlay-h, derived in tokens.css — standalone that is our
   own fixed header, and inside a host it is usually zero).

   Using --chrome-h here would subtract our own header twice, once as
   height and once as padding, leaving the composition in a box 76px
   shorter than the space it has.

   min-height, not height, and svh rather than vh: a window too short
   for the composition grows the section and scrolls rather than
   clipping it, and svh is the small viewport — the one that survives
   a mobile URL bar expanding. */
.scene--hero {
  min-height: calc(100svh - var(--chrome-flow-h));
  padding-top: calc(var(--chrome-overlay-h) + var(--space-4));
  padding-bottom: var(--space-7);
}
.scene--hero .scene-inner { max-width: 1100px; gap: var(--space-4); align-items: flex-start; }
.scene--hero h1 { font-size: clamp(48px, 7vw, 104px); }

/* ── Hero headline — staircase composition ────────────── */
.hero-headline {
  font-family: var(--font-body);
  font-weight: 800;
  letter-spacing: -0.015em;
  line-height: 1;
  color: var(--text);
  margin: 0;
  padding: 0;
  font-size: clamp(72px, 13vw, 256px);
  width: 100%;
  display: block;
}
.hero-headline .hh-line {
  display: block;
  position: relative;
}
.hero-headline .hh-word { display: inline-block; }
.hero-headline em { font-style: normal; }

.hero-headline .hh-small {
  font-family: var(--font-body);
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.22em;
  color: var(--muted);
  font-size: clamp(13px, 1.1vw, 16px);
  margin-bottom: var(--space-3);
  padding-left: 0.4em;
}

/* ── CAPPED ON HEIGHT AS WELL AS WIDTH ─────────────────────
   THE HERO'S TYPE IS SIZED OFF vw INSIDE A BOX CONSTRAINED BY vh,
   and that is the whole of why the section overflowed its slide. A
   wide, short window — a 2560x1440 monitor, or any laptop with the
   browser not maximised vertically — took the vw term to its cap and
   drew a 196px figure over a 124px display line in a frame that had
   no room for either.

   The min() adds the missing axis. Each step keeps its clamp, so the
   staircase composition is unchanged at every width it already read
   well at; the vh term only ever bites on a window too short to hold
   the result. The three sizes fall together, so the composition
   scales rather than collapsing: 17 / 11 / 3.6 holds the same
   1.58 : 1 : 0.32 ratio the caps do.

   These are solved against the budget at the top of .scene--hero,
   not chosen. If a step here moves, re-measure the whole stack — the
   test is `#scene-hero` scrollHeight against innerHeight, at the
   narrowest and shortest window the figure is shown at. */
.hero-headline .hh-figure {
  font-family: var(--font-display);
  font-weight: 900;
  font-variant-numeric: tabular-nums;
  font-size: min(clamp(72px, 13vw, 196px), 14vh);
  letter-spacing: -0.05em;
  line-height: 0.9;
  color: var(--primary);
  padding-bottom: var(--space-2);
  margin-bottom: var(--space-2);
}
.hero-headline .hh-counter .hh-dim,
.scene--big-number .figure .counter .hh-dim { opacity: 0; }

.hero-headline .hh-counter .hh-letter { color: var(--text-soft); }

/* Fixed-width per-character cells: gives Fraunces tabular-figure behavior
   without dropping to a monospace font. */
.hero-headline .hh-counter .hh-cell,
.scene--big-number .figure .counter .hh-cell {
  display: inline-block;
  width: 0.58em;
  text-align: center;
}
.hero-headline .hh-counter .hh-cell--punct,
.scene--big-number .figure .counter .hh-cell--punct {
  width: 0.14em;
}

/* Counter cells stay at full opacity throughout the count-up. */

.hero-headline .hh-counter .hh-comma,
.hero-headline .hh-counter .hh-dot,
.scene--big-number .figure .counter .hh-comma,
.scene--big-number .figure .counter .hh-dot {
  font-size: 0.55em;
  color: var(--gold-text);
  vertical-align: baseline;
}

.hero-headline .hh-display {
  font-family: var(--font-display);
  font-weight: 900;
  font-style: italic;
  /* vh-capped with .hh-figure above — see the note there. */
  font-size: min(clamp(48px, 8.4vw, 124px), 9vh);
  letter-spacing: -0.045em;
  line-height: 0.9;
  color: var(--text);
  padding-left: clamp(var(--space-3), 4vw, 56px);
  padding-bottom: var(--space-3);
}

/* THE RULE UNDER "miles of curb", and the reason it is an element
   rather than that line's border-bottom.

   It is the one mark in the hero that passes BEHIND the globe: the
   sphere occludes it, while every word around it stays in front. A
   border cannot be given that treatment, because a border paints in
   the same box as the text it belongs to and the two would have to
   share a layer. So the rule left the line and became a box of its
   own, carrying the margin that used to sit under `.hh-display`, so
   the composition is unchanged to the pixel.

   Its z-index is what does the work — see the stacking note over
   `.scene--hero .scene-inner` in viz.css, which is what lets a
   negative index here reach below the artwork instead of being
   trapped above it. */
.hero-headline .hh-rule {
  display: block;
  position: relative;
  z-index: -1;
  border-top: 1px solid var(--line-strong);
  margin-bottom: var(--space-3);
  /* Starts where the words above it start. The same clamp is the
     padding-left on .hh-display above, so the rule and the line it
     sits under share one left edge at every width — change one and
     the other has to move with it. */
  margin-left: clamp(var(--space-3), 4vw, 56px);
}

.hero-headline .hh-mid {
  font-family: var(--font-body);
  font-weight: 600;
  font-size: clamp(22px, 2.4vw, 34px);
  letter-spacing: -0.005em;
  color: var(--text-soft);
  padding-left: clamp(var(--space-4), 6vw, 96px);
  margin-bottom: var(--space-2);
}
.hero-headline .hh-mid em {
  color: var(--text);
  font-weight: 700;
}

.hero-headline .hh-accent {
  font-family: var(--font-body);
  font-weight: 600;
  /* vh-capped with .hh-figure above — see the note there. */
  font-size: min(clamp(24px, 2.8vw, 40px), 2.9vh);
  letter-spacing: -0.005em;
  line-height: 1.1;
  color: var(--text-soft);
  padding-left: clamp(var(--space-3), 4vw, 56px);
}
.hero-headline .hh-accent em {
  color: var(--text);
  font-weight: 700;
  font-style: normal;
}
.hero-headline .hh-italic {
  font-family: var(--font-display);
  font-style: italic;
  font-weight: 900;
  color: var(--primary);
  font-size: 1.25em;
  letter-spacing: -0.02em;
  margin-left: 0.1em;
}
.hero-headline .hh-stop {
  color: var(--gold-text);
  font-family: var(--font-display);
  font-size: 0.5em;
  line-height: 0;
  margin-left: -0.02em;
  vertical-align: -0.05em;
}

.hero-figure-cap { display: none; }

/* ── The two-row header takes its 25px out of the headline ──
   Below this width the masthead wraps to two rows and --header-h
   goes 76px -> 100px (tokens.css). The hero's top padding follows
   it, so the slide's usable height drops by 25px at exactly the
   widths where the window tends to be shortest — measured, a
   1100x740 window overflowed by 34px while 1366x768 had 60px to
   spare.

   Same breakpoint as the token, so the two move together. The
   headline gives up the difference because it is the one part of
   the stack that can: the paragraph is already floored at
   --text-md, and the search box and the CTA are controls with a
   44px minimum a pointer target cannot go under. */
@media (max-width: 1120px) {
  .hero-headline .hh-figure  { font-size: min(clamp(72px, 13vw, 196px), 11vh); }
  .hero-headline .hh-display { font-size: min(clamp(48px, 8.4vw, 124px), 7vh); }
  .hero-headline .hh-accent  { font-size: min(clamp(24px, 2.8vw, 40px), 2.4vh); }
}

@media (max-width: 720px) {
  .hero-headline .hh-display,
  .hero-headline .hh-accent { padding-left: var(--space-2); }
  .hero-headline .hh-display { font-size: clamp(44px, 12vw, 84px); }
  .hero-headline .hh-figure  { font-size: clamp(56px, 16vw, 96px); }
}
.scene--hero .hero-figure {
  display: flex;
  align-items: baseline;
  gap: var(--space-4);
  flex-wrap: wrap;
}
.scene--hero .hero-figure .figure {
  font-family: var(--font-display);
  font-weight: 900;
  font-size: clamp(56px, 9vw, 144px);
  letter-spacing: -0.04em;
  line-height: 0.95;
  color: var(--primary);
}
.scene--hero .hero-figure .figure-cap {
  font-family: var(--font-body);
  font-weight: 800;
  font-size: clamp(22px, 2.6vw, 36px);
  letter-spacing: -0.01em;
  color: var(--text);
  line-height: 1.15;
}
/* The hero's paragraph is the longest thing in the slide and the
   other half of the fit. At 22px in a 684px measure it ran nine
   lines, about 385px — nearly half the usable height on a 900px
   window — and every word of it is staying (that was the call), so
   the size is what gives.

   17px is not a demotion to --text-md-and-hope: a narrower glyph
   fits MORE characters per line, so the block loses height twice
   over, once per line and once in the line count. It stays --text-
   soft, which clears AA comfortably at this size on both themes. */
.scene--hero .lede {
  color: var(--text-soft);
  /* CAPPED ON HEIGHT INSIDE THE CLAMP, which is a different shape
     from the headline's min() and deliberately so. The vh term goes
     in the MIDDLE of the clamp, so --text-md still floors it: a
     min() wrapped round the outside would happily take this below
     15px on a short window, and a paragraph is not a decoration
     that can shrink to fit.

     It is here because of a step, not a slope. The measure is fixed
     at --reading-width, so as the font approaches its 17px cap the
     line count tips from seven to eight — 48px of height arriving
     all at once, between 1280 and 1366 wide. Tying the size to the
     height as well means a short window takes the seven-line
     setting, which is the one that fits. */
  font-size: clamp(var(--text-md), min(1.3vw, 2vh), 17px);
  /* Matched to .dek in base.css. It was inheriting the body's looser
     leading — 1.77 measured — which is right for a page of prose and
     costs 3.7px a line in a block that runs seven of them. */
  line-height: 1.55;
  max-width: var(--reading-width);
}

/* ── The gap is the spacing, so the paragraphs carry none ──
   .scene-inner is a flex column with `gap: var(--space-5)`, and the
   user-agent's default `margin: 1em 0` on a <p> stacks ON TOP of
   that rather than collapsing through it — flex items do not
   margin-collapse. Measured, that was 60px of unasked-for height in
   the hero alone: 17px either side of the lede and 13px either side
   of the meta line, none of it visible as anything except the
   section overflowing its slide. .dek in base.css already zeroes its
   own for the same reason. */
.scene--hero .scene-inner > p { margin: 0; }
/* Source credit, with the report CTA to the left of it. The button is
   the only thing on the landing page that leaves it, so it leads the
   line; the credit trails as the caption it is. align-items: center
   rather than baseline because the button is a box, not a run of text —
   on a baseline the pill's padding hangs below the credit. */
.scene--hero .meta {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--space-4);
  color: var(--muted);
  font-size: var(--text-sm);
  letter-spacing: 0.04em;
}
/* The button carries its own tracking; the meta line's 0.04em is for
   the credit beside it and looks slack on a bold pill. */
.scene--hero .meta .btn { letter-spacing: 0.01em; }
/* CENTRED IN LAYOUT, NOT IN A TRANSFORM, and that is the whole point
   of the auto margins. The hero's entrance tweens every [data-animate]
   element on `y`, and for as long as it does GSAP owns the element's
   entire `transform`: a `translateX(-50%)` written here is read once,
   when the tween initialises, and baked into a pixel. Read before
   layout has settled it comes back as `none`, the cue is written out
   as `translate(0px, 24px)`, and it finishes half its own width right
   of centre — permanently, because nothing re-runs. Auto margins put
   the centring somewhere the tween cannot reach. The same applies to
   any element that both carries a CSS transform and animates here. */
.scene--hero .scroll-cue {
  position: absolute;
  bottom: var(--space-6);
  left: 0;
  right: 0;
  width: max-content;
  max-width: 100%;
  margin-inline: auto;
  text-align: center;
  color: var(--muted);
  font-size: var(--text-xs);
  letter-spacing: 0.2em;
  text-transform: uppercase;
}

/* ── Big-number scene ──────────────────────────────────── */
.scene--big-number .scene-inner { align-items: center; text-align: center; }
.scene--big-number .figure {
  font-family: var(--font-display);
  font-weight: 900;
  font-size: clamp(72px, 12vw, 200px);
  letter-spacing: -0.04em;
  line-height: 0.95;
  color: var(--primary);
}
.scene--big-number .figure .unit { color: var(--muted); font-size: 0.4em; margin-left: 0.1em; letter-spacing: 0; }
.scene--big-number .figure--padded {
  font-size: clamp(36px, 9vw, 132px);
  letter-spacing: -0.05em;
}
.scene--big-number .figure-label {
  font-family: var(--font-body);
  font-weight: 700;
  font-size: clamp(16px, 1.6vw, 22px);
  letter-spacing: 0;
  color: var(--text-soft);
}
/* The longest prose on the landing page sits in these three scenes —
   four and five lines apiece — and it was set at the same 17px as a
   one-line caption. Bumped a step, given more leading, and given a
   real measure: --reading-width at this size runs to about 70
   characters, well past where a line stops being easy to pick up.
   58ch is the cap that actually applies on a wide screen. */
.scene--big-number .dek {
  text-align: center;
  font-size: clamp(var(--text-lg), 1.35vw, var(--text-xl));
  line-height: 1.65;
  max-width: min(var(--reading-width), 58ch);
}
.scene--big-number .dek strong { color: var(--text); font-weight: 700; }

/* ── The figure key ────────────────────────────────────────
   A legend for the one backdrop on this page whose colours are
   categories rather than a level. See renderFigureKey() in
   render.js for why the split left the paragraph.

   Laid out as a grid with four explicit columns rather than as
   flex, because the point of a key is that the eye can run DOWN a
   column: three counts of different digit-lengths have to share a
   right edge or they read as a ragged list rather than as parts of
   one total. `max-content` on the value column takes its width from
   the longest of the three and holds every row to it.

   Stacked, not in a row, and the field is why: the bands are
   stacked top to bottom in the same order, so a vertical key is
   parallel to the thing it explains. */
.figure-key {
  list-style: none;
  margin: var(--space-4) 0 var(--space-2);
  padding: 0;
  display: grid;
  gap: var(--space-2);
  width: 100%;
  max-width: min(var(--reading-width), 58ch);
}
.figure-key-row {
  display: grid;
  grid-template-columns: auto max-content max-content 1fr;
  align-items: baseline;
  column-gap: var(--space-3);
}

/* The swatch is a stall, drawn the way the field draws one: a
   rounded upright rect with a tinted fill and a coloured edge, and
   for the third category an empty outline — which is precisely what
   an unlit cell is.

   The tints are the --*-soft tokens rather than the field's own
   composited alphas. A 14px swatch carrying 0.13 of a fill over the
   page would be invisible, and the swatch's job is not to match the
   field's LUMINANCE — it is to name the hue the reader is looking
   at. The soft tokens are the palette's existing answer to "this
   colour, quietly", so no new value is introduced. */
.figure-key-swatch {
  align-self: center;
  width: 14px;
  height: 20px;
  border-radius: 3px;
  border: 1.5px solid var(--line-strong);
}
.figure-key-row.is-paid .figure-key-swatch {
  background: var(--gold-soft);
  border-color: var(--gold);
}
.figure-key-row.is-free .figure-key-swatch {
  background: var(--primary-soft);
  border-color: var(--primary);
}
/* The unit mark, in the field's blue: one cell of the grid behind
   the curb and lots scenes. */
.figure-key-row.is-unit .figure-key-swatch {
  background: var(--primary-soft);
  border-color: var(--primary);
}
/* The explainer's two marks: the dashed gold disc every map on the
   site draws for a downtown area, at key size, and a mapped
   neighborhood.

   THE DISC FOLLOWS THE MARK, which gained a filled field and a
   darkened gold on light — see addDowntownAreaLayers() in render.js
   and --area-gold in tokens.css. It carried --gold-bright on both
   themes, and on the light one that is the palest of the three golds
   against a near-white page: the key for the drawing's one gold mark
   was fainter than the mark. One per-theme token now, so no override
   is needed here. */
.figure-key-row.is-disc .figure-key-swatch {
  --swatch-gold: var(--area-gold);
  position: relative;
  width: 18px;
  height: 18px;
  border-radius: 50%;
  /* The casing ring, with the gold dash laid over it by ::after —
     see .map-legend__swatch in map.css for why the two halves are
     split across the element and a pseudo-element rather than being
     one dashed border over a background layer. */
  border: 1.5px solid color-mix(in srgb, var(--area-casing) 55%, transparent);
  background: color-mix(in srgb, var(--swatch-gold) 40%, transparent) padding-box;
}
.figure-key-row.is-disc .figure-key-swatch::after {
  content: '';
  position: absolute;
  inset: -1.5px;
  border: 1.5px dashed var(--swatch-gold);
  border-radius: 50%;
}
.figure-key-row.is-hood .figure-key-swatch {
  width: 18px;
  height: 14px;
  border-radius: 2px;
  background: var(--primary-soft);
  border-color: var(--line-strong);
}
/* ── The off-street scene's marks: none ────────────────────
   `is-field` and `is-ground` lived here, the two rows of the off-street
   key: what one mark is, and what all of them add up to. The scene no
   longer carries a key at all — the drawing tags its own gold field on
   a leader touching it, and the ground moved into the paragraph — so
   the two tones went with it rather than sitting here unreferenced.
   See the key note in js/scenes.js. */
/* ── The hero globe's four marks ───────────────────────────
   The globe is the one backdrop carrying two kinds of dot and two
   lines, so its key has to draw marks rather than swatches: the
   dots are round and sized against each other the way the globe
   sizes them (gold wider than blue), and the two routes are bars
   at the stroke colour each one is drawn in — gold for this
   scorecard's curb, --primary for the wider coverage spiral, which
   is exactly .viz-route--wide. No colour is declared here that the
   drawing does not already use. */
.figure-key-row.is-dot-gold .figure-key-swatch,
.figure-key-row.is-dot-blue .figure-key-swatch {
  border-radius: 50%;
  border: none;
}
.figure-key-row.is-dot-gold .figure-key-swatch {
  width: 12px;
  height: 12px;
  background: var(--gold-bright);
}
.figure-key-row.is-dot-blue .figure-key-swatch {
  width: 9px;
  height: 9px;
  margin-inline: 1.5px;
  background: var(--primary);
  opacity: 0.85;
}
.figure-key-row.is-line-spiral .figure-key-swatch {
  width: 16px;
  border: none;
  border-radius: 2px;
  height: 2px;
  background: var(--primary);
  opacity: 0.7;
}

/* ── The availability drawing's four marks ─────────────────
   Every one of these is the mark the chart draws, at key size and
   from the same token: the range is .viz-cs-range, the dot is
   .viz-cs-dot at a mid-ramp colour, the band is .viz-cs-band and the
   average is .viz-cs-mean. The dot row is the one that earns the
   legend — a dot on a range reads as an average, and this one is a
   median sitting a centimetre from the mean. */
.figure-key-row.is-cs-range .figure-key-swatch,
.figure-key-row.is-cs-band .figure-key-swatch,
.figure-key-row.is-cs-mean .figure-key-swatch {
  width: 16px;
  border: none;
  border-radius: 1px;
}
.figure-key-row.is-cs-range .figure-key-swatch {
  height: 2px;
  background: var(--line-strong);
}
.figure-key-row.is-cs-dot .figure-key-swatch {
  width: 11px;
  height: 11px;
  border: none;
  border-radius: 50%;
  background: var(--avail-50);
}
.figure-key-row.is-cs-band .figure-key-swatch {
  height: 14px;
  border-radius: 2px;
  background: var(--primary);
  opacity: 0.18;
}
.figure-key-row.is-cs-mean .figure-key-swatch {
  height: 12px;
  width: 2px;
  margin-inline: 7px;
  background: var(--gold-mark, var(--gold));
}

/* A plain row: swatch and label, no count — see renderFigureKey. */
.figure-key-row.is-plain {
  grid-template-columns: auto 1fr;
}
/* "1 mark" is a unit, not a figure, so it does not shout. */
.figure-key-row.is-unit .figure-key-value {
  font-size: clamp(16px, 1.4vw, 22px);
}

/* The count leads: it is the figure the reader came for, and it is
   the only part of the row set in the display face so the three
   read as siblings of the big number above them. */
.figure-key-value {
  font-family: var(--font-display);
  font-weight: 800;
  font-size: clamp(20px, 2vw, 30px);
  line-height: 1.1;
  letter-spacing: -0.02em;
  color: var(--text);
  font-variant-numeric: tabular-nums;
  justify-self: end;
}
/* Demoted on purpose. The share is a restatement of the count, and
   two figures at equal weight in one row make the reader choose. */
.figure-key-share {
  font-size: var(--text-sm);
  color: var(--muted);
  font-variant-numeric: tabular-nums;
}
.figure-key-label {
  font-size: clamp(var(--text-md), 1.15vw, var(--text-lg));
  color: var(--text-soft);
}

/* Narrow: the label drops to its own line rather than squeezing the
   count, which is the figure that has to stay whole. */
@media (max-width: 620px) {
  .figure-key-row {
    grid-template-columns: auto max-content 1fr;
    row-gap: 2px;
  }
  .figure-key-label { grid-column: 2 / -1; }
}

/* ── The key as map furniture ──────────────────────────────
   The same component, moved onto the drawing and shrunk to legend
   size — what `keyPlacement: 'figure'` buys, and what the maps have
   always done with .map-legend. Three scenes take it: the hero's
   globe, the downtown-area disc and the availability chart. The
   off-street scene keeps the reading-size key under its paragraph,
   because its rows carry figures that are part of the argument
   rather than names for marks.

   IT IS THE MAP LEGEND'S PANEL, deliberately: the same strong glass,
   the same hairline, the same radius, the same --text-xs in --muted.
   A reader meets both on one scroll and a second, differently
   dressed legend would be a second convention to learn. --map-glass-
   strong rather than the soft one for the reason map.css sets out at
   length — an overlay that carries words bets its legibility on
   whatever is underneath it, and only the strong glass holds --muted
   above AA over the lightest thing a drawing can put there.

   Inert. It names marks, it does not answer to a pointer, and the
   hosts it mounts in are already pointer-events: none. */
.figure-key--overlay {
  position: absolute;
  z-index: var(--z-raised);
  width: max-content;
  max-width: min(46%, 300px);
  margin: 0;
  padding: var(--space-2) var(--space-3);
  gap: 6px;
  background: var(--map-glass-strong);
  border: 1px solid var(--line);
  border-radius: var(--radius-md);
  backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px);
  pointer-events: none;
}
.figure-key--overlay.is-bottom-right { right: var(--space-4); bottom: var(--space-4); }
.figure-key--overlay.is-bottom-left { left: var(--space-4); bottom: var(--space-4); }
.figure-key--overlay.is-top-right { right: var(--space-4); top: var(--space-4); }

/* THE HERO IS THE ONE THAT HAS TO AIM. Its key mounts in
   .viz-host--marks, which fills the whole section — the corner of
   that host is the corner of the page, a couple of hundred pixels
   below and right of the sphere, which would read as a caption
   floating in the dark rather than as furniture on the globe. So the
   inset is written off the sphere's own box instead: --globe-w and
   --globe-mr come from the rule that sizes it (viz.css), and the
   sphere is vertically centred in its host, so its bottom edge sits
   exactly 50% - --globe-w / 2 above the host's.

   The fractions then pull the panel in from that corner, because the
   drawing is a circle inscribed in the square: 14% of the diameter
   up from the bottom, the limb has come in by a fifth of the width,
   so 16% in from the right is where the panel's bottom-right corner
   meets it. Everything above and left of that point is sphere. */
.scene--hero .figure-key--overlay.is-bottom-right {
  right: calc(var(--globe-mr) + 0 * var(--globe-w));
  bottom: calc(50% - 0.5 * var(--globe-w));
}

/* Below 1100 the lede stops giving ground: the copy column holds its
   measure while the sphere keeps its share of the width, so the two
   close on each other (the same squeeze the globe's fade answers in
   viz.css) and the sphere's lower right is where the last lines of
   the paragraph now end up. A panel there would sit on the words —
   measured, at the three portrait-tablet widths, as 137px of overlap
   with the "Find your city" search at 768, 88px at 820 and 13px at
   900, because the copy column runs to x=708 and spans y=504-807
   while the sphere's corner lands inside both.

   THE ANSWER IS NOT TO LET GO OF THE SPHERE, which is what the first
   cut did: it took the frame's bottom-right corner instead, and on a
   portrait tablet that is 250px below the globe with the hero's
   search, settings and CTA in between — a caption adrift at the foot
   of the page rather than furniture on the drawing (UAT, 2026-10-06).

   So the panel hangs off the sphere's BOTTOM EDGE, still flush with
   its right: `50%` is the sphere's centre in this host, so half the
   diameter below it is the lower edge, and the panel's top sits
   there. It stays the globe's lower right — directly under the right
   limb, touching it — and it is the same move the availability
   chart's key already makes below its drawing, so it is a convention
   the reader meets twice rather than a new one.

   It clears the copy because everything the hero puts in that band is
   narrow and left-aligned: the search ends at x=584 and above y=697,
   the settings row at x=302 and the CTA at x=241, while the panel
   starts at x=447 at the narrowest width that draws it. The gap under
   the sphere is --space-4 rather than --space-3 because at exactly
   721x1080 the search's lower edge and the panel's upper one met to
   within a pixel; four more pixels is the whole of the margin and
   costs nothing, since the panel is reading as the sphere's lower
   right either way. Verified at 721, 730, 745, 768, 800, 820, 834,
   900, 1000, 1024 and 1100. Below 720 neither the sphere nor this
   panel is drawn at all, so there is nothing to aim. */
@media (max-width: 1100px) {
  .scene--hero .figure-key--overlay.is-bottom-right {
    right: var(--globe-mr);
    top: calc(50% + 0.5 * var(--globe-w) + var(--space-4));
    bottom: auto;
  }
}

/* UNDER THE DRAWING, NOT ON IT. The availability chart has no empty
   corner — the bottom rows are its highest readers and the left of
   every row is a country name — so its key hangs off the drawing's
   bottom edge instead, flush right, reading as the chart's caption
   (UAT, 2026-09-23).

   `top: 100%` on the host is the anchor, and it is the reason the
   panel is mounted in the host rather than in the section: the row
   is centred in the slide and changes height with the copy, so the
   drawing's bottom edge is not a fixed distance from anything the
   section could offer. Off the host it is exactly one edge. The
   panel costs no layout — it is absolutely positioned, so the row it
   hangs from cannot grow, and a growing row would grow the SVG,
   which sizes itself from its own measured box. */
.viz-host--keyed > .figure-key--overlay.is-below-right {
  top: 100%;
  right: 0;
  bottom: auto;
  margin-top: var(--space-3);
  max-width: min(100%, 420px);
}
/* The band it hangs into is reserved by the SLIDE. Left alone, the
   figure row is centred in the full height and the panel would hang
   off the bottom of the section — and on a short window the copy
   reaches down there too (at 1024x700 its last line would land
   inside the panel). The reserve is the section's own bottom
   padding, which shortens the row both columns are centred in and
   lifts them by exactly as much as the panel needs.

   NOT IN THE COPY COLUMN, which was the first attempt and is a trap:
   the figure beside it is an SVG at `height: 100%` whose viewBox is
   rewritten from its own measured box, so anything that makes the
   grid row taller makes the drawing taller, a rebuild at a time, and
   the slide walks past 100vh. Padding the section only ever makes
   that row shorter, which the chart handles by redrawing smaller. */
.scene--keyed {
  padding-bottom: calc(var(--space-7) + var(--space-8) + var(--space-3));
}
.figure-key--overlay .figure-key-row { column-gap: var(--space-2); }
.figure-key--overlay .figure-key-label {
  font-size: var(--text-xs);
  line-height: 1.35;
  color: var(--muted);
}
/* The marks come down with the type. Each is still the mark the
   drawing makes — same token, same shape — at about two thirds the
   size, which is the ratio the type moved by. The gold dot stays
   wider than the blue one because the globe draws it that way. */
.figure-key--overlay .figure-key-swatch { width: 10px; height: 13px; border-width: 1px; }
.figure-key--overlay .is-dot-gold .figure-key-swatch { width: 8px; height: 8px; }
.figure-key--overlay .is-dot-blue .figure-key-swatch { width: 6px; height: 6px; margin-inline: 1px; }
.figure-key--overlay .is-line-spiral .figure-key-swatch { width: 12px; height: 2px; }
.figure-key--overlay .is-cs-range .figure-key-swatch { width: 12px; height: 2px; }
.figure-key--overlay .is-cs-dot .figure-key-swatch { width: 8px; height: 8px; }
.figure-key--overlay .is-cs-band .figure-key-swatch { width: 12px; height: 10px; }
.figure-key--overlay .is-cs-mean .figure-key-swatch { width: 2px; height: 9px; margin-inline: 5px; }
.figure-key--overlay .is-disc .figure-key-swatch,
.figure-key--overlay .is-hood .figure-key-swatch { width: 11px; height: 11px; }

/* The host has to be a positioning context. The hero's marks host
   already is one — it is absolutely positioned over the section —
   but .viz-host--figure is deliberately static (viz.css: it is a
   plain grid item), so only that one is turned back on. Two classes,
   because the static rule is one class and lands later in the
   cascade. Set by attachViz on the host that received the key. */
.viz-host--figure.viz-host--keyed { position: relative; }

/* Narrow: back into the flow. The drawings are small here — the disc
   loses most of its margin and the chart is squeezed to its rows —
   and a panel over a corner of either would be covering the thing it
   explains. It keeps the legend type size; what it gives up is the
   overlap. */
@media (max-width: 720px) {
  .figure-key--overlay {
    position: static;
    /* BOTH HALVES, OR NEITHER. The panel is sized `width: max-content`
       up there, and the only thing that kept that from running away was
       the `max-width: min(46%, 300px)` beside it. Lifting the cap on
       its own left max-content unopposed: the key measured its widest
       row — 429px — inside a 330px host in a 378px scene, hanging 26px
       off both edges and giving the page a horizontal scrollbar on the
       one viewport this block exists to serve. In flow it should fill
       its host, which is what the plain .figure-key does. */
    width: 100%;
    max-width: none;
    margin-top: var(--space-3);
    background: none;
    border: 0;
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
    padding: 0;
  }
  /* The key keeps hanging off the drawing here — it has to, because
     the host is a definite slice of the viewport at this width and a
     panel in its flow would spill past the bottom of it. What it
     gives up is the glass, which the rule above already strips: at
     full width under the chart there is nothing behind it to read
     against. The slide still reserves the band it hangs into. */
  .viz-host--keyed > .figure-key--overlay.is-below-right {
    position: absolute;
    top: 100%;
    right: 0;
    margin-top: var(--space-3);
    max-width: none;
  }
  .scene--keyed .scene-copy { padding-bottom: 0; }
  /* The reserve is what js/render.js measured, not a guess at it. The
     fallback is the constant this replaced, so a slide whose key has
     not been measured yet — or a reader with no ResizeObserver — still
     gets the band rather than none. Set after .scene--keyed above, and
     at the same weight, so it supersedes it for the scene placement
     too: both kinds of key hang, and both are measured the same way. */
  .scene--key-hangs {
    padding-bottom: calc(var(--space-7) + var(--figure-key-overhang, var(--space-7)));
  }
}

/* ── Scene rhythm: break the 100vh + space-7 metronome ─────
   The scroll spine is a sequence of scenes. Equal spacing
   between every scene reads as a stutter; varying it gives
   the page an editorial pulse (pair · breath · sequence · rest). */

/* Adjacent big-number scenes pair visually — tighten the top
   gap so the second reads as a follow-up, not a new beat. */
.scene--big-number + .scene--big-number {
  padding-top: var(--space-5);
}

/* The "paired" big-number breaks the centered template into a
   left-anchored reading column — same data, different cadence. */
.scene--big-number.is-paired .scene-inner {
  align-items: flex-start;
  text-align: left;
  max-width: var(--reading-width);
}
.scene--big-number.is-paired .figure { font-size: clamp(72px, 9vw, 144px); }
.scene--big-number.is-paired .dek,
.scene--big-number.is-paired .figure-label { text-align: left; }

/* The "aside" big-number: the copy holds the left.
   Two reasons, and a scene needs only one of them.

   Composition — two backdrops on the page are asymmetric and both
   lean the same way. Manhattan is an island on the right-hand side
   of the footprint's frame, and the odds scene's distribution rises
   left to right, so its ink gathers in the bottom right. A centred
   column ran straight into the first and sat in the middle of the
   second's climb.

   Reading — these scenes carry the longest prose on the site, five
   and six lines apiece. Centred text gives the eye no fixed edge to
   return to, which is tolerable for a two-line dek and not for six.

   The column keeps --content-width rather than dropping to
   --reading-width, so its left edge lands where the hero's does and
   the reader gets one reading edge down the whole spine. The measure
   is held on the paragraphs themselves instead. */
.scene--big-number.is-aside .scene-inner {
  align-items: flex-start;
  text-align: left;
}
.scene--big-number.is-aside .dek,
.scene--big-number.is-aside .figure,
.scene--big-number.is-aside .figure-label,
.scene--big-number.is-aside .figure-note { text-align: left; }
/* The centred template pushes the footnote to the middle; here it
   belongs on the same left edge as everything above it. */
.scene--big-number.is-aside .figure-note { margin-inline: 0; }

/* ── THE TWO MAP SLIDES ARE ONE PAIR, SET AS ONE ───────────
   "On the street" and "Off the street" are the two halves of a
   single idea, and the off-street dek says so in as many words: on-
   street and off-street are different kinds of inventory, and the
   scorecard keeps each in its own unit — a length of curb, and a
   count of lots. They are also the only two scenes in the spine that
   put a full-bleed map behind the copy.

   They did not look like a pair. Same template, adjacent in the
   scroll, and every dimension of the copy differed:

     copy block top   113px  ·  43px
     figure           95px   ·  130px
     dek              17px   ·  19.4px
     dek measure      769px  ·  654px
     stack gap        12px   ·  16px

   None of that was a decision about the off-street scene. It was the
   template default on one side and, on the other, a set of numbers
   solved for the wall — which has a real constraint the plane does
   not: its subject lives in the LOWER part of its frame, because a
   wall runs the width of a country and a paragraph cannot sit on it,
   so the copy has to hold the top and leave a band.

   The constrained one sets the pair. The plane has no geometry
   asking it to be bigger; it was only bigger because nothing had
   asked it to be anything. So both now hold the top and share one
   scale, and scrolling from one to the other moves the map without
   moving the words: the eyebrow, the figure and the headline all
   land in the same place twice.

   On a short window the wall's paragraph reaches into its band; the
   host's mask fades the band's top out under the copy, which is why
   the overlap costs no contrast. */
.scene--big-number.is-mapped {
  align-items: flex-start;
  padding-top: var(--space-6);

  /* 78 characters of Lato at --text-lg. The dek's clamp reaches that
     cap at about 1400px and holds it at every width above, so on the
     windows this pair is composed for the measure is a constant —
     which is why it is written in px rather than in `ch`. The layout
     has to do arithmetic with it and `ch`
     resolves against whichever element the calc sits on, not against
     the dek. The dek's own max-width is set from this property, so
     the number the layout uses and the number the paragraph obeys
     cannot drift apart. */
  --map-measure: 769px;
}
/* A smaller padded figure, the key's gap rather than the
   paragraph's, and a wider measure so the dek runs six or seven
   lines instead of nine. On the wall this is what keeps the stack
   above the band — viz.js sets the wall below the copy's MEASURED
   bottom, so the shorter the copy the more height the wall gets. On
   the plane it buys back about 150px of drawing. */
.scene--big-number.is-mapped .scene-inner { gap: var(--space-3); }
.scene--big-number.is-mapped .figure--padded { font-size: clamp(36px, 6.6vw, 96px); }
.scene--big-number.is-mapped .dek {
  max-width: min(var(--content-width), var(--map-measure));
  font-size: clamp(var(--text-md), 1.2vw, var(--text-lg));
  line-height: 1.55;
}
/* THE CHIPS TAKE THE DEK'S MEASURE, NOT THE READING MEASURE. The
   strip's default cap is 58ch, which is a measure for prose; a chip
   is `white-space: nowrap`, so that cap does not shorten a line, it
   decides how many chips fit on one. On these two slides the dek is
   wider than 58ch, so a strip clamped to 58ch wraps chips the
   paragraph above it would have fitted — the off-street pair
   ("6.2% of the ground…" and the playing-field count) stacked
   into two rows and read as two findings rather than as one row of
   peers. Matching the dek puts the wrap where the reader can see the
   reason for it: the column's own edge. */
.scene--big-number.is-mapped .figure-facts {
  max-width: min(var(--content-width), var(--map-measure));
}
/* Both slides set their copy over a live drawing — one over contours
   and a gold line, the other over Manhattan filled with Central Parks — so the stack wears the halo the wall's footnote already
   wore: a page-coloured glow under the glyphs, which is the only form
   the drawings' own `paint-order: stroke` can take in HTML.

   It goes on the text and not on a box behind it. A scrim would punch
   a page-coloured hole in the middle of a full-bleed drawing, which
   is the one region a backdrop of this kind cannot afford to lose —
   the same conclusion the plane reached, written up over
   .viz--field-horizon in viz.css. The figure is exempt: at 95px in
   the brand blue it is not in any danger from a street. */
.scene--big-number.is-mapped .figure-label,
.scene--big-number.is-mapped .figure-key,
.scene--big-number.is-mapped .dek,
.scene--big-number.is-mapped .figure-note {
  text-shadow: 0 0 2px var(--bg), 0 0 4px var(--bg), 0 0 8px var(--bg);
}

.scene--big-number.is-wall .figure-facts { max-width: none; }
/* The chips are the wall's own: three pills that can meet the band
   on a short window, so each stands on the page colour. */
.scene--big-number.is-wall .figure-fact { background: var(--bg); }

/* ── The plane centres; the wall cannot ────────────────────
   The one dimension of the pair that stays deliberately apart.

   `is-mapped` pins the copy to the top because that is the wall's
   only option: its band is bottom-anchored and `viz.js` sizes it from
   the copy's measured bottom, so copy that drifted down would eat the
   drawing. The plane has no such tie — it is full-bleed and
   drawn to the frame, not to the copy — and top-pinned it left 161px
   of empty page under the footnote and none above, which on a slide
   whose whole background is a picture reads as the column having
   slipped rather than having been placed.

   So this one centres, and it centres in the space a reader can
   actually see. It used to need a --header-h term in the padding to
   do that: the masthead is a fixed bar over the top of the scene, so
   without one the copy was optically high by half the masthead on
   every window. The term is gone because the reason for it is — the
   scene is now sized to the usable viewport and js/snap.js lands its
   top at the chrome's lower edge, so `align-items: center` centres in
   visible space on its own and the term would be counted twice.
   Everything else about the pair — the measure, the type scale, the
   stack gap, the halo — is unchanged, because those are what make the
   two slides read as one idea; where the copy sits in the frame is a
   fact about each drawing. */
.scene--big-number.is-mapped.is-plane {
  align-items: center;
  padding-top: var(--space-7);
}

/* A short window — a laptop with the browser not maximised — has
   the copy and the map competing for the same 200px. On the wall the
   band is bottom-anchored, so the only lever is the copy; on the
   flood the copy simply has to end above the scene's own foot. One
   step smaller everywhere, and both keep their map. */
@media (max-height: 820px) {
  .scene--big-number.is-mapped .scene-inner { gap: var(--space-2); }
  .scene--big-number.is-mapped .figure--padded { font-size: clamp(36px, 5.5vw, 76px); }
  .scene--big-number.is-mapped .dek {
    max-width: min(var(--content-width), 88ch);
    font-size: var(--text-md);
    line-height: 1.5;
  }
  .scene--big-number.is-mapped .figure-facts {
    max-width: min(var(--content-width), 88ch);
  }
}

/* The facts strip: the figure three more ways, as quiet chips.
   Flex rather than the key's grid because these are peers in a
   row, not rows the eye runs down; they wrap when the measure
   runs out. The value takes the display face so the three read
   as small siblings of the big number; the label stays soft. */
.figure-facts {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2) var(--space-3);
  max-width: min(var(--reading-width), 58ch);
  /* A grid item's automatic minimum size is its min-content, and a
     strip of nowrap chips has no min-content worth speaking of — the
     longest chip's full width is the floor. That floor blew the track
     out past the scene: "6.2% of the ground in the 121 downtown areas"
     wants 317px, and at 320px there are only 257. */
  min-width: 0;
}
.figure-fact {
  display: inline-flex;
  align-items: baseline;
  gap: 6px;
  padding: 5px 12px 5px 10px;
  border: 1px solid var(--line-strong);
  border-radius: 999px;
  color: var(--text-soft);
  font-size: var(--text-sm);
  line-height: 1.3;
  /* Wraps only when it has to. A chip is a one-line object and on
     every width that can hold one it still is — the strip's own
     flex-wrap moves whole chips to the next line long before this
     matters. What this prevents is the last 45px of phone, where the
     longest chip cannot fit on any line: nowrap there did not make it
     narrower, it made it hang off the page. Two lines of a chip is a
     smaller loss than a reading pushed out of the frame. */
  max-width: 100%;
}
.figure-fact-glyph {
  width: 16px;
  height: 16px;
  flex: none;
  align-self: center;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.5;
  stroke-linecap: round;
  stroke-linejoin: round;
  color: var(--gold-text, var(--gold));
}
.figure-fact-value {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 15px;
  letter-spacing: -0.01em;
  color: var(--text);
  font-variant-numeric: tabular-nums;
  /* The figure never breaks, now that the chip around it can. It is
     the thing the chip is for, and "1,157,262" split across a line end
     would read as two numbers. */
  white-space: nowrap;
}
.figure-fact-label { color: var(--text-soft); }

/* Phone: the band cannot sit behind copy that fills the frame, so
   the section becomes a column and the map takes a definite slice
   under the words — the same reasoning as the figure scenes below.
   The host is the section's first child (it paints under the copy
   everywhere else); `order` moves it last here.

   The off-street scene joins it, and only at this width. Its drawing
   is full-bleed by design — that is the whole of why it centres
   rather than holding the top — but a backdrop stops being one when
   the copy is the width of the frame, and its subject is an island
   drawn the long way. See .viz-host--park-spine in viz.css. The
   `is-plane` fallback drawing stays absolute and is unaffected by
   the order; `align-items: stretch` is all it sees. */
@media (max-width: 720px) {
  .scene--big-number.is-wall,
  .scene--big-number.is-mapped.is-plane {
    flex-direction: column;
    align-items: stretch;
    padding-top: var(--space-5);
    row-gap: var(--space-4);
  }
  .scene--big-number.is-wall .scene-inner,
  .scene--big-number.is-mapped.is-plane .scene-inner { order: 0; }
}

/* ── A scene whose drawing is a figure, not a backdrop ─────
   Every other visual on this spine sits behind the copy and is
   absolutely positioned, so the scene stays a single column and the
   artwork costs no layout. The availability scene's drawing has an
   axis and fourteen row labels, and none of those survive being
   held at backdrop opacity under a paragraph — see the note at the
   head of the country-spread block in viz.css. So it takes a column
   of its own, and this is the layout that gives it one.

   TWO ITEMS IN ONE ROW. attachViz() gathers the copy into
   .scene-copy before it mounts the host, so this is a plain
   two-column grid rather than a span across the copy's rows — see
   the note there for why spanning cannot work when the rows are
   automatic.

   Note the wrapper flips the axis that `align-items` refers to. In
   the flex column above it means horizontal; here it means vertical,
   so it is restated rather than inherited from the centred template. */
.scene--big-number.has-figure .scene-inner {
  display: grid;
  /* The figure takes the larger share. It is the only element here
     with a minimum useful size — below roughly 480px of plot the
     drawing drops its country names for codes (COMPACT_W in viz.js) — where the
     copy is comfortable at any width down to its measure. */
  grid-template-columns: minmax(0, 0.88fr) minmax(0, 1.12fr);
  column-gap: var(--space-6);
  align-items: center;
  /* Wider than --content-width, and only here: two columns out of
     1080px would hand the drawing about 500px and tip it into its
     compact form on a desktop, which is the one place it has room to
     be the full thing. */
  max-width: var(--max-width);
}
/* The copy keeps the stack it has in every other big-number scene;
   it has simply moved down a level. */
.scene--big-number.has-figure .scene-copy {
  display: flex;
  flex-direction: column;
  gap: var(--space-4);
  min-width: 0;
}
.scene--big-number.has-figure .viz-host--figure {
  align-self: stretch;
  /* A floor, not a height, so the row is the taller of the drawing
     and the copy. Kept well inside the section's 100vh — the scene
     is a snap target and anything past that loses its bottom on
     desktop. Both terms matter: the vh keeps it proportionate on a
     short window, the px stops a tall one from turning fifteen rows
     into fifteen stripes. */
  min-height: min(62vh, 620px);
}

/* Stacked on a phone, with the drawing last so the number still
   leads.

   IT STILL MAY NOT GROW THE SECTION, and that is why the height here
   is a hard one rather than a floor. It is tempting to let a stacked
   scene run long on the assumption that snap is off — but snap.js
   tests `(hover: hover) and (pointer: fine)` and nothing about width,
   so a narrow desktop WINDOW still snaps, and a section past 100vh
   loses its bottom for exactly that reader. The drawing takes a
   definite slice of the viewport and builds itself into it. */
@media (max-width: 720px) {
  .scene--big-number.has-figure .scene-inner {
    grid-template-columns: minmax(0, 1fr);
    row-gap: var(--space-4);
    max-width: var(--content-width);
  }
  .scene--big-number.has-figure .viz-host--figure {
    min-height: 0;
    height: min(34vh, 300px);
  }
}

/* The hero's jump-to-a-city search.

   The component brings its own styling (nav-search.css); this only
   places it. It sits between the lede and the meta line, which is
   where a reader who does not want the story looks after reading one
   sentence of it — and it keeps the hero's left edge rather than
   centring, because every other line in this scene is ragged-right
   off that same edge.

   z-index because the listbox opens downward over the globe: the
   backdrop is a sibling painted under .scene-inner, and a dropdown
   that the artwork can cover is a dropdown a reader cannot use. */
.scene--hero .hero-search {
  position: relative;
  z-index: 2;
  width: 100%;
  max-width: 560px;
}
.scene--hero .hero-search .nav-search { margin-top: 0; }

/* AND IT HAS TO CLEAR ITS OWN SIBLINGS, not just the artwork. Every
   child of .scene-inner is lifted to --z-raised by the rule in
   viz.css, and that rule outspecifies the `z-index: 2` above — so the
   search, the prices/units row and the meta line with the report
   button all sat at the same index, and the two that come after it in
   the DOM painted straight over the open list. Raised only while the
   list is up: permanently raised it would ride over the fixed header
   on scroll. :has() covers a list held open by the pointer after the
   input loses focus; :focus-within covers browsers without it. */
.scene--hero .scene-inner > .hero-search:focus-within,
.scene--hero .scene-inner > .hero-search:has(.nav-search__listbox:not([hidden])) {
  z-index: var(--z-popover);
}

/* A second-order fact set apart from the sentence above it. Used for the
   one figure on the landing page that describes INRIX's whole footprint
   rather than these 121 downtown areas — it has to be readable and it
   must not read as part of the scorecard's own totals. */
.dek-aside {
  /* No scroll-margin-top. It reserved the header height for an anchor
     jump that no longer happens — the superscript link that pointed
     here is gone, because an in-page anchor fights the scroll-driven
     scenes. See js/render.js. */
  display: inline-block;
  margin-top: var(--space-3);
  padding-top: var(--space-3);
  border-top: 1px solid var(--line);
  color: var(--muted);
  font-size: var(--text-sm);
}

/* The inline assumption footnote.
   A derived area is arithmetic on a stated stall size, not a
   measurement, and the reader has to see the assumption beside the
   number rather than only on the methodology page — the site's own rule
   is that what we did not measure is said at the same volume as what we
   did. Visible text, not a title= tooltip: a tooltip is not
   keyboard-reachable and WCAG 2.2 AA is a gate here, not an aspiration. */
.figure-note {
  max-width: var(--reading-width);
  margin-top: var(--space-4);
  padding-top: var(--space-3);
  border-top: 1px solid var(--line);
  color: var(--muted);
  font-size: var(--text-xs);
  line-height: 1.5;
  text-align: left;
}
/* The template centers everything; a two-line footnote reads better
   ragged-right, and centering it would make it compete with the dek.

   The measure is capped as well as the width. --reading-width is sized
   for body copy; at the footnote's 12px it runs to about 120
   characters a line, which is roughly twice a comfortable measure and
   was also what pushed this line under the island at 1024px. */
.scene--big-number .figure-note {
  margin-inline: auto;
  max-width: min(var(--reading-width), 68ch);
}

/* ── Headline + chart scene ────────────────────────────── */
.scene--chart .scene-inner { gap: var(--space-5); align-items: stretch; }
.scene--chart h2 { max-width: var(--reading-width); }
.scene--chart .chart-host {
  background: var(--card-grad);
  border: 1px solid var(--line);
  border-radius: var(--radius-lg);
  padding: var(--space-5);
}

/* ── Progress dots (right rail) ────────────────────────── */
.scroll-progress {
  position: fixed;
  right: var(--space-5);
  top: 50%;
  transform: translateY(-50%);
  z-index: var(--z-progress);
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
}
.scroll-progress .dot {
  width: 10px;
  height: 10px;
  border-radius: 999px;
  border: 1px solid var(--line-strong);
  background: transparent;
  cursor: pointer;
  padding: 0;
}
.scroll-progress .dot[aria-current="true"] {
  background: var(--primary);
  border-color: var(--primary);
}

/* Same decision as the narrow block below, on the other axis. A 10px
   dot in an 8px-gapped column is a touch target no finger can resolve,
   and the usual remedy — a transparent overlay — cannot be used here
   because at that spacing every overlay would swallow its neighbours,
   and a target that fires the wrong control is worse than a small one.

   So the rail is hidden on a phone held either way, for the reason it
   is already hidden in portrait: it is a shortcut, not the way through
   the page. Scrolling reaches every scene, and the hero's "Skip to
   map" still offers the one jump that saves real distance.

   Not folded into the narrow block because that block carries rules
   that only make sense when the viewport is NARROW; this one is about
   it being short. */
@media (max-height: 500px) {
  .scroll-progress { display: none; }
}

/* Pre-hide elements that animate in, but ONLY for users
   who haven't requested reduced motion.
   This is the single anti-FOUC guard for the whole site. */
@media (prefers-reduced-motion: no-preference) {
  .scene [data-animate] { opacity: 0; }
}

.scene-copy {
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
  max-width: var(--reading-width);
}

.mover-list {
  list-style: none;
  padding: 0;
  margin: var(--space-4) 0 0;
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
}

.mover-list li {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  gap: var(--space-2) var(--space-3);
  align-items: center;
  padding-bottom: var(--space-3);
  border-bottom: 1px solid var(--line);
}

.mover-list li:last-child { border-bottom: 0; padding-bottom: 0; }
.mover-state { font-weight: 800; color: var(--text); }
.mover-values { color: var(--muted); font-size: var(--text-sm); }
.mover-list .delta { grid-row: span 2; justify-self: end; }

@media (max-width: 900px) {
}

@media (max-width: 720px) {

  /* The right-rail scene dots overlap content on narrow viewports and
     mobile users already have a scrollbar — hide them. The hero-mode CTA
     ("Skip to map") still provides quick-jump affordance. */
  .scroll-progress { display: none; }
}

/* ── SCROLL ANCHORING IS OFF, AND THAT IS THE WHOLE FIX FOR
      THE SLIDE-TO-SLIDE FLICKER ───────────────────────────
   Chrome keeps the thing you are looking at in place when content
   above it changes size: it picks an anchor element each frame and
   silently adjusts scrollTop by whatever the layout shifted. On a
   document of articles that is a kindness. On this one it is the
   bug the reader reported — the page visibly snapping back toward
   the slide it just left, mid-gesture, worst on a slide whose
   drawing had not finished loading.

   Every ingredient is here at once. A scene is a viewport-sized
   block whose contents arrive late and asynchronously: fetched JSON
   resolves, an SVG is drawn into its host, a Mapbox canvas sizes
   itself, a webfont swaps and reflows a dek. Each of those is a
   layout change *above* the reader during a fling, and each one buys
   an anchor adjustment. The adjustments are small — we recorded
   50-58px, twice per gesture — but a 58px jump backwards while the
   page is travelling forwards is read as a flicker between the two
   slides, not as a scroll.

   And it is invisible to every scroll-position check we could write
   in JS, because no script of ours makes it: the instrumented run
   logged zero scrollTo / scrollBy / scrollIntoView calls across the
   reversals. The compositor does it under us.

   Anchoring buys us nothing here in exchange. There is no case on
   this page where content grows above a reader who must not move:
   the scenes are fixed-height viewport blocks, the ranking tables
   that do stream rows append them below the fold on other pages, and
   both snap.js controllers position the scroller deliberately and
   would rather not be corrected. So: off for the whole
   scrollytelling document, on `body` so the exclusion covers every
   descendant, and keyed on the map scene exactly as the snap rule
   below is — the ranking and methodology pages are long documents
   where anchoring is doing its job, and they keep it.

   Not inside a pointer query. A fine pointer runs the JS slideshow,
   which animates the scroll itself and has even less use for a
   correction it did not ask for. */
html:has(.scene--map-pinned) body { overflow-anchor: none; }

/* ── The slideshow on a touch screen ───────────────────────
   js/snap.js is the slideshow, and it excuses itself on a coarse
   pointer: it swallows the gesture and animates the scroll itself,
   which on a phone means fighting momentum, rubber-banding and the
   URL bar for control of the one input the reader has. It is right
   to stay out. But what it left behind was one contiguous scroll
   eleven thousand pixels long, with every section boundary falling
   wherever the flick happened to die — a section's heading arriving
   two-thirds up the screen with the previous one's last line still
   above it, which is the thing the slideshow exists to prevent.

   So the phone gets the same contract from the browser instead.
   `scroll-snap-type` on the scroller and a snap point at each
   section's top is the native form of the same idea: a gesture that
   ends near a section top lands ON it, with the heading at the
   chrome's lower edge, exactly where a wheel notch puts it on a
   desktop.

   PROXIMITY, NOT MANDATORY, and that is not timidity. A scene is
   `min-height`, not `height`: on a 393x852 phone the downtown-area
   explainer measures about 1,450px against roughly 750px of usable
   height, so a section is routinely two screens of content. Under
   `mandatory` the scroller may not rest anywhere but a snap point,
   and the second screen of every such section becomes unreachable —
   the reader scrolls and is pulled back, or past. Proximity snaps
   the boundaries and leaves the inside of a long section alone,
   which is the only reading of "snap to the section top" that does
   not also mean "delete half the section".

   Scoped to the scroller that has this spine in it, so the ranking
   and methodology pages — long documents, not slideshows — are
   untouched; and to a coarse pointer, which is precisely the case
   js/snap.js declines, so the two can never both be driving.

   Reduced motion opts out: the snap itself is positioning rather
   than decoration, but the correction it performs is an unrequested
   movement of the whole page, and that is the category. */
@media (hover: none) and (pointer: coarse) and (prefers-reduced-motion: no-preference) {
  html:has(.scene--map-pinned) { scroll-snap-type: y proximity; }

  /* ── THE SNAP AREA IS A MARKER, NOT THE SECTION ────────
     The obvious form of this rule — `scroll-snap-align: start` on
     `.scene` itself — compiles, inspects correctly, and snaps
     nothing at all. A snap area taller than the snapport has no
     single snap position: the spec says the scroller may rest
     anywhere that keeps the snapport covered by it, so for a section
     two screens tall EVERY offset inside it is already a valid
     resting place and there is nothing for the scroller to correct.
     Every scene here is taller than the phone's viewport — the
     downtown-area explainer is about 1,450px against 750px — and so
     is every map panel, so the first cut of this block was inert on
     the one class of device it was written for.

     A one-pixel box pinned to the section's top edge is a snap area
     the snapport cannot be covered by, which gives it exactly one
     snap position: the top of the section. It paints nothing, takes
     no space (absolute, inside the `position: relative` both boxes
     already declare) and takes no hits.

     THE HERO IS EXEMPT, and it is the only section that is. Its snap
     position is y = 0, which is where the reader already is — there
     is nothing above it for the snap to correct, so the point can
     only ever pull BACKWARDS. On a phone that is not theoretical: the
     hero is the one scene solved against the viewport rather than its
     content, and on a 393x760 handset it comes out 806px against 760,
     so its last 46px — the typeahead and the prices/units pair — sit
     below the fold. Every gesture that reached them ended inside the
     snap point's catchment and was dragged straight back to the top,
     which read as the page refusing to scroll. A section whose own
     top is the document's needs no help landing on it. */
  .scene:not(.scene--hero)::after,
  .map-panel::after {
    content: '';
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 1px;
    pointer-events: none;
    scroll-snap-align: start;
    /* NO scroll-margin-top. The chrome's room is already reserved,
       once, by `html { scroll-padding-top }` in base.css — snapping
       insets the snapport by it like every other scroll-into-view on
       the site, so a margin here is the header counted twice and the
       section lands a header high. Measured: 214px of inset where
       101 was wanted. */
  }

  /* A card is one fixed frame with nothing below its fold, so a flick
     that crosses two of them has skipped a slide rather than scrolled
     through one. `always` stops it at each, which is the desktop
     slideshow's behaviour exactly. */
  .map-panel::after { scroll-snap-stop: always; }

  /* ── ONE GESTURE, ONE SECTION ──────────────────────────
     Snapping the boundaries is not on its own the contract the
     desktop slideshow has. There, one wheel notch moves one slide and
     momentum cannot carry past it. Here a flick still crossed a whole
     section and landed in the middle of the one after, because
     proximity only corrects a gesture that happens to END near a
     boundary; a long fling ends nowhere near one. A reader working
     down the page had to feather every swipe.

     The first fix was a snap point at each END of a section, and it
     worked, but it bought the stop with an attraction nobody asked
     for: a snap point does not merely stop a gesture that would pass
     it, it PULLS one that merely ends nearby, so a swipe that settled
     200px short of the foot was dragged forward onto it and the
     reader lost the line they were on. The brake wanted here is a
     barrier, not a magnet — stop a gesture that would leave the
     section, and leave every gesture that would not exactly where it
     landed.

     That is not a thing CSS can say, so the foot is no longer a snap
     point at all. initTouchSpine() in js/snap.js clamps INERTIA to
     the section's bottom edge: it arms on touchend, watches the
     momentum phase, and pins the scroll the moment it would cross the
     boundary. A finger still dragging is never clamped, because a
     drag is deliberate by definition — which is exactly the contract
     asked for, that momentum stops at the end of a section and a
     second, deliberate gesture goes on to the next heading.

     What is left here is `scroll-snap-stop` on the head, which costs
     nothing and still asks the browser not to skip a heading on the
     gestures proximity does act on.

     SCOPED TO A VIEWPORT THE SECTIONS OVERFLOW, which is the whole
     premise, and the script reads the same query before it arms. On
     a phone they overflow by hundreds of pixels — the explainer is
     about 1,300px against 650 of usable height at 320px wide — so the
     boundary is well past the heading and the free run between them
     is most of the section. Give the same coarse pointer a tablet and
     every scene FITS: measured on a 820x1180 iPad the foot of each
     one resolves ABOVE its own head, so a clamp there would fire
     before the reader had moved. The width arm is the phone and the
     height arm the same phone on its side, matching every other
     cramped-viewport query on the site. A tablet keeps the boundary
     snapping it already had and none of this. */
  @media (max-width: 720px), (max-height: 500px) {
    .scene::after { scroll-snap-stop: always; }

    /* ── THE HEAD POINT IS ARMED ONLY ON THE APPROACH ────
       Proximity attracts a gesture that ENDS near a snap point, in
       either direction, and Chrome's idea of near is about a third
       of the viewport. The point that lands the reader on a heading
       is therefore the same point that will not let them leave it:
       measured from the downtown-area heading at 393x852, swipes of
       60, 100, 150 and 200px all ended back on the heading to the
       pixel, and only a 300px shove escaped. A section two screens
       tall cannot be read through a brake on its first screen, and
       the brake this spine is supposed to have is at its END.

       There is no directional snapping in CSS, so the arming is
       done from script: js/snap.js releases a section's head once
       the reader is resting at or past it, and re-arms it as soon
       as they are above it again. That module is touch-only; this
       rule is what scopes the behaviour to a viewport the sections
       overflow, which is the same premise as the barrier above and
       the reason the two live in one block. With the class absent
       — no script, or a tablet — every point stays live and the
       boundary snapping is exactly what it was. */
    .scene.is-past-head:not(.scene--hero)::after { scroll-snap-align: none; }
  }

  /* ── Air at the foot of a section, on the phone only ───
     A section that overflows its viewport ends mid-screen, and with
     nothing between its last line and the next section's first the
     boundary the snap is about to land on is invisible until it has
     already been crossed. The desktop never shows this: every scene
     there fits one frame, so the fold is the boundary.

     Taken as padding on the section rather than margin between two,
     because the scenes are full-bleed — several of them paint a
     drawing edge to edge behind the words — and a gap between the
     boxes would be a stripe of page background cutting through one.
     Padding grows the box the drawing fills and pushes the words up
     off its bottom edge, which is the same gap without the seam.

     THE HERO TAKES IT TOO, and takes it for a second reason. The
     boundary below it is the only one a reader meets before they
     have learned how the page moves, and it was also the tightest:
     the hero overflows a phone viewport by a few dozen pixels, so
     the next section's heading arrived immediately under the hero's
     own controls with nothing between them. The air is also what
     keeps the next section's snap point clear of the hero's last
     screen — at --space-8 + --space-6 the whole hero, typeahead and
     prices/units included, can be rested on with no snap in reach of
     it. That, and the exemption above, are the two halves of one fix.

     The hero was held out of this rule while it also carried a snap
     point of its own, on the grounds that padding would grow a box
     solved against exactly one viewport. It no longer carries one,
     and the overflow it was being protected from is the thing the
     padding is now for.

     Not the map, whose cards are its slides and whose frame is the
     viewport itself.

     THE AIR IS ADDITIVE WHERE A KEY HANGS, which is the half of this
     that was wrong. Two scenes hang their figure key into the foot
     band on a `top: 100%` overlay — the downtown-area explainer and
     the availability chart, the latter being the slide immediately
     before the map — and a flat padding on every section therefore
     bought them much less air than it bought the others: the key
     took 85px of it on one and 111px on the other, leaving the
     section before the map with 65px of clear ground where the curb
     and lots scenes had a full band. The key's own reserve
     (`--figure-key-overhang`, published from `js/render.js` after
     it measures the panel) is added to the air rather than carved
     out of it, so every slide ends on the same gap whatever hangs
     into it. The foot stop still lands on the box's edge, which is
     what keeps a hanging key on screen.

     RAISED TWICE on 2026-10-05: --space-8 + --space-6, then two of
     --space-8, now two of --space-8 plus --space-7. A section
     resting on its foot showed its last line close enough to the
     bottom edge that the boundary read as the fold rather than as
     the end of something, and the slide arriving behind it had
     nothing to arrive out of. 232px is about a quarter of a phone
     screen, which is the one offset the reader is most likely to be
     stopped at and so the one worth spending it on. */
  .scene { --scene-foot-air: calc(var(--space-8) * 2 + var(--space-7)); }
  .scene:not(.scene--map-pinned) {
    padding-bottom: var(--scene-foot-air);
  }
  .scene--key-hangs:not(.scene--map-pinned) {
    padding-bottom: calc(var(--scene-foot-air) + var(--figure-key-overhang, 0px));
  }
  /* THE SCROLL CUE RIDES THE TOP OF THE BAND, not the section's
     bottom edge. It is positioned against the section, so the air
     added below the hero carried it 97px past the fold — the one
     instruction on the opening screen, off the opening screen.
     Pulling it back by the air leaves it where it has always sat,
     just under the hero's content box and inside the band's first
     few pixels, which is also the only place it can be while still
     pointing at something: the air below it is what the reader is
     being asked to scroll into. */
  .scene--hero .scroll-cue {
    bottom: calc(var(--scene-foot-air) - var(--space-6));
  }
}

/* ── The explainer scene ───────────────────────────────────
   One scene on the spine has no number: it says what a downtown
   area is and draws one beside the words. It borrows the
   big-number scene's figure layout (the two-column grid above) and
   the left-anchored copy of the aside scenes, and carries a
   headline in the figure-label's place. See renderExplainer() in
   render.js. */
.scene--explainer .scene-inner { align-items: flex-start; text-align: left; }
.scene--explainer.has-figure .scene-inner {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  column-gap: var(--space-6);
  align-items: center;
  max-width: var(--max-width);
}
.scene--explainer.has-figure .scene-copy {
  display: flex;
  flex-direction: column;
  gap: var(--space-4);
  min-width: 0;
}
.scene--explainer.has-figure .viz-host--figure {
  align-self: stretch;
  min-height: min(62vh, 620px);
}
.scene--explainer .explainer-headline {
  margin: 0;
  font-family: var(--font-display);
  font-weight: 800;
  font-size: clamp(32px, 3.6vw, 52px);
  line-height: 1.05;
  letter-spacing: -0.02em;
  color: var(--text);
  max-width: min(var(--reading-width), 22ch);
}
.scene--explainer .dek {
  font-size: clamp(var(--text-lg), 1.35vw, var(--text-xl));
  line-height: 1.65;
  max-width: min(var(--reading-width), 58ch);
}
.scene--explainer .dek strong { color: var(--text); font-weight: 700; }
@media (max-width: 720px) {
  .scene--explainer.has-figure .scene-inner {
    grid-template-columns: minmax(0, 1fr);
    row-gap: var(--space-4);
    max-width: var(--content-width);
  }
  .scene--explainer.has-figure .viz-host--figure {
    min-height: 0;
    height: min(44vh, 360px);
  }
}
