/* ============================================================
   hero-figure.css — the decorative illustration in the right-hand
   column of a ranking-page hero.

   ── What these drawings are, and are not ──────────────────
   Line art. Nothing in them encodes a value: no length is a
   quantity, no colour is a reading, and the dots on the globe are
   the cities we measured, not how they scored. Every one of them
   sits in an aria-hidden box, because a reader on a screen reader
   loses nothing — the meta line above already states every fact the
   drawing gestures at.

   ── Colour ────────────────────────────────────────────────
   Classes only. js/hero-viz.js writes no fill and no stroke, so a
   theme flip repaints with no listener and no JS, and tokens.css
   stays the only file with a hex in it.

   ── Units ─────────────────────────────────────────────────
   The flat outlines render into a viewBox 1000 user units on the
   long side, so one set of stroke widths serves both. The drawings
   are now several hundred pixels across, so a unit is roughly half a
   pixel — which is why these numbers look large. The globe is
   js/viz.js's and takes its weights from viz.css.
   ============================================================ */

.hero-figure {
  /* A backdrop, not a column. It spans the hero and bleeds below it
     by --hf-drop, and that bleed is what SIZES the drawing: the art
     is fitted to the box's height, so a taller box is a bigger
     picture. It only ever grows downward — the fixed site header sits
     just above the hero, and art behind the nav links is art behind
     text nobody agreed to put it behind. */
  position: absolute;
  inset: 0 0 calc(-1 * var(--hf-drop, 0px)) 0;
  /* Pulls the art in from the right edge, so a narrow drawing still
     lands somewhere the eye reads as composed rather than jammed
     against the margin. */
  padding-right: var(--hf-pull, 0%);
  z-index: 0;
  pointer-events: none;

  /* ── The mask ─────────────────────────────────────────────
     ONE GRADIENT PER ELEMENT, not two layers on one.

     They were two mask-image layers composited with `intersect`, and
     that is where a whole afternoon went: each layer masks correctly
     on its own, and together they came out empty in Chrome 151 while
     painting fine in another build of the same engine. A picture that
     depends on multi-layer mask compositing is a picture that
     disappears on somebody's browser. Nesting is boring and it works:
     the horizontal ramp rides the figure, the vertical fade rides the
     SVG inside it, and the two multiply the way any two nested masks
     do.

     Left to right, on the figure: a fraction of full strength across
     the copy column, full strength past it. The copy runs to at most
     900px — the h1's cap — so the ramp is written in px against the
     hero's own width and clears it just after. The percentage in each
     min() keeps that true on a narrower page: there the copy fills
     the hero, and the art is dimmed nearly all the way across because
     nearly all of it is behind words.

     --hf-dim is that fraction, and it is a knob because what a FILLED
     figure needs and what a drawn one needs are different numbers:
     the globe is a disc and reads at 0.25, a 1.5px coastline does
     not.

     --hf-edge dissolves the right edge, for a figure that is a map
     crop: a context layer clipped by the box (see .hv, overflow:
     hidden) meets the frame as a straight cut. Default 0 — a hard
     edge, which is right when the drawing ends before the box does.
     The fade has to land on the DRAWING's edge, not the element's:
     --hf-pull is padding, so the art stops that far short.

     --hf-edge-in is the same fade at the LEFT edge, and a map crop
     needs it for a reason the right edge does not have: the box is
     the page container, so its left edge is where the copy column
     starts — the cut lands in the middle of the composition rather
     than at the margin, right where the eye is already reading. The
     ramp runs 0 → --hf-dim, so it dissolves into the dimmed band
     behind the words rather than into full strength, and every stop
     collapses to 0 when it is unset.

     ── Why every ramp is four stops and not two ──────────────
     A two-stop ramp is a STRAIGHT line in alpha, and a straight line
     has a corner at each end of itself. Those corners are visible:
     widening a straight ramp does not remove the edge, it just moves
     the edge further from the thing it was cutting and adds a second
     one. Every ramp here is therefore eased — a slow start, a middle
     that moves, a slow arrival — which is what makes a fade read as
     an atmosphere rather than as a boundary. The k values below are
     that curve sampled; the alphas are written against --hf-dim so
     one number still sets the whole dimmed band.

     ── The climb out of the dimmed band ──────────────────────
     --hf-ramp-start / --hf-ramp-end bound it, and the end is a min()
     against where the RIGHT fade begins for a reason that is not
     cosmetic: gradient stop positions are clamped to be
     non-decreasing, so a climb that finishes past the start of the
     dissolve drags the dissolve's stops up with it and the right edge
     silently goes hard. Bounding it means the two ramps meet rather
     than fight, and on a wide hero they meet at a point — the picture
     touches full strength once, at its brightest, instead of holding
     a plateau. That is the intended shape, not a shortfall. */
  -webkit-mask-image: var(--hf-mask-x);
  mask-image: var(--hf-mask-x);

  /* Read end-first. The climb finishes where the right dissolve
     begins, and it starts 760px in — or a fifth of the hero back from
     that end, whichever comes sooner.

     That second term is the one that matters, and it is not belt and
     braces. The figure lives from a 900px viewport up, so the hero
     runs roughly 840 → 1390px, and a fixed 760px start is past the
     end of the climb for most of that range: the two bounds cross,
     the stops clamp, and the whole eased ramp collapses back into the
     single hard step it was built to replace — at exactly the narrow
     widths where the art is most crowded and the step is most
     visible. Holding the span to a fifth of the hero keeps a real
     ramp at every width the figure is shown at, and the dek is 55ch
     — about 540px, whatever the hero is — so even at the narrowest it
     ends before the climb has risen a tenth of the way. */
  --hf-ramp-end: min(1150px, 100% - var(--hf-pull, 0%) - var(--hf-edge, 0%));
  --hf-ramp-start: min(760px, var(--hf-ramp-end) - 20%);
  --hf-ramp-span: calc(var(--hf-ramp-end) - var(--hf-ramp-start));
  /* The right fade measured back from the drawing's edge, not the
     element's: --hf-pull is padding, so the art stops that far short. */
  --hf-edge-at: calc(100% - var(--hf-pull, 0%));

  --hf-mask-x:
    linear-gradient(to right,
      /* left edge — nothing, easing up into the dimmed band */
      rgba(0, 0, 0, 0) 0,
      rgba(0, 0, 0, calc(var(--hf-dim, 0.25) * 0.09)) calc(var(--hf-edge-in, 0%) * 0.28),
      rgba(0, 0, 0, calc(var(--hf-dim, 0.25) * 0.40)) calc(var(--hf-edge-in, 0%) * 0.55),
      rgba(0, 0, 0, calc(var(--hf-dim, 0.25) * 0.76)) calc(var(--hf-edge-in, 0%) * 0.79),
      rgba(0, 0, 0, var(--hf-dim, 0.25)) var(--hf-edge-in, 0%),

      /* the dimmed band, which is the part behind the copy */
      rgba(0, 0, 0, var(--hf-dim, 0.25)) var(--hf-ramp-start),

      /* the climb to full strength, eased */
      rgba(0, 0, 0, calc(var(--hf-dim, 0.25) + (1 - var(--hf-dim, 0.25)) * 0.09)) calc(var(--hf-ramp-start) + var(--hf-ramp-span) * 0.28),
      rgba(0, 0, 0, calc(var(--hf-dim, 0.25) + (1 - var(--hf-dim, 0.25)) * 0.40)) calc(var(--hf-ramp-start) + var(--hf-ramp-span) * 0.55),
      rgba(0, 0, 0, calc(var(--hf-dim, 0.25) + (1 - var(--hf-dim, 0.25)) * 0.76)) calc(var(--hf-ramp-start) + var(--hf-ramp-span) * 0.79),
      rgba(0, 0, 0, 1) var(--hf-ramp-end),

      /* right edge — easing back down, collapsed to nothing when
         --hf-edge is 0 and to a hard cut when --hf-edge-out is */
      rgba(0, 0, 0, 1) calc(var(--hf-edge-at) - var(--hf-edge, 0%)),
      rgba(0, 0, 0, calc(1 - var(--hf-edge-out, 0) * 0.24)) calc(var(--hf-edge-at) - var(--hf-edge, 0%) * 0.72),
      rgba(0, 0, 0, calc(1 - var(--hf-edge-out, 0) * 0.60)) calc(var(--hf-edge-at) - var(--hf-edge, 0%) * 0.45),
      rgba(0, 0, 0, calc(1 - var(--hf-edge-out, 0) * 0.91)) calc(var(--hf-edge-at) - var(--hf-edge, 0%) * 0.21),
      rgba(0, 0, 0, calc(1 - var(--hf-edge-out, 0))) var(--hf-edge-at));
}

