/* ============================================================
   viz.css — backdrop visuals on the landing scroll spine.

   These sit BEHIND the big number and draw the comparison the copy
   states in words.

   ── THE RULE THAT CHANGED ─────────────────────────────────
   This file used to be organised around CLEARING the copy: every
   size was derived from "the reading column's right edge sits at
   50vw + 350px, so the artwork gets whatever is left", with
   measured clearances at a dozen viewports and breakpoints that
   hid the artwork once the margin ran out.

   The artwork now sits UNDER the copy on purpose. The headline
   crosses the globe; the whole footprint copy block sits on the
   New York region. Overlap is the design, not a defect.

   So clearance is no longer the constraint — CONTRAST is:

     the artwork may occupy any amount of the frame, provided body
     copy over its busiest region still clears AA on both themes.

   In practice that means the context geography sits barely above
   --bg, the globe is a step or two above it, and only the gold
   route and Manhattan's fill carry real presence — because those
   two are what the copy is actually about. If a fill ever needs
   to be stronger, the copy has to move, not the contrast floor.

   Two rules survive unchanged:

   1. NO LAYOUT HEIGHT. The host is absolutely positioned. These
      scenes are snap targets and snap.js only ever scrolls to
      section tops, so a scene that grows past 100vh silently loses
      its bottom for desktop-mouse readers.

   2. Colour comes from tokens only, so a theme flip repaints the
      SVG with no listener and no JS. tokens.css stays the only
      file that writes a hex.
   ============================================================ */

.viz-host {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  pointer-events: none;
  z-index: var(--z-base);
  overflow: hidden;
}

/* The scene shell is the positioning context, and the copy has to
   sit above the artwork. */
.scene--big-number,
.scene--hero { position: relative; }
.scene--big-number .scene-inner { position: relative; z-index: var(--z-raised); }

/* ── THE HERO SHELL MUST OWN ITS STACKING CONTEXT ──────────
   `position: relative` alone does not make one — z-index stays
   `auto` — so before this line the hero's negative-z child (the rule
   under "miles of curb", see the note below) escaped all the way out
   to the ROOT stacking context. That is a trap rather than a
   nuisance, because of where a body background paints.

   base.css puts --bg on both html and body. html's propagates to the
   canvas and is painted first; body's, no longer propagated because
   html already has one, paints as an ordinary block background —
   which in the root context lands AFTER negative-z descendants.
   Painting order there is: root background, then negative z-index,
   then block backgrounds. So the rule was drawn, correctly sized and
   coloured, and then the page background was painted on top of it.
   It rendered at 1100 x 1 with a live border and was invisible.

   `isolation: isolate` scopes the negative index to this section, so
   the rule now sits below the globe (z-index 0) and above nothing
   but the section itself — which is exactly the layering the note
   below describes and thought it already had. Isolation rather than
   a z-index so the hero does not start competing with the fixed
   header for a place in the root order. */
.scene--hero { isolation: isolate; }

/* ── The hero raises its copy PIECE BY PIECE, and that is a fix ──
   Every other scene lifts the whole reading column in one move, as
   the rule above does. The hero cannot, because one mark in it — the
   rule under "miles of curb" — has to sit BEHIND the globe while the
   words around it stay in front.

   A z-index on `.scene-inner` makes that impossible rather than
   merely awkward. It opens a stacking context, and nothing inside a
   stacking context can paint below something outside it: a negative
   index on the rule would only push it behind its own siblings, with
   the globe still on top of the lot. The same trap is one level
   further down — `.hero-headline` must stay context-free too, which
   is why the lines are raised individually and the h1 is not.

   So the column stays at `auto` and each piece is lifted on its own,
   leaving the rule free to go the other way. `.hh-line` already
   carries `position: relative` in scene.css; this only adds the
   index. The scroll cue needs nothing — it is positioned and sits
   after the host in the DOM, so it already paints over it.

   All of that is necessary and none of it was sufficient: keeping
   every ancestor context-free let the rule's negative index escape
   the section entirely, where the body background buried it. The
   context has to exist SOMEWHERE above the globe and below the page.
   `isolation: isolate` on .scene--hero, above, is that somewhere. */
.scene--hero .scene-inner { position: relative; z-index: auto; }
.scene--hero .scene-inner > :not(.hero-headline),
.scene--hero .hero-headline .hh-line { position: relative; z-index: var(--z-raised); }

/* `display: block` is load-bearing, not tidiness. An inline SVG whose
   viewBox is in degrees — Manhattan's is 0.107 x 0.196 — sized only by
   width/max-height resolved to a degenerate box during layout, and
   ScrollTrigger threw measuring the scene around it. Giving every
   visual a block box removes that whole class of problem. */
.viz {
  display: block;
  width: min(84vw, 620px);
  max-height: 76vh;
  overflow: visible;
}

/* ── Reveal ────────────────────────────────────────────────
   Only for readers who accept motion. Under reduced motion the
   artwork must already be visible at its final state, so this
   whole block is skipped and opacity stays at its default.

   Note this is opacity on the HOST, not on marks inside the SVG —
   the fill itself is driven by geometry from viz.js, never by CSS. */
@media (prefers-reduced-motion: no-preference) {
  .viz-host { opacity: 0; animation: viz-in var(--dur-slow) var(--ease-out) 0.35s forwards; }
}
@keyframes viz-in { to { opacity: 1; } }

/* ── globe ─────────────────────────────────────────────────
   Sized to overlap the headline rather than to avoid it. The route
   passing behind "miles of curb" is the effect being asked for. */
/* The sphere's box, as two numbers rather than two literals, because
   the hero's legend has to find the sphere. The legend sits in a host
   that fills the whole section, so the only way it can land ON the
   globe at every width is to be positioned off the same size and the
   same right margin the sphere is. See .scene--hero
   .figure-key--overlay in scene.css. */
.scene--hero {
  --globe-w: min(46vw, 900px, 86vh);
  --globe-mr: clamp(0px, 6vw, 190px);
}
.viz--globe {
  /* Square, so the height limit is written as a width for the same
     reason the plane is: a max-height would letterbox the sphere
     on short windows rather than shrinking it. */
  width: var(--globe-w, min(46vw, 900px, 86vh));
  max-height: none;
  justify-self: end;
  /* Pulled right so the sphere's left third sits under the copy
     column and the rest runs toward the margin. The overlap is the
     point: at 1440 the headline column ends at x=1270 and the globe
     starts near x=690, so the gold route crosses the last two words
     of the headline rather than stopping short of them. */
  margin-right: var(--globe-mr, clamp(0px, 6vw, 190px));
}

/* ── THE SPHERE THINS UNDER THE READING COLUMN ─────────────
   The overlap above is the design. What it does not survive is a
   headline that grows. "miles of curb" clears the dot scatter at
   most widths; "kilometres of curb" is four glyphs longer and does
   not — at 1500 it runs to three quarters of the frame with the
   North America dots directly behind it. Narrowing the window does
   the same thing from the other side: the copy holds its measure
   while the sphere keeps its share of the width, so the two close
   on each other. Both cases put body weight over the busiest part
   of the artwork, which is the one thing the contrast rule at the
   top of this file does not allow.

   So the sphere dims toward the reading column and recovers toward
   the margin. Left is where the words are; right is where the
   picture is allowed to be itself.

   WHY A MASK AND NOT A SCRIM OF --bg. The two are the same pixels
   here: .scene--hero paints no background of its own, so the host
   composites straight onto the page. Fading the host toward nothing
   and laying page colour over it land in the same place, and the
   mask needs no colour token and no theme split to do it.

   The mask also stays out of a trap the scrim walks into. .hh-rule
   sits at z-index -1 so the sphere occludes it (see the note in
   scene.css). A stacking context has no slot between a negative
   index and zero, so anything painted over the artwork in this
   section paints over that rule as well — and a scrim strong enough
   to help the headline would wash the rule out along the half of
   its run that crosses plain page, where it is the only thing
   drawn. A mask reaches only the host's own subtree. The rule keeps
   its weight end to end and simply reappears, faintly, through the
   part of the sphere that has thinned.

   The stops are read off the copy rather than chosen for their
   roundness: the left limb enters near 47% of the frame, the lede's
   measure ends near 60%, the longest headline near 78%. Full
   strength by 88% leaves the gold route and the equator bands
   untouched over the margin, which is where they carry the
   finding. */
.scene--hero .viz-host--globe {
  --globe-fade: linear-gradient(to right,
      rgba(0, 0, 0, 0.34) 0%,
      rgba(0, 0, 0, 0.52) 44%,
      rgba(0, 0, 0, 0.86) 74%,
      rgba(0, 0, 0, 1) 88%);
  -webkit-mask-image: var(--globe-fade);
  mask-image: var(--globe-fade);
}

/* THE SPHERE STILL OCCLUDES THE RULE, and the fade above is why it
   has to be said again here. A mask thins the ocean disc along with
   everything else, so .hh-rule (z-index -1) showed through the left
   limb at a third of its weight. This disc of page colour sits at
   z 0 ahead of the host in paint order — above the rule, below the
   artwork — on the same geometry as .viz--globe: the host's grid
   centres it vertically, ends it --globe-mr from the right, and the
   sphere is R=100 in a 204-unit viewBox. */
