/* Reproductive Rights & Maternal Health — stylesheet.
   Extracted from app.py's index template; loaded by Dash's assets
   pipeline. Sections: fonts/tokens, dashboard, intro. */

/* Display type, declared once with two sources.

   The browser takes the first source it can fetch. On the deployed site that
   is the Sharp Grotesk cut, which is present locally and excluded from this
   repository. On a fresh clone those files are absent, the request falls
   through, and Cabinet Grotesk renders instead: one variable file, 41 KB,
   free for commercial use and self hosting from Indian Type Foundry.

   Each face pins a weight in its descriptor because the rules downstream were
   written against families that carry their weight in the name, so they ask
   for font-weight 400 and expect the family to supply the rest. A static face
   ignores the descriptor and draws as cut; a variable face clamps its axis to
   it. Both land on the same weight. Book 400, Medium 500, SemiBold 600,
   Black 900. */

@font-face {
   font-family: 'Display Black';
   src: url('/assets/fonts/shg_black.otf') format('opentype'),
        url('/assets/fonts/CabinetGrotesk-Variable.woff2') format('woff2-variations');
   font-weight: 900;
   font-display: swap;
}

@font-face {
   font-family: 'Display Book';
   src: url('/assets/fonts/SharpGrotesk-Book20.otf') format('opentype'),
        url('/assets/fonts/CabinetGrotesk-Variable.woff2') format('woff2-variations');
   font-weight: 400;
   font-display: swap;
}

@font-face {
   font-family: 'Display Medium';
   src: url('/assets/fonts/SharpGrotesk-Medium22.otf') format('opentype'),
        url('/assets/fonts/CabinetGrotesk-Variable.woff2') format('woff2-variations');
   font-weight: 500;
   font-display: swap;
}

@font-face {
   font-family: 'Display Semi';
   src: url('/assets/fonts/SharpGrotesk-SemiBold22.otf') format('opentype'),
        url('/assets/fonts/CabinetGrotesk-Variable.woff2') format('woff2-variations');
   font-weight: 600;
   font-display: swap;
}

:root {
   /* Role system. Each role names a weight, not a typeface, so the same
           rules hold whichever of the two sources above the browser fetched.
           Headings take SemiBold, which is wider and calmer than Black, and
           only reach for Black when SemiBold is missing. */
   --ui: 'Display Medium', 'Space Grotesk', system-ui, sans-serif;
   --ui-med: 'Display Medium', 'Space Grotesk', system-ui, sans-serif;
   --ui-semi: 'Display Semi', 'Space Grotesk', system-ui, sans-serif;
   --display: 'Display Semi', 'Display Black', 'Space Grotesk', sans-serif;
   --mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace;
   --stage: #FFFFFF;
   --panel: #F7F5F1;
   --edge: #12100E;
   --ease: cubic-bezier(0.16, 1, 0.3, 1);
}

body,
html {
   margin: 0;
   padding: 0;
   height: 100%;
   overflow: hidden;
   background: #FFFFFF;
   /* the page is light-only; without this, a phone in system dark mode gets
      BLACK Safari bars around the paper (and around the story) */
   color-scheme: light;
   font-family: var(--ui);
   -webkit-font-smoothing: antialiased;
   color: var(--edge);
}

/* Warm ground with a fine grain so the backdrop is never flat. */
body {
   background: #FFFFFF;
}

/* 9.5px is the floor. Mono uppercase with tracking stays legible there;
         anything smaller was decoration pretending to be text. */
.kicker {
   font-family: var(--mono);
   text-transform: uppercase;
   letter-spacing: 0.13em;
   font-size: 9.5px;
   font-weight: 500;
}

.num {
   font-variant-numeric: tabular-nums;
   letter-spacing: -0.025em;
}

.app {
   position: fixed;
   inset: 0;
   overflow: hidden;
}

/* A white vignette painted over the map. Masking .mapwrap instead would
         break the WebGL canvas compositing and blank the map entirely. */
/* ------------------------------------------------------------------
         Dot cartogram. Circles fly out from the centre of the field into their
         relaxed geographic positions, expand to a pill on hover, and select on
         click. No basemap at all.
         ------------------------------------------------------------------ */
/* Nudged right of centre: the left column is 418px of cards, and a
         centred field put the western states underneath them.

         Pan and zoom are separate variables on purpose. The zoom callback owns
         --zoom as an inline style on this element; the drag handler owns
         --panx/--pany on :root. Written that way neither can clobber the
         other, which they did when both wrote `transform`. */
/* Clear of the 418px left column: the field's west edge starts around
         33vw, so the score-0 column is never hidden under the panels. */
.dotfield {
   position: absolute;
   left: 64.5%;
   top: 50%;
   transform: translate(-50%, -50%);
   width: 62vw;
   height: 66vh;
   z-index: 8;
}

/* One state, one square. Colour carries the tier, position carries the
         score; left/top transitions make every square travel to its new column
         when the period flips. */
.ssq {
   position: absolute;
   transform: translate(-50%, -50%);
   padding: 0;
   border: none;
   border-radius: 5px;
   cursor: pointer;
   display: grid;
   place-items: center;
   box-shadow: 0 2px 8px rgba(28, 20, 14, 0.14);
   opacity: 1;
   transition: left 0.7s var(--ease), top 0.7s var(--ease),
      background 0.4s var(--ease),
      transform 0.25s var(--ease);
}

.ssq:hover {
   transform: translate(-50%, -50%) scale(1.18);
   z-index: 30;
}

.ssq .sdot-abbr {
   position: static;
   width: auto;
   height: auto;
   font-family: var(--mono);
   font-size: 9.5px;
   letter-spacing: .02em;
   font-weight: 600;
   color: rgba(255, 255, 255, 0.95);
}

.ssq.is-selected {
   outline: 2.5px solid #16130E;
   outline-offset: 2.5px;
   z-index: 25;
}

.ssq--protective {
   background: linear-gradient(160deg, #9FBFAC 0%, #7FA891 100%);
}

.ssq--mixed {
   background: linear-gradient(160deg, #E8CD9C 0%, #D9B678 100%);
}

.ssq--restrictive {
   background: linear-gradient(160deg, #ECA08D 0%, #DE7C64 100%);
}

/* Where the states stood in the other period: hollow, behind, silent. */
/* Hollow squares answer hover and tap too: the link reads in both
         directions. They stay behind the filled squares, so where a state
         has not moved the filled square still wins the pointer. */
/* border-box, like the filled square it shares a slot with. Left as
         content-box its 1.4px border was added OUTSIDE the same declared
         size, so a hollow square sat a pixel proud of the filled one on every
         edge -- the grey ring around the pale states, invisible on the deep
         ones because the border and the fill were nearly the same colour. */
.ghostsq {
   position: absolute;
   transform: translate(-50%, -50%);
   box-sizing: border-box;
   border: 1.4px solid rgba(22, 19, 14, 0.22);
   border-radius: 5px;
   pointer-events: auto;
   cursor: pointer;
   z-index: 0;
   transition: left 0.7s var(--ease), top 0.7s var(--ease),
      border-color 0.22s ease, background 0.22s ease,
      box-shadow 0.22s ease;
}

/* Silent until its state is the one being read, then it speaks: the
         hollow square is the same state's other-period position, so it earns
         the accent and its own initials only while that pairing is on show. */
.ghostsq.is-linked {
   border-color: #16130E;
   border-width: 2px;
   background: rgba(22, 19, 14, 0.05);
   box-shadow: 0 0 0 3.5px rgba(22, 19, 14, 0.07);
}

.ghost-abbr {
   position: absolute;
   inset: 0;
   display: grid;
   place-items: center;
   font-family: var(--mono);
   font-size: 9.5px;
   font-weight: 600;
   letter-spacing: .02em;
   color: #16130E;
   opacity: 0;
   transition: opacity 0.22s ease;
}

.ghostsq.is-linked .ghost-abbr {
   opacity: 0.92;
}

/* :not(.is-selected) so a click still outranks a hover on the same
         square -- otherwise this rule, being later, would overwrite the
         selection ring with the weaker hover one. */
.ssq.is-linksrc:not(.is-selected) {
   outline: 2px solid rgba(22, 19, 14, 0.55);
   outline-offset: 2.5px;
}

/* The link itself. Both layers hang off <body>, not off .dotfield:
         React owns that subtree and reconciles away anything appended into
         it. Viewport coordinates instead, re-projected every frame.
         z-index 7 puts the line under the field (8) so it threads behind the
         squares; the caption rides above everything in the field. */
/* width/height stated outright: an svg is a replaced element, so
         inset: 0 alone leaves it at its intrinsic 300x150 and everything
         drawn past that is silently clipped. */
/* Above the field (8): a straight run between two columns crosses the
         stacks in between, and threading behind them chopped the line into
         disconnected fragments. Ink dots ride over the squares instead. */
#linklayer {
   position: fixed;
   inset: 0;
   width: 100vw;
   height: 100vh;
   z-index: 9;
   pointer-events: none;
   opacity: 0;
   overflow: visible;
}

/* Dotted by way of zero-length dashes with round caps -- a real dotted
         curve, and the growth is done by shortening the path itself rather
         than by a dash offset, which this pattern has already spent. */
.linkpath {
   fill: none;
   stroke: #16130E;
   stroke-width: 2.7;
   stroke-linecap: round;
   stroke-dasharray: 0.01 7.5;
   opacity: 0.8;
}

.linkhead {
   fill: #16130E;
   opacity: 0.88;
}

/* Kept as a guard: since the slots are shared, a state that holds its
         score now sits exactly on its own ghost and draws no line at all. */
.linkpath.is-flat {
   stroke: rgba(22, 19, 14, 0.3);
}

/* Frosted glass, and it may drift over the columns on purpose: the
         blur reading the squares underneath is the point of the material. */
#linktag {
   position: fixed;
   z-index: 900;
   pointer-events: none;
   transform: translate(-50%, -50%);
   opacity: 0;
   display: flex;
   align-items: center;
   gap: 13px;
   padding: 11px 16px;
   border-radius: 8px;
   background: rgba(255, 255, 255, 0.34);
   -webkit-backdrop-filter: blur(18px) saturate(185%);
   backdrop-filter: blur(18px) saturate(185%);
   border: 1px solid rgba(255, 255, 255, 0.55);
   box-shadow: 0 10px 28px rgba(28, 20, 14, 0.14),
      inset 0 1px 0 rgba(255, 255, 255, 0.6);
}

/* equal-width cells so the arrow sits dead centre between the numbers */
.lt-cell {
   display: flex;
   flex-direction: column;
   align-items: center;
   gap: 5px;
   line-height: 1;
   min-width: 58px;
}

.lt-cell b {
   font-family: var(--ui-semi);
   font-weight: 600;
   font-size: 17px;
   letter-spacing: -0.01em;
   color: #16130E;
}

.lt-cell i {
   font-family: var(--mono);
   font-style: normal;
   font-size: 7.5px;
   letter-spacing: .15em;
   color: #A79C8E;
}

.lt-arrow {
   font-family: var(--mono);
   font-size: 15px;
   line-height: 1;
   color: #16130E;
   opacity: .8;
   align-self: center;
}

.lt-delta {
   font-family: var(--mono);
   font-size: 9.5px;
   font-weight: 600;
   letter-spacing: .04em;
   padding: 5px 8px;
   border-radius: 8px;
   align-self: center;
}

.lt-delta.up {
   color: #A4402A;
   background: rgba(222, 124, 100, 0.20);
}

.lt-delta.down {
   color: #38614A;
   background: rgba(127, 168, 145, 0.26);
}

.lt-same {
   font-family: var(--mono);
   font-size: 8.5px;
   letter-spacing: .15em;
   color: #8C8478;
   align-self: center;
}

.histaxis {
   position: absolute;
   inset: 0;
   pointer-events: none;
}

/* Bottom-right legend: the top bar sits 22px from the top and 26px
         from the right, and this mirrors both distances below. */
/* D3, the ghost trail: three fading hollow echoes behind the filled
         square -- a long-exposure photograph of the move. */
.fieldlegend {
   position: absolute;
   right: 26px;
   bottom: 22px;
   z-index: 25;
   display: flex;
   align-items: center;
   gap: 12px;
   pointer-events: none;
}

.lgtrail {
   position: relative;
   width: 96px;
   height: 16px;
}

.lgtrail .g {
   position: absolute;
   top: 1px;
   width: 14px;
   height: 14px;
   border-radius: 3.5px;
   border: 1.4px solid rgba(22, 19, 14, 0.30);
}

.lgtrail .g1 {
   left: 0;
   opacity: .25;
}

.lgtrail .g2 {
   left: 26px;
   opacity: .5;
}

.lgtrail .g3 {
   left: 52px;
   opacity: .8;
}

.lgtrail .lgf {
   position: absolute;
   top: 1px;
   left: 80px;
   width: 14px;
   height: 14px;
   border-radius: 3.5px;
   background: linear-gradient(160deg, #EED9AC, #C08F3C);
}

.lgcap {
   font-family: var(--mono);
   font-size: 8px;
   letter-spacing: .1em;
   text-transform: uppercase;
   color: #5E574D;
   line-height: 1.6;
}

.lgcap .dim {
   color: #B9B2A6;
}

/* the legend steps aside while the walkthrough is teaching */
.fieldlegend {
   transition: opacity .35s ease;
}

body.tour-on .fieldlegend {
   opacity: 0;
}

@media (max-width: 640px) {
   .fieldlegend {
      display: none;
   }
}

/* ==========================================================================
   THE FIELD TEACHES ITSELF  (desktop only -- legend pick E, 27 Aug)

   The two-line key named two swatches and left the reader to do the work.
   It is retired: the corner now carries three words, and shortly after the
   field settles the dashboard runs its OWN hover -- no pointer involved --
   on the biggest mover in the data. The square lights, its ghost lights,
   the dotted link draws between them and the tag reads both scores. The
   explanation is the page performing itself once.

   The markup is untouched (the phone keeps its key); out here the swatches
   and the caption are simply hidden and the container speaks for itself.
   ========================================================================== */
@media (min-width: 641px) {
   .fieldlegend .lgtrail,
   .fieldlegend .lgcap {
      display: none;
   }

   .fieldlegend::after {
      content: 'Hover any square';
      font-family: var(--mono);
      font-size: 8.5px;
      letter-spacing: .16em;
      text-transform: uppercase;
      color: #8C8478;
      transition: opacity .5s ease;
   }

   /* while the page is demonstrating, the instruction stands down: the
      reader is already being shown, and being told at the same time is
      noise */
   body.legend-demo .fieldlegend::after {
      opacity: .25;
   }
}

.histaxis-n {
   position: absolute;
   bottom: 8.5%;
   transform: translateX(-50%);
   font-family: var(--mono);
   font-size: 10px;
   color: #A79C8E;
}

.histaxis-end {
   position: absolute;
   bottom: 4.2%;
   white-space: nowrap;
   font-family: var(--mono);
   font-size: 9.5px;
   letter-spacing: .14em;
   color: #B9B2A6;
}

/* The abbreviation is pinned to the circular part and never moves; the
         name slides out beside it. Centring both in a flex row made the
         initials jump sideways the instant the width changed. */
/* DC and Vermont are the floor of the size scale, and a fixed 11px with
         letter-spacing did not fit two characters inside them. */
.sdot-abbr {
   position: absolute;
   left: 0;
   top: 0;
   width: var(--d);
   height: var(--d);
   display: grid;
   place-items: center;
   font-size: clamp(8.5px, calc(var(--d) * 0.34), 12px);
   letter-spacing: 0.02em;
   font-weight: 600;
   color: rgba(22, 19, 14, 0.78);
}

@keyframes rise {
   from {
      opacity: 0;
      transform: translateY(22px);
   }

   to {
      opacity: 1;
      transform: none;
   }
}

@keyframes fadeUp {
   from {
      opacity: 0;
      transform: translateY(12px);
   }

   to {
      opacity: 1;
      transform: none;
   }
}

@keyframes dropIn {
   from {
      opacity: 0;
      transform: translateY(-14px);
   }

   to {
      opacity: 1;
      transform: none;
   }
}

/* Overshoot then settle: the spring the title and card ride in on. */
/* Gentle settle rather than a bounce: small offsets, long tail. */
/* Never drops to zero: the old word stays legible while the new one
         pulls into focus, so the change reads as a lens rather than a swap. */
/* Crossfade + blur. The incoming half runs here; the outgoing half runs
         on a ghost copy so both are briefly on screen together. */
@keyframes xfadeIn {
   from {
      opacity: 0;
      filter: blur(8px);
   }

   to {
      opacity: 1;
      filter: blur(0);
   }
}

@keyframes xfadeOut {
   from {
      opacity: 1;
      filter: blur(0);
   }

   to {
      opacity: 0;
      filter: blur(11px);
   }
}

/* The subtitle carries it harder, as asked. */
@keyframes xfadeInSub {
   from {
      opacity: 0;
      filter: blur(15px);
   }

   to {
      opacity: 1;
      filter: blur(0);
   }
}

@keyframes xfadeOutSub {
   from {
      opacity: 1;
      filter: blur(0);
   }

   to {
      opacity: 0;
      filter: blur(18px);
   }
}

/* Two stacked layers rendered together: the outgoing copy sits on top of
         the incoming one, absolutely positioned so they occupy the same space. */
.xfade {
   position: relative;
   display: block;
}

/* nowrap on both: without it the outgoing word re-flows to the width of
         the incoming one and "New Mexico" breaks onto a second line. */
.xfade-in {
   position: relative;
   z-index: 1;
   white-space: nowrap;
}

/* right:0 as well: an absolute layer shrink-wraps its content, and the
         card's right-aligned values collapsed leftward mid-fade without it. */
.xfade-out {
   position: absolute;
   left: 0;
   top: 0;
   right: 0;
   white-space: nowrap;
   pointer-events: none;
   z-index: 2;
}

/* The docked card enters in place. cardGrow carries the floating
         card's centring translate, which in flow shoved it half a column
         off the left edge of the window. */
@keyframes cardDock {
   from {
      opacity: 0;
      transform: translateY(10px) scale(0.975);
   }

   to {
      opacity: 1;
      transform: none;
   }
}

/* Every floating surface shares one frosted material. */
.glass {
   background: rgba(255, 255, 255, 0.66);
   -webkit-backdrop-filter: blur(30px) saturate(170%);
   backdrop-filter: blur(30px) saturate(170%);
   border: 1px solid rgba(255, 255, 255, 0.7);
   box-shadow: 0 14px 44px rgba(28, 20, 14, 0.15),
      inset 0 1px 0 rgba(255, 255, 255, 0.9);
}

.topbar {
   position: absolute;
   top: 22px;
   left: 26px;
   right: 26px;
   z-index: 30;
   height: 40px;
   display: flex;
   align-items: center;
   gap: 12px;
}

.brandmark {
   flex: 0 0 auto;
}

.bar-break {
   display: none;
}

.searchwrap {
   position: relative;
   flex: 0 1 300px;
   margin-left: auto;
   transition: flex-basis .5s var(--ease);
}

/* Focus feeds it room: the auto margin sits to its left, so the extra
         basis is taken from that side and the field morphs leftward. */
@media (min-width: 641px) {
   .searchwrap:focus-within {
      flex-basis: 440px;
   }
}

/* One field. Typing filters the list beneath it; no nested search box. */
/* One radius for the whole bar. A 40px-tall control at 999px is a
         capsule; 12px is unmistakably rounded without going oval, and the
         menus that hang off these controls take 16 -- a bigger box carries a
         slightly bigger corner, the same relation the state card has to the
         rows inside it. */
:root {
   --btn-radius: 12px;
   --btn-radius-inner: 9px;
   --menu-radius: 16px;
}

/* `isolation` keeps the backdrop filter from painting a rectangle of
         blur past the rounded ends, which read as a smudge beside the field. */
.searchwrap,
.searchfield {
   isolation: isolate;
}

.searchfield {
   width: 100%;
   height: 40px;
   box-sizing: border-box;
   padding: 0 20px;
   border-radius: var(--btn-radius);
   outline: none;
   font-family: var(--mono);
   font-size: 10px;
   letter-spacing: .06em;
   color: #16130E;
   background: rgba(255, 255, 255, 0.30);
   border: 1px solid rgba(22, 19, 14, 0.22);
   -webkit-backdrop-filter: blur(18px) saturate(140%);
   backdrop-filter: blur(18px) saturate(140%);
   transition: background 0.3s var(--ease);
}

.searchfield input::placeholder {
   color: #7C746A;
   font-family: var(--mono);
   font-size: 9px;
   letter-spacing: .13em;
   text-transform: uppercase;
}

/* Dash 4 renders dcc.Input as a wrapper div with the real <input>
         inside, and its own stylesheet paints the wrapper purple through
         :focus-within (border and outline both). Same pseudo-class back at
         it, with the id to outrank its specificity. */
#state-search:focus-within,
.searchfield:focus-within {
   background: rgba(255, 255, 255, 0.6) !important;
   border-color: var(--accent, #63997A) !important;
   outline: none !important;
   box-shadow: none !important;
}

/* The wrapper IS the field; the inner input Dash renders must be
         invisible chrome -- it was drawing its own white box inside the
         pill. */
.searchfield input {
   width: 100% !important;
   height: 100% !important;
   background: transparent !important;
   border: none !important;
   outline: none !important;
   box-shadow: none !important;
   padding: 0 !important;
   margin: 0 !important;
   font: inherit !important;
   color: inherit !important;
   border-radius: var(--btn-radius);
}

/* 10px below the bar, same as every other header popup.

         The two menus in the bar -- this one and the tier picker's -- are the
         same object wearing two coats: one was a 24px blur over an opaque
         white, the other a 46px blur over near-nothing, and their rows lit up
         differently on hover (a square full-bleed band there, a rounded one
         here). They share one material and one row treatment now; the tier
         picker's own rules below inherit these values rather than restating
         them. */
.searchresults {
   position: absolute;
   top: calc(100% + 10px);
   left: 0;
   right: 0;
   z-index: 50;
   border-radius: var(--menu-radius);
   overflow: hidden;
   padding: 6px;
   background: rgba(255, 255, 255, 0.42);
   border: 1px solid rgba(255, 255, 255, 0.5);
   -webkit-backdrop-filter: blur(46px) saturate(190%);
   backdrop-filter: blur(46px) saturate(190%);
   box-shadow: 0 20px 52px rgba(28, 20, 14, 0.20);
   animation: dropIn 0.28s var(--ease) both;
}

/* The list stands ready in the DOM and the field's focus reveals it, so
         an untouched box can offer its first ten states with no round trip.
         A callback could not open it in time; focus is not a Dash event. */
.searchresults {
   display: none;
}

/* grid, not block: this selector is more specific than the shell's own
         rule, so `block` here quietly undid the grid the growth animation
         needs and the menu appeared at full height. */
.searchwrap:focus-within .searchresults {
   display: grid;
}

.searchrow {
   padding: 9px 14px;
   font-size: 12.5px;
   color: #3A352E;
   cursor: pointer;
   border-radius: 12px;
   transition: background 0.18s var(--ease),
      color 0.18s var(--ease);
}

.searchrow:hover {
   background: rgba(255, 255, 255, 0.6);
   color: #16130E;
}

.searchrow--empty {
   color: #9A9186;
   cursor: default;
}

.searchrow--empty:hover {
   background: none;
}

.topright {
   width: 272px;
}

/* The thumb is a separate layer that slides, so the switch glides rather
         than the highlight jumping from one button to the other. */
/* No backdrop-filter here on purpose. This control sits between the
         search field and the metric dropdown, both of which cast soft shadows
         sideways, and a backdrop filter samples whatever is painted beneath
         it -- so every neighbouring shadow was being pulled inside the toggle
         and blurred across it. An opaque background removes the mechanism
         rather than the symptom. */
.segment {
   position: relative;
   display: flex;
   padding: 3px;
   border-radius: var(--btn-radius);
   border: 1px solid rgba(22, 19, 14, 0.22);
   background: rgba(252, 251, 249, 0.94);
}

.segment::before {
   content: '';
   position: absolute;
   top: 3px;
   bottom: 3px;
   left: 3px;
   width: calc(50% - 3px);
   border-radius: var(--btn-radius-inner);
   background: var(--accent, #63997A);
   z-index: 0;
   transition: transform .42s var(--ease);
}

.segment:has(#btn-post.is-on)::before {
   transform: translateX(100%);
}

.segment:has(#btn-pre.is-on)::before {
   transform: translateX(0);
}

/* The Story button is the house blueprint: mono 9px, .13em
         tracking, muted #7C746A. The toggle keeps its sliding ink pill but
         speaks in that voice. */
.segbtn {
   position: relative;
   z-index: 1;
   border: none;
   background: transparent;
   cursor: pointer;
   height: 32px;
   padding: 0 15px;
   /* A column: the zero-height bold copy that reserves the
                     width stacks UNDER the label instead of beside it, where
                     it was shoving the visible text off centre. */
   display: inline-flex;
   flex-direction: column;
   align-items: center;
   justify-content: center;
   border-radius: var(--btn-radius-inner);
   font-family: var(--mono);
   font-size: 9px;
   letter-spacing: .13em;
   text-transform: uppercase;
   color: #7C746A;
   white-space: nowrap;
   flex: 1 1 0;
   transition: color .3s var(--ease);
}

/* Bold marks the live side. The label's own width would grow with it
         and shove the bar sideways on every flip, so each button reserves the
         bold width up front: a zero-height copy of its label, always bold,
         sets the box and the visible text sits inside it. */
/* Declared here, ahead of the phone pass: put after it, this rule wins
         on source order and the phone toggle renders with no text at all. */
.seg-short {
   display: none;
}

.segbtn.is-on {
   background: transparent;
   color: #fff;
   font-weight: 700;
}

.segbtn::after {
   content: attr(data-label);
   display: block;
   height: 0;
   line-height: 0;
   overflow: hidden;
   visibility: hidden;
   font-weight: 700;
   pointer-events: none;
}

.barctrls {
   display: flex;
   gap: 7px;
}

/* The score tile. Two ramps of four blurred radial blobs, all keyed to
         the same 0-14 value, so the colour is a function of the number rather
         than decoration. The grain layer on top is what stops a gradient this
         large from banding, and gives it a printed rather than a screen look. */
/* The aurora and its grain, plain: no frost, no hairline, no glass
         dressing. The colour is the surface. */
.scoretile {
   position: relative;
   overflow: hidden;
   border-radius: 18px;
   min-height: 132px;
   padding: 15px 16px;
   margin: 0 0 13px 0;
   display: flex;
   flex-direction: column;
   justify-content: space-between;
   box-shadow: 0 4px 12px rgba(28, 20, 14, 0.07);
}

/* Phone only: the desktop panel is already open beside the tile. */
.profile-cta {
   display: none;
}

.scoretile-grain {
   position: absolute;
   inset: 0;
   z-index: 1;
   pointer-events: none;
   opacity: 0.5;
   mix-blend-mode: overlay;
   background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='sn'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='3'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23sn)'/%3E%3C/svg%3E");
}

/* `flex: 1`, not `height: 100%`: a percentage height collapses when the
         tile stands alone (its height is a min-height, so indefinite), which
         let the text float mid-tile on the state card while the national pair
         -- stretched to a definite height by the row -- pinned correctly.
         Growing into the parent's flex column behaves the same in both. */
.scoretile-in {
   position: relative;
   z-index: 2;
   display: flex;
   flex-direction: column;
   justify-content: space-between;
   flex: 1 1 auto;
   gap: 20px;
}

/* The tier pill and the score caption used to sit under the headline as
         well as in here, which said the same thing twice a centimetre apart. */
/* A plain label. The capsule behind it read as a smudged halo against
         the mesh, so the pill is now only type. */
/* Tiny period tag: the movement subtitle describes where a state
         ENDED (post-Dobbs), while the tile's tier follows the toggle -- and
         with both on screen the two labels read as a contradiction unless
         each names its period. */
.periodtag {
   font-family: var(--mono);
   font-size: 7.5px;
   font-weight: 500;
   letter-spacing: .12em;
   text-transform: uppercase;
   padding: 6px 11px;
   border-radius: 999px;
   line-height: 1;
   color: #A79C8E;
   border: 1px solid rgba(22, 19, 14, 0.20);
   white-space: nowrap;
}

.periodtag--onglass {
   color: rgba(255, 255, 255, 0.92);
   border-color: rgba(255, 255, 255, 0.55);
}

.scoretile-pill {
   align-self: flex-start;
   font-family: var(--mono);
   font-size: 10px;
   letter-spacing: .15em;
   text-transform: uppercase;
   color: rgba(255, 255, 255, 0.94);
   text-shadow: 0 1px 8px rgba(20, 16, 12, 0.35);
}

.scoretile-num {
   font-family: var(--display);
   font-size: 46px;
   font-weight: 400;
   letter-spacing: 0;
   line-height: 0.95;
   color: #FFFFFF;
}

.scoretile-cap {
   font-family: var(--mono);
   font-size: 10px;
   letter-spacing: .13em;
   text-transform: uppercase;
   color: rgba(255, 255, 255, 0.94);
   margin-top: 7px;
   text-shadow: 0 1px 8px rgba(20, 16, 12, 0.35);
}

.scorepanel {
   display: flex;
   flex-direction: column;
}

.scorepair {
   display: flex;
   gap: 9px;
}

/* Identical to the wide tile in everything but width: same height, same
         numeral, same pill, same caption, so the national pair and a selected
         state's tile are one design in two widths. */
.scoretile--half {
   flex: 1 1 0;
   min-width: 0;
}

/* The call to action from the layout studies. It owns the chart stack:
         collapsed, the three cards are not on the page at all. */
.ctabar {
   width: 100%;
   border: none;
   cursor: pointer;
   border-radius: 15px;
   padding: 13px 15px;
   margin-bottom: 3px;
   display: flex;
   align-items: center;
   justify-content: space-between;
   gap: 10px;
   background: #1B409B;
   color: #FFFFFF;
   font-family: var(--mono);
   font-size: 9px;
   letter-spacing: .15em;
   text-transform: uppercase;
   transition: background .3s var(--ease);
}

.ctabar:hover {
   background: #16357F;
}

.ctabar-arrow {
   width: 26px;
   height: 26px;
   flex: 0 0 26px;
   border-radius: 50%;
   background: #FFFFFF;
   color: #1B409B;
   display: grid;
   place-items: center;
   font-size: 13px;
   line-height: 1;
   transition: transform .45s var(--ease);
}

.ctabar.is-open .ctabar-arrow {
   transform: rotate(90deg);
}

/* The stroke stops 26 units short of the right edge, so it never runs off
         the card the way it did when the viewBox ended at the last point. */
.spark {
   height: 44px;
   margin-top: 9px;
   background-repeat: no-repeat;
   background-size: 100% 100%;
}

.bigchart {
   height: 132px;
   background-repeat: no-repeat;
   background-size: 100% 100%;
   margin-top: 2px;
}

.chartcap {
   font-family: var(--mono);
   font-size: 9.5px;
   letter-spacing: .11em;
   text-transform: uppercase;
   color: #A79C8E;
   margin: 7px 0 0;
}

/* Two layers rendered together, the outgoing one on top, so the tile
         crossfades between states instead of cutting. Same shape as the
         headline's xfade. */
.tilefade {
   position: relative;
   display: block;
}

.tilefade-out {
   position: absolute;
   inset: 0;
   pointer-events: none;
   z-index: 2;
}

.tilefade-in {
   position: relative;
   z-index: 1;
}

/* The score tile crossfades like the headline does rather than cutting. */
@keyframes tileIn {
   from {
      opacity: 0;
      filter: blur(12px);
      transform: scale(.97);
   }

   to {
      opacity: 1;
      filter: blur(0);
      transform: none;
   }
}

@keyframes tileOut {
   from {
      opacity: 1;
      filter: blur(0);
   }

   to {
      opacity: 0;
      filter: blur(14px);
   }
}

/* Each frosted control gets its own stacking context so it cannot sample
         a sibling's shadow. `will-change` is deliberately absent: promoting
         these to their own compositor layers made the artefact worse. */
.brandmark,
.barctrls,
.topright,
button.dash-dropdown.metricbox {
   isolation: isolate;
}

.brandmark {
   display: flex;
   align-items: center;
   gap: 9px;
   padding: 0 18px;
   height: 40px;
   border-radius: var(--btn-radius);
   background: transparent;
   -webkit-backdrop-filter: blur(30px) saturate(150%);
   backdrop-filter: blur(30px) saturate(150%);
   border: 1px solid rgba(22, 19, 14, 0.22);
   white-space: nowrap;
}

/* The one live thing in a bar of static controls: the dot breathes and
         sends a ring out behind it. Both are on the dot itself, never on an
         ancestor -- a transform up the tree would turn the top bar into a
         containing block and take the tier menu's positioning with it. */
.dot {
   width: 9px;
   height: 9px;
   border-radius: 50%;
   background: #63997A;
   display: inline-block;
   position: relative;
   flex: none;
   animation: dotBreath 2.6s cubic-bezier(.4, 0, .6, 1) infinite;
}

.dot::after {
   content: '';
   position: absolute;
   inset: 0;
   border-radius: 50%;
   border: 1.5px solid #63997A;
   opacity: 0;
   pointer-events: none;
   animation: dotRing 2.6s cubic-bezier(.2, .6, .3, 1) infinite;
}

@keyframes dotBreath {

   0%,
   100% {
      transform: scale(1);
      opacity: 1;
   }

   50% {
      transform: scale(.86);
      opacity: .78;
   }
}

@keyframes dotRing {
   0% {
      transform: scale(1);
      opacity: .55;
   }

   70% {
      transform: scale(2.6);
      opacity: 0;
   }

   100% {
      transform: scale(2.6);
      opacity: 0;
   }
}

@media (prefers-reduced-motion: reduce) {

   .dot,
   .dot::after {
      animation: none;
   }

   .dot::after {
      opacity: 0;
   }
}

/* Dash 4's own dropdown. Class names confirmed against the live DOM:
         the control is a <button class="dash-dropdown">, the popup is
         .dash-dropdown-content, rows are .dash-options-list-option. */
/* One control family for the whole top bar: pill corners, the
         Story button's mono voice, one border, one text colour. */
button.dash-dropdown.metricbox {
   height: 40px !important;
   padding: 0 16px !important;
   border-radius: var(--btn-radius) !important;
   cursor: pointer;
   font-family: var(--mono) !important;
   background: transparent !important;
   border: 1px solid rgba(22, 19, 14, 0.22) !important;
   -webkit-backdrop-filter: blur(30px) saturate(150%) !important;
   backdrop-filter: blur(30px) saturate(150%) !important;
   box-shadow: none !important;
   transition: background 0.3s var(--ease);
}

button.dash-dropdown.metricbox:hover {
   background: rgba(255, 255, 255, 0.5) !important;
}

.dash-dropdown-value,
.dash-dropdown-placeholder,
.dash-dropdown-value-item {
   color: #7C746A !important;
   font-size: 9px !important;
   letter-spacing: .13em !important;
   text-transform: uppercase !important;
   font-family: var(--mono) !important;
}

.dash-dropdown-placeholder {
   color: #7C746A !important;
}

/* Dash measures the label and hard-codes a pixel grid column for it;
         with different font metrics the measurement lands short and the
         label ellipsises. Let the column size itself. */
/* 1fr, not max-content: the label column takes all the room so the
         caret anchors to the button's right edge. The grid's own 12px
         padding is stripped -- it stacked on the button's and pushed the
         label 31px in while the caret sat 7px from its border. Both sides
         read the same slim inset now. */
button.metricbox .dash-dropdown-grid-container {
   grid-template-columns: 1fr 15px !important;
   width: 100% !important;
   box-sizing: border-box !important;
   padding: 0 !important;
}

button.metricbox .dash-dropdown-value {
   justify-self: start;
   text-align: left;
}

button.metricbox .dash-dropdown-value,
button.metricbox .dash-dropdown-value-item {
   max-width: none !important;
   overflow: visible !important;
   text-overflow: clip !important;
   white-space: nowrap !important;
}

/* Dash paints a purple focus outline on its dropdown button too. */
#map-color-dropdown:focus,
#map-color-dropdown:focus-visible {
   outline: none !important;
   border-color: var(--accent, #63997A) !important;
}

.dash-dropdown-wrapper {
   position: relative;
   width: 100%;
}

/* Radix positions this menu itself: it leaves the popper wrapper inside
         the trigger's own wrapper, `position: fixed`, carrying a viewport
         transform it measured at open time. That maths is fragile off this
         machine -- any ancestor that picks up a transform, filter or
         backdrop-filter turns the wrapper's containing block into that
         ancestor, and a wrapper wider than the content it holds re-centres
         the whole menu. Both showed up as the menu sitting up and to the left
         of its own button, overlapping it.

         So the menu is not positioned by Radix any more. `.dash-dropdown-wrapper`
         is `position: relative` and is exactly the trigger's box, which makes
         `left: 0; top: 100%` the button's lower-left corner by construction --
         no measurement, nothing to go stale, same result in every browser.
         !important beats Radix's inline style. */
.dash-dropdown-wrapper>[data-radix-popper-content-wrapper] {
   position: absolute !important;
   transform: none !important;
   left: 0 !important;
   top: 100% !important;
   right: auto !important;
   bottom: auto !important;
   width: 100% !important;
   min-width: 0 !important;
   max-width: none !important;
   z-index: 60 !important;
}

.dash-dropdown-content {
   /* was `overflow: hidden` with no height cap, which clipped the
                 list at the popup's natural height and left no way to scroll */
   margin-top: 10px !important;
   border-radius: var(--menu-radius) !important;
   max-height: 46vh !important;
   overflow-y: auto !important;
   overscroll-behavior: contain;
   /* The wrapper above is pinned to the trigger's box, so 100%
                 of it IS the trigger's width -- no variable to depend on. */
   width: 100% !important;
   min-width: 0 !important;
   box-sizing: border-box;
   padding: 6px !important;
   background: rgba(255, 255, 255, 0.42) !important;
   border: 1px solid rgba(255, 255, 255, 0.5) !important;
   -webkit-backdrop-filter: blur(46px) saturate(190%) !important;
   backdrop-filter: blur(46px) saturate(190%) !important;
   box-shadow: 0 20px 52px rgba(28, 20, 14, 0.20) !important;
   animation: dropIn 0.28s var(--ease) both;
}

/* Single-select options carry a radio input, which is the only marker
         Dash gives the chosen row -- it ships no styling of its own for it. */
.dash-options-list-option:has(input:checked),
.dash-options-list-option-wrapper:has(input:checked),
.dash-options-list-option[aria-selected='true'] {
   background: rgba(22, 19, 14, 0.07) !important;
   border-radius: 12px !important;
}

.dash-options-list-option:has(input:checked) .dash-options-list-option-text,
.dash-options-list-option-wrapper:has(input:checked) .dash-options-list-option-text,
.dash-options-list-option[aria-selected='true'] .dash-options-list-option-text {
   font-weight: 700 !important;
   color: #16130E !important;
}

.dash-dropdown-search {
   border-radius: var(--btn-radius) !important;
   height: 34px !important;
   padding: 0 14px !important;
   font-family: var(--ui) !important;
   font-size: 12.5px !important;
   color: #16130E !important;
   background: rgba(255, 255, 255, 0.55) !important;
   border: 1px solid rgba(255, 255, 255, 0.7) !important;
   outline: none !important;
}

.dash-dropdown-search:focus {
   border-color: rgba(99, 153, 122, 0.5) !important;
   box-shadow: 0 0 0 3px rgba(99, 153, 122, 0.14) !important;
}

.dash-dropdown-search-container {
   padding: 0 0 6px 0 !important;
}

/* Same row as .searchrow: same padding, same 12px corner, same wash,
         same darkening of the label. They read as one control family. */
.dash-options-list-option,
.dash-dropdown-option {
   border-radius: 12px !important;
   border: none !important;
   padding: 9px 14px !important;
   margin: 0 !important;
   background: transparent !important;
   box-shadow: none !important;
   cursor: pointer;
   transition: background 0.18s var(--ease),
      color 0.18s var(--ease);
}

.dash-options-list-option:hover,
.dash-dropdown-option:hover {
   background: rgba(255, 255, 255, 0.6) !important;
}

.dash-options-list-option:hover .dash-options-list-option-text,
.dash-dropdown-option:hover .dash-options-list-option-text {
   color: #16130E !important;
}

.dash-options-list {
   padding: 0 !important;
}

.dash-options-list-option-text {
   font-family: var(--ui) !important;
   font-size: 12.5px !important;
   color: #3A352E !important;
}

.dash-options-list-option-checkbox {
   display: none !important;
}

/* One column down the left of the map: title, KPIs, state card, openers. */
/* The column is a full-height box over the western states. Left solid it
         swallowed every click meant for CA, OR, WA and the AK/HI markers, so
         the container itself is transparent to the pointer and only the blocks
         that actually hold content take events back. */
/* Centred on the page rather than hung from the top bar: the column
         is sized by its content and pinned to the vertical middle, exactly
         like the field beside it. It may grow past the middle band (an open
         profile card is tall), so it caps at the old top/bottom margins and
         scrolls inside itself past that. */
/* The column is not centred on the window; it is centred on the band
         of free air between the two things that bracket it. The top bar ends
         at 62px (22 top + 40 tall) and the credit ring begins 90px off the
         bottom (28 + 62), so the band is not symmetric -- centring on the
         window alone left 28px more air above the title than below the
         profile card, which reads as the column sagging. Half the difference,
         lifted. */
:root {
   --band-top: 62px;
   --band-bot: 100px;
}

.leftcol {
   position: absolute;
   left: 30px;
   top: calc(50% - (var(--band-bot) - var(--band-top)) / 2);
   transform: translateY(-50%);
   width: 418px;
   z-index: 20;
   display: flex;
   flex-direction: column;
   gap: 0;
   overflow: visible;
   padding-right: 6px;
   pointer-events: none;
}

/* The two panel slots (national and state) both stay in the flow, one
         of them collapsed to zero height. A flex `gap` is paid between every
         pair of children regardless of size, so the collapsed slot was still
         charging 18px -- and since it sits last in the national view and in
         the middle in the state view, that phantom row moved the visible ink
         off centre by a different amount in each. Spacing by margin instead
         lets the collapsed slot cost nothing. */
.leftcol>*+* {
   margin-top: 18px;
}

.leftcol>.reveal:not(.on) {
   margin-top: 0;
}

/* Laptop heights: the column has to fit the band, so the rows close up
         rather than the panel being cut. */
@media (min-width: 641px) and (max-height: 940px) {
   .leftcol>*+* {
      margin-top: 12px;
   }

   .herokpis {
      padding: 13px 16px;
   }

   .kpiline {
      padding: 4px 0;
   }
}

@media (min-width: 641px) and (max-height: 850px) {
   .leftcol>*+* {
      margin-top: 9px;
   }

   .herokpis {
      padding: 11px 15px;
   }

   .kpiline {
      padding: 2px 0;
   }
}

/* Only a genuinely short window brings back the internal scroll -- and
         with it the horizontal clip, which is why it is not the default.
         152px = the band's own margins, the real constraint. */
@media (max-height: 760px) and (min-width: 641px) {
   .leftcol {
      max-height: calc(100vh - var(--band-top) - var(--band-bot));
      overflow-y: auto;
   }
}

.leftcol>* {
   pointer-events: auto;
}

.leftcol #hero-title,
.leftcol .herosub {
   pointer-events: none;
}

/* Opening a card pushes the column past the viewport. It still scrolls,
         but a bar appearing down the side of the whole panel read as breakage
         rather than as affordance. */
.leftcol {
   scrollbar-width: none;
   scroll-behavior: smooth;
}

.leftcol::-webkit-scrollbar {
   width: 0;
   height: 0;
   display: none;
}

/* The column's 418px sizes the cards, not the headline: the title may
         run past it into the open air left of the field, where it blocks
         nothing (the whole column ignores the pointer). */
#hero-title {
   width: max-content;
   max-width: 60vw;
   margin: 0;
   font-family: var(--display);
   font-size: 46px;
   font-weight: 400;
   letter-spacing: -0.01em;
   line-height: 1.0;
   color: #16130E;
}

.herosub {
   display: flex;
   align-items: center;
   gap: 10px;
   flex-wrap: wrap;
}

.herokpis {
   flex: 0 0 auto;
   padding: 16px 18px;
   border-radius: 18px;
   background: rgba(255, 255, 255, 0.42);
   border: 1px solid rgba(22, 19, 14, 0.22);
   -webkit-backdrop-filter: blur(34px) saturate(160%);
   backdrop-filter: blur(34px) saturate(160%);
}

.kpiline {
   display: flex;
   justify-content: space-between;
   align-items: baseline;
   gap: 12px;
   padding: 7px 0;
}

/* Laptop heights: the averages panel now carries ten rows, not three,
         and the column has to fit the band it is centred in -- so the rows
         close up rather than the panel being cut off. Scoped by id and
         placed below the base rules: at equal specificity the base would
         otherwise win on source order. */
@media (min-width: 641px) and (max-height: 940px) {
   .leftcol>*+* {
      margin-top: 12px;
   }

   #herokpis.herokpis {
      padding: 13px 16px;
   }

   #herokpis .kpiline {
      padding: 4px 0;
   }

   #herokpis .kptrack {
      margin: 4px 0 0;
   }

   #herokpis .kpikey {
      padding: 7px 0 1px;
   }
}