/* How far each drawing bleeds past the hero, which is how big it is.
   The globe is square and gets the most; the country pair is 2:1 and
   reaches its width long before it needs the height. */
/* The hero is one height on all three pages now, so these differ only
   by what each drawing is: a sphere fills a square, and both the
   country pair and the island are tall frames that want the same
   downward room. */
/* Line art still needs a higher floor than a filled sphere does — a
   hairline and a 400px disc do not read at the same alpha — but this
   spent most of its life too HIGH, propped up while an outline kept
   vanishing for reasons that turned out to be the dash (see
   measureDraws() in js/hero-viz.js). With that fixed the floor can
   come back down to where the copy wants it: full strength beside the
   words, and behind them only enough to say the drawing continues.

   0.33 was still enough to compete: the US coastline crossed the
   countries dek line by line, and a stroke that lands mid-sentence is
   read before the sentence is. The test is the dek, not the h1 — 40px
   display type wins against anything, body copy at 0.9rem does not —
   so the number is set by what that paragraph can carry, and 0.18 is
   the most it can. */
.hero-figure[data-figure='cities'],
.hero-figure[data-figure='countries'] { --hf-dim: 0.18; }

.hero-figure[data-figure='global']    { --hf-drop: 300px; --hf-pull: 2%; }
.hero-figure[data-figure='countries'] {
  /* Shrinking one shape shortens the frame, and a shorter frame is a
     LARGER drawing at the same box — so the box comes down to hold
     the EU at the size it already was. Only the US was asked to
     change. */
  --hf-drop: 160px;
  /* 0%, NOT 0. The mask subtracts this from a percentage, and
     calc(100% - 0) is invalid arithmetic — a unitless zero is not a
     length. One invalid stop invalidates the whole gradient, and an
     invalid mask-image is no mask: this page spent its life with the
     art at FULL strength behind the dek, which is why the 49th
     parallel read as a strikethrough through line two. Nothing about
     it looked like a syntax error, because the drawing was fine and
     only the dimming was missing. Every value here carries a unit for
     that reason. */
  --hf-pull: 0%;
  /* The drawing FILLS this box — measured, its ink comes within 2.8%
     of the right edge, 8.7% of the left and 3% of the top and bottom,
     because the frame is derived from the placed shapes rather than
     cropped to a subject. So every edge is the drawing's edge, and
     every one of them was a cut. They dissolve now, the same as the
     Cities crop, with the amounts set by what each edge is worth: the
     left ramp is generous because that side is the faint tail of the
     US and it sits behind the copy anyway, while the right is the
     smallest that still reads, because the far side of the EU is the
     subject of the picture and fading it is spending the drawing to
     soften a border. */
  --hf-edge: 12%;
  --hf-edge-out: 1;
  --hf-edge-in: 26%;
  --hf-fade-top: 14%;
  --hf-fade-start: 46%;
  --hf-fade-end: 98%;
}
.hero-figure[data-figure='cities'] {
  --hf-drop: 200px;
  --hf-pull: 6%;
  /* A map crop: the boroughs and Jersey City run past every edge, so
     all four of them dissolve rather than cut. The left ramp is the
     wider one because it has further to travel — the right edge fades
     out of full strength over the free side of the hero, the left out
     of a band that is already behind the headline, where a short ramp
     still reads as an edge. Nothing here is a real coastline, so the
     ramps can be as wide as the composition takes. */
  --hf-edge: 15%;
  --hf-edge-out: 1;
  --hf-edge-in: 26%;
  --hf-fade-top: 22%;
}