.scene--hero::before {
  content: '';
  position: absolute;
  inset: 0;
  z-index: var(--z-base);
  pointer-events: none;
  background: radial-gradient(
    circle calc(var(--globe-w) * 100 / 204)
      at calc(100% - var(--globe-mr) - var(--globe-w) / 2) 50%,
    var(--bg) 99%, transparent 100%);
}
@media (max-width: 720px) {
  .scene--hero::before { content: none; }
}

/* THE 121 GOLD DOTS ARE NOT FADED. They are the brand gold and have
   to read as the token wherever they sit, so the globe draws that
   tier into a second SVG which render.js mounts in this sibling host
   — same layout as .viz-host, none of the kind class the ramp above
   is keyed on. Ocean, land, graticule, the route, the ticks and the
   INRIX bands all stay under the fade; only the downtown areas are
   lifted out of it (a decision, 2026-09-10). The cost is that the
   few dozen dots over the Americas sit at full strength under the
   headline, where they are three-pixel marks against a 200px
   numeral. */
.viz-host--marks { /* inherits .viz-host; the ramp above is keyed on --globe and never reaches this host */ }

/* Narrower frames need MORE of the ramp, not a steeper one, and the
   reason is that only one side of the overlap can give. The lede is
   already at its minimum measure by about 1100, so from there down
   every pixel the frame loses comes out of the margin the sphere was
   recovering in. The copy's right edge walks from three fifths of
   the frame at 1500 to four fifths at 900 while the sphere holds its
   46vw share, which is the same collision the long headline makes,
   arriving by a different route.

   So the stops walk right with it and the floor drops underneath
   them. The sphere is not being hidden — it is being spent later,
   in the strip of frame the words have stopped reaching. */
@media (max-width: 1100px) {
  .scene--hero .viz-host--globe {
    --globe-fade: linear-gradient(to right,
        rgba(0, 0, 0, 0.2) 0%,
        rgba(0, 0, 0, 0.36) 48%,
        rgba(0, 0, 0, 0.7) 80%,
        rgba(0, 0, 0, 1) 96%);
  }
}

/* The last step before the host goes entirely (720, below). Here the
   lede is edge to edge and the sphere's own middle — the gold route
   and the equator bands, the two marks that carry the finding — is
   what the third and fourth lines of copy land on. A left-to-right
   ramp cannot clear text that has no clear side, so what is left to
   do is spend the whole width: a wash under the copy and full
   strength only in the last tenth of the frame.

   That reads as the artwork receding rather than as the artwork
   damaged, which is the same call the breakpoint below makes when it
   drops the host outright. This width just gets to make it by
   degrees. */
@media (max-width: 900px) {
  .scene--hero .viz-host--globe {
    --globe-fade: linear-gradient(to right,
        rgba(0, 0, 0, 0.1) 0%,
        rgba(0, 0, 0, 0.2) 55%,
        rgba(0, 0, 0, 0.48) 86%,
        rgba(0, 0, 0, 1) 100%);
  }
}

/* Quieter than the artwork it replaced. The globe is ground, not
   figure: the ocean is nearly the page, the land one step up, and
   the graticule at the same weight as a chart gridline.

   ── ONE POLYGON PALETTE, AND THIS IS THE FILE THAT JOINED IT ──
   The land, the graticule and the limb were surface and line tokens
   — --surface-2 on --surface-1, hairlines in --line and
   --chart-grid — which is a GREY drawing. Every other polygon on
   this site is blue: the Cities and Countries heroes both paint
   --primary-soft inside a --primary coastline (see the palette note
   in hero-figure.css). A
   reader moves between those pages in two clicks, and the globe was
   the one picture that did not look like it came from the same
   publication.

   So the sphere's LAND takes the shared palette and its two line
   layers follow it. What does NOT move:

     · the OCEAN disc, which stays a surface token. It is the ground
       the land is drawn on, not a polygon in the palette's sense,
       and tinting it too would leave land-on-ocean resting entirely
       on the hairline — over the stretch the headline crosses.
     · the DOTS, which stay --text-soft. Neutral by decision; the
       note below says why and it is unaffected by this.
     · the ROUTE, which stays gold. It is the finding.

   The stroke opacities are the tuned values, not the token's own
   alpha: --primary-soft is 0.16, and 0.16 of anything on a 0.35-unit
   hairline is not a line. Fill carries the wash, stroke carries the
   edge, which is the same split the disc map makes. */
.viz-globe-ocean { fill: var(--surface-1); }
/* THE LAND IS PAINTED AT THE SUBJECT WASH, NOT THE GROUND ONE, and
   the arithmetic is why it is written as an alpha rather than as
   --primary-soft.

   Every figure on this site paints ground once in --primary-soft and
   a subject twice, so a subject lands at 1 - (1 - a)^2 — 0.294 on
   dark, 0.226 on light. On the globe the land IS the subject: it is
   the only geography in the drawing, and at a single wash it sat a
   couple of levels off the ocean and read grey, which is the
   complaint this change answers. The countries hero's polygons are
   the reference, and they are subjects.

   There is no way to say "this token, composited with itself" in
   CSS, so the composite is written out. */
.viz-globe-land  { fill: var(--primary); fill-opacity: 0.29; stroke: var(--primary); stroke-opacity: 0.85; stroke-width: 0.4; }
.viz-graticule   { fill: none; stroke: var(--primary); stroke-opacity: 0.14; stroke-width: 0.35; }
.viz-globe-limb  { fill: none; stroke: var(--primary); stroke-opacity: 0.55; stroke-width: 1; }

/* The city scatter. Two tiers on one sphere: the 121 downtown cores
   this scorecard ranks, in gold, scattered through the wider
   municipal-scale coverage in a lighter blue. Between the land and
   the curb lines, and under them — the lines are still the subject.

   ONE SIZE AND ONE COLOUR PER TIER, and nothing here moves. The layer
   states two facts — where a city is, and which of the two datasets
   it belongs to — and a mark that changed while a reader looked at it
   would be claiming a third. It replaced a twinkle that lit the dots
   a few at a time; what that bought in liveliness it spent on making
   a fixed scatter look like a live feed, and this drawing is a
   measurement, not a monitor.

   Standing out is therefore the tier's job, not the animation's: the
   gold is brighter, wider and painted last, and it has a halo under
   it rather than a pulse over it. A halo gives the mark a falloff,
   and a falloff is what the eye reads as a light rather than a speck
   of ink. It is a fixed size too.

   Every mark is a dot at THREE fixed weights: halo, keyline, dot.
   See the keyline note below for what the middle one is for.

   Round cap on a zero-length segment is what makes a dot out of a
   path, so `stroke-linecap` here is load-bearing rather than
   cosmetic: with the default butt cap the whole layer paints nothing.
   Width IS the dot's diameter — at R=100 a 1.63 unit dot is about
   two and a half pixels on a 300px globe and a downtown core at 2.13
   is about three, outline included. */
.viz-globe-dots {
  fill: none;
  stroke: var(--dot-ink);
  stroke-width: var(--dot-w);
  stroke-linecap: round;
  opacity: var(--dot-op);
}

/* The halo. Two and a half times the dot's width at a fifth of its
   ink — wide enough to be a glow, faint enough that a cluster of
   cities does not fill in as a smear, and the smear is the real
   constraint here rather than the brightness. At three and a half the
   American Northeast and Europe close up into one mass and the layer
   stops being a thousand places. */
.viz-globe-dots-glow {
  fill: none;
  stroke: var(--dot-ink);
  stroke-width: var(--glow-w);
  stroke-linecap: round;
  opacity: var(--glow-op);
}

/* The keyline: the same dot a hair wider, in the page's ground
   colour, painted directly under it — dark on the dark theme, light
   on the light one, so the outline is always the ground rather than
   a fourth colour to explain.

   WHAT IT IS FOR is separation, not decoration. The scatter is at its
   densest exactly where the reader looks longest — the American
   Northeast, the Rhine, southern England — and a dozen dots at one
   weight touching each other read as one shape. A ground-coloured
   ring keeps a boundary between neighbours, and between a dot and the
   land stroke it happens to land on, so the layer stays a count of
   places rather than a set of blobs.

   A third of a unit a side on the gold, a quarter on the blue, which
   is the definition of thin here: about a pixel on a 300px globe,
   enough to part two touching dots and not enough to make the mark
   visibly bigger. THE BLUE RING IS THINNER BECAUSE THE BLUE DOT IS
   SMALLER — the outline eats into the mark it outlines, and the same
   ring that merely defines a 1.5-unit gold core leaves a
   municipal-scale dot with almost nothing lit inside it. Both tiers
   also carry a little more width than they did before the outline,
   for the same reason.

   Drawn ABOVE the halo and below the dot, so the glow reads outside
   the outline as a falloff rather than being cut in half by it.

   Not fully opaque: at 1 the ring punches a hard hole in the land and
   the scatter reads as perforation. At this weight it separates and
   still lets the geography through. */
.viz-globe-dots-ring {
  fill: none;
  stroke: var(--bg);
  stroke-width: calc(var(--dot-w) + var(--ring-t) * 2);
  stroke-linecap: round;
  opacity: 0.78;
}