@media (min-width: 641px) and (max-height: 850px) {
   .leftcol>*+* {
      margin-top: 9px;
   }

   #herokpis.herokpis {
      padding: 10px 15px;
   }

   #herokpis .kpiline {
      padding: 1.5px 0;
   }
}

/* The four counts under the rule used to sit here and they were filler --
         51 states scored, 6 changed tier, the average score and the widest
         move, none of which the panel needed to say twice. Taking them out
         freed almost exactly what the six strips cost, so the strips no
         longer have to stand down on a short window. */

.kpivals {
   display: flex;
   align-items: baseline;
   gap: 7px;
   font-size: 15px;
}

/* Positioned per selection by card_anchor(); it grows upward out of the
         circle that was clicked. */
/* Switching states used to move the card instantly. Transitioning the
         two position properties makes it travel to the next circle while the
         body cross-fades. */
/* A flex child of the column now, not a floating layer: it can never
         collide with the movement line or the score pill again. */
.statecard {
   z-index: 30;
   width: auto;
   transform-origin: 50% 0%;
   /* Bounded by the room the column actually has -- title,
                      subtitle and score tile take ~470px -- so expanding the
                      indicators scrolls inside the card instead of shoving
                      the title off the top. */
   flex: 0 0 auto;
   /* 580 = the 196px of avatar-safe margin plus the ~384px
                      the title, subtitle and score tile occupy above. */
   max-height: clamp(260px, calc(100vh - 580px), 54vh);
   display: flex;
   flex-direction: column;
   overflow: hidden;
   /* clip to the radius */
   border-radius: 18px;
   padding: 16px 6px 16px 18px;
   /* Same material as the comparison block it replaces. The
                      floating card's 60px drop shadow read as grime once the
                      card sat flat inside the column. */
   background: rgba(255, 255, 255, 0.42);
   -webkit-backdrop-filter: blur(34px) saturate(160%);
   backdrop-filter: blur(34px) saturate(160%);
   /* the same border the top bar's buttons and the search
                      field wear, so the left column speaks one voice */
   border: 1px solid rgba(22, 19, 14, 0.22);
   box-shadow: none;
}

.statecard-head {
   display: flex;
   justify-content: space-between;
   align-items: center;
   margin-bottom: 14px;
   padding-right: 16px;
}

/* Where the state sits in the national range: a quiet track, a tick at
         the average, and a coloured dot for this state. */
/* Departure (variant A, the user's pick): every dot is born on the
         national-average tick and travels to the state's value, towing a
         faint trail in its own colour -- how far it sits from average is
         the animation. Rows fire one after another down the card. */
.mtrack {
   position: relative;
   height: 4px;
   border-radius: 2px;
   background: rgba(22, 19, 14, 0.08);
   margin: 7px 0 1px;
   animation: stripFade 0.4s ease both;
}

.mavg {
   position: absolute;
   top: -3px;
   width: 1.5px;
   height: 10px;
   background: rgba(22, 19, 14, 0.30);
   animation: tickIn 0.3s ease both;
}

.mdot {
   position: absolute;
   top: 50%;
   transform: translate(-50%, -50%);
   width: 9px;
   height: 9px;
   border-radius: 50%;
   /* the dot's own hue at low strength (gallery pick E) -- the search box's
      focus-ring treatment. Reads as emphasis, not outline; the hue rides in
      as `color` from the builder. Both surfaces share this rule. */
   box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 18%, transparent);
   animation: dotGo 0.55s var(--ease) both;
}

/* the trail: average -> value, grown from the average end */
.mseg {
   position: absolute;
   top: 0;
   height: 100%;
   border-radius: 2px;
   opacity: 0.3;
   animation: segGrow 0.55s var(--ease) both;
}

@keyframes stripFade {
   from {
      opacity: 0;
   }

   to {
      opacity: 1;
   }
}

@keyframes tickIn {
   from {
      opacity: 0;
      transform: scaleY(0);
   }

   to {
      opacity: 1;
      transform: none;
   }
}

@keyframes dotGo {
   from {
      left: var(--avg);
      opacity: 0;
   }

   12% {
      opacity: 1;
   }

   to {
      left: var(--p);
      opacity: 1;
   }
}

@keyframes segGrow {
   from {
      transform: scaleX(0);
   }

   to {
      transform: scaleX(1);
   }
}

/* The scroller sits inside the padding, so the bar never touches a corner. */
.statecard-body {
   overflow-y: auto;
   padding-right: 14px;
   margin-right: 2px;
}

.statecard-body::-webkit-scrollbar {
   width: 5px;
}

.statecard-body::-webkit-scrollbar-thumb {
   background: rgba(22, 19, 14, 0.18);
   border-radius: 3px;
}

.statecard-body::-webkit-scrollbar-track {
   background: transparent;
}

.ghostbtn {
   font-family: var(--mono);
   font-size: 9px;
   letter-spacing: 0.14em;
   text-transform: uppercase;
   background: none;
   border: none;
   cursor: pointer;
   color: #9A9186;
   padding: 5px 2px;
   transition: color 0.25s var(--ease);
}

.ghostbtn:hover {
   color: #16130E;
}

/* Policy indicators as inspection stamps (gallery pick C12): laws in
         force land bordered and tinted at their own slight angle; absent
         laws wait as dashed outlines, unknowns as dotted ones, and an
         active protection stamps in the accent sage. */
.stampwrap {
   display: flex;
   flex-wrap: wrap;
   gap: 9px;
   padding: 2px 2px 6px;
}

.pstamp {
   font-family: var(--mono);
   font-size: 9px;
   letter-spacing: .08em;
   text-transform: uppercase;
   padding: 8px 11px;
   border-radius: 7px;
   line-height: 1.35;
   max-width: 150px;
   animation: stampIn 0.34s cubic-bezier(0.2, 0.7, 0.3, 1) both;
}

.pstamp.on {
   color: #A4402A;
   border: 1.8px solid rgba(201, 85, 63, 0.55);
   background: rgba(222, 124, 100, 0.07);
   transform: rotate(var(--r, 0deg));
}

.pstamp.off {
   color: rgba(22, 19, 14, 0.32);
   border: 1.4px dashed rgba(22, 19, 14, 0.18);
}

.pstamp.na {
   color: rgba(22, 19, 14, 0.25);
   border: 1.4px dotted rgba(22, 19, 14, 0.15);
}

.pstamp.prot {
   color: #38614A;
   border: 1.8px solid rgba(99, 153, 122, 0.55);
   background: rgba(127, 168, 145, 0.08);
   transform: rotate(var(--r, 0deg));
}

/* A stamp slams DOWN: it arrives oversized and above the paper,
         hits slightly compressed, and settles -- not a bubble growing. */
@keyframes stampIn {
   0% {
      opacity: 0;
      transform: rotate(var(--r, 0deg)) scale(1.6);
   }

   55% {
      opacity: 1;
      transform: rotate(var(--r, 0deg)) scale(0.96);
   }

   100% {
      opacity: 1;
      transform: rotate(var(--r, 0deg)) scale(1);
   }
}

/* the card's Close is a phone-sheet affordance only */
.cardclose {
   display: none;
}

@media (max-width: 640px) {
   .cardclose {
      display: inline-block;
   }
}

/* Grid rows ease from 0fr to 1fr, which animates an unknown height. */
.collapsible {
   display: grid;
   grid-template-rows: 0fr;
   transition: grid-template-rows 0.55s var(--ease);
}

/* The comparison block and the state profile share one slot in the
         column. Toggling display cut instantly and, because the column is
         vertically centred, reflowed everything above it in the same frame
         -- the snap in the hover video. These morph height and opacity
         instead, so the title glides while the blocks trade places. */
/* Ease-in-out, not the house spring: the spring opens at maximum
         velocity, and with ~115px of column at stake the first frame read
         as a snap. This one gathers speed and settles. */
/* margin-top rides the same curve as the height: the slot's spacing is
         switched by the `.on` class, which flips instantly, and an 18px jump
         under a card that is still growing reads as a stutter. */
.reveal {
   display: grid;
   grid-template-rows: 0fr;
   opacity: 0;
   transition: grid-template-rows 0.55s cubic-bezier(0.45, 0, 0.22, 1),
      margin-top 0.55s cubic-bezier(0.45, 0, 0.22, 1),
      opacity 0.42s ease;
}

.reveal.on {
   grid-template-rows: 1fr;
   opacity: 1;
}

.reveal-inner {
   overflow: hidden;
   min-height: 0;
}

.collapsible-inner {
   overflow: hidden;
   min-height: 0;
}

/* The stamps leave one at a time and the block closes on the space
         they give up -- the same beat they arrive on, run backwards. The fold
         is deliberately slower than the exit and eased so it barely moves at
         first: the first stamps go while the room is still there, and the
         room closes behind them rather than under them. */
@keyframes stampOut {
   0% {
      opacity: 1;
      transform: rotate(var(--r, 0deg)) scale(1);
   }

   60% {
      opacity: 0;
      transform: rotate(var(--r, 0deg)) scale(0.82) translateY(-4px);
   }

   100% {
      opacity: 0;
      transform: rotate(var(--r, 0deg)) scale(0.82) translateY(-4px);
   }
}

.collapsible[style*="0fr"] .pstamp {
   animation: stampOut .58s cubic-bezier(.4, 0, .6, 1) both;
}

.collapsible[style*="0fr"] .pstamp:nth-child(2) {
   animation-delay: .04s;
}

.collapsible[style*="0fr"] .pstamp:nth-child(3) {
   animation-delay: .08s;
}

.collapsible[style*="0fr"] .pstamp:nth-child(4) {
   animation-delay: .12s;
}

.collapsible[style*="0fr"] .pstamp:nth-child(5) {
   animation-delay: .16s;
}

.collapsible[style*="0fr"] .pstamp:nth-child(6) {
   animation-delay: .20s;
}

.collapsible[style*="0fr"] .pstamp:nth-child(7) {
   animation-delay: .24s;
}

.collapsible[style*="0fr"] .pstamp:nth-child(8) {
   animation-delay: .28s;
}

.collapsible[style*="0fr"] .pstamp:nth-child(9) {
   animation-delay: .32s;
}

.collapsible[style*="0fr"] .pstamp:nth-child(10) {
   animation-delay: .36s;
}

.collapsible[style*="0fr"] .pstamp:nth-child(11) {
   animation-delay: .40s;
}

.collapsible[style*="0fr"] .pstamp:nth-child(12) {
   animation-delay: .44s;
}

.collapsible[style*="0fr"] .pstamp:nth-child(13) {
   animation-delay: .48s;
}

.collapsible[style*="0fr"] .pstamp:nth-child(n+14) {
   animation-delay: .52s;
}

/* Opening is the same motion run forwards: the room grows on a long,
         even curve and the stamps land just behind its edge, one after
         another, instead of the block snapping to half its height and the
         rest arriving into space that was already there. Closing keeps its
         mirror -- barely moving at first, so the early stamps go while their
         room still exists, and it closes behind them. */
.collapsible {
   transition: grid-template-rows 0.95s cubic-bezier(.35, 0, .3, 1);
}

.collapsible[style*="0fr"] {
   transition: grid-template-rows 0.95s cubic-bezier(.7, 0, .4, 1);
}

/* Drawn from two borders rather than a glyph: a text arrow sits low in
         its line box, so rotating it visibly shifts the arrow up and down. */
/* The glyph is drawn from two borders and rotated, which leaves it
         sitting low in its own box; the box is nudged back up so the mark
         lands on the label's centre line rather than under it. */
/* The button is a flex row so the mark and the label are centred on one
         another rather than sharing a text baseline the rotated glyph does
         not sit on. */
.expandbtn {
   display: flex;
   align-items: center;
   justify-content: center;
}

/* The two labels are stacked, the outgoing one taken out of flow, so the
         wrapper's box belongs to whichever is in flow -- the mark has to be
         centred on the LINE, not on that box, or it rides at the top of the
         word once the button flips to Hide. */
.xlblwrap {
   display: flex;
   align-items: center;
   line-height: 1;
}

.xlbl {
   line-height: 1;
}

.caretbox {
   width: 9px;
   height: 9px;
   display: inline-grid;
   place-items: center;
   margin-right: 8px;
   flex: none;
}

/* The box is centred, but the mark inside it is not the box: two
         borders of a rotated square draw an L that sits in one half of it --
         low for the v, high for the ^ -- by about a quarter of its diagonal.
         The nudge corrects the INK, which is the only thing anyone sees.
         Measuring the box is what made this look solved when it was not. */
.caret {
   width: 4.5px;
   height: 4.5px;
   display: block;
   border-right: 1.5px solid currentColor;
   border-bottom: 1.5px solid currentColor;
   transform: translateY(-1.6px) rotate(45deg);
   transition: transform 0.45s var(--ease);
}