@media (max-width: 1240px) {
  .hero-figure[data-figure='global']    { --hf-drop: 200px; }
  .hero-figure[data-figure='countries'] { --hf-drop: 110px; }
  .hero-figure[data-figure='cities']    { --hf-drop: 140px; }
}

/* The globe is js/viz.js's, and viz.css sizes it for a scrollytelling
   backdrop — a viewport-relative width, pushed off the right margin.
   Here the box does the sizing. */
.hero-figure .viz--globe {
  width: 100%;
  height: 100%;
  max-height: none;
  justify-self: auto;
  margin: 0;
}

/* Clipped, not visible: with `frame: 'subject'` the context layers
   are deliberately larger than the frame — the boroughs run off every
   edge of the Cities figure — and unclipped they would paint across
   the whole hero, outside the box the mask is sized to.

*/
.hv { display: block; width: 100%; height: 100%; overflow: hidden; }

/* The vertical fade is a SCRIM, not a mask: the art dissolves into
   the page at the bottom before it reaches the settings bar, and at
   the top for a figure that is a map crop.

   Painted rather than masked because two masks would not survive. As
   two layers on one element they cancelled; moved onto the SVG the
   percentages resolved against the drawing instead of the box; on a
   wrapper div they cut the figure to ribbons in Chrome 151 while
   painting correctly in another build of the same engine. A gradient
   of the page colour over the top does the same job with nothing to
   get wrong — it works because --bg is a flat token, so painting it
   over the art IS fading the art out. */