/* ── The two tiers ─────────────────────────────────────────
   Set on the group, inherited by its two paths, so the whole palette
   is these blocks and the mark rules above stay tier-blind.

   GOLD IS THE 121 DOWNTOWN CORES. Gold on this scene means what THIS
   SCORECARD measured — the route, the ticks, the full stop in the
   headline — and the downtown cores are exactly that: the areas every
   ranking on the site is computed over. Painting them in the accent
   puts the finding and the places it came from in one colour. It is a
   change of mind, and the reasoning it replaces is worth keeping: the
   scatter used to be all --primary on the argument that a dot states
   WHERE a city is and gold states WHAT WAS FOUND, so a thousand gold
   dots would spend the accent on a second dataset. That argument
   still holds for the wider coverage, which is why only 121 of the
   1,142 marks moved and the rest stayed blue.

   THE COASTLINE'S BLUE FOR THE REST — literally: --primary at 0.85,
   which is exactly what .viz-globe-land strokes its outlines with.
   Those cities are municipal-scale coverage rather than downtown
   measurements, and taking the colour and the weight of the geography
   they sit on is what says so. They are the same statement as the
   coastline: this is the ground, here is how much of it we have.

   It was --primary-bright at 0.55 first, a deliberate step down meant
   to keep the field quiet under the gold. What kept it quiet was
   sitting BELOW the drawing it belongs to, which made the coverage
   read as faded rather than as ground. The gold has other ways to
   win: it is wider, it has a heavier halo, and it is painted last.

   THE GOLD IS THE ONLY THING HERE THAT COMPETES WITH THE ROUTE, and
   it is held under it by width rather than by colour: 1.5 units
   against the route's 2.5, so the line is still the heaviest gold on
   the sphere and the dots read as marks on it. */
/* EVERY WIDTH HERE IS 1.25x ITS FIRST VALUE — dot, keyline and halo
   together, which is what keeps a bigger mark the same mark rather
   than a fat dot in a thin outline. The ratios inside the mark are
   what the notes above are about; the absolute size is a reading
   distance, and the scatter was small for the globe it sits on. */
.viz-dots-tier--other {
  --dot-ink: var(--primary);
  --dot-w: 1.63;
  --dot-op: 0.85;
  --ring-t: 0.31;
  --glow-w: 3.25;
  --glow-op: 0.08;
}
.viz-dots-tier--downtown {
  --dot-ink: var(--gold);
  --dot-w: 2.13;
  /* Fully opaque, by decision: the 121 are painted in the brand gold
     and have to READ as the brand gold (the --gold token) — at 0.95 the
     ground showed through and every dot sat a shade off the accent used
     everywhere else on the site. The halo below is what softens the
     mark; the mark itself is the token. */
  --dot-op: 1;
  --ring-t: 0.41;
  --glow-w: 4.75;
  --glow-op: 0.22;
}
/* Light needs the gold's halo lighter — the same glow spreads further
   on a pale ground. The blue needs no override at all now that it is
   the coastline's own colour and weight: the land stroke carries the
   same 0.85 on both themes, and matching it is the point. */
:root[data-theme='light'] .viz-dots-tier--other {
  --glow-op: 0.09;
}
:root[data-theme='light'] .viz-dots-tier--downtown {
  --glow-op: 0.18;
}

/* The line on the sphere, drawn along real parallels — never an
   abstract arc. Geometry only: the one line the globe draws carries
   its own colour on `--wide` below. (A gold equatorial route for the
   121 downtown areas' curb was stroked here until the 2026-09-30 UAT;
   a sixth of a lap was not a picture worth the crossing it made with
   the headline, and the helix is now the only length drawn.) */
.viz-route {
  fill: none;
  stroke-linecap: round;
  opacity: 1;
}
/* Every kilometre of curb INRIX maps, drawn as full bands plus a
   partial last one — see viz.js for why the laps are drawn rather
   than stated. It hangs south of the equator, so the headline never
   sits on it. */
.viz-route--wide {
  stroke: var(--primary);
  stroke-width: 1.75;
  opacity: 0.55;
}
:root[data-theme='light'] .viz-route--wide { opacity: 0.7; }

/* ── country-spread: the likelihood, as a figure ──────────
   The 121 downtown areas on a 0–100% axis, one row per country — a
   line from the lowest reading to the highest, a dot at the middle
   one — with a band for the middle half of all 121 and a fixed
   reference line at the average.

   ── WHY THIS ONE IS NOT A BACKDROP ────────────────────────
   Everything else in this file obeys the contrast rule at the top:
   the artwork may take the whole frame provided body copy over its
   busiest region still clears AA. That is affordable for a
   silhouette, whose job survives being faint, and it is not
   affordable for a chart. A backdrop opacity that lets a 20px
   paragraph sit on top also erases a 10px tick label, and a chart
   with no legible tick is not a quiet chart, it is a texture.

   So this one sits in the second column, where nothing is drawn
   over it and the contrast question does not arise. Marks are at
   full strength here because they can be.

   The no-layout-height rule at the top of this file still binds:
   the figure fits inside the section, it never grows it. */

/* The host, in the layout rather than behind it. Two backdrop
   properties are undone deliberately: position, so it takes a grid
   column, and overflow, so a row label is never clipped by a
   gutter a pixel too narrow.

   `pointer-events` stays at the backdrop's `none`: a figure that
   answers nothing should not be eating the pointer. */
.viz-host--figure {
  position: static;
  inset: auto;
  display: block;
  overflow: visible;
  /* A grid item's automatic minimum is its content, which for an
     SVG that sizes to its box is a circular definition; both
     minimums have to be released or the column refuses to shrink
     and the copy beside it gets squeezed instead. */
  min-width: 0;
  min-height: 0;
  height: 100%;
}

.viz--country-spread {
  width: 100%;
  height: 100%;
  max-height: none;
  /* Full strength. See the note above — nothing is drawn on top of
     this, so there is no contrast budget to spend. */
  opacity: 1;
}

/* OUT OF FLOW, and that is what keeps this slide the height of the
   screen across a rotation.

   `height: 100%` on the SVG resolves against the host, the host
   resolves against its grid row, and the row is auto — so the
   percentage falls back to the SVG's own INTRINSIC height, which an
   SVG takes from its viewBox. viz.js writes that viewBox from the
   box it last measured, so the two chase each other and settle on a
   fixed point that is self-consistent and wrong. Measured: at
   768x1024 the viewBox is 383x620; rotate to 1024x768 and the
   column is 526 wide, 526 x 620/383 is 852px of drawing, the
   rebuild writes 526x852 back, and nothing ever pulls it in again.
   The section stood 1237px tall against 667px of room, where a
   fresh load at that same size gives 519px.

   Taking the drawing out of flow cuts the loop in one direction:
   the row is sized by the copy beside it and the host's own
   min-height floor, never by the drawing, and the drawing then
   fills whatever that comes to. After the change the chart measures
   620px portrait and 519px landscape, the same rotated as freshly
   loaded, both ways round.

   The host is a positioning context already — scene.css turns it on
   for a keyed figure, and this one always carries its key — but it
   is declared here too, because the containing block is now
   load-bearing rather than incidental. */
.viz-host--country-spread.viz-host--figure { position: relative; }
.viz--country-spread {
  position: absolute;
  inset: 0;
}

/* The middle half, as a region of the axis. The faintest thing in
   the drawing on purpose: ground the dots stand on, never a shape
   competing with them, and it carries no stroke because a bounded
   rectangle inside a chart reads as a panel however lightly it is
   drawn. */
.viz-cs-band {
  fill: var(--primary);
  fill-opacity: 0.07;
}

/* Grid and axis: solid hairlines, one shade off the surface. Never
   dashed — dashing reads as a projection or a threshold when this
   is only a grid, and there is a real reference in this drawing
   already (the average) that has to stay distinguishable from it. */
.viz-cs-grid {
  stroke: var(--chart-grid);
  stroke-width: 1;
}
.viz-cs-base {
  stroke: var(--chart-line);
  stroke-width: 1;
}
.viz-cs-tick {
  fill: var(--muted);
  font-family: var(--font-body);
  font-size: 12px;
  font-variant-numeric: tabular-nums;
}

/* The row label. This is the channel the beeswarm did not have: a
   mark's row is named in the frame, so the reader gets an answer
   without a pointer. */
.viz-cs-label {
  fill: var(--text-soft);
  font-family: var(--font-body);
  font-size: 14px;
  font-weight: 600;
}

/* A country's span, from its lowest downtown area to its
   highest. Deliberately uncoloured: length already carries the
   spread, and running the availability ramp along it as well would
   spend the only free channel on something the geometry states. */
.viz-cs-range {
  stroke: var(--line-strong);
  stroke-width: 2;
  stroke-linecap: round;
}

/* The two named extremes: the lowest and highest readings in the
   scorecard, sat above their own row at their own value. Quieter and
   a size down from the row labels — they annotate a mark rather than
   name a row — and they carry the figure with the name, because a
   name alone at one end of a line says "this end" and not "7%". */
.viz-cs-extreme {
  fill: var(--muted);
  font-family: var(--font-body);
  font-size: 11px;
  font-variant-numeric: tabular-nums;
}
.viz--country-spread.is-compact .viz-cs-extreme { display: none; }