.expandbtn.is-open .caret {
   transform: translateY(1.6px) rotate(-135deg);
}

/* The label swap is the house lens-dissolve in miniature: the old line
         blurs upward out while the new one pulls into focus from below. */
.xlblwrap {
   position: relative;
   display: inline-block;
}

.xlbl {
   display: block;
   white-space: nowrap;
   transition: opacity .3s ease, filter .3s ease,
      transform .45s var(--ease);
}

/* Centred on the line it is absolutely placed against, then offset for
         the dissolve -- the offset used to BE its position, which parked the
         word at the top of the button once it faded in. */
.xlbl-hide {
   position: absolute;
   left: 50%;
   top: 50%;
   transform: translate(-50%, calc(-50% + 7px));
   opacity: 0;
   filter: blur(4px);
}

.expandbtn.is-open .xlbl-show {
   opacity: 0;
   filter: blur(4px);
   transform: translateY(-7px);
}

.expandbtn.is-open .xlbl-hide {
   opacity: 1;
   filter: blur(0);
   transform: translate(-50%, -50%);
}

.expandbtn {
   width: 100%;
   margin-top: 12px;
   padding: 11px 0;
   display: flex;
   align-items: center;
   justify-content: center;
   border-radius: 14px;
   background: rgba(255, 255, 255, 0.34);
   border: 1px solid rgba(255, 255, 255, 0.5);
   transition: background 0.3s var(--ease), transform 0.3s var(--ease);
}

.expandbtn:hover {
   background: rgba(255, 255, 255, 0.6);
   color: #7C746A !important;
}

.expandbtn:active {
   transform: scale(0.985);
}

::-webkit-scrollbar {
   width: 7px;
   height: 7px;
}

::-webkit-scrollbar-thumb {
   background: #D8D1C6;
   border-radius: 4px;
}

::-webkit-scrollbar-track {
   background: transparent;
}

/* ==================== the intro ==================== */

:root {
   --accent: #63997A;
}

#intro {
   position: fixed;
   inset: 0;
   z-index: 3000;
   background: #FBFAF8;
   overflow: hidden;
   display: flex;
   flex-direction: column;
   align-items: center;
   justify-content: center;
   gap: 0;
   font-family: 'Space Grotesk', sans-serif;
   transition: opacity .6s cubic-bezier(.16, 1, .3, 1);
}

#intro.is-gone {
   opacity: 0;
   pointer-events: none;
}

/* The byline: ONE surface. At rest it is a 54px circle showing only
         the avatar. On hover the same element grows in all four directions
         into the card while the avatar -- never fading, never resizing --
         slides right to meet the card's own 16px padding and THAT'S ME
         arrives beside it. Reversing the hover morphs everything back. */
/* Closed, the surface is invisible -- transparent fill, transparent
         border -- so it can be generously sized (78px) and give the ring room
         to breathe without ever meeting the overflow clip. The white card
         only appears as it opens. */
/* The corner mirrors the top bar: 26px from the left edge, 22px
         from the bottom -- the same distances the brand pill keeps from
         the screen's top-left. */
#cp-card {
   position: fixed;
   left: 26px;
   bottom: 22px;
   z-index: 3002;
   width: 78px;
   height: 78px;
   overflow: hidden;
   background: transparent;
   box-sizing: border-box;
   /* No border at all: #cp-ring draws the outline now, and a
                  1.5px transparent border here pushed the avatar off the
                  ring's centre by exactly that much on both axes. */
   border: none;
   border-radius: 50%;
   cursor: pointer;
   transition: width .46s cubic-bezier(.16, 1, .3, 1),
      height .46s cubic-bezier(.16, 1, .3, 1),
      border-radius .46s cubic-bezier(.16, 1, .3, 1),
      background .28s ease,
      border-color .3s ease;
}

#cp-card.on {
   width: 226px;
   height: 216px;
   border-radius: 16px;
   background: #FFFFFF;
}

/* The avatar: same size in both states, anchored to the bottom-left,
         sliding right by the difference between the circle's inset and the
         card's padding. */
.cp-avwrap {
   position: absolute;
   left: 13px;
   bottom: 13px;
   width: 48px;
   height: 48px;
   z-index: 2;
   animation: avBreath 2.4s cubic-bezier(.4, 0, .6, 1) infinite;
   transition: left .46s cubic-bezier(.16, 1, .3, 1),
      top .46s cubic-bezier(.16, 1, .3, 1),
      bottom .46s cubic-bezier(.16, 1, .3, 1),
      width .46s cubic-bezier(.16, 1, .3, 1),
      height .46s cubic-bezier(.16, 1, .3, 1);
}

#cp-card.on .cp-avwrap {
   left: 14px;
   bottom: 12px;
}

/* Open: the photo keeps breathing, the ring stands down. */
#cp-card.on .cp-avwrap::after {
   animation: none;
   opacity: 0;
   transition: opacity .25s ease;
}

/* THE RING IS THE BORDER. It is its own fixed layer, not a pseudo
         inside the card, so it can sit outside the card's overflow clip and
         travel: closed it is a circle breathing around the photo; open it has
         morphed -- position, size and radius all easing together -- into the
         card's own outline. One stroke, two jobs, no hand-off. */
#cp-ring {
   position: fixed;
   left: 32px;
   bottom: 28px;
   z-index: 3003;
   width: 62px;
   height: 62px;
   border-radius: 50%;
   border: 2.5px solid var(--accent, #63997A);
   box-sizing: border-box;
   pointer-events: none;
   animation: ringBreath 2.4s cubic-bezier(.4, 0, .6, 1) infinite;
   transition: left .46s cubic-bezier(.16, 1, .3, 1),
      bottom .46s cubic-bezier(.16, 1, .3, 1),
      width .46s cubic-bezier(.16, 1, .3, 1),
      height .46s cubic-bezier(.16, 1, .3, 1),
      border-radius .46s cubic-bezier(.16, 1, .3, 1);
}

/* Open: the ring IS the card's outline -- same box, same radius. */
#cp-ring.on {
   left: 26px;
   bottom: 22px;
   width: 226px;
   height: 216px;
   border-radius: 16px;
   animation: none;
}

.cp-avatar {
   position: absolute;
   inset: 0;
   border-radius: 50%;
   overflow: hidden;
   background: #16130E;
}

.cp-avatar img {
   position: absolute;
   inset: 0;
   width: 100%;
   height: 100%;
   object-fit: cover;
   display: block;
}

.cp-init {
   position: absolute;
   inset: 0;
   display: grid;
   place-items: center;
   font-family: var(--ui-semi);
   font-size: 12px;
   letter-spacing: .06em;
   color: #FFFFFF;
}

/* One beat for both. The photo swells and the ring expands off the same
         keyframe timeline, peaking together at 35%; the vertical drift is gone
         because the ring cannot follow a translate without looking detached. */
/* Standalone now, so it carries the whole swell itself: the photo's
         1.045 and the ring's old 1.07, combined. */
@keyframes ringBreath {

   0%,
   100% {
      transform: scale(1);
      opacity: .9;
   }

   35% {
      transform: scale(1.11);
      opacity: .55;
   }
}

@keyframes avBreath {

   0%,
   100% {
      transform: scale(1);
   }

   35% {
      transform: scale(1.045);
   }
}

/* THAT'S ME rides beside the avatar; the rest of the card sits above. */
/* Matched to the avatar's own box, so it is centred against the photo
         rather than measured off the card's bottom edge. */
.cp-me-label {
   position: absolute;
   left: 74px;
   bottom: 12px;
   z-index: 2;
   height: 48px;
   display: flex;
   align-items: center;
   font-family: var(--ui-med);
   font-size: 10px;
   letter-spacing: .1em;
   text-transform: uppercase;
   color: rgba(22, 19, 14, 0.5);
   white-space: nowrap;
   opacity: 0;
   transform: translateX(-6px);
   transition: opacity .2s ease, transform .4s cubic-bezier(.16, 1, .3, 1);
}

#cp-card.on .cp-me-label {
   opacity: 1;
   transform: none;
   transition-delay: .2s;
}

.cp-body {
   position: absolute;
   left: 16px;
   right: 16px;
   top: 5px;
   opacity: 0;
   transform: translateY(4px);
   transition: opacity .2s ease, transform .35s cubic-bezier(.16, 1, .3, 1);
   pointer-events: none;
}

#cp-card.on .cp-body {
   opacity: 1;
   transform: none;
   pointer-events: auto;
   transition-delay: .18s;
}

/* phone: see the constant-layout rules in the mobile pass */
.cp-kicker {
   font-family: var(--ui-med);
   font-size: 8.5px;
   line-height: 1.45;
   margin: 0;
   padding: 0;
   letter-spacing: .08em;
   text-transform: uppercase;
   color: rgba(22, 19, 14, 0.45);
   white-space: normal;
}

.cp-fullname {
   font-family: var(--ui-semi);
   font-size: 15.5px;
   font-weight: 500;
   color: #16130E;
   margin: 8px 0 9px;
   letter-spacing: -.01em;
   white-space: nowrap;
}

.cp-row {
   display: flex;
   align-items: center;
   justify-content: space-between;
   gap: 18px;
   padding: 8px 0;
   text-decoration: none;
   border-top: 1px solid rgba(22, 19, 14, 0.08);
   white-space: nowrap;
}

.cp-row:first-of-type {
   border-top: none;
}

.cp-row span {
   font-family: var(--ui-med);
   font-size: 11.5px;
   letter-spacing: .01em;
   color: rgba(22, 19, 14, 0.68);
   transition: color .25s ease;
}

.cp-row i {
   font-style: normal;
   font-size: 11px;
   color: rgba(22, 19, 14, 0.35);
   transition: color .25s ease, transform .3s cubic-bezier(.16, 1, .3, 1);
}

.cp-row:hover span,
.cp-row:hover i {
   color: var(--accent);
}

.cp-row:hover i {
   transform: translate(2px, -2px);
}

@media (max-width: 640px) {

   /* Bottom-left, where it was -- just clear of the content. */
   /* Left edge sits on 4vw -- the same line as the stage and the rail --
           so the opened box lines up with the main content box. */
   /* position and size now come from the shared rules below */
   /* Width spans exactly the first three timeline blocks and the two
           gaps between them: 3*(92vw - 25px)/6 + 10px = 46vw - 2.5px */
   #cp-card.on {
      width: calc(46vw - 2.5px);
      height: 200px;
   }

   /* superseded by the shared credit rules in the dashboard pass */
   /* gap halved: 7px -> 4px, so the ring is 44 + 8 = 52 */
   /* position and size now come from the shared rules below */
   /* shared rules below place the photo */
   /* height matches the 44px avatar so the label centres on it */
   .cp-me-label {
      left: 68px;
      bottom: 12px;
      height: 44px;
   }

   /* more air at the top and along the right edge */
   .cp-body {
      top: 9px;
      left: 16px;
      right: 19px;
   }
}

/* Position only -- the look comes from the shared .storybtn rule. */
.intro-skip {
   position: absolute;
   top: 26px;
   right: 30px;
   z-index: 2;
}

.intro-stage {
   position: relative;
   z-index: 1;
   width: min(78vw, 1180px);
   height: 66vh;
   border-radius: 6px;
   background: #F5F3EE;
   overflow: hidden;
}

.intro-scene {
   position: absolute;
   inset: 0;
   display: flex;
   flex-direction: column;
   align-items: center;
   justify-content: center;
   text-align: center;
   padding: 0 9vw;
   opacity: 0;
   transition: opacity .4s ease, transform .4s ease, filter .4s ease;
   pointer-events: none;
}

.intro-scene.on {
   opacity: 1;
   pointer-events: auto;
}

/* Exit gestures, one per effect family. */
.intro-scene.fx0:not(.on) {
   transform: translateY(-16px);
   filter: blur(7px);
}

.intro-scene.fx1:not(.on) {
   transform: translateX(-30px);
   filter: blur(7px);
}

.intro-scene.fx2:not(.on) {
   transform: scale(.965);
   filter: blur(7px);
}

/* Children start hidden and are choreographed in when the scene lands. */
.intro-scene .intro-kick,
.intro-scene .intro-head,
.intro-scene .intro-body,
.intro-scene .intro-fig,
.intro-scene .intro-enter {
   opacity: 0;
}

@keyframes fxRise {
   /* `to` omits transform on purpose: it interpolates to the base value
      (none) identically, but a retained to{transform} under fill:both would
      override hover transforms forever -- the buttons could never zoom. */
   from {
      opacity: 0;
      transform: translateY(26px);
      filter: blur(12px);
   }

   to {
      opacity: 1;
      filter: blur(0);
   }
}

@keyframes fxTrack {
   from {
      opacity: 0;
      letter-spacing: .5em;
   }

   to {
      opacity: 1;
   }
}

@keyframes fxSlideL {
   from {
      opacity: 0;
      transform: translateX(-44px);
      filter: blur(8px);
   }

   to {
      opacity: 1;
      transform: none;
      filter: blur(0);
   }
}

@keyframes fxSlideR {
   from {
      opacity: 0;
      transform: translateX(44px);
      filter: blur(8px);
   }

   to {
      opacity: 1;
      transform: none;
      filter: blur(0);
   }
}

@keyframes fxZoom {
   /* `to` omits transform on purpose: it interpolates to the base value
      (none) identically, but a retained to{transform} under fill:both would
      override hover transforms forever -- the buttons could never zoom. */
   from {
      opacity: 0;
      transform: scale(.9);
      filter: blur(10px);
   }

   to {
      opacity: 1;
      filter: blur(0);
   }
}

/* fx0: everything rises, kicker tracks open */
.intro-scene.fx0.on .intro-kick {
   animation: fxTrack .8s cubic-bezier(.16, 1, .3, 1) both;
}

.intro-scene.fx0.on .intro-head {
   animation: fxRise .75s cubic-bezier(.16, 1, .3, 1) .1s both;
}

.intro-scene.fx0.on .intro-body {
   animation: fxRise .75s cubic-bezier(.16, 1, .3, 1) .24s both;
}

.intro-scene.fx0.on .intro-fig,
.intro-scene.fx0.on .intro-enter {
   animation: fxRise .8s cubic-bezier(.16, 1, .3, 1) .38s both;
}

/* fx1: headline slides from the left, body from the right */
.intro-scene.fx1.on .intro-kick {
   animation: fxRise .6s cubic-bezier(.16, 1, .3, 1) both;
}

.intro-scene.fx1.on .intro-head {
   animation: fxSlideL .8s cubic-bezier(.16, 1, .3, 1) .08s both;
}

.intro-scene.fx1.on .intro-body {
   animation: fxSlideR .8s cubic-bezier(.16, 1, .3, 1) .22s both;
}

.intro-scene.fx1.on .intro-fig,
.intro-scene.fx1.on .intro-enter {
   animation: fxRise .8s cubic-bezier(.16, 1, .3, 1) .4s both;
}

/* fx2: the whole scene resolves out of a zoomed blur */
.intro-scene.fx2.on .intro-kick {
   animation: fxTrack .7s cubic-bezier(.16, 1, .3, 1) both;
}

.intro-scene.fx2.on .intro-head {
   animation: fxZoom .8s cubic-bezier(.16, 1, .3, 1) .06s both;
}

.intro-scene.fx2.on .intro-body {
   animation: fxZoom .8s cubic-bezier(.16, 1, .3, 1) .2s both;
}

.intro-scene.fx2.on .intro-fig,
.intro-scene.fx2.on .intro-enter {
   animation: fxZoom .85s cubic-bezier(.16, 1, .3, 1) .34s both;
}

.intro-kick {
   font-family: 'JetBrains Mono', monospace;
   font-size: 10.5px;
   letter-spacing: .22em;
   text-transform: uppercase;
   color: #8C8377;
}

.intro-scene.accent .intro-kick {
   color: #B0442F;
}

/* Desktop reads the date kickers from further away: a step up in size,
   same voice. The phone keeps 10.5. */
@media (min-width: 641px) {
   .intro-kick {
      font-size: 12.5px;
   }
}

.intro-head {
   font-family: var(--display);
   font-weight: 400;
   font-size: clamp(26px, 3.1vw, 44px);
   line-height: 1.04;
   letter-spacing: 0;
   color: #16130E;
   margin: 14px 0 12px;
}

.intro-body {
   font-family: var(--ui);
   font-size: 15.5px;
   line-height: 1.75;
   color: #5D564D;
   max-width: 62ch;
}

.intro-fig {
   margin: 22px 0 0;
   display: flex;
   flex-direction: column;
   align-items: center;
   gap: 8px;
}

.intro-fig img {
   max-height: 26vh;
   max-width: 50vw;
   border-radius: 4px;
   box-shadow: 0 14px 34px rgba(28, 20, 14, .16);
}

.intro-plate {
   display: none;
   width: 200px;
   height: 15vh;
   min-height: 96px;
   border-radius: 4px;
   place-items: center;
   font-family: var(--display);
   font-size: 34px;
   color: #FFFFFF;
}

.intro-cap {
   font-family: 'JetBrains Mono', monospace;
   font-size: 9px;
   letter-spacing: .13em;
   text-transform: uppercase;
   color: #A79C8E;
}

.intro-enter {
   margin-top: 26px;
   font-family: var(--ui-semi);
   font-size: 13.5px;
   letter-spacing: .01em;
   background: var(--accent);
   color: #fff;
   border: none;
   border-radius: 999px;
   padding: 14px 28px;
   cursor: pointer;
   transition: filter .25s ease, transform .3s cubic-bezier(.16, 1, .3, 1);
}

.intro-enter:hover {
   filter: brightness(1.12);
   transform: translateY(-1px);
}

.intro-rail {
   width: min(78vw, 1180px);
   margin-top: 18px;
   position: relative;
   z-index: 1;
}

.intro-blocks {
   display: flex;
   gap: 8px;
}

/* border-box so the 2022 block's 3px border eats into its own height
         instead of making it taller than its neighbours. */
.intro-block {
   flex: 1;
   height: 64px;
   box-sizing: border-box;
   border: 3px solid transparent;
   border-radius: 10px;
   background: #F1EFE9;
   display: grid;
   place-items: center;
   cursor: pointer;
   font-family: var(--ui-med);
   font-size: 14.5px;
   font-weight: 600;
   color: #3A352E;
   transition: background .3s ease;
   user-select: none;
}

.intro-block.live {
   background: #E4E0D4;
}

.intro-block.split {
   background: transparent;
   border: 3px solid #FF3126;
}

.intro-block.split.live {
   background: rgba(255, 49, 38, 0.08);
}

.intro-head-marker {
   position: absolute;
   top: -9px;
   bottom: 0;
   width: 0;
   pointer-events: none;
   z-index: 2;
}

.intro-head-marker::before {
   content: '';
   position: absolute;
   top: 0;
   left: -6px;
   border-left: 6px solid transparent;
   border-right: 6px solid transparent;
   border-top: 8px solid #16130E;
}

.intro-head-marker::after {
   content: '';
   position: absolute;
   top: 7px;
   bottom: 0;
   left: -0.75px;
   width: 1.5px;
   background: #16130E;
}

.intro-ticks {
   display: flex;
   justify-content: space-between;
   margin-top: 7px;
   padding: 0 2px;
}

.intro-ticks i {
   width: 1px;
   height: 6px;
   background: #C6BCAE;
}

/* ---- the phone pass: intro ---- */
@media (max-width: 640px) {
   #intro {
      justify-content: center;
      padding-top: 0;
   }

   .intro-stage {
      width: 92vw;
      height: 64vh;
   }

   .intro-rail {
      width: 92vw;
      margin-top: 14px;
   }

   .intro-scene {
      padding: 0 7vw;
   }

   .intro-head {
      font-size: clamp(30px, 9vw, 40px);
   }

   .intro-body {
      font-size: 12.5px;
      line-height: 1.6;
   }

   .intro-blocks {
      gap: 5px;
   }

   .intro-block {
      height: 48px;
      border-radius: 8px;
      font-size: 11.5px;
      white-space: nowrap;
   }

   .intro-skip {
      top: 14px;
      bottom: auto;
      right: 12px;
   }

   .intro-plate {
      width: 58vw;
      min-height: 84px;
      font-size: 32px;
   }

   .intro-fig img {
      max-width: 80vw;
      max-height: 22vh;
   }

   .intro-enter {
      padding: 13px 22px;
   }
}