.hero-figure::after {
  content: '';
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: linear-gradient(
    to bottom,
    var(--bg) 0,
    /* An interpolation hint, not a colour stop: a bare percentage
       between two stops says where the halfway blend lands. Past the
       midpoint it bends the ramp so the scrim holds off, then goes —
       the art is untouched for longer and still arrives at the page
       colour on time. It is the one way to ease a fade whose two ends
       are a TOKEN and transparent, since anything in between would
       have to be a second hard-coded colour, and this file keeps its
       hexes in tokens.css. */
    calc(var(--hf-fade-top, 0%) * 0.62),
    rgba(0, 0, 0, 0) var(--hf-fade-top, 0%),
    rgba(0, 0, 0, 0) var(--hf-fade-start, 50%),
    calc(var(--hf-fade-start, 50%) + (var(--hf-fade-end, 96%) - var(--hf-fade-start, 50%)) * 0.62),
    var(--bg) var(--hf-fade-end, 96%));
}

/* ── Marks ──────────────────────────────────────────────────
   ONE palette for every polygon in a figure: --primary-soft for the
   fill, --primary for the coastline. Both themes define both.

   It used to be two. The subject — the EU, Manhattan — was drawn in
   --primary, and everything around it was --surface-2 at half
   opacity, which is a grey. That reads as a blue island sitting on a
   grey blob rather than as one map: the boroughs and the Jersey shore
   are the same kind of thing as Manhattan, drawn from the same
   coastlines, and colouring them as a different kind of thing was the
   figure disagreeing with itself.

   The subject still comes forward, and now it does so by GEOMETRY
   rather than by hue. --primary-soft is a 0.16 alpha, the ground is
   one merged path, and the subject is a second path on top of it — so
   Manhattan is the one polygon painted twice and lands at roughly
   double the wash. The rivers do the rest: an island is already
   separated from its surroundings by its own shoreline. */
/* NOTHING PAINTS THESE TWO RULES ANY MORE, and they are kept as the
   shape of an outline rather than as live style.

   hero-viz.js used to append .hv-outline--subject on a figure with no
   outlined ground — the countries pair — so that page carried a 1.5px
   coastline where every other figure carried the 1px .hv-land--edged
   draws. That was the second treatment the palette note above exists
   to prevent, and it is gone: every figure now strokes one union
   path (see the note on `unioned` in hero-viz.js). Restoring an
   unemphasised outline for some future layer starts here.

   The shared geometry of an outline — everything but its colour. The
   --muted is a sane default rather than a live value, and it is
   --muted rather than --line-strong because a hairline token is sized
   for a rule between two blocks of text and disappeared entirely at a
   fraction of a pixel.

   vector-effect, and it earns its keep: the frame each figure derives
   is 1000 units on its LONG side, so a unit is worth 1.4px on the
   wide country band and 0.7px on the tall island — one stroke-width
   drew two different weights. Non-scaling takes the width in device
   pixels instead, so these numbers mean the same thing everywhere and
   the drawings stay hairlines however large the box gets. */