/* A country's middle downtown area. ONE STATE, and that is the fix.

   There used to be a lit and an unlit dot, because a rule travelled
   the axis and "reached" countries in turn. With that gone the two
   states have nothing left to mean: a country is not at one value
   early in the animation and another later, and above-or-below the
   average is what the reference line already says. Keeping the split
   also broke the ramp — every country above the average sat grey,
   which is the colour this site uses for "not measured", so the best
   readings in the drawing looked like missing ones.

   So every dot carries the ramp colour for its own reading, handed
   in per mark by viz.js as --dot. That is the one place a colour
   enters from JS, and it is safe because the availability ramp is
   the single scale in tokens.css identical in both themes — the
   theme toggle still repaints everything else here with no listener,
   and the ramp is not one of them. The fallback is the no-data
   colour rather than a ramp stop, so a missing value can never be
   mistaken for a reading.

   The surface ring is what keeps dots apart where they crowd. At the
   tight end of a country's range they overlap, and a ring in the
   page colour separates them without a stroke around every mark
   adding ink that is not data. */
.viz-cs-dot {
  fill: var(--dot, var(--no-data));
  stroke: var(--bg);
  stroke-width: 1.5;
}

/* ── The reveal: everything moves EXCEPT along the axis ────
   The drawing assembles when it first comes into view — axis, then
   ground, then the rows, then the average. Five things move and not
   one of them travels along the value axis, which is the constraint
   the whole visual is built around: a mark that slides across this
   scale says the number it stands for is changing, and none of them
   are. See the note over countrySpread() in viz.js.

   What each one is allowed to do, and why it is allowed:

     the axis      draws left to right. It is the scale itself, not
                   a reading on it, so there is no value to misstate.
     the band      wipes downward. Its EDGES are the quartiles and
                   they never move; only how much of its height is
                   painted changes.
     the rows      fade, staggered top to bottom. That is motion
                   along an ordering (countries sorted by their
                   middle downtown area), not a magnitude.
     the ranges    expand outward from their own median — which is
                   why viz.js publishes --ox/--oy. Growing from the
                   middle reveals an interval; growing from one end
                   would draw a value across the axis.
     the dots      scale up in place. Size, never position.
     the rule      draws top to bottom, across the rows rather than
                   along the axis, and lands last.

   THE HIDDEN STATES LIVE INSIDE THIS GUARD, all of them, and that
   is load-bearing twice over. A reader on reduced motion never gets
   them, so the finished drawing is simply painted with no JS
   involved. And nothing the drawing needs in order to be VISIBLE
   depends on an animation running: viz.js only adds a class, and if
   that never happens the marks are already there.

   Two classes, because a resize rebuilds every node: is-revealing
   plays this, is-revealed is the finished state with no animation
   on it. viz.js switches over once the reveal has had time to run. */
@media (prefers-reduced-motion: no-preference) {
  .viz--country-spread .viz-cs-axis,
  .viz--country-spread .viz-cs-band,
  .viz--country-spread .viz-cs-row,
  .viz--country-spread .viz-cs-mean { opacity: 0; }

  /* The axis, first and quickest — the frame the rest arrives into. */
  .viz--country-spread.is-revealing .viz-cs-axis {
    animation: viz-cs-fade 260ms var(--ease-out) forwards;
  }
  /* --len is the plot width, set by viz.js, so the baseline can be
     drawn rather than faded. */
  .viz--country-spread.is-revealing .viz-cs-base {
    stroke-dasharray: var(--len, 0);
    stroke-dashoffset: var(--len, 0);
    animation: viz-cs-draw 520ms var(--ease-out) forwards;
  }

  /* The ground the rows stand on, wiped down from the top. scaleY
     about the top edge moves the band's BOTTOM only; both vertical
     sides stay put at p25 and p75, so the interval it states is
     never momentarily wrong. */
  .viz--country-spread.is-revealing .viz-cs-band {
    transform-origin: 0 var(--top, 0);
    animation: viz-cs-wipe 420ms var(--ease-out) 140ms forwards;
  }

  /* 70ms a row, not 45. THE SEQUENCE IS PACED TO END WITH THE
     COUNTER — see the note over .viz-cs-mean below — and the way to
     spend that time is on the stagger rather than on the durations,
     which are each tuned to what they are doing: 300ms is a fade,
     380ms is a spring settling. Stretching those makes fifteen slow
     animations; stretching the gap between them makes one unhurried
     sequence. Fourteen rows run 220 + 13 x 70ms, so the last dot
     settles at about 1.59s. */
  .viz--country-spread.is-revealing .viz-cs-row {
    animation: viz-cs-fade 300ms var(--ease-out) forwards;
    animation-delay: calc(220ms + var(--i, 0) * 70ms);
  }
  /* Both marks in a row turn about the median. --ox/--oy are in user
     units, which are CSS pixels here because the viewBox matches the
     box, so the default transform-box (view-box) resolves them
     directly and no fill-box gymnastics are needed. */
  .viz--country-spread.is-revealing .viz-cs-range {
    transform-origin: var(--ox, center) var(--oy, center);
    animation: viz-cs-open 420ms var(--ease-out) forwards;
    animation-delay: calc(220ms + var(--i, 0) * 70ms);
  }
  .viz--country-spread.is-revealing .viz-cs-dot {
    transform-origin: var(--ox, center) var(--oy, center);
    /* The spring overshoots, which is why the dot is last in its own
       row: it settles onto a range that has already stopped moving. */
    animation: viz-cs-pop 380ms var(--ease-out-spring) forwards;
    animation-delay: calc(300ms + var(--i, 0) * 70ms);
  }

  /* ── The average finishes drawing as the counter stops ─────
     The rule and the digits are the SAME NUMBER — the line stands
     where the count is heading — so they land together: 1640ms of
     delay plus a 460ms draw puts the last pixel of the line at
     2100ms, which is COUNT_DUR in render.js.

     COUPLED TO THAT CONSTANT, and there is no way round it. The
     count is a GSAP tween on the scene's timeline; this is a CSS
     animation started by an IntersectionObserver. Two clocks, which
     agree here only because the slideshow lands the whole scene at
     once and both start within a frame of each other. Retime the
     counter and these three delays have to move with it.

     It used to land at 1440ms, most of a second before the number it
     refers to had stopped moving — the drawing finished, then the
     figure it was drawn for went on counting. The rows were retimed
     to fill the gap rather than leaving a pause in front of this. */
  .viz--country-spread.is-revealing .viz-cs-mean {
    animation: viz-cs-fade 200ms var(--ease-out) 1640ms forwards;
  }
  .viz--country-spread.is-revealing .viz-cs-mean line {
    stroke-dasharray: var(--len, 0);
    stroke-dashoffset: var(--len, 0);
    animation: viz-cs-draw 460ms var(--ease-out) 1640ms forwards;
  }
  /* The tag carries the same number again, so it arrives on the
     line's last frame rather than after it. */
  .viz--country-spread.is-revealing .viz-cs-mean-tag {
    opacity: 0;
    animation: viz-cs-fade 260ms var(--ease-out) 1840ms forwards;
  }

  /* The finished drawing, for every rebuild after the reveal. */
  .viz--country-spread.is-revealed .viz-cs-axis,
  .viz--country-spread.is-revealed .viz-cs-band,
  .viz--country-spread.is-revealed .viz-cs-row,
  .viz--country-spread.is-revealed .viz-cs-mean { opacity: 1; }
}

@keyframes viz-cs-fade { to { opacity: 1; } }
@keyframes viz-cs-draw { to { stroke-dashoffset: 0; } }
/* The wipe carries opacity as well as the transform, and it has to:
   the band starts hidden alongside everything else, so a keyframe
   that animated the transform alone would scale an invisible rect
   open and leave it at opacity 0. */
@keyframes viz-cs-wipe {
  from { opacity: 0; transform: scaleY(0); }
  to   { opacity: 1; transform: scaleY(1); }
}
@keyframes viz-cs-open { from { transform: scaleX(0); } to { transform: scaleX(1); } }
@keyframes viz-cs-pop  { from { transform: scale(0);  } to { transform: scale(1);  } }

/* The average, as a fixed reference line. It does not move — the
   class is `mean` and not `sweep` for that reason. A mark that
   travels along a value axis says the value is accumulating, and an
   unweighted mean across 121 downtown areas does not accumulate; see the
   note over countrySpread() in viz.js.

   Drawn as a rule and not a band, so it can never be mistaken for
   one of the readings it stands among.

   IT WEARS GOLD, the accent this site spends sparingly on the one
   mark in a frame that has to be found first. Everything it has to
   separate itself from is already spoken for: the ramp owns the
   dots, so a ramp-coloured rule read as another country, and
   --primary owns both the big figure and the middle-half band right
   behind this line, so a blue rule read as part of the band.

   It is --gold-mark rather than --gold, and the two now carry the
   same value on both themes: the token exists so this rule can be
   re-weighted on light without touching any other gold on the page.
   It held a darkened amber there until the brand value was asked
   for; tokens.css records what that traded away. What the token is
   NOT for is a colour off the availability ramp — the drawing lost
   its reading entirely when this line was painted in
   availColor(61)'s yellow-green, which is the failure the accent is
   here to avoid.

   Values are named rather than quoted here on purpose:
   verify-data.cjs greps these files for a raw hex and does not stop
   at comments, so a hex written in prose fails the build exactly as
   a hex written in a rule would. */