/* ---- the phone pass: dashboard ---- */
@media (max-width: 640px) {

   /* The whole dashboard is sized to the VISIBLE viewport, not the
           layout one. On iOS Safari `position: fixed; inset: 0` is the tall
           viewport -- the one with the toolbars hidden -- so everything laid
           out inside it was measured against a screen a hundred-odd pixels
           taller than the one in front of you: the field slid up under the
           column, the column's percentages landed short, and the sheet opened
           below the toolbar. Pinning the app to --vvh makes every percentage
           inside it mean what it says. */
   .app {
      height: var(--vvh, 100vh) !important;
      bottom: auto !important;
   }

   /* Two rows, not four. Row one is identity and the two replays plus
           the period switch; row two is the search with the metric picker
           beside it. Everything shrinks to make that fit: the replays drop
           to their marks, the toggle says Pre / Post rather than spelling
           out Dobbs twice, and the tier keeps only its value. */
   .topbar {
      display: flex;
      flex-wrap: wrap;
      align-items: center;
      height: auto;
      /* 4, not 8: `.bar-break` is a zero-height 100%-wide flex item, so the
         row gap is charged twice between the two visible rows -- 8 rendered
         as 16. Four gives the 8px the design asked for and hands the 8px
         back to the page, which is at capacity on a phone. */
      row-gap: 4px;
      column-gap: 6px;
      /* 20, not 12: the period toggle has to occupy the skip pill's own box
         for the handoff to be a handoff -- same line, same height, same
         right edge. The 8px this costs is the 8px the row-gap fix above
         gave back. */
      top: 20px;
      left: 12px;
      right: 12px;
   }

   /* The credit mark rides at the end of the SECOND row, level with the
           tier picker, off the field entirely. Everything about it is
           re-pinned: the ring is a separate fixed layer and the photo inside
           the card is absolutely placed, so leaving either on its bottom-left
           geometry cropped the circle against the corner. */
   /* ONE credit mark, two homes. Everything about it -- the 40px
           circle, the photo inside it, what it becomes when it opens, how the
           copy sits -- is identical on both pages. Only where it is anchored
           differs: the story page keeps it in the bottom-left corner it has
           always had, the dashboard carries it in the second row of the bar.
           It used to be two different components by accident, one twice the
           size of the other. */
   #cp-card,
   #cp-ring {
      width: 40px !important;
      height: 40px !important;
      border-radius: 50% !important;
   }

   /* The ring keeps breathing here too. It was switched off when the mark
           and its ring were unified to one 40px box on the phone -- but the
           breath is the only thing that says the mark is a button rather than
           a decoration, and the photo inside it has gone on breathing the
           whole time, so the ring standing still read as the animation being
           broken rather than absent. `#cp-ring.on` still stops it: open, the
           ring is the card's outline and an outline must not pulse. */

   /* Story page: bottom-anchored in BOTH states. Switching between a
           top offset and a bottom offset is not a transition at all -- the
           browser has nothing to interpolate, so the photo jumped to its new
           corner in a single frame. One anchor, and it glides. */
   #cp-card .cp-avwrap {
      left: 5px !important;
      bottom: 5px !important;
      right: auto !important;
      top: auto !important;
      width: 30px !important;
      height: 30px !important;
   }

   #cp-card .cp-me-label {
      display: none !important;
   }

   /* Open: the same box, the same copy, the same photo, wherever it lives. */
   #cp-card.on,
   #cp-ring.on {
      height: 157px !important;
      border-radius: 16px !important;
   }

   /* Frosted, like every other floating surface here. A flat white fill
           was never the material -- it turned the one hand-made object on the
           page into a dialog box. */
   #cp-card.on {
      background: rgba(255, 255, 255, 0.62) !important;
      -webkit-backdrop-filter: blur(30px) saturate(180%);
      backdrop-filter: blur(30px) saturate(180%);
   }

   /* Story page: the copy is pushed down until the last link's baseline
           box finishes level with the bottom of the photo it sits beside. */
   /* The wrapper never fades. Its base rule is opacity 0 with the
           transition switched off, so the moment the card lost `.on` the whole
           block of copy disappeared in a single frame -- which is why no exit
           cascade was ever visible and why the box looked empty the instant
           you tapped away. The wrapper is always on; the LINES inside it are
           what arrive and leave, one at a time. */
   #cp-card .cp-body {
      opacity: 1 !important;
      transform: none !important;
      transition: none !important;
   }

   /* Layout is CONSTANT. When these paddings hung off `.on`, dropping
           the class re-flowed every line in the same frame -- the copy jumped
           left onto the photo and only then began to fade, which is the mess.
           The box changes size; the words never move. */
   /* ...and the copy is laid out against the OPEN width, always. The
           card narrows around it and clips it; without a fixed width the
           credit line re-wrapped from one line to three as the box shrank and
           dragged everything under it up the card. That reflow is what looked
           like the text jumping onto the photo. */
   /* Anchored to the card's BOTTOM edge, which is the one that does not
           move. The story card grows upward, so its top travels 128px as it
           opens and closes -- copy laid out from the top rode that whole
           distance, which is the text sliding down the screen on close. From
           the bottom it simply stays where it is while the box shrinks past
           it. (The dashboard card grows downward from a fixed top, so the
           same anchor works there for the same reason.) */
   #cp-card .cp-body {
      position: absolute;
      left: 0;
      right: 0;
      top: auto !important;
      bottom: 0;
      padding: 17px 15px 19px 15px;
      pointer-events: none;
      width: calc(100vw - 24px);
      box-sizing: border-box;
   }

   #cp-card.on .cp-body {
      pointer-events: auto;
   }

   /* Dashboard: the photo is at the top, so the credit line starts level
           with the top of it instead. */
   /* Dashboard: anchored to the top, which is its fixed edge. */
   /* padding-top puts the credit line's own text top level with the top
           of the photo -- not with the top of the card. */
   body:has(#intro.is-gone) #cp-card .cp-body {
      top: 0 !important;
      bottom: auto !important;
      padding: 32px 15px 0 15px;
   }

   #cp-card .cp-kicker {
      font-size: 8px;
      line-height: 1.5;
   }

   #cp-card .cp-fullname {
      margin-top: 3px;
   }

   /* Story page: the photo sits in the box's bottom-left, under the copy,
           because that is the corner the closed mark occupies -- so opening
           reads as the box growing out of the photo rather than the photo
           travelling to another corner. The links, being the bottom rows,
           are the ones that step aside for it. */
   #cp-card.on .cp-avwrap {
      left: 20px !important;
      bottom: 32px !important;
      top: auto !important;
      width: 42px !important;
      height: 42px !important;
   }

   #cp-card .cp-avatar {
      transition: transform .62s cubic-bezier(.16, 1, .3, 1) .06s;
   }

   #cp-card.on .cp-avatar {
      transform: scale(1.12);
   }

   /* One left edge for everything inside the card -- the credit line,
           the name and both links start on the same rule. They were in two
           different columns, which read as two different cards. */
   #cp-card .cp-kicker,
   #cp-card .cp-fullname,
   #cp-card .cp-row {
      padding-left: 78px;
   }

   #cp-card .cp-row {
      max-width: 262px;
      box-sizing: border-box;
      gap: 10px;
      justify-content: flex-start;
      position: relative;
   }

   #cp-card .cp-row i {
      margin-left: auto;
   }

   /* The rule belongs to the text, not to the box: as a border it ran the
           row's whole width, out past the photo to the card's left edge. */
   #cp-card .cp-row {
      border-top: none !important;
   }

   #cp-card .cp-row+.cp-row::before {
      content: '';
      position: absolute;
      left: 78px;
      right: 0;
      top: 0;
      height: 1px;
      background: rgba(22, 19, 14, 0.10);
   }

   /* The copy arrives a line at a time, the way the stamps do, and
           leaves the same way in reverse -- the box opening on a block of
           text that was simply already there is the one thing in this card
           that never felt made. */
   #cp-card .cp-kicker,
   #cp-card .cp-fullname,
   #cp-card .cp-row {
      opacity: 0;
      transform: translateY(6px);
      transition: opacity .28s ease, transform .38s var(--ease);
   }

   #cp-card.on .cp-kicker {
      opacity: 1;
      transform: none;
      transition-delay: .14s, .14s;
   }

   #cp-card.on .cp-fullname {
      opacity: 1;
      transform: none;
      transition-delay: .21s, .21s;
   }

   #cp-card.on .cp-row:nth-of-type(1) {
      opacity: 1;
      transform: none;
      transition-delay: .28s, .28s;
   }

   #cp-card.on .cp-row:nth-of-type(2) {
      opacity: 1;
      transform: none;
      transition-delay: .35s, .35s;
   }

   /* Closing runs in two acts, and nothing overlaps.

           ACT ONE, 0 to .45s: the lines leave one at a time, last first. The
           card does not move and the page behind it stays fully blurred, so
           the copy is always leaving a full card standing on a quiet page --
           never a pane of glass with the page showing through it.

           ACT TWO, .45s on: with the card empty, the box minimises, and the
           page's blur clears WITH it rather than before it. */
   body.credit-closing #cp-card .cp-kicker,
   body.credit-closing #cp-card .cp-fullname,
   body.credit-closing #cp-card .cp-row {
      transition: opacity .30s cubic-bezier(.4, 0, .7, 1),
         transform .34s cubic-bezier(.4, 0, .7, 1) !important;
   }

   body.credit-closing #cp-card .cp-row:nth-of-type(2) {
      transition-delay: 0s, 0s !important;
   }

   body.credit-closing #cp-card .cp-row:nth-of-type(1) {
      transition-delay: .05s, .05s !important;
   }

   body.credit-closing #cp-card .cp-fullname {
      transition-delay: .10s, .10s !important;
   }

   body.credit-closing #cp-card .cp-kicker {
      transition-delay: .15s, .15s !important;
   }

   /* the card's own glass never wavers while it is still a card */
   body.credit-closing #cp-card {
      background: rgba(255, 255, 255, 0.62) !important;
      -webkit-backdrop-filter: blur(30px) saturate(180%) !important;
      backdrop-filter: blur(30px) saturate(180%) !important;
   }

   /* The page behind it goes quiet and out of focus -- the card is the
           only thing in front of you while it is open. */
   #cp-scrim {
      position: fixed;
      inset: 0;
      z-index: 4050;
      background: rgba(22, 19, 14, 0.20);
      opacity: 0;
      pointer-events: none;
      transition: opacity .4s ease;
      -webkit-backdrop-filter: blur(14px) saturate(120%);
      backdrop-filter: blur(14px) saturate(120%);
   }

   body.credit-open #cp-scrim {
      opacity: 1;
      pointer-events: auto;
   }

   /* Dashboard: the photo is in the top-left, so the copy above the links
           is what steps aside instead. */
   /* Dashboard: top-anchored in both states, for the same reason. */
   body:has(#intro.is-gone) #cp-card .cp-avwrap {
      left: 5px !important;
      top: 5px !important;
      bottom: auto !important;
      right: auto !important;
   }

   /* TOP-left, not bottom-left. This card is pinned by its top-left corner
           and folds back into a mark that sits there, so that is the corner it
           has to grow out of -- put the photo at the other end and the morph
           stops being the box growing out of the picture and becomes the
           picture sliding across a box. The story page's photo is in ITS
           card's fixed corner for exactly the same reason; the size and the
           17px inset are shared, the corner follows the fold. 20 puts the
           picture on 17: it is scaled 1.12 about its centre. */
   body:has(#intro.is-gone) #cp-card.on .cp-avwrap {
      left: 20px !important;
      top: 37px !important;
      bottom: auto !important;
      width: 42px !important;
      height: 42px !important;
   }

   /* same single edge on the dashboard */

   /* Story page: bottom left, below the scrubber, opening upward. It is
           anchored by TOP, measured off the visible viewport, for two reasons:
           `bottom` on a fixed element resolves against the layout viewport,
           which on Safari is taller than the screen and dropped the mark onto
           the timeline; and a box anchored the same way in both places can
           travel between them instead of jumping. */
   #cp-card,
   #cp-ring {
      left: 12px !important;
      right: auto !important;
      bottom: auto !important;
      top: calc(var(--railbot, calc(var(--vvh, 100vh) * 0.865)) + 16px) !important;
   }

   /* Open: the dashboard's box exactly -- same width, same height -- but
           grown upward from the corner it lives in, so its own bottom-left
           stays put. */
   #cp-card.on,
   #cp-ring.on {
      left: 12px !important;
      right: auto !important;
      bottom: auto !important;
      top: calc(var(--railbot, calc(var(--vvh, 100vh) * 0.865)) + 16px - 128px) !important;
      /* the page's own gutter on both sides, like everything
                      else in the layout */
      width: calc(100vw - 24px) !important;
      max-width: none;
   }

   /* Dashboard: the FIRST row of the bar now, at its left end where the
           wordmark used to be, opening downward and to the right. */
   body:has(#intro.is-gone) #cp-card,
   body:has(#intro.is-gone) #cp-ring {
      top: 20px !important;
      bottom: auto !important;
      left: 12px !important;
      right: auto !important;
   }

   body:has(#intro.is-gone) #cp-card.on,
   body:has(#intro.is-gone) #cp-ring.on {
      top: 20px !important;
      bottom: auto !important;
      left: 12px !important;
      right: auto !important;
      width: calc(100vw - 24px) !important;
      max-width: none;
   }

   /* One element, two homes, so it TRAVELS between them -- and every
           property travels on the SAME clock. `top` was running .26s longer
           than the width and the radius, so the box's corners arrived at
           different moments and the morph read as a stutter, worst on the way
           back in. One duration, one curve, the desktop's own. */
   /* The handover from the story to the dashboard carries the mark the
           length of the screen, so it gets its own, longer and evenly eased
           curve. The open/close morph keeps the desktop's quicker one --
           neither has to compromise for the other. */
   /* Closing: the copy leaves first and the box follows it in, the same
           order as the policy stamps and the room they sit in. Without the
           wait, the box was already shrinking while the words were still on
           screen, which is the one thing that made it feel like a panel being
           dismissed rather than an object folding up. */
   /* Closing, everything travels together on ONE curve. The last version
           had the box on .82s while its glass dropped out in .3s, the photo
           moved on .46s and the backdrop cleared instantly -- so the fill
           vanished, the picture arrived early and got clipped by a shrinking
           box, and what was left was a bare green ring closing on nothing.
           Same duration, same easing, for the box, its outline, the photo and
           the backdrop; the glass holds until the very end. */
   body.credit-closing #cp-card,
   body.credit-closing #cp-ring {
      transition: width .58s cubic-bezier(.45, 0, .2, 1) .45s,
         height .58s cubic-bezier(.45, 0, .2, 1) .45s,
         border-radius .58s cubic-bezier(.45, 0, .2, 1) .45s,
         top .58s cubic-bezier(.45, 0, .2, 1) .45s,
         left .58s cubic-bezier(.45, 0, .2, 1) .45s,
         background 0s !important;
   }

   /* The photo holds its open size through act one -- the copy is still
           leaving -- and comes back to default with the box in act two. */
   body.credit-closing #cp-card .cp-avatar {
      transition: transform .58s cubic-bezier(.45, 0, .2, 1) .45s;
   }

   /* And the ring does NOT go back to breathing until the card has finished
           folding. `#cp-ring.on` suspends the breath while the ring is the
           card's outline; the instant `.on` drops, the animation restarts from
           its own first frame -- scale(1) climbing to scale(1.11) -- on a box
           that is still 366px wide. The outline balloons 23px past the card's
           corners and only then closes, which is the gap. It stays still until
           it is a mark again. */
   body.credit-closing #cp-ring {
      animation: none !important;
   }

   body.credit-closing #cp-card .cp-avwrap {
      transition: left .58s cubic-bezier(.45, 0, .2, 1) .45s,
         top .58s cubic-bezier(.45, 0, .2, 1) .45s,
         bottom .58s cubic-bezier(.45, 0, .2, 1) .45s,
         width .58s cubic-bezier(.45, 0, .2, 1) .45s,
         height .58s cubic-bezier(.45, 0, .2, 1) .45s !important;
   }

   /* The page's blur holds through act one and clears through act two,
           so it is never gone while there is still copy on the card. */
   body.credit-closing #cp-scrim {
      transition: opacity .58s ease .45s !important;
   }

   /* (The credit-travel suppression lived here. It killed the top/left
      tween during page switches, from the era when the story avatar sat a
      full screen from the dashboard's and the travel was a swim. The two
      rests are 8px apart now and the corner handoff WANTS the drift, so the
      mark rides its own base spring below across the switch.) */

   #cp-card,
   #cp-ring {
      transition: top .46s cubic-bezier(.16, 1, .3, 1),
         left .46s cubic-bezier(.16, 1, .3, 1),
         width .46s cubic-bezier(.16, 1, .3, 1),
         height .46s cubic-bezier(.16, 1, .3, 1),
         background .3s ease,
         border-radius .46s cubic-bezier(.16, 1, .3, 1);
   }

   /* the mark is on row one now, so row two starts at the bar's edge */
   .searchwrap {
      margin-left: 0 !important;
   }

   /* ...and the field has its bottom-left corner back */
   .histaxis-end--lo {
      transform: none;
   }

   /* Row one: the mark, the two replays and the period switch. Row two:
           the search and the metric picker. The wordmark gives up the width
           the switch needs -- it can ellipsize, the switch cannot. */
   .bar-break {
      display: block;
      flex: 0 0 100%;
      height: 0;
      margin: 0;
      order: 1;
   }

   /* The wordmark gives up the phone bar entirely. Four controls and a
           name will not sit in two rows without the search box paying for it,
           and the name is the one thing on the bar that is not a control --
           the page says what it is in its own headline. */
   .brandmark {
      display: none;
   }

   /* a size down so the whole wordmark fits beside the switch */
   .brandmark .kicker {
      font-size: 8px;
      letter-spacing: .09em;
   }

   .brandmark .kicker {
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
   }

   /* Row one, left to right: the mark, Story, Tour, the period switch.
           The mark is fixed-position and so is not in this flow at all -- the
           first control simply starts clear of it. 46 = its 40px box and a 6px
           gap, measured from the bar's own left edge, which the mark shares. */
   #intro-replay {
      flex: 0 0 auto;
      order: -2;
      margin-left: 46px;
   }

   #tour-btn {
      flex: 0 0 auto;
      order: -1;
   }

   /* Last, and it takes the row's slack. Story and Tour are glyphs and look
           wrong stretched -- a 48px pill around a single mark is right, an
           85px one is a pill with a mark lost in it -- so the space goes to
           the one control on the row that is content rather than an icon,
           and its two halves get proper thumb-sized targets out of it. */
   .segment {
      flex: 1 1 auto;
      min-width: 0;
      order: 0;
      margin-left: 0;
      /* outline-on-page like every other control in this row (and like the
         skip pill it morphs from) -- the paper fill made the corner handoff
         land as a beige slab. The sliding thumb still carries the sage. */
      background: transparent;
   }

   .segment .segbtn {
      flex: 1 1 0;
      min-width: 0;
   }

   /* Row two, left to right: the tier picker, then the field with the rest
           of the width. */
   .topright {
      flex: 0 0 168px;
      width: 168px;
      order: 2;
   }

   .searchwrap {
      flex: 1 1 auto;
      margin-left: 0;
      min-width: 0;
      order: 3;
   }

   /* the placeholder is shortened by the script at this width */
   /* 16px exactly: iOS zooms the whole page in on any focused field
           smaller than that, which shoved the visualisation off screen every
           time the search was tapped. The placeholder keeps the bar's voice --
           its size is not what triggers the zoom. */
   .searchfield {
      padding: 0 13px;
   }

   .searchfield input {
      font-size: 16px !important;
   }

   .searchfield input::placeholder {
      font-size: 9px;
      letter-spacing: .11em;
   }

   /* the replays keep their mark and drop their word */
   .storybtn {
      padding: 0 11px;
   }

   .storybtn .btn-tx {
      display: none;
   }

   .storybtn .btn-ic {
      font-size: 15px;
   }

   /* Pre-Dobbs / Post-Dobbs, spelled out. The switch used to share the
           row with the wordmark and could only afford three letters; it has
           the row's whole slack now, so the labels say what they mean. */
   .seg-long {
      display: inline;
   }

   .seg-short {
      display: none;
   }

   /* Equal halves, so the sliding thumb -- which is exactly half the
           track -- lands on the label rather than beside it. */
   .segbtn {
      padding: 0 9px;
      flex: 1 1 50%;
      width: 50%;
   }

   .segbtn::after {
      content: attr(data-short);
   }

   button.dash-dropdown.metricbox {
      padding: 0 10px !important;
   }

   #hero-title {
      font-size: 30px;
      line-height: 1.02;
   }

   /* One fixed line, whichever subtitle is showing: the nation's count
           and a state's tier are different shapes, and letting them size
           themselves moved the cards 7px between views. */
   /* The nation's subtitle is a bare span; a state's is wrapped in the
           crossfade's own layers and runs 5px taller. Fixed, so the cards
           below it land in the same place in both views. */
   #hero-sub {
      height: 20px;
      align-items: center;
      overflow: hidden;
   }

   /* Everything in the column is cut down so the whole of it -- headline,
           subtitle, tiles and the averages panel -- stands above the field
           instead of being clipped half way through, which is what put a cut
           border across the visualisation. */
   .scoretile {
      min-height: 84px;
      padding: 11px 13px;
      border-radius: 14px;
      margin: 0 0 8px 0;
   }

   .scoretile-in {
      gap: 10px;
   }

   .scoretile-num {
      font-size: 32px;
   }

   .scoretile-cap {
      font-size: 8.5px;
      letter-spacing: .11em;
   }

   .scoretile-pill {
      font-size: 8.5px;
   }

   .scorepair {
      gap: 8px;
   }

   .herokpis {
      padding: 10px 12px;
      border-radius: 14px;
   }

   .kpiline {
      padding: 4px 0;
   }

   .kpivals {
      font-size: 12.5px;
   }

   .herokpis .kicker {
      margin-bottom: 6px !important;
   }

   /* The national rows wear the state card's clothes: mono kicker on the
           left, one tabular figure on the right at the same size and weight,
           a hairline between. They were two different designs in one sheet. */
   /* Both classes, so this outranks `#herokpis .kpiline` on specificity
           rather than on source order -- the two are otherwise identical at
           (1,1,0) and the flex rule below would keep winning. */
   #herokpis .kpiline.kpiline--strip {
      display: block;
   }

   #herokpis .kpiline {
      display: flex;
      align-items: baseline;
      gap: 10px;
      padding: 7px 0;
   }

   #herokpis .kpiline:last-child {
      border-bottom: none;
   }

   #herokpis .kpiline>.kicker {
      flex: 1;
   }

   #herokpis .kpivals {
      gap: 7px;
      font-size: 15px;
   }

   #herokpis .kpivals .num {
      font-size: 15px;
      font-weight: 600;
   }

   #herokpis .kpivals .kicker {
      font-size: 8px;
   }

   /* The rule and the row below it both drew a line. */
   /* the head already says what this is */
   /* Swatches left, the tick's meaning right -- the sheet's right edge
           keeps a 6px scroll gutter against 18px of padding on the left, so a
           little of that comes back or the right-hand label sits closer to the
           edge than the left-hand one does. */
   #herokpis .kpikey {
      padding: 13px 0 2px;
      margin-right: -8px;
   }

   #herokpis .sheet-body>.kicker {
      display: none;
   }

   /* The way into the full profile. It lives on the tile because the
           tile is what a tap on the field produces -- the sheet no longer
           barges in over the movement line, it waits to be asked. */
   /* A control of its own, under the cards and the full width of them,
           in the bar's voice. The same button in both views, so switching
           between a state and the nation moves nothing. */
   .profile-cta {
      display: flex;
      align-items: center;
      justify-content: center;
      gap: 8px;
      width: 100%;
      height: 42px;
      flex: 0 0 42px;
      margin-top: 10px;
      border-radius: 12px;
      cursor: pointer;
      font-family: var(--mono);
      font-size: 9px;
      letter-spacing: .13em;
      text-transform: uppercase;
      color: #5E574D;
      background: rgba(255, 255, 255, 0.42);
      border: 1px solid rgba(22, 19, 14, 0.22);
      -webkit-backdrop-filter: blur(18px) saturate(160%);
      backdrop-filter: blur(18px) saturate(160%);
      transition: background .22s var(--ease), color .22s var(--ease),
         transform .18s var(--ease);
   }

   .profile-cta::after {
      content: '↑';
      font-size: 11px;
   }

   .profile-cta:active {
      transform: scale(.985);
      color: #16130E;
      background: rgba(255, 255, 255, 0.68);
   }

   /* the wrapped top bar runs three rows deep on a phone */
   /* The wrapped bar's third row ends at 198px; 210 gives it air. */
   /* The column starts clear of the bar rather than tucked under it,
           and its blocks sit closer together: the headline and the line that
           qualifies it belong to each other, so 8px, not 18. */
   .leftcol {
      left: 12px;
      right: 12px;
      width: auto;
      top: 128px;
      bottom: 52%;
      max-height: none;
      transform: none;
      overflow-y: auto;
   }

   /* When the column genuinely has more than it can show -- a short
           phone -- the overflow fades out instead of being sliced, which is
           what put a cut card border across the visualisation. */
   .leftcol.is-scrollable {
      -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 26px),
            transparent 100%);
      mask-image: linear-gradient(to bottom, #000 calc(100% - 26px),
            transparent 100%);
   }

   .leftcol>*+* {
      margin-top: 8px;
   }

   .leftcol>#score-panel {
      margin-top: 14px;
   }

   body.dash-enter .leftcol {
      animation-name: dashIn;
   }

   /* the field docks to the lower half; squares rescale with it */
   /* The columns are not centred in the field's own box -- the axis
           gutter on the left is wider than the margin on the right -- so the
           box is nudged until the outer squares sit the same distance from
           each edge of the screen, which is also where the end labels sit. */
   .dotfield {
      left: 50% !important;
      top: auto !important;
      bottom: 1.5%;
      transform: translateX(calc(-50% - 1.2%)) !important;
      width: 97vw !important;
      height: 46% !important;
   }

   /* the room the averages panel gave up goes to the squares */
   /* Big enough to hit, small enough that the columns still read as
           columns: the pitch is the field's width over fifteen, so every
           pixel of square is a pixel off the gap beside it. */
   .ssq,
   .ghostsq {
      width: 4.9vw !important;
      height: 4.9vw !important;
   }

   /* A filled square is its colour, edge to edge.

           The grey ring around the pale ones was the drop shadow: 8px of blur
           at 14% is a soft square slightly LARGER than the fill and with the
           same radius, so its corners sat outside the colour's corners and
           read as an unfilled edge. At 43px on a desktop that is a shadow; at
           19px it is a halo the width of the square's own corner. Tightened
           to something proportionate, and the two families share one radius so
           a filled square and a hollow one are the same shape. */
   .ssq {
      border-color: transparent !important;
      border-radius: 4px;
      box-shadow: 0 1px 2px rgba(28, 20, 14, 0.10);
   }

   .ghostsq {
      border-radius: 4px;
   }

   .ssq .sdot-abbr {
      font-size: 7.5px;
   }

   .ghost-abbr {
      font-size: 7.5px;
   }

   /* no 300ms wait between a tap and the square answering it */
   .ssq,
   .ghostsq,
   .profile-cta,
   .storybtn,
   .segbtn,
   button.dash-dropdown.metricbox {
      touch-action: manipulation;
   }

   #linktag {
      gap: 8px;
      padding: 8px 10px;
      border-radius: 8px;
   }

   .lt-cell b {
      font-size: 14px;
   }

   .lt-cell i {
      font-size: 6.5px;
      letter-spacing: .12em;
   }

   .lt-arrow {
      font-size: 11px;
   }

   .lt-delta {
      font-size: 8.5px;
      padding: 3px 5px;
   }

   .lt-same {
      font-size: 7.5px;
      letter-spacing: .12em;
   }

   .histaxis-n {
      font-size: 8px;
   }

   /* The credit ring owns the bottom-left corner, so the left-hand end
           label steps out of its way rather than sitting under it. */
   /* Both ends the same distance from the screen's edge: they were
           anchored to different columns of the field, so one sat inset and
           the other flush. */
   .histaxis-end {
      font-size: 8px;
      bottom: 1.8%;
   }

   /* Set from the field's own box, at the same inset the outer columns
           sit at, so M lines up under column zero and E under column
           fourteen. */
   .histaxis-end--lo {
      left: 5.2% !important;
      transform: none !important;
   }

   /* The field's box is nudged 1.2% left to centre the columns, so the
           right-hand label has to give that back twice over to land the same
           distance from the screen edge as its opposite number. */
   .histaxis-end:not(.histaxis-end--lo) {
      left: auto !important;
      right: 2.8% !important;
      transform: none !important;
   }

   .histaxis-end--lo {
      transform: translateX(72px);
   }

   /* ---- the state panel becomes a real bottom sheet ---------------
           The desktop card is anchored inside a column that does not exist
           here, and dropping it on top of the field left both unreadable:
           the sheet covered the squares, the link drew through it, and the
           avatar sat on the button. So on a phone the panel is a sheet with
           two detents -- peek and full -- that you drag between, and the
           dashboard rearranges around it rather than under it. */
   /* Every height here is measured from the visual viewport, published
           as --vvh by the sheet script. iOS Safari's vh is the tall viewport
           with the toolbars hidden, so a sheet placed at 58vh sat below the
           toolbar -- off screen, on a real phone, while looking perfect in
           every emulator. */

   /* The slot keeps its box; the sheet inside it is fixed, so the
           reveal grid has nothing left to animate and its 0fr/opacity:0
           resting state would only hide the sheet. */
   #card-wrap {
      opacity: 1 !important;
      grid-template-rows: 1fr !important;
      margin-top: 0 !important;
   }

   #card-wrap>.reveal-inner {
      overflow: visible;
   }

   .statecard,
   #herokpis {
      /* content-box here meant the 20px of padding and the hairline were
             added ON TOP of the height it was given, so the sheet always
             overhung the screen by exactly that much and the last rows --
             the indicators among them -- fell below the fold. */
      box-sizing: border-box !important;
      position: fixed;
      left: 0 !important;
      right: 0 !important;
      top: 0 !important;
      bottom: auto !important;
      width: auto !important;
      height: 100% !important;
      max-height: none !important;
      z-index: 3005;
      border-radius: 22px 22px 0 0;
      padding: 20px 6px 0 18px !important;
      animation: none !important;
      background: rgba(255, 255, 255, 0.58);
      -webkit-backdrop-filter: blur(44px) saturate(200%);
      backdrop-filter: blur(44px) saturate(200%);
      border: 1px solid rgba(255, 255, 255, 0.7);
      border-bottom: none;
      /* The shadow is not a STATE, it is a POSITION.

             Thrown upward -- 18px offset on a 50px blur -- roughly 50px of it
             lands above the sheet's own top edge. Parked, that edge is only
             16px below the screen, so the haze falls on the visualisation
             whenever the shadow is on and the sheet is not.

             Driving it from a class gave it a clock of its own, and the two
             clocks disagreed at both ends: `sheet-live` goes on before the
             box is even planted, so the shadow arrived a beat BEFORE the
             sheet; and it is held 560ms after the sheet has gone and only
             then faded, which is the shadow sitting over an empty screen and
             snapping off. It is now a function of how much of the sheet is
             actually on the screen, written on the same frame as its
             position, so it can neither lead it nor outstay it. */
      box-shadow: 0 -18px 50px rgba(28, 20, 14, 0);
      /* Parked below the screen until the class opens it. The transform
             is the only thing that moves -- height and layout stay put, so
             the drag never reflows the rows inside. */
      transform: translateY(var(--sheet-y, 120vh));
      /* Height travels with the transform. It is set in pixels so the
             sheet always ends exactly at the bottom of the screen, and a box
             that resized instantly while the transform was still gliding
             looked like the sheet had been cut in half on the way down. */
      /* One clock for all three. The shadow was written per frame from the
             sheet's position, which fixed it leading and outstaying -- but a
             glide sets the destination once, so the shadow arrived at full
             strength while the box was still a fifth of the way up, and went
             out the instant a dismiss began. On the transform's own curve it
             cannot do either. */
      transition: transform .52s cubic-bezier(.22, 1, .28, 1),
         height .52s cubic-bezier(.22, 1, .28, 1),
         box-shadow .52s cubic-bezier(.22, 1, .28, 1);
   }

   /* While a finger is on it the sheet answers the finger, not a curve. */
   .statecard.is-drag,
   #herokpis.is-drag {
      transition: none !important;
   }

   /* matched to the fold, so the sheet closes WITH the room rather than
           arriving after it */
   .statecard.is-folding,
   #herokpis.is-folding {
      transition: transform .95s cubic-bezier(.7, 0, .4, 1),
         height .95s cubic-bezier(.7, 0, .4, 1);
   }

   /* The national panel is not a card inside a column any more. */
   #herokpis {
      border-radius: 22px 22px 0 0 !important;
      padding: 20px 6px 0 18px !important;
      display: flex !important;
      flex-direction: column;
   }

   #kpis-wrap {
      opacity: 1 !important;
      grid-template-rows: 1fr !important;
      margin-top: 0 !important;
   }

   #kpis-wrap>.reveal-inner {
      overflow: visible;
   }

   /* The grabber. Its hit area is the whole head, which is why the head
           carries the top padding rather than the pill itself. */
   .statecard::before,
   #herokpis::before {
      content: '';
      position: absolute;
      top: 9px;
      left: 50%;
      transform: translateX(-50%);
      width: 38px;
      height: 4px;
      border-radius: 2px;
      background: rgba(22, 19, 14, 0.20);
      transition: background .2s ease;
   }

   .statecard.is-drag::before,
   #herokpis.is-drag::before {
      background: rgba(22, 19, 14, 0.34);
   }

   /* Two rows rather than one: the label and the way out on top, the
           state's own name under them. Laid out by area so the name can be
           appended last in the DOM and still read first. */
   .statecard-head,
   .sheet-head {
      touch-action: none;
      cursor: grab;
      display: flex;
      align-items: center;
      justify-content: space-between;
      padding-right: 16px;
      margin-bottom: 12px;
   }

   .statecard.is-drag .statecard-head {
      cursor: grabbing;
   }

   /* The state names itself here: at the full detent the sheet covers
           the headline that would otherwise say which state this is. */
   /* The sheet does not name the state: the headline above it already
           does, and the panel inside it does again. */
   .sheet-state {
      display: none;
   }

   .statecard-body,
   .sheet-body {
      overflow-y: auto;
      overscroll-behavior: contain;
      padding-right: 14px;
      margin-right: 2px;
      -webkit-overflow-scrolling: touch;
      transition: max-height .5s cubic-bezier(.22, 1, .28, 1);
      padding-bottom: calc(14px + env(safe-area-inset-bottom));
   }

   /* The sheet lives inside .leftcol, and .leftcol is a positioned,
           z-indexed box -- a stacking context. The sheet's 3005 is therefore
           only 3005 *within the column*, and the column's own 20 put the
           whole thing under a scrim painted from the body. So the column is
           promoted while a sheet is live, and only then: at rest it stays at
           20, where the top bar's own popups still outrank it. The class
           outlives the close by the length of the exit glide, or the sheet
           would drop behind the bar on its way out. */
   /* Not a promotion -- a dissolution. Giving the column a z-index makes
           it a stacking context, and everything inside it is then judged as
           one block against the rest of the page; the sheet's own 3005 counted
           for nothing outside. Dropping the column's z-index while a sheet is
           live removes the context, so the sheet's number finally means what
           it says and it sits above the field instead of behind it. */
   /* THE stacking fact behind all of this: `position: fixed` makes an
           element a stacking context in Blink, always. `.app` is fixed, so
           everything inside it -- the sheet included -- is confined to .app's
           own place in the page, and .app has no z-index. The credit mark is a
           body child at 3002/3003, so it outranked the whole app no matter
           what number the sheet carried. Raising .app while a sheet is live is
           the only thing that puts the sheet above it, and it puts the mark
           where it belongs: behind the frost, still visible, still itself. */
   /* 3010 cleared the credit mark at 3002/3003 but not the tour card, which
           is also a body child and sits at 4001 -- so a tour still running
           painted straight over the sheet. The sheet is the top layer while it
           is live, full stop: above the mark, above the tour, above anything
           else a body child might introduce later. */
   body.sheet-live .app {
      z-index: 4100;
   }

   body.sheet-live .leftcol {
      z-index: auto;
   }

   /* And no mask. A mask-image makes its element a containing block for
           fixed-position descendants AND clips them to itself -- so the sheet,
           which lives inside this column, was being cut off at the column's
           bottom edge. Everything below that line simply was not painted:
           on a tall screen that was empty space nobody noticed, and on a
           short one it was the whole indicators section. The fade is a nicety
           for the resting column; it has no business while a sheet is up. */
   body.sheet-live .leftcol {
      -webkit-mask-image: none !important;
      mask-image: none !important;
   }

   /* Deep: the sheet is more page than window now. */
   body.sheet-deep .statecard,
   body.sheet-deep #herokpis {
      background: rgba(255, 255, 255, 0.80) !important;
   }

   /* The link is drawn on a field the sheet is standing on, so it goes
           the moment the sheet arrives.

           Keyed to sheet-LIVE, not sheet-open, and the difference matters: the
           app is raised above the page for as long as a sheet is live, which
           puts the link's own layer -- a body-level element -- behind the
           squares. Fading on `open` brought the pill back during the half
           second the app was still raised, so it surfaced underneath the chart
           and then jumped on top of it. Tied to `live`, it returns only once
           the page is flat again, above the squares and below any sheet. */
   body.sheet-live #linklayer,
   body.sheet-live #linktag {
      opacity: 0 !important;
      transition: opacity .3s ease;
   }

   /* Behind the sheet, darkening only as it climbs past peek. */
   #sheet-scrim {
      position: fixed;
      inset: 0;
      z-index: 3004;
      margin: 0 !important;
      background: rgba(22, 19, 14, 0.34);
      opacity: 0;
      pointer-events: none;
      transition: opacity .4s ease;
   }

   #sheet-scrim.is-drag {
      transition: none;
   }

   /* The sheet rides OVER the dashboard. Nothing below it moves: the
           field keeps its place and its size, and the sheet's frost is what
           puts it behind glass. */
   /* the credit ring owns the bottom-left corner the sheet now covers */
   /* animation: none as well -- the ring's breathing keyframes animate
           its own opacity, which outranks a plain declaration, and it went on
           glowing through the sheet. */
   /* It sits in the bar, so a sheet at peek leaves it alone and it stays
           live. Only when the sheet climbs past halfway does it reach the bar
           -- and then the mark stands down, because a fixed element in the
           page's own stacking context wins the hit test over the sheet no
           matter what z-index the column carries, and the tap that should
           have closed the sheet was landing on the photo instead. */
   body.sheet-open #cp-card {
      opacity: 1;
   }

   body.sheet-open #cp-ring {
      opacity: 1;
   }

   /* It goes UNDER the sheet, not away. With the column's stacking
           context dissolved while a sheet is live, the sheet's own 3005 wins
           on the page outright, so the mark simply passes behind it -- still
           there, still itself, just covered. It only stops taking taps, so a
           finger aimed at the sheet cannot land on the photo through it. */
   body.sheet-live #cp-card,
   body.sheet-live #cp-card *,
   body.sheet-live #cp-ring {
      pointer-events: none !important;
   }

   /* Below the sheet, not above it. Promoted over the top it covered the
           sheet's own Close button -- the tap landed on the photo, so Close
           did nothing and the sheet would not let go. */
   /* The ring IS the outline, so it paints OVER the card -- put under
           it, it simply vanished. It still steps back with the card when a
           sheet climbs over the bar. */
   #cp-card {
      pointer-events: auto !important;
      z-index: 3002 !important;
   }

   #cp-ring {
      z-index: 3003 !important;
   }

   /* Open, the card and its ring ride above the scrim they raised -- and the
           scrim now clears the tour card, so a step card that fires while the
           credit box is open is BEHIND the blur, which is where the rest of
           the page is. All three stay under the sheet's 4100, so the mark
           still passes behind a sheet climbing over the bar. */
   body.credit-open #cp-card,
   body.credit-closing #cp-card {
      z-index: 4060 !important;
   }

   body.credit-open #cp-ring,
   body.credit-closing #cp-ring {
      z-index: 4070 !important;
   }
}