.hv-outline {
  fill: none;
  stroke: var(--muted);
  opacity: 0.75;
  stroke-width: 1.2;
  stroke-linejoin: round;
  vector-effect: non-scaling-stroke;
}
/* 1.5px, and the floor is set by ANTIALIASING rather than by taste.
   A hairline that lands between two device pixel rows is painted as
   two half-strength rows, and half of a dimmed stroke is nothing: at
   1.25px the 49th parallel — the one perfectly horizontal run in the
   whole drawing — vanished while every diagonal coastline stayed put.
   Thinner than this needs the stroke snapped to the pixel grid, which
   costs the diagonals their smoothing. */
.hv-outline--subject { stroke: var(--primary); opacity: 1; stroke-width: 1.5; }
.hv-fill { fill: var(--primary-soft); stroke: none; }
/* Context land: the same wash as the subject, and no opacity of its
   own. Holding it back with `opacity: 0.5` was the other half of the
   two-palette split, and it goes with the grey — the mask is what
   decides how strong any of this is, and it does that per COLUMN,
   which is the axis the copy actually runs along. An element-wide
   opacity dims the free side of the hero just as hard as the side
   behind the words, which is the opposite of what the figure wants.

   Only the theme override is gone with it: --primary-soft carries its
   own value in each theme, so there is nothing left to restate. */
.hv-land {
  fill: var(--primary-soft);
  stroke: none;
}
/* The figure's ONLY coastline, since hero-viz.js started stroking the
   union of the outlined ground and the subject as one path — see the
   note there. Everything internal to that union is covered by the
   fill, so this stroke is the outside of the land and nothing else.
   A layer opts out with `edge: false`, which routes it to the
   fill-only mass path instead; no figure currently sets it.

   HALF OF THIS NUMBER IS WHAT SHOWS. paint-order lays the stroke down
   first and the fill goes over it, so a width of 2 draws a 1px line —
   the same arithmetic that made 3 read as the 1.5 the subject's own
   outline used to carry, back when there were two of them.

   2, because that outline is gone: this line is no longer competing
   with a second one over the same island, and at 1.5px visible it was
   drawing a coast heavier than the hairline this art is. 1px holds in
   both themes and survives the mask's dimmed band, which is the floor
   — below it a stroke landing between two device pixel rows is
   painted as two half-strength rows, and half of a dimmed hairline is
   nothing. The other ceiling still stands: the sliver gaps between
   polygons that abut without sharing vertices start showing the
   stroke through as this grows, and thinner only helps. */
.hv-land--edged {
  stroke: var(--primary);
  stroke-width: 2;
  stroke-linejoin: round;
  paint-order: stroke;
  vector-effect: non-scaling-stroke;
}

/* ── The reveal ─────────────────────────────────────────────
   Once, about a second, then still. Nothing here is load-bearing:
   stroke-dasharray is set ONLY inside the no-preference query, so
   under `reduce` the paths carry no dash and paint finished on the
   first frame — no JS branch, no matchMedia, nothing to forget.

   The dash length is --hv-len, which js/hero-viz.js measures per path
   at mount. The fallback of 1 matters: if the measurement never
   happened, a dasharray of 1 with no offset is a 1-unit dotted line,
   which is a visible bug rather than an invisible one — see the note
   on measureDraws(). */
@media (prefers-reduced-motion: no-preference) {
  .hv-draw {
    stroke-dasharray: var(--hv-len, 1);
    stroke-dashoffset: var(--hv-len, 1);
  }
  .hv-fade { opacity: 0; }

  .hv.is-drawing .hv-draw { animation: hv-draw var(--dur-slow) var(--ease-out) forwards; }
  .hv.is-drawing .hv-fade {
    animation: hv-fade var(--dur-med) var(--ease-out) var(--dur-fast) forwards;
  }

  /* The finished picture. The dash is REMOVED, not completed: its
     length is in device pixels (non-scaling-stroke) while the path is
     measured in user units, so it only holds for the scale it was
     measured at — and a resize would put the tail of the outline back
     in the gap. Nothing that has finished drawing needs a dash. */
  .hv.is-drawn .hv-draw { stroke-dasharray: none; stroke-dashoffset: 0; }
  .hv.is-drawn .hv-fade { opacity: 1; }
}
@keyframes hv-draw { to { stroke-dashoffset: 0; } }
@keyframes hv-fade { to { opacity: 1; } }