/* The fallback is not decoration. An undefined custom property makes
   the whole declaration invalid at computed-value time, and `stroke`
   then resolves to its initial value — `none` — so a token that
   fails to arrive does not paint a wrong colour, it deletes the
   line. Falling back to --gold keeps the mark on screen; every other
   var() in this file is written the same way for the same reason. */
.viz-cs-mean line {
  stroke: var(--gold-mark, var(--gold));
  stroke-width: 2;
}
/* The words stay in a text token. A label is not the mark it labels,
   and gold as type is the thing --gold-text exists to keep separate
   — at 13px over the band it would be the weakest ink in the frame
   on light. The rule beside it carries the identity. */
.viz-cs-mean-tag {
  fill: var(--text);
  font-family: var(--font-body);
  /* A step up from the 13px it shared with the axis ticks. It is not
     a tick: it names the one reference in the drawing, it now sits
     alone above the rule with no neighbour to match, and the scene's
     whole claim is the number in it. */
  font-size: 15px;
  font-weight: 700;
  /* Tabular on the digits, which sit in a column with the axis
     ticks; the word beside them is ordinary text either way. */
  font-variant-numeric: tabular-nums;
}

/* Compact: the drawing keeps every row and drops only the words.
   The rows are what carries the scene's point onto a phone — that
   some countries finish under the average — so hiding the figure
   outright would drop the story on the platform most readers see
   it on. Country codes, since "United Kingdom" does not fit a
   30px gutter. */
.viz--country-spread.is-compact .viz-cs-label {
  font-size: 11px;
  font-weight: 700;
}
.viz--country-spread.is-compact .viz-cs-tick {
  font-size: 10px;
}
.viz--country-spread.is-compact .viz-cs-mean-tag {
  font-size: 12px;
}

/* Nothing to switch off for reduced motion here. The rows and the
   reference line have no transition and no default hidden state —
   the entire reveal lives inside the no-preference block above, so
   a reader who asks for no motion simply gets the finished drawing.
   That is deliberately the opposite way round from how this file
   handles the other visuals, and the reason is stated over
   .viz-cs-row: a chart may not need an animation to run in order
   to be visible. */


/* ── disc-map: one downtown area, drawn ────────────────────
   The example on the explainer scene: the published disc for one
   city as the same dashed gold circle every map on the site uses,
   over the real neighborhood polygons around it, with the city
   hall at the centre and a scale bar the length of the radius.

   Strokes are non-scaling so line weights are pixels whatever the
   host's size, and they match the map's own mark — gold, width 2,
   dash [2, 2] at that width. A reader who has seen one has seen the
   other. */
.scene--explainer.has-figure .viz-host--disc-map { min-height: min(62vh, 620px); }

.viz--disc-map {
  width: 100%;
  height: 100%;
  max-height: none;
  opacity: 1;
  overflow: visible;
}
.viz-dm-hood {
  fill: var(--primary);
  fill-opacity: 0.09;
  stroke: var(--line-strong);
  stroke-width: 1;
  vector-effect: non-scaling-stroke;
}
/* The downtown area — the same mark the maps draw, for the same
   reason. See addDowntownAreaLayers() in render.js: the brand gold
   reads 1.47:1 on a pale ground, so the FILL is what makes the area
   findable, the dash is its edge, and the casing under the dash is
   what makes that edge legible on any ground. The casing is
   CONTINUOUS and exactly the dash's width, so it shows in the gaps
   and never outside a dash; a WIDER one was tried and rejected,
   because at this width it reads as an outline around each dash
   rather than as a heavier rule. Keep the two stroke-widths equal. */
.viz-dm-disc-fill {
  /* The same alpha the maps paint inside the disc: opaque enough to
     read as a filled area rather than as a tint, and the one number
     the two surfaces have to keep in step. It was 0.07 and read as
     nothing at all.

     Its own path, under the casing, because a filled-and-stroked
     path would paint this over the casing's inner half — see the
     note in discMap(). */
  fill: var(--area-gold);
  fill-opacity: 0.24;
  stroke: none;
}
.viz-dm-disc-casing {
  fill: none;
  stroke: var(--area-casing);
  stroke-opacity: 0.55;
  stroke-width: 2;
  vector-effect: non-scaling-stroke;
}
.viz-dm-disc {
  fill: none;
  stroke: var(--area-gold);
  stroke-width: 2;
  stroke-dasharray: 5 5;
  vector-effect: non-scaling-stroke;
}
/* The lens edge: the crop drawn as a hairline, so the round frame
   reads as chosen rather than as a polygon that happens to be round. */
.viz-dm-lens {
  fill: none;
  stroke: var(--line-strong);
  stroke-width: 1;
  vector-effect: non-scaling-stroke;
}
.viz-dm-center-dot {
  fill: var(--gold-bright);
  stroke: var(--bg);
  stroke-width: 2;
  vector-effect: non-scaling-stroke;
}
/* The diameter: dotted, so it reads as a measurement across the disc
   and not as a road or a boundary; the dots are round and spaced so
   the line stays airy over the neighborhood edges it crosses. */
.viz-dm-diameter-line {
  stroke: var(--text-soft);
  stroke-width: 2;
  stroke-linecap: round;
  stroke-dasharray: 0.1 7;
  vector-effect: non-scaling-stroke;
}
/* Type is in viewBox units (1000 across the frame), so it scales
   with the drawing: 26 units is about 15px in a 600px column. A
   page-coloured halo keeps it legible over a polygon edge. */
.viz-dm-diameter-label { text-anchor: middle; }
.viz-dm-center-label,
.viz-dm-diameter-label {
  fill: var(--text);
  font-family: var(--font-body);
  font-size: 26px;
  font-weight: 700;
  paint-order: stroke;
  stroke: var(--bg);
  stroke-width: 6px;
  stroke-linejoin: round;
}

/* The reveal: neighborhoods fade in, the disc grows out from the
   city hall to its size, then the labels. Same guard as the strip:
   reduced motion gets the finished drawing with no JS involved. */
@media (prefers-reduced-motion: no-preference) {
  .viz--disc-map .viz-dm-hood,
  .viz--disc-map .viz-dm-disc-fill,
  .viz--disc-map .viz-dm-disc-casing,
  .viz--disc-map .viz-dm-disc,
  .viz--disc-map .viz-dm-center,
  .viz--disc-map .viz-dm-lens,
  .viz--disc-map .viz-dm-diameter { opacity: 0; }

  .viz--disc-map.is-revealing .viz-dm-lens {
    animation: viz-dm-fade 420ms var(--ease-out) forwards;
  }

  .viz--disc-map.is-revealing .viz-dm-hood {
    animation: viz-dm-fade 420ms var(--ease-out) forwards;
    animation-delay: calc(80ms + var(--i, 0) * 25ms);
  }
  /* The disc grows out from the city hall to its size. All three
     paths are the same geometry, so they have to grow on exactly the
     same clock — one arriving at full size while the others grew into
     it would read as a ring the disc expands to fill. */
  .viz--disc-map.is-revealing .viz-dm-disc-fill,
  .viz--disc-map.is-revealing .viz-dm-disc-casing,
  .viz--disc-map.is-revealing .viz-dm-disc {
    transform-origin: var(--ox, center) var(--oy, center);
    animation: viz-dm-grow 720ms var(--ease-out) 520ms forwards;
  }
  .viz--disc-map.is-revealing .viz-dm-center {
    animation: viz-dm-fade 300ms var(--ease-out) 1100ms forwards;
  }
  .viz--disc-map.is-revealing .viz-dm-diameter {
    animation: viz-dm-fade 300ms var(--ease-out) 1300ms forwards;
  }
}
@keyframes viz-dm-fade { to { opacity: 1; } }
@keyframes viz-dm-grow {
  from { opacity: 0; transform: scale(0.15); }
  to   { opacity: 1; transform: scale(1); }
}

:root[data-theme='light'] .viz-dm-hood { fill-opacity: 0.06; }
/* No gold override here any more: --area-gold is published per theme
   in tokens.css, and downtownAreaGold() hands the maps the same name,
   so the drawing and the maps draw the downtown area in one colour
   without either of them branching on the theme. */

/* ── wall: the Great Wall, traced, and the curb past it ────
   The curb scene's backdrop. Northern China on one full-bleed map,
   the Great Wall's main line from Shanhai Pass to Jiayu Pass across
   it, traced in gold as the digits climb, and a tail off the
   western end for the curb that is left over. See wall() in
   viz.js.

   ── Where it sits ─────────────────────────────────────────
   The whole section, cut by its edges; the copy holds the top (see
   .is-wall in scene.css) and the wall is held in a band below the
   copy's measured bottom. The top of the frame is masked, so the
   land behind the words reads as paper, and on a short window the
   top of the drawing can reach under the last line of copy without
   costing it contrast.

   Still absolutely positioned, still no layout height. The scene
   is a snap target and may not grow past 100vh.

   ── Colour ────────────────────────────────────────────────
   The land is the globe's wash, a step fainter, because copy can
   sit on it. The wall before it is laid is a hairline in the text
   colour, so a reader can see where the curb is going. Gold is
   spent on what the curb covers: the traced wall, and the tail and
   marker in the brighter gold, because the tail is the finding.
   The passes and the tally wear a page-coloured halo so they cut
   through a coastline. */