/* Short phones: the same layout, a size down, so the column still holds
         the headline, the cards and the button without squeezing any of them.
         Its own block AFTER the phone pass -- never nested inside it. */
@media (max-width: 640px) and (max-height: 720px) {
   #hero-title {
      font-size: 26px;
   }

   .scoretile {
      min-height: 74px;
      padding: 10px 12px;
   }

   .scoretile-num {
      font-size: 28px;
   }

   .scoretile-in {
      gap: 7px;
   }

   .leftcol {
      bottom: 48%;
   }

   .dotfield {
      height: 42% !important;
   }
}

/* The oldest small screens still fit, just tighter. Its own block,
         AFTER the phone pass -- nested inside it, it swallowed everything
         that followed and a 390px phone lost the whole mobile layout. */
@media (max-width: 360px) {
   .topright {
      flex: 0 0 104px;
      width: 104px;
   }

   .segbtn {
      padding: 0 7px;
   }

   .brandmark {
      padding: 0 10px;
   }

   .brandmark .kicker {
      font-size: 8px;
      letter-spacing: .10em;
   }

   .searchwrap {
      flex: 1 1 76px;
   }
}

/* The dashboard rises as one piece when the intro hands over. */
@keyframes dashIn {
   from {
      opacity: 0;
      transform: scale(.988) translateY(10px);
   }

   to {
      opacity: 1;
      transform: none;
   }
}

/* Same entrance for the centred panel: its resting transform is the
         -50% centring translate, so the keyframes ride on top of it. */
@keyframes dashInCol {
   from {
      opacity: 0;
      transform: translateY(-50%) scale(.988) translateY(10px);
   }

   to {
      opacity: 1;
      transform: translateY(-50%);
   }
}

body.dash-enter .topbar {
   animation: dashIn .7s cubic-bezier(.16, 1, .3, 1) both;
}

@media (min-width: 641px) {
   body.dash-enter .leftcol {
      animation: dashInCol .8s cubic-bezier(.16, 1, .3, 1) .08s both;
   }
}

@media (max-width: 640px) {
   body.dash-enter .leftcol {
      animation: dashIn .8s cubic-bezier(.16, 1, .3, 1) .08s both;
   }
}

/* F6, the user's pick: every square starts strewn at its own random
         offset and angle, blurred, and drifts crisply into its slot --
         disorder becoming a chart. The offsets are dealt by the intro
         script each time the dashboard is entered, so no two entrances
         scatter the same way. The container only fades: motion belongs to
         the squares (and an opacity-only container animation is also what
         lets this run on the phone, whose dotfield transform must hold). */
body.dash-enter .dotfield {
   animation: fieldFade .35s ease both;
}

body.dash-enter .ssq {
   animation: fieldSettle .8s var(--ease) both;
   animation-delay: var(--sd, 0.1s);
}

/* ghosts carry the same -50%,-50% centring as the squares; a fadeUp
         ending at transform:none rendered them half a cell off, drifting
         into place -- the entrance smear in the screen recording */
body.dash-enter .ghostsq {
   animation: ghostIn .5s ease .5s both;
}

body.dash-enter .histaxis {
   animation: fadeUp .5s ease .55s both;
}

@keyframes ghostIn {
   from {
      opacity: 0;
      transform: translate(-50%, calc(-50% + 10px));
   }

   to {
      opacity: 1;
      transform: translate(-50%, -50%);
   }
}

@keyframes fieldFade {
   from {
      opacity: 0;
   }

   to {
      opacity: 1;
   }
}

/* The squares' resting transform is the -50%,-50% centring; the
         keyframes must carry it or the whole entrance plays half a square
         off and snaps into place when the animation lets go. */
@keyframes fieldSettle {
   from {
      opacity: 0;
      filter: blur(5px);
      transform: translate(calc(-50% + var(--sx, 0px)),
            calc(-50% + var(--sy, 0px))) rotate(var(--sr, 0deg)) scale(0.85);
   }

   30% {
      opacity: 1;
   }

   to {
      opacity: 1;
      filter: blur(0);
      transform: translate(-50%, -50%);
   }
}

/* ---- Walkthrough: card-led steps (1 and 4) carry the message
         alone; the finger steps (2 and 3) are hard-gated -- they only
         advance when the user actually performs the action. ---- */

#tourcard.wide {
   width: min(600px, 92vw);
}

/* Recolour sweep: when the metric selector repaints the field, each
         square takes one desaturated breath, left to right, instead of the
         colours snapping. The delay is dealt per-square by the script. */
.ssq.recolor {
   animation: metricPulse 0.5s var(--ease) both;
   animation-delay: var(--rd, 0ms);
}

@keyframes metricPulse {
   0% {
      filter: saturate(0.15) brightness(1.12);
      transform: translate(-50%, -50%) scale(0.9);
   }

   100% {
      filter: none;
      transform: translate(-50%, -50%) scale(1);
   }
}

/* step 2's lock: only the square the tour points at answers */
body.tour-lock #dotfield .ssq:not(.tour-free) {
   pointer-events: none;
}

body.tour-lock .ghostsq {
   pointer-events: none !important;
}

/* each card of the tour dissolves in like the rest of the house */
.tour-inner {
   animation: tourCardIn .45s var(--ease) both;
}

@keyframes tourCardIn {
   from {
      opacity: 0;
      transform: translateY(8px);
      filter: blur(6px);
   }

   to {
      opacity: 1;
      transform: none;
      filter: blur(0);
   }
}

/* Its own line above the button row now: the Next button owns the
         right-hand slot on every card, so the invitation cannot sit there. */
.tour-wait {
   display: block;
   font-family: var(--mono);
   font-size: 7.5px;
   letter-spacing: .14em;
   color: var(--accent, #63997A);
   text-transform: uppercase;
   padding: 9px 0 0;
   animation: waitPulse 1.6s ease-in-out infinite;
}

@keyframes waitPulse {

   0%,
   100% {
      opacity: 1;
   }

   50% {
      opacity: .45;
   }
}

#tourcard {
   position: fixed;
   z-index: 4001;
   width: min(400px, 92vw);
   box-sizing: border-box;
   border-radius: 14px;
   padding: 14px 16px;
   background: rgba(255, 255, 255, 0.6);
   border: 1px solid rgba(255, 255, 255, 0.6);
   -webkit-backdrop-filter: blur(18px) saturate(180%);
   backdrop-filter: blur(18px) saturate(180%);
   box-shadow: 0 14px 40px rgba(28, 20, 14, 0.20);
   /* width joins left/top so a step that changes size morphs into
                 the next shape rather than snapping to it. */
   transition: left .6s var(--ease), top .6s var(--ease),
      width .6s var(--ease), opacity .35s ease;
}

#tourcard.out {
   opacity: 0;
   pointer-events: none;
}

.tour-step {
   font-family: var(--mono);
   font-size: 8.5px;
   letter-spacing: .14em;
   color: var(--accent);
   margin-bottom: 6px;
}

.tour-text {
   font-size: 12.5px;
   line-height: 1.5;
   color: #3A352E;
   min-height: 38px;
}

.tour-btns {
   display: flex;
   justify-content: space-between;
   align-items: center;
   margin-top: 10px;
}

.tour-skip {
   font-family: var(--mono);
   font-size: 8px;
   letter-spacing: .12em;
   color: #A79C8E;
   text-transform: uppercase;
   cursor: pointer;
   background: none;
   border: none;
   padding: 4px 0;
}

.tour-skip:hover {
   color: #5E574D;
}

.tour-dots {
   display: flex;
   gap: 5px;
}

.tour-dots i {
   width: 5px;
   height: 5px;
   border-radius: 50%;
   background: rgba(22, 19, 14, 0.15);
   transition: background .3s;
}

.tour-dots i.on {
   background: var(--accent);
}

.tour-next {
   font-family: var(--mono);
   font-size: 8.5px;
   letter-spacing: .12em;
   color: #fff;
   background: var(--accent);
   border: none;
   cursor: pointer;
   border-radius: 999px;
   padding: 7px 15px;
   text-transform: uppercase;
   transition: filter .25s ease;
}

.tour-next:hover {
   filter: brightness(1.08);
}

/* Story's original look is the house style; Skip Intro adopts it. */
.storybtn,
.intro-skip {
   display: inline-flex;
   align-items: center;
   gap: 7px;
   font-family: 'JetBrains Mono', monospace;
   font-size: 9px;
   letter-spacing: .13em;
   text-transform: uppercase;
   color: #7C746A;
   background: transparent;
   border: 1px solid rgba(22, 19, 14, 0.22);
   border-radius: var(--btn-radius);
   height: 40px;
   padding: 0 16px;
   cursor: pointer;
   transition: color .25s ease, border-color .25s ease;
}

.storybtn:hover,
.intro-skip:hover {
   color: #16130E;
   border-color: #C6BCAE;
}

/* The bar's two buttons answer the pointer the way the story chrome does:
   the same .46 spring, the same zoom, the same press. Desktop only -- the
   phone's bar has no pointer to answer. */
@media (min-width: 641px) {
   .storybtn {
      will-change: transform;
      transition: color .25s ease, border-color .25s ease,
         transform .46s cubic-bezier(.16, 1, .3, 1);
   }

   .storybtn:hover {
      transform: scale(1.08);
   }

   .storybtn:active {
      transform: scale(.95);
      transition-duration: .25s, .25s, .16s;
   }

   /* Show / Hide Policy Indicators speaks the same body language as the
      bar's buttons and the skip pill -- it is the card's one control and it
      was the only piece of chrome that did not answer the pointer. A gentler
      zoom than the pills': it is a full-width bar, and 1.08 on 366px reads
      as a lurch. */
   .expandbtn {
      will-change: transform;
      transition: color .25s ease, border-color .25s ease,
         background .25s ease, transform .46s cubic-bezier(.16, 1, .3, 1);
   }

   /* The SAME zoom as every other button on both pages -- one body language
      for the whole product. It is full-width inside a card that clips, so
      the card's side padding was widened to carry the 4% of overhang the
      zoom needs at the panel's width (28 left, 12 right + the body's own
      16px scrollbar gutter = 28 on both sides of the button). */
   .expandbtn:hover {
      transform: scale(1.08);
      border-color: #C6BCAE !important;
      color: #16130E !important;
   }

   .expandbtn:active {
      transform: scale(.95);
      transition-duration: .25s, .25s, .25s, .16s;
   }

   #state-modal.statecard {
      padding-left: 28px;
      padding-right: 12px;
   }
}

/* The glyph carries its own weight: the label stays in the bar's 9px
         mono voice while the mark is drawn bold, which is what makes ↩ and ✦
         read as marks rather than as two more characters of text. Tracking is
         reset -- the .13em that spaces the label would push the glyph off its
         own centre. */
/* inline-flex with a fixed box: as inline text the glyphs sat on their
         own baselines, which put ↩ and ✦ at two different heights and both of
         them低 of the label's centre. */
.btn-ic {
   font-weight: 700;
   font-size: 13px;
   letter-spacing: 0;
   line-height: 1;
   display: inline-flex;
   align-items: center;
   justify-content: center;
   width: 14px;
   height: 14px;
}
/* ==========================================================================
   THE CREDIT CARD ON DESKTOP — the same object as the phone's.

   It had drifted into a different design entirely: a 226x216 portrait, flat
   white, copy stacked at the top with the photo and a THAT'S ME caption
   underneath, and 97px of dead space below the last link. The phone's card is
   a landscape byline -- frosted, photo tucked into the bottom-left corner,
   copy beside it, every visible edge on one inset. This restates that
   composition at desktop scale so the two pages read as one object.

   Restated rather than shared because the phone's block also carries the
   bottom sheet, the viewport-height plumbing and the two-act close, none of
   which belongs out here.
   ========================================================================== */
@media (min-width: 641px) {

   /* Open on the STORY, the outline stands down entirely: the dark glass
      box needs no stroke. On the dashboard the card is white over a white
      page -- without the ring nothing says where the card ends, so there it
      keeps its outline (scoping lesson of 2026-08-26). The fade rides its
      own channel; the geometry lists keep their own clocks, so the
      invisible ring still travels and is home for the close. */
   #cp-ring {
      transition: left .46s cubic-bezier(.16, 1, .3, 1),
         bottom .46s cubic-bezier(.16, 1, .3, 1),
         width .46s cubic-bezier(.16, 1, .3, 1),
         height .46s cubic-bezier(.16, 1, .3, 1),
         border-radius .46s cubic-bezier(.16, 1, .3, 1),
         opacity .22s ease;
   }

   body:has(#intro:not(.is-gone)) #cp-ring.on {
      opacity: 0;
   }

   #cp-card.on,
   #cp-ring.on {
      /* The copy keeps 28px of air off the photo's ink (93 - 65) and the
         longest line must keep the same 28 off the right edge. The number
         cannot be hard-coded: the kicker's ampersand renders in a fallback
         face whose width differs per browser, so interactions.js measures
         the real lines after the fonts land and publishes --cp-open-w. */
      width: var(--cp-open-w, 356px);
      height: 157px;
      border-radius: 16px;
   }

   /* Frosted, like every other floating surface here. A flat white fill was
      never the material -- it turned the one hand-made object on the page
      into a dialog box. */
   #cp-card.on {
      background: rgba(255, 255, 255, 0.62);
      -webkit-backdrop-filter: blur(30px) saturate(180%);
      backdrop-filter: blur(30px) saturate(180%);
   }

   /* Layout is CONSTANT -- never hung off `.on`. When these paddings moved
      with the class, dropping it re-flowed every line in the same frame and
      the copy jumped onto the photo before it had begun to fade. And it is
      anchored to the card's BOTTOM edge, the one that does not travel: the
      card grows upward, so copy laid out from the top rode that whole
      distance on the way out. */
   #cp-card .cp-body {
      position: absolute;
      left: 0;
      right: 0;
      top: auto;
      bottom: 0;
      width: var(--cp-open-w, 356px);
      box-sizing: border-box;
      /* 17 - 1 above and 17 - 8 below: the link rows carry 8px of bottom
         padding and the credit line 1px of half-leading, so equal paddings
         would put the GitHub text 8px higher than the photo. These land the
         INK on 17 at both ends. */
      padding: 17px 15px 19px 15px;
      pointer-events: none;
   }

   #cp-card.on .cp-body {
      pointer-events: auto;
   }

   /* One left edge for everything: the credit line, the name and both links
      start on the same rule. */
   #cp-card .cp-kicker,
   #cp-card .cp-fullname,
   #cp-card .cp-row {
      padding-left: 78px;
   }

   /* Out here the credit line speaks in the links' own voice -- same family,
      same tight tracking, sentence case instead of the phone's small caps --
      one step down from them in size. */
   #cp-card .cp-kicker {
      font-family: var(--ui-med);
      font-size: 9.5px;
      line-height: 1.5;
      letter-spacing: .01em;
      text-transform: none;
   }

   /* Sharp Grotesk ships no ampersand and no dashes; the browser fills
      those in per-glyph from whatever it likes (Safari and Chrome pick
      different faces at different widths, which is how the kicker's right
      margin drifted between machines). One deterministic voice for them,
      heavy enough to sit level with the Sharp medium around it. */
   .hglyph {
      font-family: 'Helvetica Neue', Arial, sans-serif;
      font-weight: 600;
   }

   #cp-card .cp-fullname {
      margin-top: 3px;
   }

   #cp-card .cp-row {
      max-width: 262px;
      box-sizing: border-box;
      gap: 10px;
      justify-content: flex-start;
      position: relative;
      border-top: none;
   }

   #cp-card .cp-row i {
      margin-left: auto;
   }

   /* The rule belongs to the two link labels, so it starts where they start.
      As a border it ran the row's whole width, out past the photo to the
      card's left edge. */
   #cp-card .cp-row+.cp-row::before {
      content: '';
      position: absolute;
      left: 78px;
      right: 0;
      top: 0;
      height: 1px;
      background: rgba(22, 19, 14, 0.10);
   }

   /* The photo lands in the corner it already occupies when closed, so
      opening reads as the box growing out of the photo rather than the photo
      travelling somewhere else. 20, not 17, because the picture is scaled
      about its centre and overhangs its box by 3px on every side. */
   #cp-card.on .cp-avwrap {
      left: 20px;
      bottom: 32px;
      top: auto;
      width: 42px;
      height: 42px;
   }

   #cp-card .cp-avatar {
      transition: transform .62s cubic-bezier(.16, 1, .3, 1) .06s;
   }

   #cp-card.on .cp-avatar {
      transform: scale(1.12);
   }

   /* The photo is the byline now; a caption beside it is the old design
      talking over the new one. */
   #cp-card.on .cp-me-label {
      opacity: 0;
   }
}

/* The copy arrives a line at a time and leaves the same way in reverse --
   the desktop card was fading its whole block of text in and out as one
   object, which is the one thing in this card that never felt made. */
@media (min-width: 641px) {

   /* The WRAPPER never fades. Its base rule is opacity 0 with the transition
      switched off, so the moment the card lost `.on` the whole block of copy
      vanished in a single frame and no exit was ever visible. The wrapper is
      always on; the LINES inside it are what arrive and leave. */
   #cp-card .cp-body {
      opacity: 1 !important;
      transform: none !important;
      transition: none !important;
   }

   #cp-card .cp-kicker,
   #cp-card .cp-fullname,
   #cp-card .cp-row {
      opacity: 0;
      transform: translateY(6px);
      transition: opacity .28s ease, transform .38s var(--ease);
   }

   #cp-card.on .cp-kicker {
      opacity: 1;
      transform: none;
      transition-delay: .14s, .14s;
   }

   #cp-card.on .cp-fullname {
      opacity: 1;
      transform: none;
      transition-delay: .21s, .21s;
   }

   #cp-card.on .cp-row:nth-of-type(1) {
      opacity: 1;
      transform: none;
      transition-delay: .28s, .28s;
   }

   #cp-card.on .cp-row:nth-of-type(2) {
      opacity: 1;
      transform: none;
      transition-delay: .35s, .35s;
   }

   /* Closing runs in two acts, and nothing overlaps.

      ACT ONE, 0 to .45s: the lines leave one at a time, last first, while the
      card stands still and keeps its glass -- so the copy is always leaving a
      full card, never a pane of glass with the page showing through it.

      ACT TWO, .45s on: with the card empty, the box folds back to the mark. */
   body.credit-closing #cp-card .cp-kicker,
   body.credit-closing #cp-card .cp-fullname,
   body.credit-closing #cp-card .cp-row {
      transition: opacity .30s cubic-bezier(.4, 0, .7, 1),
         transform .34s cubic-bezier(.4, 0, .7, 1) !important;
   }

   body.credit-closing #cp-card .cp-row:nth-of-type(2) {
      transition-delay: 0s, 0s !important;
   }

   body.credit-closing #cp-card .cp-row:nth-of-type(1) {
      transition-delay: .05s, .05s !important;
   }

   body.credit-closing #cp-card .cp-fullname {
      transition-delay: .10s, .10s !important;
   }

   body.credit-closing #cp-card .cp-kicker {
      transition-delay: .15s, .15s !important;
   }

   /* the card's own glass never wavers while it is still a card */
   body.credit-closing #cp-card {
      background: rgba(255, 255, 255, 0.62) !important;
      -webkit-backdrop-filter: blur(30px) saturate(180%) !important;
      backdrop-filter: blur(30px) saturate(180%) !important;
   }

   /* The desktop card is pinned by left and bottom, so only its box changes:
      width, height and the corner. */
   body.credit-closing #cp-card,
   body.credit-closing #cp-ring {
      transition: width .58s cubic-bezier(.45, 0, .2, 1) .45s,
         height .58s cubic-bezier(.45, 0, .2, 1) .45s,
         border-radius .58s cubic-bezier(.45, 0, .2, 1) .45s,
         left .58s cubic-bezier(.45, 0, .2, 1) .45s,
         bottom .58s cubic-bezier(.45, 0, .2, 1) .45s,
         opacity .34s ease .45s,
         background 0s !important;
   }

   body.credit-closing #cp-card .cp-avwrap {
      transition: left .58s cubic-bezier(.45, 0, .2, 1) .45s,
         top .58s cubic-bezier(.45, 0, .2, 1) .45s,
         bottom .58s cubic-bezier(.45, 0, .2, 1) .45s,
         width .58s cubic-bezier(.45, 0, .2, 1) .45s,
         height .58s cubic-bezier(.45, 0, .2, 1) .45s !important;
   }

   /* The photo holds its open size through act one and settles back with the
      box in act two. */
   body.credit-closing #cp-card .cp-avatar {
      transition: transform .58s cubic-bezier(.45, 0, .2, 1) .45s !important;
   }

   /* The ring holds still until the card is a mark again -- see the phone
      block for why: the breath restarts at its own first frame the moment
      `.on` drops, and scales a full-width outline out past the card. */
   body.credit-closing #cp-ring {
      animation: none !important;
   }
}

/* ==========================================================================
   THE MENUS GROW ON THE SPACE THEIR ROWS TAKE UP

   The policy indicators leave one at a time and the block closes on the room
   each stamp gives up. These are the same gesture run forwards: the rows
   arrive one at a time and the shell opens on the room each one takes.

   Both menus already staggered their rows; what they did not do was grow.
   They dropped in at full height with the list already inside, so the shell
   was a finished box that the rows then appeared within -- two motions that
   happened to overlap rather than one motion with parts.

   `grid-template-rows: 0fr -> 1fr` is how a box animates to its content's
   own height with no magic number, and it only works on ONE child, which is
   why each menu has a single wrapper. Keyframes rather than a transition:
   the search list is revealed by `display`, and a transition has nothing to
   start from when an element goes from none to block.
   ========================================================================== */

@keyframes menuGrow {
   from {
      grid-template-rows: 0fr;
      opacity: 0;
   }

   /* Opaque within two frames.

            An element at opacity < 1 is composited as a whole, its own frosted
            background included -- so while this ramp was running the shell was
            a sheet of clear film with the rows already legible on it and the
            squares of the field showing through between them. The ramp exists
            only to cover the 1px border and the drop shadow at the instant the
            box has no height, which takes 37ms, not 100. */
   8% {
      opacity: 1;
   }

   /* Solid paper until 40%, then a crossfade to the glass. On a COLD first
      open the GPU is still assembling the 46px backdrop pipeline, and the
      .42 white painted with no blur behind it -- the transparent flash. By
      the time the crossfade runs, the blur exists. Warm opens look the same:
      paper to glass in 150ms reads as the glass arriving, not a bug. */
   0% { background-color: #F2F0E9; }
   40% { background-color: #F2F0E9; }

   to {
      grid-template-rows: 1fr;
      opacity: 1;
      background-color: rgba(255, 255, 255, 0.42);
   }
}

/* ---- the search menu -------------------------------------------------- */
.searchresults {
   /* No `display` here. The shell is hidden at rest and revealed by the
            field's focus, and both of those rules live earlier in this sheet
            at the same specificity -- declaring display again down here won
            on source order, so the menu was never hidden, the animation ran
            once at page load and was `finished` before anyone touched the
            box. The focus rule now says `grid`; this one keeps its hands off. */
   grid-template-rows: 1fr;
   /* the padding moves to the wrapper, or a shell at 0fr is still 12px
            tall and the menu starts as a bright line rather than nothing */
   padding: 0;
   /* it grows a little slower than the rows arrive, so the shell is always
            opening the room a row is about to land in and never clips one
            that is already on its way */
   animation: menuGrow .46s cubic-bezier(.30, 0, .22, 1) both;
}

.menurows {
   overflow: hidden;
   min-height: 0;
   padding: 6px;
}

/* ---- the policy tier menu --------------------------------------------- */
/* Dash builds this one, so the wrapper it already has -- the options list --
   is the child that gets the track, and the scrolling moves onto it. */
.dash-dropdown-content {
   display: grid !important;
   /* NOT !important: an important declaration outranks a keyframe, so the
            track would have sat at 1fr for the whole animation and the menu
            never grew. */
   grid-template-rows: 1fr;
   padding: 0 !important;
   max-height: none !important;
   overflow: visible !important;
   animation: menuGrow .46s cubic-bezier(.30, 0, .22, 1) both;
}

/* The scroller is CLOSED while the menu grows. The wrapper's height is the
   animating grid track, so for the first few frames it is far shorter than the
   list inside it -- and an `auto` scroller in that state does exactly what it
   is told: it shows a scrollbar, which then vanishes when the box finishes
   opening. The scroll is granted at the end of the animation instead. */
.dash-dropdown-content>.dash-options-list {
   overflow: hidden;
   overscroll-behavior: contain;
   min-height: 0;
   max-height: 46vh;
   padding: 6px !important;
}

.dash-dropdown-content.is-open>.dash-options-list {
   overflow-y: auto;
}

/* The rows arrive one after another, on the same 28ms beat the search menu
   keeps. Dash gives no hook for an index, so the first ten carry it here --
   the tier list is four rows and the search list is capped at ten. */
.dash-options-list-option {
   animation: slideRow .32s var(--ease) both;
}

.dash-options-list-option:nth-child(1)  { animation-delay: 0s }
.dash-options-list-option:nth-child(2)  { animation-delay: .028s }
.dash-options-list-option:nth-child(3)  { animation-delay: .056s }
.dash-options-list-option:nth-child(4)  { animation-delay: .084s }
.dash-options-list-option:nth-child(5)  { animation-delay: .112s }
.dash-options-list-option:nth-child(6)  { animation-delay: .140s }
.dash-options-list-option:nth-child(7)  { animation-delay: .168s }
.dash-options-list-option:nth-child(8)  { animation-delay: .196s }
.dash-options-list-option:nth-child(9)  { animation-delay: .224s }
.dash-options-list-option:nth-child(10) { animation-delay: .252s }

/* ---- the national averages panel wears the state card's strip ---------
   Same 4px track, same hairline at the national mean, same 9px dot in its
   white halo -- but two dots instead of one, and the bar between them is the
   gap the panel exists to report. Both dots fly out FROM the hairline, so the
   gap reads as a departure from the country rather than as two unrelated
   positions. */
.kpiline--strip {
   display: block;
}

/* the line the label and the two figures share, above the strip */
.kpitop {
   display: flex;
   justify-content: space-between;
   align-items: baseline;
   gap: 12px;
}

/* 7/1, which is the state card's own strip margin exactly. */
.kptrack {
   margin: 7px 0 1px;
}

/* Held for one frame so the animations below restart from their first frame.
   Only the NAME is suppressed: the shorthand would carry off the per-row
   animation-delay with it, and the six strips would then arrive together
   instead of one after another. */
.is-rewind .mtrack,
.is-rewind .mavg,
.is-rewind .mdot,
.is-rewind .mseg {
   animation-name: none !important;
}

/* A shade more presence than the state card's own segment, which is a
   flat wash -- this one is two colours mixing and needs a little more of
   itself to read as a mix rather than as a smudge. Still well under half,
   so the dots stay the loudest thing on the row. */
.kptrack .mseg {
   opacity: .38;
}

/* Hollow is the protective side. `color` carries the ring so the same rule
   works for whatever hue the row is drawn in. */
.mdot--hollow {
   background: #FBFAF8;
   box-shadow: 0 0 0 2px currentColor,
      0 0 0 5px color-mix(in srgb, currentColor 18%, transparent);
}

/* The two tiers on the left, what the tick means on the right -- the same
   left-to-right split the rows above it keep, label one side and figure the
   other. */
.kpikey {
   display: flex;
   align-items: center;
   justify-content: space-between;
   gap: 10px 18px;
   flex-wrap: wrap;
   padding: 11px 0 2px;
   font-family: var(--mono);
   font-size: 8.4px;
   letter-spacing: .12em;
   text-transform: uppercase;
   color: #B6ADA1;
}

.kpikey-tiers {
   display: flex;
   align-items: center;
   gap: 14px;
}

.kpikey .kk {
   display: inline-block;
   width: 8px;
   height: 8px;
   border-radius: 50%;
   margin-right: 6px;
   vertical-align: -1px;
}

.kpikey .kk--prot {
   background: #4E8C74;
}

.kpikey .kk--rest {
   background: #C9553F;
}

.kpikey .kk--tick {
   width: 1.5px;
   height: 10px;
   border-radius: 0;
   background: rgba(22, 19, 14, .34);
   margin-right: 7px;
   vertical-align: -2px;
}

.kpikey-span {
   color: #CFC7BB;
}


/* ============================================================================
   THE STORY PAGE, PORTED.

   Everything below came from assets/storyfx/live.html, which is where the
   video treatment, the dark chapter styling and the chart palette were built
   and signed off. It is all override: production's own rules are untouched in
   styles.css and these sit on top.

   Dash loads assets alphabetically, so the filename matters -- this has to
   come after styles.css for the source-order overrides to win.
   ========================================================================= */
/* The chart takes the slot the gradient plate had, at the size the image
     had -- `.intro-fig img` is capped at 26vh / 50vw, so the chart is too.
     That cap is what keeps the gap under it the same as production's. */

  /* ============ V3 · SETTLE ==================================================
     The whole intro flips to a dark surface because the footage is behind it.
     Everything below is an override of production's light treatment, scoped to
     this page, so styles.css is untouched. */
  :root{
    --edge:38px;                      /* skip from the top, rail from the bottom */
    --mark:rgba(245,243,238,.45);     /* the family the rail ticks are drawn in */
    /* The picture rectangle, in pure CSS. object-fit:cover scales the frame
       by max(W/1920, H/1080), and the picture is 74.5% of that frame's width,
       so the visible rectangle is max(74.5vw, 132.44vh), capped at 92vw for
       phones where the picture is wider than the screen. This used to be
       computed in JS, which meant the first paint used a placeholder and
       then jumped. */
    --span:min(92vw, max(74.5vw, 132.44vh));
    /* the band between the chrome row and the rail. Exact, not a guess: the
       avatar box is 40 and the rail is 64 + 7 + 6 = 77 on desktop, 48 + 7 + 6
       = 61 on a phone. */
    --stage-top:calc(var(--edge) + 40px);
    /* rail = block height + 7px tick margin + 6px ticks. Desktop blocks are
       52px now, not production's 64. If this constant and the CSS below ever
       disagree the band goes lopsided for one frame before fitStage() catches
       it, so they move together. */
    --stage-bot:calc(var(--edge) + 65px);
    --dotcore:#0C0A08;                /* what a "filled" chart dot is filled WITH */
  }
  #intro{background:#0C0A08}
  /* The footage lives in a box of its own so it can be clipped. On a phone
     the box is the whole screen; on desktop it is the stage rectangle, and
     the clip matters there because the settle starts at scale(1.22) and would
     otherwise spill past the card onto the page. */
  #promo-box{position:absolute;inset:0;z-index:0;overflow:hidden}
  #promo{
    position:absolute;inset:0;width:100%;height:100%;object-fit:cover;
    display:block;z-index:0;
    transform:scale(1.22);filter:blur(16px) saturate(.85);
    animation:promoSettle 2.6s cubic-bezier(.16,1,.3,1) both}
  @keyframes promoSettle{
    from{transform:scale(1.22);filter:blur(16px) saturate(.85)}
    to{transform:scale(1);filter:blur(0) saturate(1)}}
  #promo-scrim{
    position:absolute;inset:0;z-index:1;pointer-events:none;opacity:0;
    border-radius:inherit;
    background:linear-gradient(180deg,
      rgba(12,10,8,.52) 0%,rgba(12,10,8,.60) 38%,rgba(12,10,8,.80) 62%,
      rgba(12,10,8,.94) 84%,rgba(12,10,8,.98) 100%);
    animation:promoScrim 1.3s var(--ease) .55s both}
  @keyframes promoScrim{from{opacity:0}to{opacity:1}}
  /* the story rides above both layers */
  /* `#intro > div:last-child` used to be in this rule to lift the rail above
     the video. It is an id + type selector, so it outranked `.intro-rail` and
     pinned it back to `position:relative` -- the same way it had pinned the
     skip button. The rail carries its own z-index now and this rule keeps to
     the stage. */
  /* The story sits in the band BETWEEN the chrome row and the rail, rather
     than being centred on the window and landing wherever it lands. Because
     the scene centres its own column inside the stage, spanning the stage
     across that band makes the gap above the kicker and the gap below the
     caption equal by construction. The two edges are measured, not assumed:
     see fitStage(). */
  #intro-stage{position:absolute;z-index:2;left:0;right:0;margin:0 auto;
    width:var(--span);top:var(--stage-top);bottom:var(--stage-bot);
    height:auto}
  /* deliberately centred rather than production's top-right: on a full-bleed
     frame the corner reads as a stray control. Anchored off left:50% so it
     stays centred at any width. */
  /* The avatar takes the top-left, on the same edge inset as the skip button
     so the two read as one row. Left rather than right because the skip is the
     only other thing up there and it is centred: left / centre / empty is a
     balanced masthead, left / centre / right would be a toolbar. */
  #intro-ava{position:absolute;top:var(--edge);
    left:calc((100vw - var(--span)) / 2);z-index:3;
    width:40px;height:40px;border-radius:50%;padding:0;border:0;cursor:pointer;
    background:transparent;display:grid;place-items:center;
    /* Lands with the story, not two seconds after it. The kicker starts at 0,
       the headline at .1s, the body at .24s and the figure at .38s; this sits
       in that window at .12s. The gesture is deliberately not theirs -- they
       rise, this one settles in place with a slight overshoot, and the ring
       contracts onto it from outside. */
    animation:avaIn .62s cubic-bezier(.34,1.56,.64,1) .12s both}
  #intro-ava img{width:34px;height:34px;border-radius:50%;object-fit:cover;display:block}
  /* white, not the dashboard's green: on the footage the green read as a
     stray brand colour with nothing else on the page to answer it */
  #intro-ava::after{content:'';position:absolute;inset:0;border-radius:50%;
    box-shadow:0 0 0 1.5px rgba(245,243,238,.80);
    /* contracts in, then breathes -- the breath waits for the entrance to
       finish rather than fighting it */
    animation:avaRing .58s var(--ease) .12s both,
              avaBreath 3.4s ease-in-out .78s infinite}
  /* It is a button, so it has to answer the pointer. The ring brightens and
     opens out, the photo lifts a little, and a press pushes it back in. The
     breathing loop is paused on hover so the two are not fighting for the
     same property. */
  #intro-ava img{transition:transform .3s var(--ease)}
  #intro-ava::after{transition:box-shadow .25s var(--ease)}
  #intro-ava:hover::after{box-shadow:0 0 0 2px rgba(245,243,238,1);
    animation-play-state:paused;transform:scale(1.06)}
  #intro-ava:hover img{transform:scale(1.08)}
  #intro-ava:active img{transform:scale(.96)}
  #intro-ava:active::after{box-shadow:0 0 0 2.5px rgba(245,243,238,1);transform:scale(.99)}
  #intro-ava:focus-visible{outline:none}
  #intro-ava:focus-visible::after{box-shadow:0 0 0 2px rgba(245,243,238,1),
    0 0 0 5px rgba(245,243,238,.28)}
  @keyframes avaIn{from{opacity:0;transform:scale(.72)}
    55%{opacity:1}to{opacity:1;transform:none}}
  @keyframes avaRing{from{opacity:0;transform:scale(1.55)}to{opacity:1;transform:none}}
  @keyframes avaBreath{0%,100%{transform:scale(1);opacity:.85}50%{transform:scale(1.09);opacity:.45}}
  /* The avatar's BOX matches the skip button's height so the two share a
     centre line; the ring and photo shrink inside it. Sizing the box down to
     34 on a phone while the button stayed 40 left the two topped at the same
     y but centred 3px apart. */
  @media (max-width:640px){
    #intro-ava{width:40px;height:40px}
    #intro-ava img{width:29px;height:29px}
    #intro-ava::after{inset:3px}
  }

  /* One row, two corners: avatar left, skip right. Both anchored to the
     PICTURE's edge, not the window's, so they line up with the story and the
     rail instead of floating out over the pillarbox bars. */
  #intro-skip{position:absolute;top:var(--edge);left:auto;transform:none;
    right:calc((100vw - var(--span)) / 2);z-index:2;
    /* Same clock and same easing as the avatar, so the top row arrives as a
       pair rather than one corner appearing after the other. Different
       gesture, though: the avatar settles in place, this one slides in from
       the edge it sits on. Neither is the story's vertical rise. */
    animation:skipIn .62s cubic-bezier(.34,1.56,.64,1) .12s both}
  /* Desktop only: there the picture fills the screen, so the picture-edge
     anchor above stranded the button 124px inboard -- pin it to the SCREEN's
     edge and size it up. The phone was signed off on the ported geometry and
     must never see these numbers. */
  @media (min-width:641px){
    #intro-skip{right:28px;height:46px;padding:0 20px;font-size:10px}
  }
  @keyframes skipIn{from{opacity:0;transform:translateX(14px)}
    55%{opacity:1}to{opacity:1}}
  /* The rail leaves the flow so it can sit the SAME distance off the bottom
     that the skip button sits off the top. In flow it was centred with the
     stage and landed 106px up, against the button's 26. One token drives both
     edges, so they cannot drift apart again. Centred with auto margins rather
     than a transform: a transformed ancestor is a containing block, and this
     one wraps the travelling marker. */
  .intro-rail{position:absolute;left:0;right:0;bottom:var(--edge);
    width:var(--span);margin:0 auto;z-index:2}
  @media (max-width:640px){:root{--edge:20px;--stage-bot:calc(var(--edge) + 61px)}}

  /* ---- type, inverted ---- */
  .intro-head{color:#F7F5F1}
  .intro-body{color:rgba(245,243,238,.82)}
  .intro-kick{color:rgba(245,243,238,.60)}
  .intro-scene.accent .intro-kick{color:#E08A72}
  .intro-cap{color:rgba(245,243,238,.50)}
  .intro-block{background:rgba(245,243,238,.10);color:rgba(245,243,238,.72)}
  .intro-block.live{background:rgba(245,243,238,.24);color:#F7F5F1}
  /* production's Dobbs marker is #FF3126, a pure signal red that was the
     brightest thing on a black page. Pulled down a little, not redesigned. */
  .intro-block.split{border-color:#D8412F}
  .intro-block.split.live{background:rgba(216,65,47,.12)}
  .intro-ticks i{background:rgba(245,243,238,.26)}
  /* the travelling marker is production's near-black triangle and stem, which
     on this background was simply gone. Same family as the ticks below it,
     carried further up so it still reads as the thing that moves. */
  .intro-head-marker::before{border-top-color:rgba(245,243,238,.78)}
  .intro-head-marker::after{background:rgba(245,243,238,.78)}
  .intro-skip{color:rgba(245,243,238,.72);border-color:rgba(245,243,238,.30)}
  .intro-skip:hover{color:#F7F5F1;border-color:rgba(245,243,238,.55)}

  /* Hover fills BOTH chrome buttons with the avatar card's own glass. The
     glass lives on a ::before that fades in -- backdrop-filter itself cannot
     transition from none, but a blurring layer arriving through opacity can.
     isolation keeps the z-index:-1 layer inside the button, under its text. */
  .intro-skip,.intro-enter{position:relative;isolation:isolate}
  .intro-skip::before,.intro-enter::before{
    content:'';position:absolute;inset:0;z-index:-1;border-radius:inherit;
    background:rgba(16,13,10,.40);
    -webkit-backdrop-filter:blur(18px) saturate(185%);
    backdrop-filter:blur(18px) saturate(185%);
    opacity:0;transition:opacity .28s ease}
  /* Enter sits on the vignette's near-black floor, where a dark tint over a
     blur has nothing to show. Same glass, but lit from the paper side: a pale
     frost with a faint top sheen, so the material reads even on black. */
  .intro-enter::before{
    background:linear-gradient(180deg,rgba(245,243,238,.13),rgba(245,243,238,.07))}
  /* the phone's tap-hover shows the same layer: half the white there */
  @media (max-width:640px){
    .intro-enter::before{
      background:linear-gradient(180deg,rgba(245,243,238,.10),rgba(245,243,238,.05))}}
  .intro-skip:hover::before,.intro-enter:hover::before{opacity:1}
  /* Same material as the skip button -- mono, uppercase, letterspaced, an
     outline rather than a fill -- so the two read as one family. Not the same
     weight, though: skip is an escape hatch, this is the thing the whole
     story walks you toward. It runs larger and brighter and fills on hover.
     Styling them identically would flatten the most important button on the
     page into the least important one. */
  .intro-enter{
    font-family:'JetBrains Mono',monospace!important;font-size:11px;
    letter-spacing:.15em;text-transform:uppercase;font-weight:500;
    color:#F7F5F1;background:transparent;
    border:1px solid rgba(245,243,238,.55);border-radius:var(--btn-radius,12px);
    padding:0 28px;height:52px;
    transition:background .28s var(--ease),color .28s var(--ease),
               border-color .28s var(--ease),transform .3s var(--ease)}
  /* No more beige fill: the glass underlay carries the hover, the stroke
     and the type just step up to full. The zoom lives in the chrome block
     (translateY(-2px) scale(1.06)) and is untouched. */
  .intro-enter:hover{
    background:transparent;color:#FFFFFF;border-color:rgba(245,243,238,.85);
    filter:none}

  /* Mobile sets the figure 22px below a body running 20px line-height: 1.1
     line-heights of air. Desktop kept the same 22px under a 27.1px line, which
     is 0.81 -- the same number reads tighter the moment the type grows. 30px
     restores the phone's rhythm at desktop size. */
  /* Desktop only -- fenced, not just margin-patched: the first cut of this
     block sat at root and the phone caught the display sizes. The phone was
     signed off as-is and falls back to the ported base rules.
     Sized against the full-screen footage: a 70px display title, a clear gap
     to the copy, and the figure pushed off the paragraph. */
  @media (min-width:641px){
    .intro-head{font-size:70px;line-height:1.02;margin-bottom:46px}
    .intro-fig{margin-top:68px}
    /* the Explore chapter goes the other way: its button sits close to the
       copy rather than a chart's distance from it */
    .intro-fig.cta{margin-top:26px}
  }
  @media (max-width:640px){
    /* a fuller breath between the headline and the copy -- 12px read as one
       block on the phone */
    .intro-head{margin-bottom:22px}
    .intro-fig{margin-top:22px}
    .intro-fig.cta{margin-top:22px}
  }

  /* The scrim is NOT weaker on desktop -- measured at the same landmarks it
     is within a few hundredths of the phone (0.69 vs 0.72 at the top of the
     chart, 0.88 vs 0.88 at the bottom). What differs is the footage. A
     portrait phone crops so hard into a 1118px-wide picture that whatever
     sits behind the chart is a few enormous soft shapes; a 1440px window
     shows the whole frame, so the chart lands on faces, placards and railings
     at full detail.

     So the fix is not to copy the phone's scrim, it is to give the desktop
     MORE than the phone has, weighted to the band the charts occupy: .82 at
     the top of the chart instead of .69, and .95 by its foot. */
  @media (min-width:641px){
    /* production's blocks are 64px tall at 14.5px; at this size they were the
       heaviest thing on the page after the headline */
    .intro-block{height:52px;font-size:13px}
    /* and the row itself no longer runs the full width of the picture -- at
       1192 it was nearly twice the chart above it */
    .intro-rail{width:min(var(--span), 920px)}

    #promo-scrim{background:linear-gradient(180deg,
      rgba(12,10,8,.50) 0%,
      rgba(12,10,8,.58) 32%,
      rgba(12,10,8,.86) 52%,
      rgba(12,10,8,.95) 76%,
      rgba(12,10,8,.99) 100%)}
  }

  /* The two-line break is written into the copy, as a <br> at the point that
     makes the phrase read. A max-width cap used to force it, which meant the
     break landed wherever the measure happened to run out.

     Desktop only: on a phone the headline already wraps to two lines on its
     own and that was signed off, so the break is collapsed there. Each one is
     authored as ", <br>" with the space BEFORE it -- hiding a <br> removes a
     line break but contributes no whitespace of its own, so without that
     space the two halves would run together as "Eleven bans,pre-written." */
  @media (max-width:640px){.intro-head br{display:none}}

  .intro-chart{
    width:min(44vw,660px); height:26vh; min-height:150px;
    position:relative; margin:0 auto;
  }
  /* a step up on desktop only -- the phone keeps 80vw x 22vh from its own
     pass below, which overrides all three of these */
  @media (min-width:641px){
    .intro-chart{width:min(47vw,700px); height:28vh; min-height:162px}
  }
  .intro-chart svg{width:100%;height:100%;display:block;overflow:visible}
  /* Chart captions wrap as whole clauses. On a phone the 2022 caption broke
     mid-clause and left "MN" alone at the end of one line with "INTO
     PROTECTIVE" on the next, which reads as a different fact. Each clause is
     a nowrap span now, and the separator sits between them so it can still
     break there. */
  .intro-cap .nb{white-space:nowrap}
  /* scoped to the one caption that has clauses -- the others are plain text
     and must keep wrapping normally */
  .intro-cap .cap-row{display:flex;flex-wrap:wrap;justify-content:center;
    column-gap:22px;row-gap:0}
  /* the CTA sits where a chart would, centred in the height a chart reserves */
  .intro-fig.cta{display:grid;place-items:center}
  /* The Explore chapter never reserves a chart's height under its button --
     on either viewport now. Reserving it pinned the chapter to the other
     four's grid and left the button marooned below a block of empty space;
     dropping it lets the chapter centre on the page as its own thing. The
     rule needs !important because the levelling pass writes an inline
     min-height, and nothing but !important outranks one. */
  .intro-fig.cta{min-height:0!important}
  @media (max-width:640px){
    /* The button carries a 26px top margin of its own, which sits on top of
       the figure's 22px and left it 48px below the copy while every chart
       starts at 22. Dropped here so the button is spaced off the body the
       same way a chart is. Mobile only -- desktop has the room. */
    .intro-fig.cta .intro-enter{margin-top:0}
  }
  .intro-chart .ax{font-family:'JetBrains Mono',monospace;font-size:8.4px;
    letter-spacing:.12em;fill:#9A9186}
  .intro-chart .endlab{font-family:var(--ui);font-size:10.5px;opacity:0;
    transition:opacity .5s var(--ease) .45s}
  .lc.lit .endlab{opacity:1}
  .lc .dob{opacity:0;transition:opacity .5s var(--ease) .3s}
  .lc.lit .dob{opacity:.45}
  .lc .scrub,.lc .scrubdot{opacity:0;transition:opacity .2s ease}
  .lc.hov .scrub{opacity:.28}.lc.hov .scrubdot{opacity:1}

  /* The squares land the way the dashboard's do -- see the note in
     charts.js. `both` holds the from-frame through the delay, so a square
     waiting its turn is invisible rather than sitting there at full strength;
     and `to` restates transform:none because a keyframe that omits a
     property snaps to it at the end instead of easing into it. */
  .fc .sq{opacity:0;transform-origin:center;transform-box:fill-box;
    transition:fill .55s var(--ease),stroke .18s var(--ease);cursor:pointer}
  .fc.lit .sq{animation:sqSettle .8s var(--ease) both;animation-delay:var(--sd,.1s)}
  @keyframes sqSettle{
    from{opacity:0;filter:blur(5px);
      transform:translate(var(--sx,0px),var(--sy,0px)) rotate(var(--sr,0deg)) scale(.85)}
    30%{opacity:1}
    to{opacity:1;filter:blur(0);transform:none}
  }
  .fc .sq:hover{stroke:#16130E;stroke-width:1.4}

  .sl .st{stroke:#CFC8BC;stroke-width:.9;fill:none;stroke-linecap:round;
    transition:stroke .3s var(--ease),stroke-width .3s var(--ease),opacity .3s var(--ease)}
  .sl .st.mv{stroke-width:2.3;opacity:0}
  .sl.lit .st.mv{opacity:1}
  .sl.lit .st:not(.mv){stroke:#DCD6CB}
  .sl .st.hot{stroke:#16130E!important;stroke-width:2.7}
  .sl.probing .st:not(.hot){opacity:.42}
  .sl .hit{stroke:transparent;stroke-width:12;fill:none;cursor:pointer}
  .sl .mvlab,.sl .lead,.sl .mvdot{opacity:0;transition:opacity .5s var(--ease)}
  .sl.lit .mvlab,.sl.lit .mvdot{opacity:1}
  .sl.lit .lead{opacity:.5}
  .sl .mvlab{font-family:var(--ui);font-size:10.5px}
  .sl .lead{stroke-width:1;fill:none}

  .ev .ci,.ev .evdot{opacity:0;transform:scaleY(.2);transform-origin:center;
    transform-box:fill-box;transition:opacity .5s var(--ease),transform .6s var(--ease)}
  .ev.lit .ci,.ev.lit .evdot{opacity:1;transform:none}
  .ev .band{opacity:0;transition:opacity .6s var(--ease) .2s}
  .ev.lit .band{opacity:.10}

  /* PRODUCTION'S GLASS -- #linktag, declaration for declaration.

     Copying these numbers was never the problem; I had them byte-identical
     twice and the panel still read as a window. The problem was where the
     node lived. `backdrop-filter` blurs the BACKDROP ROOT, and an ancestor
     with a filter, a transform or an opacity under 1 starts a new one --
     after which the blur reaches only the pixels inside that ancestor and is
     clamped at its edges. `.intro-fig` finishes its entrance holding
     `filter:blur(0px)`, because an animation with `fill:both` keeps its last
     frame, and a no-op filter opens a root exactly like a real one. So the
     tooltip was blurring a small clipped patch of its own wrapper: raising
     the radius to 46px did nothing, and raising the white to 0.80 only hid
     the failure behind paint.

     The tooltip is mounted on <body> and fixed now, which is where #linktag
     has always been, so its root is the document and its blur reaches the
     whole page. With that fixed, production's own numbers produce production's
     own result and there is nothing here to tune. */
  .sx-tip{
    /* 900 is production's number and it is right there, because the dashboard
       stacks below it. This page does not: #intro is a fixed shell at 3000,
       so a tooltip on <body> at 900 is laid out correctly and painted
       underneath the entire story. It has to clear the shell it floats over.
       Nothing else changes -- the glass below is #linktag's, untouched. */
    position:fixed;z-index:4000;pointer-events:none;
    display:flex;align-items:center;gap:13px;
    padding:11px 16px;border-radius:8px;
    background:rgba(255,255,255,.34);
    -webkit-backdrop-filter:blur(18px) saturate(185%);
    backdrop-filter:blur(18px) saturate(185%);
    border:1px solid rgba(255,255,255,.55);
    box-shadow:0 10px 28px rgba(28,20,14,.14),
               inset 0 1px 0 rgba(255,255,255,.6);
    opacity:0;transform:translateY(4px) scale(.97);
    transition:opacity .2s var(--ease),transform .26s var(--ease);
    white-space:nowrap}
  .sx-tip.on{opacity:1;transform:none}

  .sx-tip .lt-cell{display:flex;flex-direction:column;align-items:center;gap:5px;
    line-height:1;min-width:62px}
  .sx-tip .lt-wide{min-width:96px}
  /* Production sets numbers at 17px, but its tooltip only ever carries two
     cells. At three or four the same size is a wall of figures, so the dense
     ones step down and give their gaps back. */
  .sx-tip.dense{gap:18px}
  .sx-tip.dense .lt-cell b{font-size:14px}
  .sx-tip.dense .lt-cell{min-width:52px}
  .sx-tip.dense .lt-wide{min-width:88px}
  .sx-tip .lt-cell b{font-family:var(--ui-semi);font-weight:600;font-size:17px;
    letter-spacing:-.01em;color:#16130E}
  .sx-tip .lt-cell i{font-family:var(--mono);font-style:normal;font-size:7.5px;
    letter-spacing:.15em;color:#A79C8E}
  .sx-tip .lt-arrow{font-family:var(--mono);font-size:15px;line-height:1;
    color:#16130E;opacity:.8}
  .sx-tip .lt-name{font-family:var(--ui-semi);font-weight:600;font-size:13px;
    letter-spacing:-.005em;color:#16130E;padding-right:2px}
  /* A fact that is either true or absent reads as a chip, not as a cell with
     YES in it -- and it belongs to the STATE, so it stacks under the state's
     name rather than standing in the row of measurements. */
  .sx-tip .lt-id{display:flex;flex-direction:column;align-items:flex-start;
    gap:6px;padding-right:2px}
  .sx-tip .lt-id b{font-family:var(--ui-semi);font-weight:600;font-size:13px;
    letter-spacing:-.005em;color:#16130E;line-height:1}
  .sx-tip .lt-id em{font-family:var(--mono);font-style:normal;font-size:7.5px;
    letter-spacing:.15em;line-height:1;border-radius:4px;padding:4px 6px;
    white-space:nowrap}
  /* Two directions, the tier palette's own red and green. A chip only ever
     appears on a state that actually moved, so colour here always means
     something happened. */
  .sx-tip .lt-id em.up{color:#A4402A;background:rgba(201,85,63,.12);
    box-shadow:inset 0 0 0 1px rgba(201,85,63,.26)}
  .sx-tip .lt-id em.down{color:#356B55;background:rgba(78,140,116,.14);
    box-shadow:inset 0 0 0 1px rgba(78,140,116,.30)}

  @media (max-width:640px){
    .intro-chart{width:80vw;height:22vh;min-height:132px}
    /* Only the two chapters that need it. 2022 and 2024 each missed two
       lines by eight pixels against the scene's 7vw side padding, which is
       sized for body copy at 12.5px and needlessly tight for a headline at
       35px. Applying it to every chapter changed where the other three broke
       for no reason, so it is opt-in per chapter via `wide: true` in CH --
       keyed to the chapter, not to a scene index, so reordering the story
       cannot silently move it to the wrong slide. The body keeps the padding
       everywhere: a 62-character measure is what makes long paragraphs
       readable on a phone. */
    .intro-scene.wide-head .intro-head{margin-left:-22px;margin-right:-22px}

    /* production's own mobile #linktag: gap 8, padding 8/10, cells at 14px */
    .sx-tip{gap:8px;padding:8px 10px}
    /* While a value is being read, the tier names step aside. The stacked
       panel is tall and wide enough on a 390px screen to reach the end of
       the lines, and "38.32" sitting on top of "restrictive" is exactly the
       mess this layout was meant to end. Nothing is lost: the panel names
       all three tiers itself. Desktop keeps its labels -- the chart is more
       than twice as wide there and the tooltip is a single row. */
    .lc.hov .endlab{opacity:0;transition:opacity .16s var(--ease)}

    /* ---- 2020 and 2024 turn vertical on a phone (gallery option B) ----
       Four figures in one row is a strip of 6px captions on a 390px screen:
       legible only in the sense that the pixels are there. The same nodes
       restack into labelled rows -- year as a header, then dot / name /
       value with the numbers sharing one right-hand column so they can be
       compared down the edge. Desktop is untouched; every rule here is
       inside this query. */
    .sx-tip.dense{
      flex-direction:column;align-items:stretch;gap:0;
      padding:9px 11px 10px;min-width:150px}
    .sx-tip.dense .lt-cell{
      flex-direction:row;align-items:center;gap:8px;min-width:0;padding:3.5px 0}
    /* the year: its caption is redundant once it sits alone on a header line */
    .sx-tip.dense .lt-head{
      padding:0 0 7px;margin-bottom:6px;
      border-bottom:1px solid rgba(22,19,14,.10)}
    .sx-tip.dense .lt-head b{
      font-family:var(--mono);font-weight:400;font-size:7px;letter-spacing:.18em;
      color:#A79C8E}
    .sx-tip.dense .lt-head i{display:none}
    /* the dot is the row's own marker, so a colour needs no extra element */
    .sx-tip.dense .lt-row::before{
      content:'';width:6px;height:6px;border-radius:50%;flex:none;
      background:var(--c,#A79C8E)}
    .sx-tip.dense .lt-row i{order:1;font-size:7px;letter-spacing:.13em;color:#8C8378}
    .sx-tip.dense .lt-row b{
      order:2;margin-left:auto;padding-left:10px;font-size:13px;
      font-variant-numeric:tabular-nums}
    /* an interval is a qualifier on the estimate above it, not a second
       headline -- at 13px "−0.4 to 4.9" shouted louder than "+2.2" did */
    .sx-tip.dense .lt-wide b{font-size:11px;color:#5E564C}
    /* the state block and its movement chip come down with everything else --
       "NEWLY RESTRICTIVE" is seventeen characters on a 390px screen */
    .sx-tip .lt-id b{font-size:11.5px}
    .sx-tip .lt-id em{font-size:6.5px;padding:3px 5px;letter-spacing:.12em}
    .sx-tip .lt-cell{min-width:46px}
    .sx-tip .lt-cell b{font-size:14px}
    .sx-tip .lt-cell i{font-size:6.5px;letter-spacing:.12em}
    .sx-tip .lt-arrow{font-size:13px}
    .sx-tip .lt-name{font-size:11.5px}
  }

  /* The stage is production's paper card, and on the footage it read as a
     a white slab with the title invisible on top of it. It becomes the
     window onto the video instead: no fill, and the hairline kept so the
     story still has an edge to sit inside. */
  .intro-stage{background:transparent}

  /* ---- the tooltip, inverted ----
     Dark ink on white glass stopped working the moment the page went dark:
     the panel was a bright patch and the numbers inside it were the only
     black on the screen. Same frosted material, dark now, and the border is
     gone -- a hairline made sense holding a light panel off a light page and
     only draws a box around it here. */
  .sx-tip{
    background:rgba(16,13,10,.40);
    border:0;
    box-shadow:0 18px 44px rgba(0,0,0,.38)}
  .sx-tip .lt-cell b,.sx-tip .lt-id b,.sx-tip .lt-name,.sx-tip .lt-arrow{color:#F7F5F1}
  .sx-tip .lt-cell i{color:rgba(245,243,238,.58)}
  .sx-tip .lt-head b{color:rgba(245,243,238,.58)}
  .sx-tip .lt-id em.up{color:#EE9B84;background:rgba(201,85,63,.22);
    box-shadow:inset 0 0 0 1px rgba(201,85,63,.42)}
  .sx-tip .lt-id em.down{color:#8FD3B2;background:rgba(78,140,116,.22);
    box-shadow:inset 0 0 0 1px rgba(78,140,116,.42)}
  @media (max-width:640px){
    .sx-tip.dense .lt-head{border-bottom-color:rgba(245,243,238,.16)}
    .sx-tip.dense .lt-wide b{color:rgba(245,243,238,.78)}
    .sx-tip.dense .lt-row i{color:rgba(245,243,238,.58)}}

  /* ---- charts, inverted ----
     CSS beats presentation attributes, so every stroke charts.js sets as an
     attribute is overridable from here. Inline styles are not, which is why
     the square fills are changed in the CHARTS config below instead. */
  .intro-chart .ax{fill:rgba(245,243,238,.55)}
  .fc .sq:hover{stroke:#F7F5F1;stroke-width:1.4}
  .fc line{stroke:rgba(245,243,238,.20)}
  /* Two rules set this, not one: `.sl .st` for the resting state and
     `.sl.lit .st:not(.mv)` once the chart has drawn, which is the one that
     actually shows and which my first override missed entirely. Both go
     light, and the settled value stays under the movers so 45 unchanged
     states read as a field rather than as the subject. */
  .sl .st{stroke:rgba(245,243,238,.16)}
  .sl.lit .st:not(.mv){stroke:rgba(245,243,238,.24)}
  .sl .st.hot{stroke:#F7F5F1!important}
  /* the mover rings are drawn hollow in charts.js, in their tier colour --
     inline styles, which no rule here could have overridden anyway */
  .sl .lead{stroke:rgba(245,243,238,.30)}
  .sl .mvlab{fill:rgba(245,243,238,.85)}
  /* the ring is set in charts.js from --dotcore, the same way the 2022
     movers are, so nothing here needs to override it */
  .ev line[stroke-dasharray]{stroke:rgba(245,243,238,.34)}
  /* (the zero line was removed from the chart entirely) */
  .lc .scrub{stroke:#F7F5F1}
  .lc .scrubdot{stroke:#0C0A08}


/* Production's credit card is a dashboard object: bottom-left, green ring,
   expands to the links. The story page was designed with its own avatar
   top-left instead, so the card stays out of the intro and comes back the
   moment the story hands over. */
/* (the rule that used to hide the card during the intro lived here; the card
   is wanted on screen now, re-anchored just below) */

/* ---- the credit card during the story -----------------------------------
   The card is not hidden any more. The story page was designed with an avatar
   top-left, and this IS that avatar -- the real one, which opens to the
   credits and the links. It is re-anchored rather than duplicated: top-left
   instead of bottom-left, on the same --edge inset the skip button uses, with
   the ring in the story's white rather than the dashboard's green.

   :has() rather than a class, because a class added by intro.js lands one
   frame after the first paint and the card visibly jumped corners. This is
   true before anything renders. Everything reverts the moment #intro takes
   .is-gone, so the dashboard keeps the card it always had. */
/* THE DASHBOARD'S BUTTON, VERBATIM.

   No geometry overrides at all on desktop: the story card sits exactly where
   the dashboard's sits (left 26 / bottom 22), with production's untouched
   open/close choreography, breathing, ring offsets and photo positions.
   Every earlier attempt to relocate it fought some production transition
   mid-flight. Only the SURFACE changes on the story page -- dark glass,
   light text, white ring -- and crossing into the dashboard changes the
   ring back to green and the glass to white: the button itself never moves,
   which is the cohesion the transition wanted. (The phone keeps its own
   signed-off top-left pair in the mobile block below.) */

@media (max-width: 640px) {
   /* Production already pinned the card under the timeline on a phone, via
      `top: calc(var(--railbot) + 16px)`. That anchor assumed the old rail,
      which sat in the flow; the rail is now fixed 20px off the bottom, so the
      same expression put the card at y=840 of an 844-tall screen -- off the
      edge. It takes the story page's own corner instead.

      !important because the rules it is overriding carry it too, and a
      specificity war with them is not worth winning twice. */
   /* 12px, the bar's own inset, not the picture's edge: the mark must be in
      the same place on both pages or the crossing is a jump. --edge is 20 on
      a phone, which is also where the bar's first row now sits. */
   body:has(#intro:not(.is-gone)) #cp-card,
   body:has(#intro:not(.is-gone)) #cp-ring {
      top: var(--edge) !important;
      bottom: auto !important;
      left: 12px !important;
   }
   body:has(#intro:not(.is-gone)) #cp-card.on,
   body:has(#intro:not(.is-gone)) #cp-ring.on {
      top: var(--edge) !important;
      bottom: auto !important;
      left: 12px !important;
   }
   body:has(#intro:not(.is-gone)) #cp-card .cp-avwrap {
      top: 5px !important;
      bottom: auto !important;
   }
   /* open: (20,37), the dashboard's own mobile position */
   body:has(#intro:not(.is-gone)) #cp-card.on .cp-avwrap {
      left: 20px !important;
      top: 37px !important;
      bottom: auto !important;
   }
   body:has(#intro:not(.is-gone)) #cp-card .cp-body {
      top: 0 !important;
      bottom: auto !important;
   }
}


/* ==========================================================================
   THE CARD HOLDS ITS SIZE -- THE NOTARY'S STILL EYE (desktop)

   The first desktop cut shrank the card on open to pin the Hide button at
   the window's top (the ride) -- the 26 Aug video shows why that reads
   wrong: the panel contracts, the list's tail is unreachable, and closing
   jumps the view back to the score strips. the-notary.html is the contract:
   the card does not move, the room glides open BELOW the button inside the
   card's own scroller, and whatever exceeds the cap is scrolled to, not
   chased.

   ONE cap for both states, so no state change can ever resize the card:
   never below the closed profile's own height (~440px, so rest never grows
   a scrollbar), and taking the window's spare room when it exists. There is
   no max-height transition -- the card grows toward the cap only as the
   room's grid glide feeds it content, so all motion rides the room's one
   clock. Desktop only; the mobile sheet has its own rules.
   ========================================================================== */
@media (min-width: 641px) {
   #state-modal.statecard {
      /* The cap IS the ride's landing frame, so it is content arithmetic,
         not viewport arithmetic: bottom-pinned, the window shows the last
         clientHeight pixels of the list, and clientHeight = cap - 27px of
         chrome. 446 puts the window's top 6px above the Hide button
         (button at content-y 327 of 740), so the ride lands on the button
         with the last dot strip just out of frame -- on every window size.
         Still comfortably above the closed profile's 388px of content, so
         rest never scrolls. */
      max-height: 446px;
   }
}


/* ==========================================================================
   THE STORY TOOLTIP'S NON-NEGOTIABLES

   After a restart Dash auto-injects assets/storyfx/shell.css (until the
   assets_ignore in app.py takes effect at the NEXT restart), and its own
   .sx-tip rules -- position:absolute, z-index:20 -- overrode the ported
   ones. The tip kept firing and filling, but at z20 it painted BENEATH the
   z-3000 intro overlay: invisible, "not working". These two properties are
   what make the tip exist at all on this page, so they are pinned beyond
   any stylesheet's reach. Values identical to live.html's own, so the
   galleries are unaffected.
   ========================================================================== */
body > .sx-tip {
   position: fixed !important;
   z-index: 4000 !important;
   pointer-events: none !important;
}


/* ==========================================================================
   DESKTOP TOPBAR ORDER: SEARCH, THEN TIER, THEN THE TOGGLE

   Flex order, not DOM order: the phone bar's two-row arrangement is signed
   off and reads the DOM as-is, so the swap is desktop-only. Everything else
   keeps order 0 and sits where the markup puts it; the tier picker takes 1
   and the Pre/Post toggle 2, landing the tier right after the search box.
   Its menu also gains from the move: further inboard, the 46px blur drops
   over the squares rather than bare paper.
   ========================================================================== */
@media (min-width: 641px) {
   .topbar .topright { order: 1; }
   .topbar .segment  { order: 2; }
}


/* ---- the credits box ---------------------------------------------------- */
@media (min-width: 641px) {
   #credits-box{position:fixed;right:26px;bottom:22px;z-index:3002;
      height:40px;width:112px;overflow:hidden;cursor:pointer;
      display:flex;align-items:center;justify-content:center;
      border:1px solid transparent;border-radius:12px;background:transparent;
      transition:width .46s cubic-bezier(.16,1,.3,1),height .46s cubic-bezier(.16,1,.3,1),
         border-radius .46s cubic-bezier(.16,1,.3,1),background .28s ease,border-color .3s ease}
   #credits-box .cb-label{font-family:'JetBrains Mono',monospace;font-size:9px;
      letter-spacing:.13em;text-transform:uppercase;color:#7C746A;transition:opacity .2s ease}
   #credits-box .cb-body{position:absolute;inset:0;padding:17px 20px;opacity:0;
      pointer-events:none;display:flex;flex-direction:column;gap:11px;
      transition:opacity .3s ease .12s}
   #credits-box .cb-title{font-family:'JetBrains Mono',monospace;font-size:8px;
      letter-spacing:.16em;color:#A79C8E}
   #credits-box .cb-row{display:flex;flex-direction:column;gap:1px;
      text-decoration:none;cursor:pointer}
   #credits-box .cb-row b i{font-style:normal;font-size:10px;font-weight:400;
      margin-left:6px;opacity:.5;display:inline-block;
      transition:transform .3s cubic-bezier(.16,1,.3,1),opacity .25s ease}
   #credits-box .cb-row:hover b i{transform:translate(2px,-2px);opacity:1}
   #credits-box .cb-row:active{transform:translateY(1px)}
   #credits-box .cb-row b{font-family:var(--ui-semi,'Space Grotesk'),sans-serif;
      font-size:12.5px;font-weight:600;color:#16130E}
   #credits-box .cb-row span{font-size:10.5px;color:#6B6359}
   #credits-box.on{width:266px;height:182px;border-radius:16px;background:#FFFFFF;
      border-color:transparent;box-shadow:0 18px 44px rgba(28,20,14,.18)}
   #credits-box.on .cb-label{opacity:0}
   #credits-box.on .cb-body{opacity:1;pointer-events:auto}
}
@media (max-width: 640px){ #credits-box{display:none} }

/* ==========================================================================
   THE CREDITS TAKE THE STORY'S BOTTOM-LEFT CORNER

   It used to sit in the dashboard's bottom-right and was hidden during the
   story. The two have swapped: the mark now owns the story's top-left (with
   the skip pill opposite it), which leaves the corner the mark vacated --
   and the sources belong with the story, where the reader is being told
   something, rather than over the field they are exploring.

   Closed it is the skip pill's twin: the same outline, the same mono voice,
   the same 46px height -- so the two bottom corners and the two top corners
   read as one set of chrome. It sits on the timeline's own centre line.
   Opening is unchanged: the same growth, the same hover and click.
   ========================================================================== */
body:has(#intro.is-gone) #credits-box { display: none !important; }

/* 2026-08-26: shelved on request -- the whole pill is parked for now. All
   its machinery (builder, links, glass) stays live underneath; delete this
   one rule to bring it back. */
#credits-box { display: none !important; }

@media (min-width: 641px) {
   body:has(#intro:not(.is-gone)) #credits-box {
      right: auto;
      left: 28px;                              /* mirrors the skip's inset */
      /* the timeline's own centre line: the blocks are 52 tall and their
         feet sit 13px above the rail's base, so their middle is edge+39 --
         a 46px pill centred on it starts at edge+16 */
      bottom: calc(var(--edge) + 16px);
      box-sizing: border-box;                  /* 46 means 46, padding inside */
      height: 46px;
      width: 150px;
      padding: 0 20px;
      /* no outline out here: the word alone, the hover carrying the state */
      border-color: transparent;
      background: transparent;
   }
   body:has(#intro:not(.is-gone)) #credits-box .cb-label {
      font-size: 10px;
      color: rgba(245, 243, 238, .72);
   }
   body:has(#intro:not(.is-gone)) #credits-box:hover .cb-label {
      color: #F7F5F1;
   }
   /* open, it takes the story's glass -- the same recipe the mark's card and
      the chart tooltips use, so nothing on this page is made of paper */
   body:has(#intro:not(.is-gone)) #credits-box.on {
      width: 266px;
      height: 182px;
      padding: 0;
      background: rgba(16, 13, 10, .40);
      -webkit-backdrop-filter: blur(18px) saturate(185%);
      backdrop-filter: blur(18px) saturate(185%);
      border-color: transparent;
      /* the card's own glass, but half its shadow: over the timeline's dark
         corner the .38 read as a black slab rather than a lift */
      box-shadow: 0 12px 30px rgba(0, 0, 0, .22);
   }
   body:has(#intro:not(.is-gone)) #credits-box .cb-title { color: rgba(245, 243, 238, .55); }
   body:has(#intro:not(.is-gone)) #credits-box .cb-row b { color: #F7F5F1; transition: color .25s ease; }
   body:has(#intro:not(.is-gone)) #credits-box .cb-row span { color: rgba(245, 243, 238, .78); }
   /* the same hover voice as the story's other chrome: brighten to full */
   body:has(#intro:not(.is-gone)) #credits-box .cb-row:hover b { color: #FFFFFF; }
   body:has(#intro:not(.is-gone)) #credits-box .cb-row:hover span { color: rgba(245, 243, 238, .95); }
}

/* A 2px always-alive blur layer. The tier menu's glass is a 46px
   backdrop-filter, and on the FIRST open after load the GPU still has to
   assemble that pipeline -- the menu painted transparent for a frame and
   then snapped to glass. Keeping one tiny blurred element compositing from
   page load means the first real menu open reuses a warm pipeline. */
@media (min-width: 641px) {
   .topbar::after {
      content: '';
      position: fixed;
      right: 0; bottom: 0;
      width: 2px; height: 2px;
      opacity: .01;
      pointer-events: none;
      -webkit-backdrop-filter: blur(46px);
      backdrop-filter: blur(46px);
   }
}


/* ---- the chrome answers the pointer -------------------------------------
   Skip Intro and Enter Dashboard zoom a touch on hover and compress on
   press; :active covers the tap on phones. Possible only because the
   entrance keyframes above release `transform` after they finish. */
#intro-skip,.intro-enter{will-change:transform}
/* the credits button's spring -- .46s on cubic-bezier(.16,1,.3,1) -- and a
   zoom big enough to read as a gesture rather than a shiver */
#intro-skip{transition:color .25s ease,border-color .25s ease,
   transform .46s cubic-bezier(.16,1,.3,1)}
#intro-skip:hover{transform:scale(1.08)}
#intro-skip:active{transform:scale(.95);transition-duration:.25s,.25s,.16s}
.intro-enter{transition:background .28s var(--ease,ease),color .28s var(--ease,ease),
   border-color .28s var(--ease,ease),transform .46s cubic-bezier(.16,1,.3,1)}
.intro-enter:hover{transform:translateY(-2px) scale(1.06)}
.intro-enter:active{transform:scale(.97);transition-duration:.28s,.28s,.28s,.16s}

/* THE MAGNET (desktop story). intro.js feeds --magx/--magy from the pointer
   while it is over the button; every transform state composes them in, so
   the zoom and the press ride the displaced position. Two clocks: following
   is short and eased (the slight lag that reads as pull), release falls back
   to a longer curve with overshoot -- the elastic return in the reference
   video. Vars go to 0 on leave and the spring carries it home. */
@media (min-width:641px){
  .intro-enter{transform:translate(var(--magx,0px),var(--magy,0px));
    transition:background .28s var(--ease,ease),color .28s var(--ease,ease),
      border-color .28s var(--ease,ease),
      transform .58s cubic-bezier(.34,1.56,.64,1)}
  .intro-enter.magnet-live{
    transition:background .28s var(--ease,ease),color .28s var(--ease,ease),
      border-color .28s var(--ease,ease),
      transform .16s cubic-bezier(.25,.6,.35,1)}
  .intro-enter:hover{
    transform:translate(var(--magx,0px),var(--magy,0px)) translateY(-2px) scale(1.06)}
  .intro-enter:active{
    transform:translate(var(--magx,0px),var(--magy,0px)) scale(.97)}
}


/* ==========================================================================
   THE STORY PAGE'S CREDIT-CARD SURFACE -- COLOUR ONLY, ZERO GEOMETRY

   Everything positional is production's, untouched. This block only reskins:
   the ring takes the story chrome's outline value, and the open card takes
   the story tooltip's glass byte for byte. (The previous version of this
   block was accidentally deleted along with the geometry experiments, which
   is why the white card and green ring came back.)
   ========================================================================== */
body:has(#intro:not(.is-gone)) #cp-ring {
   border-color: rgba(245, 243, 238, .55);
}
body.credit-closing:has(#intro:not(.is-gone)) #cp-card,
body:has(#intro:not(.is-gone)) #cp-card.on {
   background: rgba(16, 13, 10, .40) !important;
   -webkit-backdrop-filter: blur(18px) saturate(185%) !important;
   backdrop-filter: blur(18px) saturate(185%) !important;
   border: 0 !important;
   box-shadow: 0 18px 44px rgba(0, 0, 0, .38) !important;
}
body:has(#intro:not(.is-gone)) #cp-card .cp-kicker { color: rgba(245,243,238,.62) !important; }
body:has(#intro:not(.is-gone)) #cp-card .cp-fullname { color: #F7F5F1 !important; }
body:has(#intro:not(.is-gone)) #cp-card .cp-row,
body:has(#intro:not(.is-gone)) #cp-card .cp-row span { color: rgba(245,243,238,.88) !important; }
body:has(#intro:not(.is-gone)) #cp-card .cp-row i { color: rgba(245,243,238,.62) !important; }
body:has(#intro:not(.is-gone)) #cp-card .cp-row + .cp-row::before {
   background: rgba(245,243,238,.18) !important;
}
body:has(#intro:not(.is-gone)) #cp-card .cp-me-label { color: rgba(245,243,238,.55) !important; }

/* Hover and press for the two link rows on the dark glass: the resting
   .88 white steps up to full, the arrow does its production nudge (that
   rule was never buried), and the press dips the row a pixel. Desktop:
   the phone has no hover and its card is a different machine. */
@media (min-width: 641px) {
   /* THE ROWS ANSWER, VISIBLY. The colour-only hover was live on both pages
      and both rows (measured), but at 11.5px on glass a shift from .88 white
      to full white is nearly invisible -- "GitHub has no hover" is what that
      looks like from a chair. The row now takes a wash and a hairline of its
      own, which reads instantly and at any brightness. */
   /* NEVER touch padding here: `#cp-card .cp-row` carries the 78px indent
      that aligns both labels with the kicker and the name, and an 8px
      padding written at the same specificity silently replaced it -- on
      both pages, which is how LinkedIn and GitHub ended up jammed against
      the card's left edge. The wash is drawn as a layer instead, so the
      hover costs the layout nothing. */
   #cp-card .cp-row {
      position: relative;
      transition: transform .15s ease;
   }

   /* The wash starts where the TEXT starts, not where the row's box starts:
      the row carries a 78px indent (it is what aligns the labels with the
      kicker and the name), so an inset of -8 painted a slab from under the
      photo to halfway across the card -- nowhere near the label it belongs
      to. 70 = the indent less the same 8px of bleed the right edge gets. */
   #cp-card .cp-row::after {
      content: '';
      position: absolute;
      left: 70px;
      right: -8px;
      top: 1px;
      bottom: 1px;
      z-index: -1;
      border-radius: 8px;
      background: transparent;
      transition: background .22s ease;
   }

   #cp-card .cp-row:hover::after {
      background: rgba(22, 19, 14, .05);
   }

   #cp-card .cp-row:active {
      transform: translateY(1px);
   }

   body:has(#intro:not(.is-gone)) #cp-card .cp-row:hover::after {
      background: rgba(245, 243, 238, .10);
   }

   body:has(#intro:not(.is-gone)) #cp-card .cp-row:hover span { color: #FFFFFF !important; }
   body:has(#intro:not(.is-gone)) #cp-card .cp-row:hover i { color: #FFFFFF !important; }
}

/* mobile: the open card mirrors its left inset on the right, and the content
   group -- photo beside the text column -- centres in the card: group height
   106 in a 157 card leaves 26 above and 25 below, with the photo's top on
   the kicker's line. */
@media (max-width: 640px) {
   body:has(#intro:not(.is-gone)) #cp-card.on,
   body:has(#intro:not(.is-gone)) #cp-ring.on {
      width: calc(100vw - 24px) !important;   /* 12 each side, as the bar */
   }
   /* .on AND credit-closing: the interior keeps its open geometry through
      the delayed close, the same way production holds the card's glass --
      without the closing arm, the padding snapped shut the instant .on
      dropped, a beat before the box began to shrink */
   /* .on only -- NOT held through credit-closing. Production's closing
      transition already animates the photo's top in sync with the shrinking
      box (delayed .45s, .58s run); pinning it at 26px through the close left
      a 42px photo hanging below a 40px circle, then hopping back up. The
      body padding below stays held: padding has no transition to cover it. */
   body:has(#intro:not(.is-gone)) #cp-card.on .cp-avwrap {
      top: 26px !important;
      bottom: auto !important;
   }
   body:has(#intro:not(.is-gone)) #cp-card.on .cp-body,
   body.credit-closing:has(#intro:not(.is-gone)) #cp-card .cp-body {
      padding-top: 26px !important;
      padding-bottom: 25px !important;
   }
}


/* No tour card while the story is up -- any circumstance. The tour schedules
   itself on every dashboard entrance; going straight back to the story via
   the Story button left that timer running and the card surfaced over the
   chapters. The gate is the page state itself, so no timer can outrun it. */
body:has(#intro:not(.is-gone)) #tourcard { display: none !important; }


/* ==========================================================================
   DESKTOP: THE PICTURE FILLS THE SCREEN, NOT THE FRAME

   promo.mp4 is pillarboxed inside its own file -- the picture is 74.5% of
   the frame's width -- so object-fit:cover full-bleeds the FRAME and the
   file's baked-in black bars still show at the sides. Scaling the element
   by 1/0.745 (1.35 with a sliver of over-scan) pushes the bars past the
   viewport; #promo-box clips the overflow. The settle animation carries the
   same factor -- its resting frame must BE this scale, or the fill:both
   keyframe would land the video back at 1 and the bars would return.
   Mobile is untouched: the portrait crop already overfills its screen.
   ========================================================================== */
@media (min-width: 641px) {
   #promo {
      transform: scale(1.35);
      animation-name: promoSettleWide;
   }
   @keyframes promoSettleWide {
      from { transform: scale(1.65); filter: blur(16px) saturate(.85); }
      to   { transform: scale(1.35); filter: blur(0) saturate(1); }
   }
}


/* THE SCRIM CANNOT OUTLIVE THE SHEET. Its pointer-events ride inline styles
   written by paint(), and a Dash re-render of #card-wrap can strand a scrim
   whose last inline state was open ('auto') while the sheet itself is SHUT --
   an invisible veil that ate the reopen tap on the profile button (measured:
   elementFromPoint at the CTA returned #sheet-scrim). The body's sheet-open
   class is the sheet system's own ground truth, so it gates the scrim at the
   stylesheet level, above any stale inline value. */
body:not(.sheet-open) #sheet-scrim {
   pointer-events: none !important;
   opacity: 0 !important;
}


/* THE SHOW MIRRORS THE HIDE (phone). The exit cascade leaves one stamp at a
   time on nth-child delays; the entrance had NO delays -- fourteen stamps
   slammed in the first 340ms and the growing room merely uncovered them,
   which reads as a wipe. Same delays, run forwards: each stamp lands just
   after the room for it opens, top to bottom, finishing with the fold. */
/* The room's OPENING sweep, phone only. The closing curve (.7,0,.4,1) hits
   ~180px per frame at its midpoint -- fine for CLOSING over space the stamps
   have already left, but as a REVEAL it uncovered nearly two rows in one
   beat: smooth to MEDICATION/WAITING, a lurch past the next row to
   CONSCIENCE/PARENTAL, smooth again (the user read the rows off the video).
   Same soft ends, longer clock, mild middle: the peak stays under one row
   per frame, so every row is SEEN arriving. Closing keeps its own curve via
   the [0fr] variant. */
@media (max-width: 640px) {
   .collapsible {
      transition: grid-template-rows 1.3s cubic-bezier(0.45, 0.12, 0.5, 0.94);
   }
}

@keyframes stampInSoft {
   0% {
      opacity: 0;
      transform: rotate(var(--r, 0deg)) scale(0.82) translateY(-4px);
   }

   60% {
      opacity: 1;
      transform: rotate(var(--r, 0deg)) scale(1);
   }

   100% {
      opacity: 1;
      transform: rotate(var(--r, 0deg)) scale(1);
   }
}

@media (max-width: 640px) {
   /* The hide's motion (stampInSoft is stampOut reversed, same clock) BEHIND
      a .3s curtain. The curtain earns its keep on the phone: the body's box
      arrives at full height in one go there (the iOS climb choreography), so
      there is no visible growing edge -- without the hold, stamps trickle
      into a dead white void. Three tenths lets the sheet's own climb carry
      the eye, then the cascade sweeps top to bottom on the exit's 45ms
      rhythm. !important because the server writes inline delays. */
   .collapsible[style*="1fr"] .pstamp {
      animation: stampInSoft 0.58s cubic-bezier(0.4, 0, 0.6, 1) both;
      /* the cascade rides the glide's back half: starting at .6s the first
         stamps arrive while the curtain is decelerating, and the tail lands
         just as the view goes still -- earlier read as a wipe (arrivals
         drowned in the travel), later read as late. */
      animation-delay: .6s !important;
   }
   .collapsible[style*="1fr"] .pstamp:nth-child(2)  { animation-delay: .645s !important; }
   .collapsible[style*="1fr"] .pstamp:nth-child(3)  { animation-delay: .69s !important; }
   .collapsible[style*="1fr"] .pstamp:nth-child(4)  { animation-delay: .735s !important; }
   .collapsible[style*="1fr"] .pstamp:nth-child(5)  { animation-delay: .78s !important; }
   .collapsible[style*="1fr"] .pstamp:nth-child(6)  { animation-delay: .825s !important; }
   .collapsible[style*="1fr"] .pstamp:nth-child(7)  { animation-delay: .87s !important; }
   .collapsible[style*="1fr"] .pstamp:nth-child(8)  { animation-delay: .915s !important; }
   .collapsible[style*="1fr"] .pstamp:nth-child(9)  { animation-delay: .96s !important; }
   .collapsible[style*="1fr"] .pstamp:nth-child(10) { animation-delay: 1.005s !important; }
   .collapsible[style*="1fr"] .pstamp:nth-child(11) { animation-delay: 1.05s !important; }
   .collapsible[style*="1fr"] .pstamp:nth-child(12) { animation-delay: 1.095s !important; }
   .collapsible[style*="1fr"] .pstamp:nth-child(13) { animation-delay: 1.14s !important; }
   .collapsible[style*="1fr"] .pstamp:nth-child(n+14) { animation-delay: 1.185s !important; }
}

/* THE NOTARY, DESKTOP SIDE. The close act always lived in the shared base
   rules (stampOut cascade, then the room folds on the fast curve). The open
   is the phone's two acts re-timed for THIS stage: out here there is no
   sheet climb to hide behind -- the room's clip edge descends through the
   card while the ride carries the button to the top, so a .6s curtain put
   naked white on screen (the 26 Aug video). Instead the arrivals TRACK THE
   EDGE: the count line rises first, the first stamp lands just behind the
   clip at .35s, and one follows every 90ms -- the pace the 1.3s curve
   reveals a row -- so the tail is still landing as the ride stills. Same
   acts, same curves, same seal motion; only the curtain is different,
   because the stage is. Desktop-fenced; the phone's block above stays the
   only mobile authority. */
@media (min-width: 641px) {
   .collapsible {
      transition: grid-template-rows 1.3s cubic-bezier(0.45, 0.12, 0.5, 0.94);
   }

   /* The close's fold, held for .22s: on the phone the counter-translate
      glide hides the fold's first movement, but out here the wipe read
      naked over the lifting stamps. The hold lets the first seals visibly
      lift in place, then the room closes behind them -- the demo's order,
      staged a beat apart. */
   .collapsible[style*="0fr"] {
      transition: grid-template-rows 0.95s cubic-bezier(.7, 0, .4, 1) .22s;
   }

   /* the count line is revealed before any stamp: it eases up rather than
      popping in with the server's render */
   @keyframes countIn {
      from { opacity: 0; transform: translateY(5px); }
      to   { opacity: 1; transform: none; }
   }

   .collapsible[style*="1fr"] .collapsible-inner > .kicker {
      animation: countIn .45s cubic-bezier(.4, 0, .6, 1) .18s both;
   }

   .collapsible[style*="1fr"] .pstamp {
      animation: stampInSoft 0.58s cubic-bezier(0.4, 0, 0.6, 1) both;
      animation-delay: .35s !important;
   }
   .collapsible[style*="1fr"] .pstamp:nth-child(2)  { animation-delay: .44s !important; }
   .collapsible[style*="1fr"] .pstamp:nth-child(3)  { animation-delay: .53s !important; }
   .collapsible[style*="1fr"] .pstamp:nth-child(4)  { animation-delay: .62s !important; }
   .collapsible[style*="1fr"] .pstamp:nth-child(5)  { animation-delay: .71s !important; }
   .collapsible[style*="1fr"] .pstamp:nth-child(6)  { animation-delay: .80s !important; }
   .collapsible[style*="1fr"] .pstamp:nth-child(7)  { animation-delay: .89s !important; }
   .collapsible[style*="1fr"] .pstamp:nth-child(8)  { animation-delay: .98s !important; }
   .collapsible[style*="1fr"] .pstamp:nth-child(9)  { animation-delay: 1.07s !important; }
   .collapsible[style*="1fr"] .pstamp:nth-child(10) { animation-delay: 1.16s !important; }
   .collapsible[style*="1fr"] .pstamp:nth-child(11) { animation-delay: 1.25s !important; }
   .collapsible[style*="1fr"] .pstamp:nth-child(12) { animation-delay: 1.34s !important; }
   .collapsible[style*="1fr"] .pstamp:nth-child(13) { animation-delay: 1.43s !important; }
   .collapsible[style*="1fr"] .pstamp:nth-child(n+14) { animation-delay: 1.52s !important; }
}




/* ==========================================================================
   THE CORNER HANDOFF (phone)

   Story and dashboard share the avatar -- #cp-card and its #cp-ring are ONE
   element on both pages, resting 8px apart in the same corner -- and the
   skip pill occupies the corner the period segment takes over. On a page
   switch the shared avatar TRAVELS instead of snapping, and a ghost pill
   flies the skip's box into the segment's (and back on a story replay).
   Everything is scoped to body.corner-morph, a ~.95s window the story engine
   opens around the switch, so the card's own open/close choreography never
   inherits these transitions.
   ========================================================================== */
@media (max-width: 640px) {
   body.corner-morph #cp-card {
      transition: top .65s cubic-bezier(.16, 1, .3, 1),
                  left .65s cubic-bezier(.16, 1, .3, 1);
   }
   body.corner-morph #cp-ring {
      transition: top .65s cubic-bezier(.16, 1, .3, 1),
                  left .65s cubic-bezier(.16, 1, .3, 1),
                  border-color .65s ease;
   }
   /* THE WHOLE CONTROL IS HELD BACK while a handoff is in flight (version I).
      Not just the chip's sage: the toggle itself is clipped away, so when
      the pill lands there is nothing there yet and the shell can unroll out
      of the chip's edge as the effect's second act. */
   body.corner-handoff .topbar .segment { clip-path: inset(0 0 0 100%); }

   .corner-ghost {
      position: fixed; z-index: 4200; pointer-events: none;
      box-sizing: border-box;
      display: flex; align-items: center; justify-content: center;
      font-family: 'JetBrains Mono', monospace; font-size: 9px;
      letter-spacing: .14em; text-transform: uppercase; white-space: nowrap;
      overflow: hidden; border: 1px solid; border-radius: 12px;
      transition: top .62s cubic-bezier(.16, 1, .3, 1),
                  left .62s cubic-bezier(.16, 1, .3, 1),
                  width .62s cubic-bezier(.16, 1, .3, 1),
                  height .62s cubic-bezier(.16, 1, .3, 1),
                  color .38s ease, border-color .62s ease,
                  background-color .62s ease, border-radius .62s ease,
                  opacity .26s ease;
   }
}


/* (A full-height chip lived here, so the skip's landing box would match the
   pill exactly. Reverted on sign-off: the inset chip -- sage floating
   inside the shell with its 3px of air on every side -- is the design, and
   the 8px the flight gives up is worth less than that detail. The topbar's
   row-gap fix is separate and stays.) */


/* ==========================================================================
   THE CORNER HANDOFF (desktop)

   Version I again, verbatim in gesture -- and out here it is barely a
   flight: the mark took the top-left seat on both pages, and the segment
   now occupies the skip pill's exact box (same top, same height, same 28px
   right inset). So the pill does not travel, it CONTRACTS into the chip
   it was standing on, and the toggle unrolls leftward out of that chip's
   edge. The rules are the phone's, re-declared behind a desktop fence --
   never shared -- so the signed-off mobile block above is untouchable.
   ========================================================================== */
@media (min-width: 641px) {
   body.corner-handoff .topbar .segment { clip-path: inset(0 0 0 100%); }

   .corner-ghost {
      position: fixed; z-index: 4200; pointer-events: none;
      box-sizing: border-box;
      display: flex; align-items: center; justify-content: center;
      font-family: 'JetBrains Mono', monospace; font-size: 9px;
      letter-spacing: .14em; text-transform: uppercase; white-space: nowrap;
      overflow: hidden; border: 1px solid; border-radius: 12px;
      transition: top .62s cubic-bezier(.16, 1, .3, 1),
                  left .62s cubic-bezier(.16, 1, .3, 1),
                  width .62s cubic-bezier(.16, 1, .3, 1),
                  height .62s cubic-bezier(.16, 1, .3, 1),
                  color .38s ease, border-color .62s ease,
                  background-color .62s ease, border-radius .62s ease,
                  opacity .26s ease;
   }
}



/* ==========================================================================
   DESKTOP: THE MARK TAKES THE TOP-LEFT CORNER OF BOTH PAGES

   The phone has always put it there, opposite the skip pill; the desktop kept
   production's bottom-left, which is the dashboard's corner, not the story's.

   THE TRAP, PAID FOR ONCE: #cp-ring animates left, bottom, width, height and
   border-radius -- `top` is in neither its base transition nor the delayed
   `credit-closing` one. Pin the ring by `top` without adding it and its
   vertical position SNAPS while the card's glass tweens: the outline peels
   off the background, worst on the close, where the delay makes the mismatch
   a full half second. Both transitions are therefore re-declared below with
   `top` in them, and the ring and the glass move as one object again.

   The numbers: the visible circle is the ring (62px) and its centre sits on
   the skip pill's centre line (38 + 46/2 = 61), so the two top corners are
   levelled. Production insets the ring 6px from the card's left and 10px
   from its top, which puts the card at (22, 20); the photo keeps its own
   13px inset, read from the top instead of the bottom.
   26 Aug, second act: the dashboard's mark moved up here too (the phone's
   own arrangement -- one seat on both pages, only the ring's colour crosses
   over), so the story scoping fell away. The skin blocks stay per-page:
   story glass and beige ring, dashboard white card and green ring; the
   ring's border-colour rides a transition so the page switch melts it.
   ========================================================================== */
@media (min-width: 641px) {
   /* THE GLASS IS THE RING'S OWN BOX, NEVER A LARGER ONE.

      Production's card is 78 while its ring is 62 -- there the card is only
      a hit area and is transparent when closed, so the 16px difference never
      shows. On THIS page the glass is held through the close (so it fades
      with the shrink instead of vanishing), and that 16px became a wing of
      glass sitting outside the outline the whole way down. Closed, the card
      is now exactly the ring: same corner, same 62. Open, both are the same
      366x157 box. No frame has one bigger than the other. */
   #cp-card:not(.on) {
      top: 30px !important;
      bottom: auto !important;
      left: 28px !important;
      width: 62px !important;
      height: 62px !important;
   }
   #cp-card.on {
      top: 20px !important;
      bottom: auto !important;
      left: 22px !important;
   }
   /* THE SAME TRAP, THIRD TIME, now on the card: production pins its card by
      left/bottom forever, so its base transition carries no position
      properties at all. Here the card's corner CHANGES between states --
      without top/left in the list the glass and the photo snapped to the
      open corner the instant the hover landed, leading the ring up-left for
      the first 200ms of every open (the user's frame captures). Position
      rides the same spring as the box. */
   #cp-card {
      transition: top .46s cubic-bezier(.16, 1, .3, 1),
         left .46s cubic-bezier(.16, 1, .3, 1),
         bottom .46s cubic-bezier(.16, 1, .3, 1),
         width .46s cubic-bezier(.16, 1, .3, 1),
         height .46s cubic-bezier(.16, 1, .3, 1),
         border-radius .46s cubic-bezier(.16, 1, .3, 1),
         background .28s ease,
         border-color .3s ease;
   }
   #cp-ring {
      top: 30px !important;
      bottom: auto !important;
      left: 28px !important;
   }
   /* open, the ring IS the card's outline: same box, same corner */
   #cp-ring.on {
      top: 20px !important;
      bottom: auto !important;
      left: 22px !important;
   }

   /* the two transitions, with `top` added -- production's own timings,
      untouched otherwise */
   #cp-ring {
      transition: top .46s cubic-bezier(.16, 1, .3, 1),
         left .46s cubic-bezier(.16, 1, .3, 1),
         bottom .46s cubic-bezier(.16, 1, .3, 1),
         width .46s cubic-bezier(.16, 1, .3, 1),
         height .46s cubic-bezier(.16, 1, .3, 1),
         border-radius .46s cubic-bezier(.16, 1, .3, 1),
         opacity .22s ease,
         border-color .5s ease;
   }
   body.credit-closing #cp-card,
   body.credit-closing #cp-ring {
      transition: width .58s cubic-bezier(.45, 0, .2, 1) .45s,
         height .58s cubic-bezier(.45, 0, .2, 1) .45s,
         border-radius .58s cubic-bezier(.45, 0, .2, 1) .45s,
         left .58s cubic-bezier(.45, 0, .2, 1) .45s,
         top .58s cubic-bezier(.45, 0, .2, 1) .45s,
         bottom .58s cubic-bezier(.45, 0, .2, 1) .45s,
         opacity .34s ease .45s,
         background 0s !important;
   }

   /* closed: concentric in the 62 ring -- (62 - 48) / 2 = 7 on both axes,
      the same 7 production keeps between avwrap and ring (13 - 6) */
   #cp-card:not(.on) .cp-avwrap {
      top: 7px !important;
      left: 7px !important;
      bottom: auto !important;
   }
   /* open: photo at the card's top-left, its INK on the kicker's line. 20,
      not 17, for the same reason the left inset is 20 -- the picture is
      scaled 1.12 about its centre and overhangs its box by ~3px a side.

      The paddings are NOT touched. 17/19 are optically tuned to this card's
      own metrics (the link rows carry 8px below, the credit line 1px of
      half-leading), and the 157px box is cut to fit them exactly. Forcing
      the phone's 26/25 here pushed the content 15px past the box and the
      overflow clip ate the GitHub row. */
   /* the photo's BOX top sits on the kicker's line, exactly the phone's
      pairing (box 26, padding 26) -- it was left at 20 from the earlier
      padding scheme and floated 9px above the text */
   #cp-card.on .cp-avwrap {
      top: 26px !important;
      bottom: auto !important;
      left: 20px !important;
   }
   /* The copy is laid out from the edge that does NOT travel. Production
      anchors it to the bottom because its card grows upward; this one grows
      downward, so the top is the fixed edge and the copy hangs off that. */
   #cp-card .cp-body {
      top: 0 !important;
      bottom: auto !important;
   }
   /* and the group centres in the card, as it does on the phone: 106 of
      content in a 157 box leaves 26 above and 25 below */
   #cp-card.on .cp-body,
   body.credit-closing #cp-card .cp-body {
      padding-top: 26px !important;
      padding-bottom: 25px !important;
   }
}

/* The brand pill hands its corner to the mark (desktop): REPRODUCTIVE
   RIGHTS came off the bar and the avatar sits in the story's own seat on
   both pages. And with the mark owning the line, the whole bar drops onto
   IT: every control centres on the mark's own centreline (y61 -- the line
   the story's skip pill already sits on), every control takes the skip
   pill's 46px height, and the segment takes the skip's exact box (right
   inset 28) so the page switch lands toggle-for-pill with no jump. The
   phone's bar -- its 40px rows, its own brand -- is untouched. */
@media (min-width: 641px) {
   .brandmark {
      display: none;
   }

   .topbar {
      top: 38px;
      left: 28px;
      right: 28px;
      height: 46px;
      /* the mark's box ends at x90; 28px of air before the first button */
      padding-left: 90px;
   }

   .storybtn {
      height: 46px;
   }

   .searchfield {
      height: 46px;
   }

   /* production pins this one with !important (Dash paints the button
      itself), so the bar's height has to answer in kind */
   button.dash-dropdown.metricbox {
      height: 46px !important;
   }

   /* 38 + the segment's own 3px frame + 1px border = the skip's 46 */
   .segbtn {
      height: 38px;
   }

   /* Concentric corners: the frame insets the pill by 4px (3 padding +
      1 border), so the inner radius must be 12 - 4 = 8. At the old 9 the
      ink's corner cut a 1px crescent outside the frame's arc -- visible as
      a white nick on the POST-DOBBS corner now that the pill is 38 tall. */
   .segbtn,
   .segment::before {
      border-radius: 8px;
   }

   /* The bar speaks one material. Every other control out here is a
      transparent pane with the page blurred behind it; the segment kept a
      near-solid #FCFBF9 fill, which read as a grey slab parked at the end
      of a row of glass. Same recipe as the tier box -- the sliding green
      ink is what marks the choice, not the frame's fill. */
   .segment {
      background: transparent;
      -webkit-backdrop-filter: blur(30px) saturate(150%);
      backdrop-filter: blur(30px) saturate(150%);
   }

   /* Dash paints its own accent border on the tier button once it has been
      clicked, and it survived the menu closing -- one control left glowing
      green in a bar of muted outlines. The stroke belongs to the OPEN menu
      (and to keyboard focus), not to "you touched this once". */
   button.dash-dropdown.metricbox:focus:not(:focus-visible) {
      border-color: rgba(22, 19, 14, 0.22) !important;
      box-shadow: none !important;
   }

   /* The band the column centres on moved with the bar. Its top used to be
      the bar's own foot (22 + 40 = 62); the bar now sits at 38 and stands
      46 tall, and the MARK -- taller than the bar it sits in -- reaches 92.
      93 keeps the pre-move clearance on every window (a 760px laptop was
      down to 5px of air between the mark and the state title). The bottom
      stays 100: the credits corner did not move. */
   :root {
      --band-top: 93px;
   }
}


/* ==========================================================================
   THE SHEET  (desktop only -- fifth cut and the signed-off one, 27 Aug)

   Four cuts tried to make the CARD do something: lift it, migrate the
   column, fly it over the field, grow it in place. All rejected, and the
   reason turned out to be the same each time -- the phone's reveal is loved
   not because of what the card does but because a SHEET CLIMBS and the page
   behind it steps back. So the desktop gets that, at its own scale: a half
   sheet, inset, with the figures across its head like a masthead, the
   state's computed reading beneath them, and the seals landing after the
   words.

   The card's own collapsible stays exactly as it is for the phone; out here
   it is hidden and the sheet is the surface. One callback, one stamp
   builder, two frames -- the surfaces cannot drift.
   ========================================================================== */
@media (min-width: 641px) {
   /* the page recedes -- the part everyone recognises from a made thing */
   .app {
      transform-origin: 50% 0;
      transition: transform .8s cubic-bezier(.16, 1, .3, 1),
         filter .6s ease, border-radius .8s cubic-bezier(.16, 1, .3, 1);
   }

   body.sheet-up .app {
      transform: scale(.955) translateY(-14px);
      border-radius: 16px;
      filter: brightness(.9);
      overflow: hidden;
   }

   .shscrim {
      position: fixed;
      inset: 0;
      z-index: 4300;
      background: rgba(22, 19, 14, .26);
      opacity: 0;
      pointer-events: none;
      transition: opacity .6s ease;
   }

   body.sheet-up .shscrim { opacity: 1; pointer-events: auto; }

   .indsheet {
      position: fixed;
      left: 11%;
      right: 11%;
      bottom: 0;
      /* it is as tall as it needs to be, never taller: a sheet padded out to
         a fixed 74vh left a hand's width of empty glass under the seals */
      height: auto;
      max-height: 82vh;
      z-index: 4400;
      overflow: hidden;
      display: flex;
      flex-direction: column;
      background: rgba(255, 255, 255, .74);
      -webkit-backdrop-filter: blur(44px) saturate(200%);
      backdrop-filter: blur(44px) saturate(200%);
      border: 1px solid rgba(255, 255, 255, .75);
      border-bottom: none;
      border-radius: 24px 24px 0 0;
      box-shadow: 0 -22px 60px rgba(28, 20, 14, .22);
      /* the phone's own climb: one spring, nothing else moving */
      transform: translateY(101%);
      transition: transform .72s cubic-bezier(.16, 1, .3, 1);
      padding: 16px 34px 0;
   }

   .indsheet.is-up { transform: translateY(0); }

   .shgrip {
      width: 44px; height: 4px; border-radius: 2px;
      background: rgba(22, 19, 14, .16);
      margin: 0 auto 14px; cursor: pointer;
   }

   .shhead {
      display: flex; align-items: baseline; justify-content: space-between;
      margin-bottom: 16px;
   }

   .shhead b {
      font-family: var(--mono); font-size: 9px; letter-spacing: .18em;
      text-transform: uppercase; color: #7C746A; font-weight: 500;
   }

   .shclose {
      border: 1px solid rgba(22, 19, 14, .22); background: none; cursor: pointer;
      border-radius: 9px; padding: 6px 12px;
      font-family: var(--mono); font-size: 8.4px; letter-spacing: .14em;
      text-transform: uppercase; color: #7C746A;
      transition: transform .46s cubic-bezier(.16, 1, .3, 1),
         border-color .25s ease, color .25s ease;
   }

   .shclose:hover { transform: scale(1.08); border-color: #C6BCAE; color: #16130E; }
   .shclose:active { transform: scale(.95); }

   /* THE MASTHEAD: four computed figures, ruled off */
   .shfigs {
      display: flex; gap: 44px;
      padding: 0 0 16px; margin-bottom: 20px;
      border-bottom: 1px solid rgba(22, 19, 14, .14);
   }

   .shfig { opacity: 0; transform: translateY(7px);
      transition: opacity .5s ease, transform .7s cubic-bezier(.16, 1, .3, 1); }
   .indsheet.is-up .shfig { opacity: 1; transform: none; }
   .indsheet.is-up .shfig:nth-child(1) { transition-delay: .34s; }
   .indsheet.is-up .shfig:nth-child(2) { transition-delay: .40s; }
   .indsheet.is-up .shfig:nth-child(3) { transition-delay: .46s; }
   .indsheet.is-up .shfig:nth-child(4) { transition-delay: .52s; }

   .shfig b {
      display: block; font-family: var(--ui-semi); font-size: 27px;
      font-weight: 700; letter-spacing: -.6px; color: #16130E;
   }

   .shfig span {
      font-family: var(--mono); font-size: 7.6px; letter-spacing: .14em;
      text-transform: uppercase; color: #7C746A;
   }

   .shlower {
      display: grid; grid-template-columns: minmax(0, 330px) minmax(0, 1fr);
      gap: 38px;
      padding-bottom: 30px;      /* the seals never sit on the window's edge */
      overflow-y: auto;          /* only if a short window demands it */
      overscroll-behavior: contain;
   }

   /* the reading: it arrives BEFORE the seals, so the sheet reads first */
   .shread p {
      font-size: 14px; line-height: 1.55; letter-spacing: -.1px;
      margin-bottom: 12px; color: #7C746A;
      opacity: 0; transform: translateY(8px);
      transition: opacity .6s ease, transform .8s cubic-bezier(.16, 1, .3, 1);
   }

   .shread p:first-child { font-size: 16.5px; line-height: 1.45; color: #16130E; }
   .indsheet.is-up .shread p { opacity: 1; transform: none; }
   .indsheet.is-up .shread p:nth-child(1) { transition-delay: .50s; }
   .indsheet.is-up .shread p:nth-child(2) { transition-delay: .60s; }
   .indsheet.is-up .shread p:nth-child(3) { transition-delay: .70s; }

   /* the seals: the builder's own markup, laid out for a wide surface */
   .shstamps .collapsible-inner > div:first-child { display: none; }  /* the 22px spacer */
   .shstamps .kicker { display: none; }        /* the count is in the masthead now */
   .shstamps .stampwrap { display: flex; flex-wrap: wrap; gap: 8px; align-content: flex-start; }
   .shstamps .pstamp { max-width: 168px; }

   .indsheet .pstamp { animation: none; opacity: 0; }
   .indsheet.is-up .pstamp {
      animation: stampInSoft .58s cubic-bezier(.4, 0, .6, 1) both;
      animation-delay: calc(.80s + var(--n, 0) * .045s) !important;
   }

   /* the card's collapsible belongs to the phone; out here the sheet is it */
   #modal-indicators-content { display: none !important; }

   /* the tour card and the field's floats sit under the sheet */
   body.sheet-up .sx-tip,
   body.sheet-up #tourcard,
   body.sheet-up #linktag,
   body.sheet-up #linklayer {
      opacity: 0 !important;
      pointer-events: none !important;
      transition: opacity .25s ease;
   }
}

@media (max-width: 640px) {
   .indsheet, .shscrim { display: none !important; }
}

/* ==========================================================================
   THE MARK'S STAGE  (desktop dashboard only, 27 Aug)

   On the story the mark is beautiful for a reason that has nothing to do
   with the mark: it opens over MOVING PICTURE behind glass. On the
   dashboard the same object opens over flat paper and reads as a plain
   white card. So the dashboard borrows the story's ground for the length
   of the hover: the film comes up behind a scrim, the page recedes, and
   the card opens in the story's own dark glass with the ring standing
   down -- the exact treatment it has one page over.

   Fenced to 641+ and to the dashboard (the story already IS this). */
   #mark-stage {
      position: fixed;
      inset: 0;
      z-index: 3800;
      opacity: 0;
      pointer-events: none;
      overflow: hidden;
      transition: opacity .55s ease;
   }

   #mark-stage video {
      position: absolute;
      inset: 0;
      width: 100%;
      height: 100%;
      object-fit: cover;
      /* 1.35, exactly as the story's #promo does out here: this footage is
         narrower than the desktop viewport, and at any smaller scale the
         picture sits between two black bars -- the stripes we already paid
         to remove once. The settle starts a notch wider and rests on 1.35;
         it must REST there, or the bars come back on the last frame. */
      transform: scale(1.35);
      filter: saturate(.92);
      transition: transform 1.6s cubic-bezier(.16, 1, .3, 1);
   }

   #mark-stage .ms-scrim {
      position: absolute;
      inset: 0;
      background:
         /* a vignette, so the frame has a middle and the card has a corner
            to sit in -- without it the film read as wallpaper */
         radial-gradient(120% 90% at 22% 26%,
            rgba(12, 10, 8, 0) 0%, rgba(12, 10, 8, .30) 52%, rgba(12, 10, 8, .70) 100%),
         linear-gradient(180deg,
            rgba(12, 10, 8, .48) 0%, rgba(12, 10, 8, .56) 38%,
            rgba(12, 10, 8, .74) 62%, rgba(12, 10, 8, .90) 84%,
            rgba(12, 10, 8, .96) 100%);
   }

   /* THE STANDFIRST. The story's frame is full because it carries words;
      this one was a picture with a small card in the corner and nothing to
      read. The project says its own name at the foot of the film, in the
      story's voice, arriving after the picture has settled. */
   #mark-stage .ms-line {
      position: absolute;
      left: 28px;
      right: 28px;
      bottom: 30px;
      display: flex;
      align-items: baseline;
      gap: 14px;
      font-family: var(--mono);
      font-size: 10px;
      letter-spacing: .2em;
      text-transform: uppercase;
      color: rgba(245, 243, 238, .58);
      opacity: 0;
      transform: translateY(6px);
      transition: opacity .5s ease, transform .8s cubic-bezier(.16, 1, .3, 1);
   }

   #mark-stage .ms-line b {
      font-weight: 500;
      color: rgba(245, 243, 238, .92);
   }

   #mark-stage .ms-line i {
      font-style: normal;
      flex: 1;
      height: 1px;
      background: rgba(245, 243, 238, .22);
      transform: scaleX(0);
      transform-origin: left center;
      transition: transform 1.1s cubic-bezier(.16, 1, .3, 1) .12s;
   }

   body.mark-stage #mark-stage .ms-line {
      opacity: 1;
      transform: none;
      transition-delay: .28s;
   }

   body.mark-stage #mark-stage .ms-line i { transform: scaleX(1); }

   body.mark-stage #mark-stage {
      opacity: 1;
   }

   /* the stage opens a hair wide and settles -- the story's own gesture */
   #mark-stage video { transform: scale(1.44); }
   body.mark-stage #mark-stage video { transform: scale(1.35); }

   /* the mark rides above its own stage */
   body.mark-stage #cp-card { z-index: 3900 !important; }
   body.mark-stage #cp-ring { z-index: 3910 !important; }

   /* THE STORY'S SKIN, borrowed whole. Same glass, same copy colours, same
      standing-down ring -- the values are the story block's, restated here
      rather than shared, so neither page can drift the other. */
   body.mark-stage #cp-card.on,
   body.mark-stage.credit-closing #cp-card {
      background: rgba(16, 13, 10, .40) !important;
      -webkit-backdrop-filter: blur(18px) saturate(185%) !important;
      backdrop-filter: blur(18px) saturate(185%) !important;
      border: 0 !important;
      box-shadow: 0 18px 44px rgba(0, 0, 0, .38) !important;
   }

   body.mark-stage #cp-ring { border-color: rgba(245, 243, 238, .55) !important; }
   body.mark-stage #cp-ring.on { opacity: 0; }

   body.mark-stage #cp-card .cp-kicker { color: rgba(245, 243, 238, .62) !important; }
   body.mark-stage #cp-card .cp-fullname { color: #F7F5F1 !important; }
   body.mark-stage #cp-card .cp-row,
   body.mark-stage #cp-card .cp-row span { color: rgba(245, 243, 238, .88) !important; }
   body.mark-stage #cp-card .cp-row i { color: rgba(245, 243, 238, .62) !important; }
   body.mark-stage #cp-card .cp-row + .cp-row::before { background: rgba(245, 243, 238, .18) !important; }
   body.mark-stage #cp-card .cp-row:hover::after { background: rgba(245, 243, 238, .10); }
   body.mark-stage #cp-card .cp-row:hover span,
   body.mark-stage #cp-card .cp-row:hover i { color: #FFFFFF !important; }

   body.ind-open #modal-indicators-content .collapsible-inner {
      padding-bottom: 20px;
   }
}


/* The desktop measure widens to carry each chapter in THREE authored lines
   (the breaks live in intro.js, desktop copy only -- the phone reads its own
   shorter cut). 62ch broke every body onto a fourth line and made the
   waterfall impossible; this holds the longest authored line. */
@media (min-width: 641px) {
   .intro-body { max-width: 900px; }
}