.viz-host--wall {
  inset: 0;
  display: block;
  mask-image: linear-gradient(to bottom,
      rgb(0 0 0 / 0.16) 0%,
      rgb(0 0 0 / 0.45) 14%,
      rgb(0 0 0 / 0.85) 34%,
      rgb(0 0 0 / 1) 52%);
  -webkit-mask-image: linear-gradient(to bottom,
      rgb(0 0 0 / 0.16) 0%,
      rgb(0 0 0 / 0.45) 14%,
      rgb(0 0 0 / 0.85) 34%,
      rgb(0 0 0 / 1) 52%);
}
.viz--wall {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  max-height: none;
  opacity: 1;
  overflow: visible;
}
.viz-wl-land {
  fill: var(--primary);
  fill-opacity: 0.10;
  stroke: var(--primary);
  stroke-opacity: 0.35;
  stroke-width: 0.75;
  stroke-linejoin: round;
}
/* The ground under the wall, from viz-relief.json: contours in the
   text colour at a whisper, water in the brand blue a step up, so
   the Yellow River reads as a river where it loops around the Ordos
   and the contours read as terrain rather than as more lines. Lakes
   are cut out of the land in the page colour, like the sea. All of
   it sits in a layer masked out under the copy (see wall() in
   viz.js), so none of these strokes ever crosses a line of text. */
.viz-wl-contour {
  fill: none;
  stroke: var(--text);
  stroke-opacity: 0.1;
  stroke-width: 0.75;
  stroke-linejoin: round;
}
:root[data-theme='light'] .viz-wl-contour { stroke-opacity: 0.12; }
.viz-wl-river {
  fill: none;
  stroke: var(--primary);
  stroke-opacity: 0.5;
  stroke-width: 1;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.viz-wl-lake {
  fill: var(--bg);
  stroke: var(--primary);
  stroke-opacity: 0.45;
  stroke-width: 0.6;
}
.viz--wall.is-compact .viz-wl-contour { stroke-width: 0.6; }
.viz--wall.is-compact .viz-wl-river { stroke-width: 0.8; }
.viz-wl-base {
  fill: none;
  stroke: var(--text);
  stroke-opacity: 0.3;
  stroke-width: 1.5;
  stroke-linecap: round;
  stroke-linejoin: round;
}
/* The wall as the curb covers it. Dash-offset from unlit to lit by
   viz.js; it brightens while the marker is on it. */
.viz-wl-lit {
  fill: none;
  stroke: var(--gold);
  stroke-width: 2.75;
  stroke-linecap: round;
  stroke-linejoin: round;
  opacity: 0.75;
}
:root[data-theme='light'] .viz-wl-lit { opacity: 0.95; }
.viz-wl-lit.is-tracing { stroke: var(--gold-bright); opacity: 1; }
/* The curb past the end of the wall. */
.viz-wl-tail {
  stroke: var(--gold-bright);
  stroke-width: 3.25;
  stroke-linecap: round;
}
.viz-wl-marker {
  fill: var(--gold-bright);
  stroke: var(--bg);
  stroke-width: 2;
}
.viz-wl-marker-glow {
  fill: var(--gold-bright);
  opacity: 0.22;
}
.viz-wl-end {
  fill: var(--text);
  stroke: var(--bg);
  stroke-width: 1.5;
}
.viz-wl-landmark {
  fill: var(--muted);
}
.viz-wl-end-label,
.viz-wl-landmark-label,
.viz-wl-tally,
.viz-wl-tally-label,
.viz-wl-credit {
  font-family: var(--font-body);
  paint-order: stroke;
  stroke: var(--bg);
  stroke-width: 4px;
  stroke-linejoin: round;
}
.viz-wl-end-label {
  fill: var(--text);
  font-size: 12px;
  font-weight: 700;
}
.viz-wl-landmark-label {
  fill: var(--muted);
  font-size: 11px;
}
/* The tally is the scene's figure, set like the off-street scene's:
   mono and tabular, so the digits do not jitter as the trace runs, with
   the comparator's name tracked out under it. The SIZE is set from the
   frame in viz.js — this is the fallback before the first measure. */
.viz-wl-tally {
  fill: var(--text);
  font-family: var(--font-mono);
  font-weight: 700;
  font-size: 48px;
  letter-spacing: -0.01em;
  font-variant-numeric: tabular-nums;
}
.viz-wl-tally-label {
  fill: var(--text-soft);
  font-family: var(--font-display);
  font-weight: 900;
  font-size: 13px;
  letter-spacing: 0.14em;
  text-transform: uppercase;
}
.viz-wl-credit {
  fill: var(--muted);
  font-size: 10px;
}
/* Under 520px the reference point and the long labels go and the
   marks stay: a gold line across a coastline still reads, a row of
   11px words over it does not. The figure and its own label stay —
   they are the reading — and the figure is sized from the frame in
   viz.js, so it needs no override here. The credit stays in the note. */
.viz--wall.is-compact .viz-wl-landmark,
.viz--wall.is-compact .viz-wl-landmark-label,
.viz--wall.is-compact .viz-wl-credit { display: none; }
.viz--wall.is-compact .viz-wl-tally-label { font-size: 11px; }
.viz--wall.is-compact .viz-wl-lit { stroke-width: 2.25; }
.viz--wall.is-compact .viz-wl-tail { stroke-width: 2.75; }
/* On a phone the tail is under 20px, and a full-size glow sat on
   nearly all of it. */
.viz--wall.is-compact .viz-wl-marker { r: 3.5px; }
.viz--wall.is-compact .viz-wl-marker-glow { r: 6px; }

/* ── field-horizon: the ground, tiled with the unit ────────
   Full bleed, edge to edge, and the only visual here meant to run off
   the frame: the plane is ground, and ground does not stop politely
   short of a margin. See the note over fieldHorizon() in viz.js for
   why cropping cannot misstate it — the count is solved against the
   host's own box on every build, so what is on screen is the whole
   figure at whatever size the window is.

   Positioned rather than placed, for the reason the other full-bleed
   backdrops were: `place-items: center` on the host does not centre an
   item that overflows a clipped container — Chrome clamps it back to
   the start edge. inset:0 sidesteps the question. */
.viz--field-horizon {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  max-height: none;
}

/* The edge fade. The host is clipped to its own section, so the plane
   cannot paint outside scene-lots; what it can do — and what the old
   full-bleed field did — is announce the scene early, with a hard row
   of marks sitting against the boundary of the scene above.

   The top fade is the longer of the two because the horizon is where
   the drawing is quietest and a fade there costs nothing. At the foot
   the nearest row is the unit itself, drawn in gold and labelled, so
   the fade has to be short enough to leave it alone. */
.viz-host--field-horizon {
  mask-image: linear-gradient(
    to bottom,
    rgb(0 0 0 / 0) 0%,
    rgb(0 0 0 / 1) 16%,
    rgb(0 0 0 / 1) 96%,
    rgb(0 0 0 / 0) 100%
  );
}

/* ── THE INK IS A PAIR OF CUSTOM PROPERTIES, AND IT HAS TO BE ──
   The plane is drawn three times over: the unlit base, the lit copy
   clipped to the reveal, and the wavefront — and the wavefront is a
   `<use>` of the lit paths rather than a fourth set of geometry. A
   `<use>` clone keeps its own class, so `.viz-fh-row.is-lit` still
   matches inside the shadow tree and any rule written on the layer
   would lose to it. Custom properties DO inherit across that boundary,
   so the layer sets the colour and the strength and the row reads
   them. One set of paths, three weights, no duplicated geometry.

   Fill is none throughout. Every mark is an outline and its markings
   drawn as one subpath, so a fill would flood the open line segments
   as well — and a tiled plane of filled boxes is the wall of
   rectangles this drawing exists not to be. The picture's weight
   comes from how densely the lines stack with distance. */
.viz-fh-row {
  fill: none;
  stroke: var(--fh-ink);
  stroke-opacity: var(--fh-strength);
  stroke-linecap: round;
  stroke-linejoin: round;
}
.viz-fh-row.is-near { stroke-width: 1.25; }
.viz-fh-row.is-mid { stroke-width: 1; }
/* The far tier is dozens of rows deep and a hairline is the whole
   point: at the horizon the ticks stay twenty-odd pixels apart while
   the rows collapse to a pixel, so the plane reads as a fine
   striation. A full-weight stroke there fills it in solid. */
.viz-fh-row.is-far { stroke-width: 0.75; }

.viz-fh-base { --fh-ink: var(--line-strong); --fh-strength: 0.5; }
.viz-fh-lit { --fh-ink: var(--primary-bright); --fh-strength: 0.62; }
/* The leading run, drawn a third time. A TRANSIENT and only a
   transient: viz.js collapses its clip to zero width at rest, so
   nothing in the finished picture carries this weight. */
.viz-fh-edge { --fh-ink: var(--gold); --fh-strength: 0.9; }

/* THE ONE NAMED FIELD. It is not a legend beside the drawing, it is
   one of the fields being counted — the second mark in from the right
   of the nearest row, where the frame will not crop it and the copy is
   not. Gold, because it is the unit the whole picture is a count of,
   and the only gold in the frame other than the wavefront that is
   about to pass over it. */
.viz-fh-unit {
  fill: none;
  stroke: var(--gold-bright);
  stroke-width: 1.5;
  stroke-linecap: round;
  stroke-linejoin: round;
  stroke-opacity: 0.95;
}

/* THE PLANE RUNS OUT OF RESOLUTION; IT DOES NOT END AT A LINE. A
   gradient to the page colour over the last of the depth is the only
   honest way to stop drawing — the alternative is a hard edge at
   whatever depth the arithmetic happened to give out, which would read
   as a boundary in the data. Offset 0 sits AT the horizon, which is
   the far end of the plane, so the stops run far → near. */
.viz-fh-haze-far { stop-color: var(--bg); stop-opacity: 0.92; }
.viz-fh-haze-mid { stop-color: var(--bg); stop-opacity: 0.45; }
.viz-fh-haze-near { stop-color: var(--bg); stop-opacity: 0; }

/* And the light that crosses it: one soft band, a twentieth of an
   alpha. It is never information — nothing about it is keyed to the
   data — and it exists because a flat plane drawn in one weight reads
   as a printed diagram. Reduced motion removes the movement and the
   band with it (see the idle block below). */
.viz-fh-sweep-end { stop-color: var(--primary-bright); stop-opacity: 0; }
.viz-fh-sweep-mid { stop-color: var(--primary-bright); stop-opacity: 0.05; }
.viz-fh-sweep { opacity: 0; }

/* ── The drawing's own words ───────────────────────────────
   TWO ANNOTATIONS, NOT A LEGEND. An earlier build stacked four
   right-aligned strings into the frame's right shoulder in two blocks,
   which is what a legend looks like — a key parked beside a picture,
   competing with the picture for the reader's first look and winning
   nothing. What is written now is:

     · a TAG ON THE OBJECT — the unit's name and its two sides on one
       line, hung off the gold field by a short leader. It labels a
       field that is being counted, not the drawing; and
     · the FIGURE — the running tally, set large at the vanishing
       point, because how many fields there are is the subject of the
       scene and it was previously the smallest thing in the frame.

   They sit OUTSIDE the copy dim — the dim is there to protect the
   scene's paragraph, and these are annotations on the picture, which
   have to stay legible at full strength. So they wear the halo the
   maps' labels wear: a page-coloured stroke under the glyphs, which is
   the only form `paint-order: stroke` can take over a live drawing. */
.viz-fh-unit-label,
.viz-fh-tally,
.viz-fh-tally-label {
  paint-order: stroke;
  stroke: var(--bg);
  stroke-width: 3px;
  stroke-linejoin: round;
  pointer-events: none;
}
.viz-fh-unit-label {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 13px;
  letter-spacing: 0.1em;
  /* Words, so --text rather than the brand gold: the gold on this
     scene is the outline of the named field, which is a picture and
     carries no meaning on its own. Gold reads 1.56:1 on the light
     theme (see the note over --gold-text), and this label is real
     words at 13px — exactly the case that token's comment flags. */
  fill: var(--text);
}
/* The unit's two sides, in the mono face the rest of the site sets
   figures in, on the same line as its name. Not an area: one field is
   5,351 m², which rounds to 0.002 sq mi and tells a reader nothing —
   the sides are the figure a reader can stand in. */
.viz-fh-unit-dims {
  font-family: var(--font-mono);
  font-weight: 400;
  font-size: 11px;
  letter-spacing: 0.02em;
  fill: var(--muted);
}
/* The leader, which is what makes the tag a label on a field rather
   than a key to the drawing: it touches the mark it names. */
.viz-fh-unit-leader {
  stroke: var(--gold-bright);
  stroke-width: 1;
  stroke-opacity: 0.7;
  fill: none;
}
/* The tally counts what is on screen rather than counting up to a
   target, so the reader can watch the plane fill and the figure agree.
   Mono and tabular, like every figure on the site, so the digits do
   not jitter as it climbs. The SIZE is set from the frame in viz.js —
   this is the fallback for the frame before the first measure. */
.viz-fh-tally {
  font-family: var(--font-mono);
  font-weight: 700;
  font-size: 48px;
  font-variant-numeric: tabular-nums;
  fill: var(--text);
}
.viz-fh-tally-label {
  font-family: var(--font-display);
  font-weight: 900;
  font-size: 13px;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  fill: var(--text-soft);
}

/* ── The idle motion, and it starts only when the drawing is done ──
   Two movements, both far slower than anything a reader tracks: the
   band of light crossing the plane once every half minute, and a long
   breath on the farthest rows, which is where a real horizon is least
   stable anyway. A backdrop that breathes while it is still being
   drawn is two motions competing on the beat the reader is watching,
   so `is-settled` is only set once the reveal has finished.

   Reduced motion gets neither, and needs no JS branch to get neither:
   the whole block is skipped and the sweep stays at the opacity: 0 it
   is declared with, so the plane is simply a still picture. */
@media (prefers-reduced-motion: no-preference) {
  .viz--field-horizon.is-settled .viz-fh-sweep {
    opacity: 1;
    /* fill-box, so the percentage resolves against the band's own
       width rather than against the SVG's user space — 600% of a band
       half the frame wide is three frames of travel, which is the
       whole plane plus the run-in and run-out. */
    transform-box: fill-box;
    transform-origin: center;
    animation: viz-fh-sweep 34s linear infinite;
  }
  .viz--field-horizon.is-settled .viz-fh-lit .viz-fh-row.is-far {
    animation: viz-fh-breathe 26s ease-in-out infinite;
  }
}
@keyframes viz-fh-sweep {
  from { transform: translateX(0); }
  to { transform: translateX(600%); }
}
@keyframes viz-fh-breathe {
  0%, 100% { stroke-opacity: 0.62; }
  50% { stroke-opacity: 0.42; }
}

/* A narrow frame keeps the plane and the figure and loses the tag on
   the unit: at this width it lands on the copy's own column, and the
   key under the paragraph already says what one mark is. The gold
   field itself stays — it is the unit, and it is drawn rather than
   written. The tally stays too, and stays the figure; it is sized from
   the frame in viz.js, so it needs no override here. */
.viz--field-horizon.is-compact .viz-fh-unit-label,
.viz--field-horizon.is-compact .viz-fh-unit-leader { display: none; }
.viz--field-horizon.is-compact .viz-fh-tally-label { font-size: 11px; }

/* ── park-spine: Central Park, and the island filling with it ─
   Full bleed, like the plane it replaced, and positioned for the same
   reason. A map of New York at true scale: the land around the island
   as quiet ground, Manhattan a step stronger, and on it the parks —
   the real one in gold, the copies in the brand blue. See parkSpine()
   in viz.js for how the copies are laid.

   The edge fade is short: the run reaches close to the top and the
   bottom of the frame, and the parks at its two ends are the ones the
   drawing is counting toward. */
.viz--park-spine {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  max-height: none;
}
.viz-host--park-spine {
  mask-image: linear-gradient(
    to bottom,
    rgb(0 0 0 / 0) 0%,
    rgb(0 0 0 / 1) 6%,
    rgb(0 0 0 / 1) 96%,
    rgb(0 0 0 / 0) 100%
  );
}

/* The land around the island. Context, never a claim: it is what
   makes Manhattan read as an island on a map rather than as a shape
   on a page, so it is drawn just above the page and no more. */
.viz-ps-ground {
  fill: var(--primary);
  fill-opacity: 0.05;
  stroke: var(--line-strong);
  stroke-opacity: 0.45;
  stroke-width: 0.75;
  stroke-linejoin: round;
}
.viz-ps-island {
  fill: var(--primary);
  fill-opacity: 0.12;
  stroke: var(--primary-bright);
  stroke-opacity: 0.55;
  stroke-width: 1;
  stroke-linejoin: round;
}

/* THE PARKS. Each is its outline and a fill clipped to how much of it
   the reveal has reached. A copy's outline arrives a beat before its
   fill (`is-on`, set in viz.js), so the reader sees where the next park
   goes and then watches it fill — and nothing is drawn for a copy the
   multiple has not reached yet. */
.viz-ps-park-line {
  fill: none;
  stroke: var(--primary-bright);
  stroke-width: 1.5;
  stroke-linejoin: round;
  stroke-opacity: 0;
  transition: stroke-opacity var(--dur-fast) ease-out;
}
.viz-ps-park.is-on .viz-ps-park-line { stroke-opacity: 0.95; }
.viz-ps-park-fill {
  fill: var(--primary-bright);
  fill-opacity: 0.5;
  stroke: none;
}
/* The real one. Gold, because it is the unit the whole picture is a
   count of — the only gold in the frame. */
.viz-ps-park.is-origin .viz-ps-park-line { stroke: var(--gold-bright); stroke-width: 2; }
.viz-ps-park.is-origin .viz-ps-park-fill { fill: var(--gold); fill-opacity: 0.8; }

/* The drawing's own words: a tag on the park and the figure. Outside
   the copy dim and haloed, as on the plane — see .viz-fh-unit-label. */
.viz-ps-unit-label,
.viz-ps-tally,
.viz-ps-tally-label {
  paint-order: stroke;
  stroke: var(--bg);
  stroke-width: 3px;
  stroke-linejoin: round;
  pointer-events: none;
}
.viz-ps-unit-label {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 13px;
  letter-spacing: 0.06em;
  fill: var(--text);
}
.viz-ps-unit-note {
  font-family: var(--font-mono);
  font-weight: 400;
  font-size: 11px;
  letter-spacing: 0.02em;
  fill: var(--muted);
}
.viz-ps-unit-leader {
  stroke: var(--gold-bright);
  stroke-width: 1;
  stroke-opacity: 0.8;
  fill: none;
}
/* The tag names the real park, so it keeps that park's timing: both
   are out of the frame until the run reaches the island's own Central
   Park, and neither is ever on screen without the other. */
.viz-ps-unit-label,
.viz-ps-unit-leader {
  opacity: 0;
  transition: opacity var(--dur-fast) ease-out;
}
.viz--park-spine.is-named .viz-ps-unit-label,
.viz--park-spine.is-named .viz-ps-unit-leader { opacity: 1; }
.viz-ps-tally {
  font-family: var(--font-mono);
  font-weight: 700;
  font-size: 48px;
  font-variant-numeric: tabular-nums;
  fill: var(--text);
}
.viz-ps-tally-label {
  font-family: var(--font-display);
  font-weight: 900;
  font-size: 13px;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  fill: var(--text-soft);
}
.viz--park-spine.is-compact .viz-ps-unit-label,
.viz--park-spine.is-compact .viz-ps-unit-leader { display: none; }
.viz--park-spine.is-compact .viz-ps-tally-label { font-size: 11px; }
@media (prefers-reduced-motion: reduce) {
  .viz-ps-park-line,
  .viz-ps-unit-label,
  .viz-ps-unit-leader { transition: none; }
}

/* ── Where the opacities in this file come from ────────────
   Every number here that sets how strongly a primary-tinted mark
   paints is SOLVED, not chosen: each is the largest value at which
   every glyph that actually lands on that layer still clears its WCAG
   floor, sampled per-glyph off the composited colour under each text
   box, on both themes.

   The binding case is BLUE ON BLUE — the big numbers are --primary and
   so is most of the ink in these drawings, so contrast collapses from
   both ends as a fill strengthens.

   ── The field horizon, which is measured somewhere else ────
   The plane is solved the same way but at a different POINT, and that
   is the whole difference. A visual with edges is measured under the
   glyph sitting on its brightest region, because the artwork under
   that glyph is whatever the CSS says it is. The plane is not: the dim
   mask takes it to between 0 and about 0.2 of these values wherever
   copy lands, so measuring at the middle of the frame would be
   measuring a place no text can reach.

   Since the dim is built from the copy's own line boxes, the worst
   place any glyph can sit is its own line's edge, and the shape is
   grown well past every line before it starts to fade. Two numbers in
   copyDim() set what a glyph actually sits on — the transfer's OFFSET,
   the floor the darkness stops at (0.04, so the plane is never quite
   gone under the copy), and the pad, which decides how much of the
   falloff has happened by the time the text ends.

   The floor is 4.5:1: this scene's copy is 14px and 12px --muted, over
   a plane whose ink at full strength is a 0.62 stroke of
   --primary-bright on hairlines covering a small fraction of any
   glyph's area. Under the dim that lands near 0.04 of it, which is
   inside a hundredth of bare --bg — the ceiling a dim can approach and
   never pass.

   The drawing's OWN strings are the case this does not cover, and
   they are handled differently on purpose: they sit outside the dim,
   clear of the reading column, and they carry a page-coloured halo
   instead. See the block over .viz-fh-unit-label.

   ── Light theme ───────────────────────────────────────────
   Light surfaces sit much closer together than the dark ones, so the
   same tokens give less separation and the drawing has to be pulled a
   step further from the page to register at all. The plane and the
   globe need no split on their geometry; what they need is a step of
   INK, because raising alpha is not available — the contrast note
   above fixes the ceiling — and a darker pigment at the same alpha
   lands on the same composited luminance while carrying far more hue.

   --primary-bright is a token for DARK surfaces: over a near-white
   page it draws a pale wash and the plane loses the painted-line
   reading its whole picture rests on. --gold has the same problem and
   worse, at 1.56:1 on white, so the wavefront takes --gold-text. */
:root[data-theme='light'] .viz-fh-lit { --fh-ink: var(--primary); --fh-strength: 0.55; }
:root[data-theme='light'] .viz-fh-base { --fh-ink: var(--line-strong); --fh-strength: 0.55; }
:root[data-theme='light'] .viz-fh-edge { --fh-ink: var(--gold-text); --fh-strength: 0.8; }
:root[data-theme='light'] .viz-fh-unit { stroke: var(--gold-text); }
:root[data-theme='light'] .viz-fh-unit-leader { stroke: var(--gold-text); }
:root[data-theme='light'] .viz-fh-sweep-mid { stop-color: var(--primary); stop-opacity: 0.06; }
/* The map: --primary-bright is a dark-surface token, so on white the
   island's edge and the copies take the brand's own --primary, and
   the real park's outline and leader take --gold-text. */
:root[data-theme='light'] .viz-ps-island { stroke: var(--primary); stroke-opacity: 0.45; }
:root[data-theme='light'] .viz-ps-park-line { stroke: var(--primary); }
:root[data-theme='light'] .viz-ps-park-fill { fill: var(--primary); fill-opacity: 0.42; }
:root[data-theme='light'] .viz-ps-park.is-origin .viz-ps-park-line { stroke: var(--gold-text); }
:root[data-theme='light'] .viz-ps-park.is-origin .viz-ps-park-fill { fill: var(--gold); fill-opacity: 0.85; }
:root[data-theme='light'] .viz-ps-unit-leader { stroke: var(--gold-text); }
/* The ocean is still a surface, so it still needs the theme's step.
   The land needs one too, but only because its wash is written as an
   explicit alpha rather than as a token — 0.226 is the light theme's
   --primary-soft composited with itself, against 0.294 on dark. See
   the note over .viz-globe-land. */
:root[data-theme='light'] .viz-globe-ocean { fill: var(--surface-2); }
:root[data-theme='light'] .viz-globe-land { fill-opacity: 0.23; }

/* ── Narrow screens ────────────────────────────────────────
   The old breakpoints hid the artwork once it stopped CLEARING the
   copy, which no longer means anything. What survives is the point
   at which the copy fills the frame edge to edge: below that a
   backdrop behind every line is noise rather than overlap. */
@media (max-width: 720px) {
  .viz-host { display: none; }

  /* The plane survives the cut the others do not, and the reason is
     the same one that lets it bleed: it is solved against the host's
     own box on every build, so a phone gets the whole count on a
     shorter plane rather than a crop of a wide one. A cropped globe
     loses its route; this loses nothing.

     Nothing else changes at this width. The dim is built from the
     copy's line boxes, so a column that has become the whole width is
     already handled — that is the case the dim replaced a breakpoint
     to solve. */
  .viz-host--field-horizon { display: grid; }

  /* ── The parks come out from behind the words ──────────
     The park spine is the one backdrop a phone could not carry. Its
     subject is an island: tall, narrow, and drawn at true scale, so
     on a 393px frame it is a thin ribbon running the full height of
     the scene — directly under every line of a paragraph that now
     fills the width — and the run of parks on it is a few pixels
     wide. Worse, the tally it is counting toward has nowhere to
     stand: `parkSpine()` falls back to the band above the copy, and
     on a phone there is no band, so "4.5x CENTRAL PARKS" landed on
     top of the eyebrow.

     So it does what the wall does at this width — an in-flow band
     under the copy, which is where a drawing goes once it can no
     longer sit behind one. Read in the order the sentence is: the
     count, the comparison in words, then the picture of it.

     Taller than the wall's band, because the subjects are the
     opposite shape: a wall runs across its frame and a 240px strip
     is generous for it, while Manhattan runs down one and the run of
     parks along its spine is the whole drawing. The copy dim needs
     no switching off — it is built from the copy's line boxes
     measured against this host, and with the words entirely above it
     every hole falls outside the frame on its own. */
  .viz-host--park-spine {
    display: block;
    /* In flow, but still POSITIONED — the SVG inside is absolute, so
       a static host would hand it the section as its containing
       block and the island would paint the whole scene from behind
       the copy. See the wall below, which has the same shape. */
    position: relative;
    inset: auto;
    width: 100%;
    height: min(46vh, 380px);
    order: 2;
    mask-image: none;
    -webkit-mask-image: none;
  }

  /* The figure survives for a different reason: it is not a
     backdrop, so the rule above — a drawing behind every line of
     copy is noise — never applied to it. It stacks under the words
     instead of sitting behind them, and drops to its compact form
     (see the note in the dot-strip block, and COMPACT_W in
     viz.js, which decides it from the measured box rather than from
     this breakpoint). Hiding it would take the scene's whole point
     off the platform most readers meet it on. */
  .viz-host--figure { display: block; }

  /* The wall's band survives as an in-flow block under the copy:
     northern China on a phone is small, but a gold line crossing it
     still reads at 170px, where a hidden host would take the
     scene's whole point off the platform most readers meet it on.
     `order` lands it after .scene-inner (scene.css makes the
     section a column). No mask — nothing sits over it here. */
  .viz-host--wall {
    display: block;
    /* In flow here, but still POSITIONED: the SVG inside is
       absolute, so a static host would hand it the section as its
       containing block and the map would paint the whole scene
       from behind the copy. Relative keeps it inside the band.
     */
    position: relative;
    inset: auto;
    width: 100%;
    height: min(30vh, 240px);
    order: 2;
    mask-image: none;
    -webkit-mask-image: none;
  }
}
