/* Spendthrift marketing site, hand-authored. Consumes tokens.css only;
   no primitive colour or spacing value is chosen here, per the design system. */

/* ---- v3.2: category tokens, PROPOSED, not yet in tokens.css ----
   Section 11 of CLAUDE.md: categories are unlimited and belong to the user;
   colour is theirs to set. What ships as defaults is the eight below, each
   generated (never hand-picked) with a mid tone for solid marks, a tone-91
   tint for ribbons/areas of the same series (section 10.6's "two tones per
   hue"), and a dark-theme step. Values from foundations-v1/defaults.json,
   validated in both modes with dataviz-method/scripts/validate_palette.js
   (ALL CHECKS PASS, see CHANGES.md for the run). --cat-1..8 is retired:
   these replace it, named for the category, not a slot number. */
:root {
  --cat-food: #C64100;         --cat-food-tint: #FAD9CE;
  --cat-transport: #00AFAF;    --cat-transport-tint: #CFE7E6;
  --cat-health: #C13480;       --cat-health-tint: #F9D7E4;
  --cat-groceries: #007932;    --cat-groceries-tint: #D4E7D6;
  --cat-shopping: #B459CF;     --cat-shopping-tint: #EDD9F4;
  --cat-rent: #6B7F00;         --cat-rent-tint: #DEE5CF;
  --cat-entertainment: #8C85FF;--cat-entertainment-tint: #DDDFFB;
  --cat-bills: #0095BE;        --cat-bills-tint: #D0E5EF;
  /* not one of the eight defaults: the neutral swatch a chart uses when it
     folds anything past its shown series into "Other" (section 11). */
  --cat-other: #7E7E7F;        --cat-other-tint: #E3E3E3;
  /* job 18 section 6b, CATEGORY-TEXT-TIER.md: the third colour tier, for
     label and value TEXT only, never a mark (section 28.6). The eight
     mids were generated for marks and four of eight fail 4.5:1 as text
     in light, five of eight in dark. A constant shift preserves every
     category's own lightness difference from the others, which is what
     carries the colour vision separation: generated L minus 0.13 here,
     plus 0.08 in the dark block below. Generated from colour-tokens.json's
     own rule, never hand picked. Used by job 19's stat grid. */
  --cat-food-text: #8B2B00;
  --cat-transport-text: #008383;
  --cat-health-text: #891E59;
  --cat-groceries-text: #004F1E;
  --cat-shopping-text: #853B9B;
  --cat-rent-text: #495700;
  --cat-entertainment-text: #685ECD;
  --cat-bills-text: #006C8A;
}
@media (prefers-color-scheme: dark) {
  :root {
    --cat-food: #C83E00;
    --cat-transport: #00A8A8;
    --cat-health: #D00083;
    --cat-groceries: #007A2E;
    --cat-shopping: #BF43E1;
    --cat-rent: #6B7F00;
    --cat-entertainment: #867CFF;
    --cat-bills: #0095C0;
    --cat-other: #969697;
    /* job 18 section 6a: the dark block redefined all eight mids and none
       of the eight tints, so every ribbon chart drew a pale light-theme
       fill on a near black ground. A tint sits a constant lightness
       distance from its own ground, on the side away from that ground's
       extreme: light ground L 1.000, tints at L 0.910 (distance 0.090);
       dark ground L 0.196, so tints sit at L 0.286. Generated from
       colour-tokens.json's own rule, not hand picked. */
    --cat-food-tint: #481A09;
    --cat-transport-tint: #222D2D;
    --cat-health-tint: #48132F;
    --cat-groceries-tint: #202F22;
    --cat-shopping-tint: #391D41;
    --cat-rent-tint: #282C1E;
    --cat-entertainment-tint: #262058;
    --cat-bills-tint: #1D2D34;
    --cat-food-text: #D9643C;
    --cat-transport-text: #09CBCB;
    --cat-health-text: #D15F96;
    --cat-groceries-text: #01943F;
    --cat-shopping-text: #C480D9;
    --cat-rent-text: #819900;
    --cat-entertainment-text: #A6A5FF;
    --cat-bills-text: #00B0DF;
  }
}
/* inside a canvas the category set stays the light-mode set in both
   themes, same reasoning as every other canvas token: a canvas is always
   the light surface, so its charts read against #FFFFFF-family grounds. */
.canvas {
  --cat-food: #C64100;
  --cat-transport: #00AFAF;
  --cat-health: #C13480;
  --cat-groceries: #007932;
  --cat-shopping: #B459CF;
  --cat-rent: #6B7F00;
  --cat-entertainment: #8C85FF;
  --cat-bills: #0095BE;
  --cat-other: #7E7E7F;
}
/* Subcategory ramp (section 11): a subcategory takes its parent's hue and
   chroma, lightness stepped evenly from 0.72 down to 0.48 in OKLCH so a
   family reads together before a single word is read. No subcategory UI
   ships on this marketing site; documented here so the app and any future
   site chart derive shades from this rule rather than picking one by eye. */

@font-face {
  font-family: 'InterVariable';
  src: url('../fonts/inter-var-latin.woff2') format('woff2');
  font-weight: 400 700;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: 'Material Symbols Subset';
  src: url('../fonts/material-symbols-subset.woff2') format('woff2');
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body, h1, h2, h3, p, figure, ul, ol, dl, dd { margin: 0; }
ul, ol { padding: 0; list-style: none; }
img, svg { display: block; max-width: 100%; }
a { color: inherit; }
button, input, select, textarea { font: inherit; color: inherit; }
fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
legend { padding: 0; }

/* v2.1 palette correction: Spendthrift is light-first, pure white ground,
   ink at brand green, following prefers-color-scheme for dark rather than
   forcing one look. The pure-black-always ground in the v2 layout
   references was Brag Art's house style, not Spendthrift's; the
   references taught layout, not colour. */
html {
  background: var(--bg);
  color-scheme: light dark;
  /* the sticky header (job 12) sits over the top 64/56px of the
     viewport permanently: keep it from covering a jumped-to heading
     or a tabbed-to focusable element. */
  scroll-padding-top: 80px;
}

[id],
:focus-visible {
  scroll-margin-top: 80px;
}

body {
  background: var(--bg);
  color: var(--ink);
  font-family: 'InterVariable', system-ui, -apple-system, 'Segoe UI', sans-serif;
  font-size: var(--size-body);
  line-height: var(--line-body);
  font-weight: 400;
  font-variant-numeric: tabular-nums;
  -webkit-font-smoothing: antialiased;
  min-height: 100dvh;
  display: flex;
  flex-direction: column;
}

/* Pins the footer to the bottom of the viewport on short pages
   (/thanks/, /404.html, /survey/), job 7 addendum item 3. main is the
   only flexible child; header and footer keep their natural height. */
main { flex: 1 0 auto; }

/* ---- type rhythm, four steps, sentence case throughout ---- */

h1, h2, h3 { font-weight: 600; letter-spacing: -0.01em; }

.step-display-lg { font-size: var(--size-display-lg); line-height: var(--line-display-lg); }
.step-display    { font-size: var(--size-display);    line-height: var(--line-display); }
.step-title      { font-size: var(--size-title);      line-height: var(--line-title); font-weight: 600; }
.step-body       { font-size: var(--size-body);       line-height: var(--line-body); }
.step-caption    { font-size: var(--size-caption);     line-height: var(--line-caption); }

p, .prose li { max-width: 38em; }
p + p { margin-top: var(--space-4); }

a.text-link {
  text-underline-offset: 0.15em;
  text-decoration-color: var(--hairline-strong);
}
a.text-link:hover { text-decoration-color: currentColor; }

/* ---- amounts, colour never travels alone ---- */

.amount { font-variant-numeric: tabular-nums; font-weight: 500; }
.amount--positive { color: var(--positive); }
.amount--negative { color: var(--negative); }
.amount--pending  { color: var(--on-caution); background: var(--caution); border-radius: var(--radius-sm); padding: 0 var(--space-1); }

/* ---- layout ---- */

/* Unified with .spine-shell 1 Sep 2026 (job 7, section 3.1): .shell used
   to max out at 60rem while .spine-shell ran to 1300px, so the header,
   footer, breadcrumbs, .announce sections, the roundup grid and every
   calculator sat at a different left edge than the spine content on the
   same page. One measurement now, matching section 12 exactly. */
.shell {
  max-width: 1300px;
  margin-inline: auto;
}
@media (max-width: 85rem) { /* 1360px */
  .shell { width: 100%; box-sizing: border-box; padding-inline: 24px; }
}

.section-inner {
  max-width: 38rem;
  margin-inline: auto;
  padding-inline: var(--space-5);
}

.section {
  padding-block: var(--space-8);
}

.section--tight { padding-block: var(--space-6); }

.hairline-top { border-top: 1px solid var(--hairline); }
.hairline-bottom { border-bottom: 1px solid var(--hairline); }

/* ---- v2: the announcement system ----
   A section is a huge display heading on the left, a small bold kicker and
   a body paragraph on the right, and, where there is something to show,
   a full-width canvas below the text band. Big vertical rhythm between
   sections; one column on phones. */

.announce {
  padding-block: var(--space-9);
}
.announce--tight { padding-block: var(--space-8); }

.announce-grid {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--space-6);
}
@media (min-width: 60rem) {
  .announce-grid {
    grid-template-columns: 1.4fr 1fr;
    gap: var(--space-8);
    align-items: start;
  }
}

.announce-heading {
  font-size: var(--size-hero);
  line-height: var(--line-hero);
  font-weight: 600;
  letter-spacing: -0.02em;
  max-width: 14ch;
}

.announce-aside { padding-top: var(--space-2); }
.announce-kicker {
  font-size: var(--size-title);
  font-weight: 700;
  letter-spacing: -0.01em;
}
.announce-body { margin-top: var(--space-3); color: var(--ink-muted); max-width: 34em; }

.canvas-wrap { margin-top: var(--space-7); }

/* v2.1: the canvas contrast survives in both themes through the tone
   ladder. On light ground (default), a canvas just sits a rung apart,
   bg-raised, with a hairline for definition, exactly like any other
   raised surface in the system. In dark mode, canvases become the light
   moments: every value in the dark-mode block below is the existing
   *light* semantic set, reapplied as a scoped override, the same
   primitives tokens.css already generates, just recombined for a
   context (a light card on a dark page) the token set didn't originally
   anticipate. Nothing here is hand-picked. */
.canvas {
  background: var(--canvas-ground);
  border: 1px solid var(--hairline);
  color: var(--ink);
  border-radius: var(--radius-canvas);
  padding: var(--space-7);
}
@media (prefers-color-scheme: dark) {
  .canvas {
    --bg: var(--brand-99);
    --bg-raised: var(--neutral-99);
    --bg-sunken: var(--brand-91);
    --ink: var(--brand-35);
    --ink-muted: var(--neutral-51);
    --hairline: var(--neutral-83);
    --hairline-strong: var(--neutral-67);
    --brand: var(--brand-35);
    --on-brand: var(--neutral-100);
    --brand-quiet: var(--brand-91);
    --positive: var(--brand-35);
    --on-positive: var(--neutral-100);
    --negative: var(--negative-51);
    --on-negative: var(--neutral-100);
    --focus: var(--brand-51);
    color-scheme: light;
    background: var(--bg);
    border-color: var(--hairline);
  }
}
@media (max-width: 30rem) {
  .canvas { padding: var(--space-5); }
}

/* ---- section 10.6: charts sit on white, not the tinted near-white brand-99
   ground every other canvas gets. A canvas that holds a chart adds this
   modifier alongside .canvas. Light mode: pure white instead of bg-raised
   (the same near-white the "canvas becomes a light card" rule below reuses,
   which is exactly the tint a chart must not sit on). Dark mode: the chart
   canvas does NOT flip to a light card the way an ordinary canvas does, it
   stays the page's own dark surface (the raised neutral rung), so category
   colour can use its dark-theme step instead of being forced back to light
   values, which is why the dark block re-declares the --cat-* tokens. */
.canvas--chart { background: var(--neutral-100); }
@media (prefers-color-scheme: dark) {
  .canvas.canvas--chart {
    --cat-food: #C83E00;
    --cat-transport: #00A8A8;
    --cat-health: #D00083;
    --cat-groceries: #007A2E;
    --cat-shopping: #BF43E1;
    --cat-rent: #6B7F00;
    --cat-entertainment: #867CFF;
    --cat-bills: #0095C0;
    --cat-other: #969697;
    --bg-raised: var(--neutral-19);
    --ink: var(--neutral-91);
    --ink-muted: var(--neutral-67);
    --hairline: var(--neutral-27);
    --hairline-strong: var(--neutral-43);
    --negative: var(--negative-67);
    --on-negative: var(--neutral-0);
    background: var(--bg-raised);
    border-color: var(--hairline);
    color: var(--ink);
    color-scheme: dark;
  }
}

/* the pale negative track a bar switches to once its envelope goes over
   budget (never the category's own hue, section 8's envelope-grid rule,
   reused here). Light mode wants a pale tint against a white chart canvas;
   dark mode wants a dark, quiet one against a near-black canvas, the same
   tone-ladder logic as everything else in a dark chart canvas. */
:root { --negative-tint: var(--negative-91); }
@media (prefers-color-scheme: dark) {
  .canvas.canvas--chart { --negative-tint: var(--negative-27); }
}

/* canvases are doors: where a deeper page already exists for a canvas's
   topic, the whole canvas is the link, with a visible affordance below
   the content. Canvases that are already the deep content (a live tool,
   a form) are never links, there is nowhere further to go. Canvases
   with no built deeper page stay plain, deliberately, no fake doors. */
a.canvas--link {
  display: block;
  text-decoration: none;
}
a.canvas--link .canvas {
  transition: transform var(--duration-fast) var(--easing-standard),
              border-color var(--duration-fast) var(--easing-standard);
}
a.canvas--link:hover .canvas,
a.canvas--link:focus-visible .canvas {
  transform: translateY(-2px);
  border-color: var(--hairline-strong);
}
a.canvas--link:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 4px;
  border-radius: var(--radius-canvas);
}
@media (prefers-reduced-motion: reduce) {
  a.canvas--link .canvas { transition: none; }
  a.canvas--link:hover .canvas, a.canvas--link:focus-visible .canvas { transform: none; }
}

.canvas-affordance {
  margin-top: var(--space-5);
  padding-top: var(--space-5);
  border-top: 1px solid var(--hairline);
  font-weight: 600;
  display: flex;
  align-items: center;
  gap: var(--space-2);
}
.canvas-affordance .arrow {
  display: inline-block;
  transition: transform var(--duration-fast) var(--easing-standard);
}
a.canvas--link:hover .canvas-affordance .arrow,
a.canvas--link:focus-visible .canvas-affordance .arrow {
  transform: translateX(4px);
}
@media (prefers-reduced-motion: reduce) {
  .canvas-affordance .arrow { transition: none; }
  a.canvas--link:hover .canvas-affordance .arrow { transform: none; }
}

/* reactive canvases: several images become side-by-side canvases, each with
   its own heading + caption below the canvas, not inside it. */
.canvas-row {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--space-5);
  margin-top: var(--space-7);
}
@media (min-width: 46rem) {
  .canvas-row { grid-template-columns: repeat(3, 1fr); }
}

/* alignment law: every canvas in a row shares one height, matching the
   tallest, so captions after it start on the same baseline regardless of
   how much content or affordance any one canvas carries. Grid's default
   stretch already gives .canvas-item the row's full height; flex:1 here
   is what actually makes the canvas box (and, when a door wrapper sits
   between them, the link) grow to fill it, rather than just sizing to
   its own content. height:100% alone does not do this reliably once
   .canvas-item is itself a flex column, because a flex item's size is
   governed by flex-basis/grow, not the block percentage-height rules. */
.canvas-item { display: flex; flex-direction: column; }
.canvas-item > a.canvas--link { display: flex; flex-direction: column; flex: 1 1 auto; }
.canvas-item > .canvas,
.canvas-item > a.canvas--link > .canvas {
  min-height: 15rem;
  flex: 1 1 auto;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
}
.canvas-item .canvas-affordance { margin-top: auto; align-self: stretch; }
.canvas-caption { margin-top: var(--space-4); }
.canvas-caption h3 { font-size: var(--size-body); font-weight: 600; }
.canvas-caption p { margin-top: var(--space-2); color: var(--ink-muted); font-size: var(--size-caption); }
.canvas-row-footnote { margin-top: var(--space-6); }

/* flat in-system diagrams that live inside a canvas, until real product
   screenshots exist. Ink plus one system colour (brand), no gradients, no
   shadows, no icons, and never a padlock or a badge, per the anti-brief. */
.diagram { width: 100%; }

.diagram-pair { display: flex; gap: var(--space-3); }

.diagram-chip {
  flex: 1;
  border: 1px solid var(--hairline);
  border-radius: var(--radius-md);
  padding: var(--space-4);
  text-align: center;
  font-weight: 500;
}
.diagram-chip--brand { background: var(--brand); color: var(--on-brand); border-color: var(--brand); }
.diagram-chip--empty { border-style: dashed; color: var(--ink-muted); font-weight: 400; }

.diagram-note { display: block; margin-top: var(--space-1); font-size: var(--size-caption); font-weight: 400; opacity: 0.85; }

.diagram-connector {
  height: var(--space-4);
  width: 1px;
  background: var(--hairline-strong);
  margin: var(--space-3) auto;
}

/* ---- header / nav ---- */

/* The header is sticky, not floating: it never leaves the top of the
   viewport and never resizes, shrinks or hides on scroll. Background is
   the solid --bg token, never translucent, never blurred: this brand's
   flat rule forbids glassmorphism, and a translucent bar over a chart
   is unreadable in both themes. z-index: 50 is the only z-index value
   this site uses to mean "site chrome above content"; the skip link
   (below) is deliberately higher still, since it must never end up
   hidden behind the header it precedes, and the two chart-local
   z-indices elsewhere in this file (a hover tooltip, the hero's own
   internal layers) never compete with page chrome and are left as
   they were.

   The one state change is a hairline, and it reports a true fact
   (has content scrolled beneath the chrome), not a flourish: the
   site's one scroll-linked signature moment stays reserved for the
   bridge canvas. It is present by default, so a reader with
   JavaScript disabled always sees it; header-sticky.js removes it
   only once it has confirmed, via a sentinel placed immediately
   before the header, that the page is genuinely at the top. No
   shadow, not even a faint one: depth here is the tone ladder, the
   hairline and space. */
.site-header {
  position: sticky;
  top: 0;
  z-index: 50;
  background: var(--bg);
  height: 64px;
  display: flex;
  align-items: center;
  border-bottom: 1px solid var(--hairline);
  transition: border-color var(--duration-fast) var(--easing-standard);
}
@media (max-width: 860px) {
  .site-header { height: 56px; }
}
/* a landscape phone (short viewport height, not narrow width) has the
   sticky header AND the static nav-mobile row both eating into a much
   smaller screen: at 844x390 the two together were 115px, 29% of the
   viewport, against 18% in portrait at the same width. This is a fixed
   size for the short case, not a scroll-triggered shrink, which stays
   forbidden (10.3). The nav links' own tap targets stay 44px throughout
   via the invisible hit-area expansion above, not through this row's
   padding, so shrinking the padding here does not cost touch accuracy.
   The .site-nav-mobile half of this lives after that rule's own
   definition further down, not here: same specificity, same media
   query shape, so source order decides and this file's .site-nav-mobile
   .shell rule comes later and would otherwise win. */
@media (max-width: 860px) and (max-height: 500px) {
  .site-header { height: var(--space-7); } /* 48px */
}
.site-header[data-stuck="false"] {
  border-bottom-color: transparent;
}
@media (prefers-reduced-motion: reduce) {
  .site-header { transition: none; }
}

/* 1px, placed immediately before the header in the document, observed
   by header-sticky.js to know when the page has scrolled away from
   the very top. Carries no visual weight of its own. */
.header-sentinel {
  height: 1px;
}

.site-header .shell {
  display: flex;
  align-items: center;
  justify-content: space-between;
  width: 100%;
  gap: var(--space-4);
}
.site-header .brand-mark { flex-shrink: 0; }

/* home page only: the wordmark starts hidden here because the hero
   already carries it at 90px, and two at once on first paint is a
   repetition. header-sticky.js adds this class immediately on the one
   page whose header carries [data-hero-wordmark], then removes it once
   the hero has genuinely scrolled past (see .hero-sentinel below). With
   JavaScript disabled the class is never added, so the wordmark stays
   at its default, fully visible state.
   v2: a larger header wordmark throughout, light-first per v2.1, the
   dark-ink cut by default, swapping to the white cut in dark mode. */
.brand-mark {
  position: relative;
  display: flex;
  align-items: center;
  gap: var(--space-2);
  opacity: 1;
  visibility: visible;
  transition: opacity var(--duration-fast) var(--easing-standard),
    visibility var(--duration-fast);
}
.brand-mark img { height: 30px; width: auto; }
/* the mark itself is 30px tall, under the 44px tap floor; the header's
   own height already clears 44 so an invisible hit-area expansion
   inside it, rather than more padding, keeps the header height fixed. */
.brand-mark::before {
  content: "";
  position: absolute;
  inset-block: calc(-1 * var(--space-3));
  inset-inline: calc(-1 * var(--space-2));
}
/* visibility: hidden alongside opacity: 0, not opacity alone: without
   it the pending wordmark is an invisible but still focusable and
   clickable link, a real tab stop and hit target on nothing. */
.brand-mark.wordmark-pending { opacity: 0; visibility: hidden; }
@media (max-width: 860px) {
  /* at this width the three nav links have already moved to the row
     beneath the header, so the sticky bar has nothing else in it: the
     repetition the hero-wordmark rule exists to avoid never happens
     here, since the hero's own 90px mark and the header's are never
     both on screen together at once at this width. Showing the header
     wordmark immediately is the fix, not a delayed reveal. */
  .brand-mark.wordmark-pending { opacity: 1; visibility: visible; }
}
@media (prefers-reduced-motion: reduce) {
  .brand-mark { transition: none; }
}

.footer-brand { display: flex; flex-direction: column; align-items: flex-start; }
/* mark.svg carries about 26.5% padding inside its 1080 viewBox
   (measured via getBBox: ink is 794 of 1080), so a 20px box height
   renders as roughly 14.7px of actual mark. 28px brings the ink itself
   to about 20.6px, matching what the header's own cropped asset
   achieves by trimming the viewBox instead. */
.footer-brand img { height: 28px; width: auto; }

.logo-dark { display: none; }
@media (prefers-color-scheme: dark) {
  .logo-light { display: none; }
  .logo-dark { display: block; }
}

/* job 7, 3.3: at narrow widths .site-nav used to wrap its own links onto
   several lines, growing the header three lines tall and pushing the
   wordmark to the vertical centre of that instead of sitting beside the
   nav. .site-nav-links scrolls as a single line instead, the same
   pattern already used for a card row that runs past its container
   (section 12): no new interaction, no JS, no hamburger. The Waitlist
   link sits outside the scroller so it never needs discovering. */
.site-nav {
  display: flex;
  align-items: center;
  gap: var(--space-4);
  min-width: 0;
}
/* at 860 and below the sticky bar carries the wordmark and the
   currency control only (job 12): the three nav links and the
   Waitlist door move to .site-nav-mobile, a static row directly
   beneath the header that scrolls away with the page. The footer
   sitemap already lists every page, so nothing here becomes
   unreachable. */
@media (max-width: 860px) {
  .site-header .site-nav-links,
  .site-header a.nav-cta { display: none; }
}
.site-nav-links {
  display: flex;
  align-items: center;
  flex-wrap: nowrap;
  gap: var(--space-5);
  min-width: 0;
  overflow-x: auto;
  -webkit-overflow-scrolling: touch;
  scrollbar-width: none;
}
.site-nav-links::-webkit-scrollbar { display: none; }
.site-nav a { flex-shrink: 0; }

.site-nav a {
  position: relative;
  text-decoration: none;
  font-size: var(--size-body);
  white-space: nowrap;
}
/* a plain nav link is text at line-height 24, well under the 44px tap
   target floor; expanding it with real padding would grow the header
   itself, so the hit area grows invisibly instead, top and bottom only
   (the row's own gap already keeps neighbours from overlapping). Applies
   to .nav-cta too, for the same reason: its own pill padding stays the
   size it was designed at rather than growing to carry the tap target
   on its own, which would make it the tallest thing in the row and
   defeat the short-viewport header height below. */
.site-nav a::before {
  content: "";
  position: absolute;
  inset-block: calc(-1 * var(--space-3));
  inset-inline: 0;
}

.site-nav a.nav-cta {
  border: 1px solid var(--ink);
  border-radius: var(--radius-pill);
  padding: var(--space-1) var(--space-4);
}

/* job 10 section 3: the display-currency control, in the header as a
   navigation control rather than a form field (reverses 21.8's "one
   inline link" placement, since a reader who lands mid-page cannot find
   a link that lives beside the first price). Sits outside the
   scrolling nav-links, alongside the Waitlist CTA, so it never wraps
   the nav at narrow widths. */
.site-nav select.currency-select {
  flex-shrink: 0;
  appearance: none;
  -webkit-appearance: none;
  font: inherit;
  font-size: var(--size-caption);
  color: var(--ink-muted);
  background-color: transparent;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%23767676' stroke-width='1.4' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right var(--space-2) center;
  background-size: 10px 6px;
  border: 1px solid var(--hairline-strong);
  border-radius: var(--radius-sm);
  padding: var(--space-3) var(--space-6) var(--space-3) var(--space-2);
  cursor: pointer;
}
.site-nav select.currency-select:focus-visible {
  outline: 2px solid var(--brand);
  outline-offset: 2px;
}
@media (max-width: 30rem) {
  .site-nav select.currency-select {
    padding-right: var(--space-5);
    background-position: right var(--space-1) center;
  }
}

/* at 860 and below (job 12): the three nav links and the Waitlist door,
   displaced from the now wordmark-and-currency-only sticky bar, live
   here instead, a plain row directly beneath the header that is not
   sticky and scrolls away with the page. */
.site-nav-mobile {
  display: none;
}
@media (max-width: 860px) {
  .site-nav-mobile {
    display: block;
    border-bottom: 1px solid var(--hairline);
  }
  .site-nav-mobile .shell {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    /* space-5, not space-4: the plain links' invisible tap-target
       expansion below reaches 12px above and below each line, so a
       wrapped row needs at least 24px between lines or two stacked
       links' hit areas would overlap. */
    gap: var(--space-5);
    padding-block: var(--space-3);
  }
  .site-nav-mobile a {
    position: relative;
    text-decoration: none;
    font-size: var(--size-body);
  }
  /* same reasoning as .site-nav a: text at line-height 24 is well under
     the 44px tap floor; grow the hit area, not the row. Applies to
     .nav-cta too, so its own pill doesn't become the row's tallest
     element and undo the short-viewport header height below. */
  .site-nav-mobile a::before {
    content: "";
    position: absolute;
    inset-block: calc(-1 * var(--space-3));
    inset-inline: 0;
  }
  .site-nav-mobile a.nav-cta {
    border: 1px solid var(--ink);
    border-radius: var(--radius-pill);
    padding: var(--space-1) var(--space-4);
  }
}
/* the short-viewport half of the rule above, kept here rather than
   there: same specificity and media-query shape as the base
   .site-nav-mobile .shell padding-block rule, so it has to come after
   that rule in source order to win. */
@media (max-width: 860px) and (max-height: 500px) {
  .site-nav-mobile .shell { padding-block: var(--space-1); }
}

.icon {
  font-family: 'Material Symbols Subset', 'Material Symbols Outlined';
  font-weight: 400;
  font-style: normal;
  font-size: 24px;
  line-height: 1;
  display: inline-block;
  font-variation-settings: 'FILL' 0, 'GRAD' 0, 'opsz' 24;
  -webkit-font-smoothing: antialiased;
}

/* ---- footer ---- */

.site-footer {
  border-top: 1px solid var(--hairline);
  padding-block: var(--space-8);
  color: var(--ink-muted);
}

/* job 14: .footer-brand is a fixed ~352px block; .footer-groups
   carries flex:1 1 0 with min-width:0 so its grid can shrink, but that
   same min-width:0 is what stopped the flex-wrap in the rule below
   from ever triggering, since the browser never sees a hypothetical
   width worth wrapping over. The result was .footer-groups squeezed
   into whatever space was left, its own grid-track minimums forcing
   real overflow rather than a wrap, from 431px up to about 735px on
   every page. Stacking the two blocks explicitly below the same
   56.25rem the groups grid already steps to three columns at removes
   the reliance on that wrap entirely: below it they are always full
   width, one above the other; at or above it there is always enough
   room (352 brand + gap + a three-column grid) for both side by side. */
.footer-top {
  display: flex;
  flex-direction: column;
  gap: var(--space-7);
}
@media (min-width: 56.25rem) {
  .footer-top {
    flex-direction: row;
    flex-wrap: wrap;
    justify-content: space-between;
  }
}

.footer-tagline { margin-top: var(--space-3); }

.footer-links {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-4) var(--space-5);
}

.footer-links a { text-decoration: none; font-size: var(--size-caption); }
.footer-links a:hover { color: var(--ink); }

/* job 10 section 4: a classified sitemap, one column per group at
   1440, collapsing to two columns and then one as the viewport
   narrows, never the reverse. */
.footer-groups {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--space-7) var(--space-5);
  /* .footer-top is a flex row (space-between against .footer-brand);
     a flex item defaults to flex-basis:auto, sized to its own content,
     so without this the grid never grows past its columns' own
     max-content width even once minmax(0,1fr) lets those columns
     divide evenly. flex:1 is what actually makes it fill the
     remaining row instead of stopping short of it. */
  flex: 1 1 0;
  min-width: 0;
}
@media (min-width: 30rem) {
  .footer-groups { grid-template-columns: repeat(2, minmax(9rem, 1fr)); }
}
@media (min-width: 56.25rem) {
  /* a middle step: five columns crowd every link onto two or three
     lines well before the container gets as narrow as 30rem, so three
     takes over here and holds until there is room for five. */
  .footer-groups { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (min-width: 85rem) {
  .footer-groups { grid-template-columns: repeat(5, minmax(0, 1fr)); }
}
.footer-group h3 {
  font-size: var(--size-caption);
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.02em;
  color: var(--ink-muted);
}
.footer-group ul { margin-top: var(--space-3); display: flex; flex-direction: column; gap: var(--space-3); }
.footer-group a { text-decoration: none; font-size: var(--size-body); color: var(--ink); }
.footer-group a:hover { color: var(--brand); }

.footer-currency-note {
  margin-top: var(--space-3);
  font-size: var(--size-caption);
  color: var(--ink-muted);
  max-width: 22rem;
}

.footer-fine-wrap {
  margin-top: var(--space-8);
  padding-top: var(--space-6);
}

.footer-fine {
  font-size: var(--size-caption);
  max-width: 32rem;
}
.footer-fine + .footer-fine { margin-top: var(--space-2); }

/* ---- buttons and controls ---- */

.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-2);
  border-radius: var(--radius-pill);
  padding: var(--space-3) var(--space-5);
  font-size: var(--size-body);
  font-weight: 500;
  cursor: pointer;
  border: 1px solid var(--hairline-strong);
  background: var(--bg);
  color: var(--ink);
  text-decoration: none;
}
.btn:hover { border-color: var(--ink); }
.btn:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }

.btn--brand {
  background: var(--brand);
  color: var(--on-brand);
  border-color: var(--brand);
}
.btn--brand:hover { opacity: 0.92; }

.btn--quiet {
  border-color: transparent;
  padding: var(--space-2) var(--space-3);
}

.btn--small { padding: var(--space-3) var(--space-4); font-size: var(--size-caption); }

.field {
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
}

.field label {
  font-size: var(--size-caption);
  color: var(--ink-muted);
}

.field input[type="text"],
.field input[type="email"],
.field input[type="number"],
.field select,
.field textarea {
  border: 1px solid var(--hairline-strong);
  border-radius: var(--radius-md);
  background: var(--bg);
  color: var(--ink);
  padding: var(--space-3) var(--space-4);
  font-size: var(--size-body);
  width: 100%;
}

.field input:focus-visible,
.field select:focus-visible,
.field textarea:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 1px;
}

.field-row {
  display: flex;
  gap: var(--space-3);
  flex-wrap: wrap;
}
.field-row .field { flex: 1 1 12rem; }

.radio-row {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2);
}

.radio-chip {
  border: 1px solid var(--hairline-strong);
  border-radius: var(--radius-pill);
  padding: var(--space-3) var(--space-4);
  font-size: var(--size-body);
  cursor: pointer;
}

.radio-chip input {
  position: absolute;
  opacity: 0;
  pointer-events: none;
}

.radio-chip:has(input:checked) {
  border-color: var(--brand);
  background: var(--brand-quiet);
}

.radio-chip:has(input:focus-visible) {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

/* honeypot, hidden from people, open to bots */
.hp-field {
  position: absolute;
  left: -9999px;
  width: 1px;
  height: 1px;
  overflow: hidden;
}

/* ---- hero ---- */

.hero {
  padding-block: var(--space-9) var(--space-8);
  text-align: center;
}

.hero h1 { max-width: 20ch; margin-inline: auto; }
.hero .sub { margin-top: var(--space-5); color: var(--ink-muted); max-width: 34em; margin-inline: auto; }
.hero .price-line { margin-top: var(--space-5); font-weight: 500; }

.hero-lockup { margin-inline: auto; }
.hero-lockup img { width: min(80vw, 620px); margin-inline: auto; }

.hero-tagline {
  margin-top: var(--space-7);
  font-size: var(--size-title);
  font-weight: 700;
  letter-spacing: 0.01em;
}

.hero-description {
  margin-top: var(--space-4);
  color: var(--ink-muted);
  max-width: 34em;
  margin-inline: auto;
}

.hero .canvas-wrap { max-width: 34rem; margin-inline: auto; text-align: left; }

/* ---- bridge diagram ---- */

.bridge-total {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--space-3);
  padding-bottom: var(--space-5);
  margin-bottom: var(--space-5);
  border-bottom: 1px solid var(--hairline);
}

.bridge-total .amount { font-size: var(--size-display); }

.bridge-rows { display: flex; flex-direction: column; gap: var(--space-3); }

.bridge-row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--space-3);
}

.bridge-row .amount { font-size: var(--size-title); }

figcaption, .bridge-caption {
  margin-top: var(--space-5);
  color: var(--ink-muted);
  font-size: var(--size-caption);
}

/* ---- waitlist / survey blocks ----
   Card chrome now comes from wrapping these in .canvas; this class only
   carries the internal typographic spacing. */

.waitlist-block h2 { font-size: var(--size-title); }
.lede { margin-top: var(--space-4); color: var(--ink-muted); font-size: var(--size-title); max-width: 34em; }
.waitlist-block .lede { margin-top: var(--space-3); font-size: var(--size-body); }

/* ---- commitments list ---- */

.commitment-list {
  display: flex;
  flex-direction: column;
  gap: var(--space-4);
}
.commitment-list li {
  padding-bottom: var(--space-4);
  border-bottom: 1px solid var(--hairline);
}
.commitment-list li:last-child { border-bottom: none; padding-bottom: 0; }

.commitment-promise {
  margin-top: var(--space-6);
  padding-top: var(--space-6);
  border-top: 1px solid var(--hairline);
  font-size: var(--size-title);
  font-weight: 600;
}

form.waitlist-form,
form.survey-form {
  margin-top: var(--space-5);
  display: flex;
  flex-direction: column;
  gap: var(--space-4);
}

.form-note {
  font-size: var(--size-caption);
  color: var(--ink-muted);
}

.confirmation {
  padding: var(--space-4);
  border: 1px solid var(--hairline);
  border-radius: var(--radius-md);
  background: var(--brand-quiet);
}

/* ---- calculator ---- */

.calc-people {
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
}

.calc-person-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-3);
}

.calc-person-row input {
  flex: 1;
  min-width: 0; /* an <input>'s automatic flex-shrink floor defaults to its
    intrinsic content width, not 0, so without this the name field never
    shrinks and wraps or overflows well before it needs to */
  border: 1px solid var(--hairline-strong);
  border-radius: var(--radius-md);
  background: var(--bg);
  color: var(--ink);
  padding: var(--space-3) var(--space-4);
  font-size: var(--size-body);
}
.calc-person-row input:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 1px;
}

.calc-controls {
  display: flex;
  gap: var(--space-3);
  flex-wrap: wrap;
  align-items: flex-end;
  margin-top: var(--space-4);
}

.calc-controls .field { flex: 1 1 16rem; }

.calc-results {
  margin-top: var(--space-6);
  border-top: 1px solid var(--hairline);
  padding-top: var(--space-5);
}

.calc-result-row {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  padding-block: var(--space-2);
  border-bottom: 1px solid var(--hairline);
  gap: var(--space-3);
}

.calc-result-name { font-weight: 500; }
.calc-result-detail { color: var(--ink-muted); font-size: var(--size-caption); margin-top: 2px; }

.calc-total-check {
  margin-top: var(--space-4);
  font-size: var(--size-caption);
  color: var(--ink-muted);
}

.calc-share-row {
  margin-top: var(--space-5);
  display: flex;
  gap: var(--space-3);
  align-items: center;
  flex-wrap: wrap;
}

.calc-share-link {
  flex: 1 1 16rem;
  border: 1px solid var(--hairline-strong);
  border-radius: var(--radius-md);
  padding: var(--space-3) var(--space-4);
  font-size: var(--size-caption);
  color: var(--ink-muted);
  overflow-wrap: anywhere;
}

/* ---- v3: group and trip expense calculator, /group-expenses ---- */
.group-expense-row {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  flex-wrap: wrap;
}
.group-expense-row input,
.group-expense-row select {
  border: 1px solid var(--hairline-strong);
  border-radius: var(--radius-md);
  background: var(--bg);
  color: var(--ink);
  padding: var(--space-3) var(--space-4);
  font-size: var(--size-body);
}
.group-expense-row input:focus-visible,
.group-expense-row select:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 1px;
}
/* an <input>'s automatic flex-shrink floor defaults to its intrinsic
   content width, not 0 (the same fix already applied to .calc-person-row
   input); without it this field never shrinks past that floor and
   overflows at narrow widths instead. */
.group-expense-desc { flex: 2 1 12rem; min-width: 0; }
.group-expense-amount { flex: 1 1 7rem; width: 7rem; min-width: 0; }
.group-expense-payer { flex: 1 1 10rem; min-width: 0; }

noscript .noscript-note {
  display: block;
  border: 1px solid var(--hairline);
  border-radius: var(--radius-md);
  padding: var(--space-5);
  background: var(--bg-raised);
  color: var(--ink-muted);
  margin-block: var(--space-5);
}

/* ---- close question ---- */

.close-question {
  text-align: left;
  padding-block: var(--space-8);
}
.close-question p { font-size: var(--size-display); font-weight: 600; max-width: 18ch; }

/* ---- motion: the one signature transition, rationed to once per page ---- */

@keyframes settle {
  from { opacity: 0; transform: translateY(8px); }
  to   { opacity: 1; transform: translateY(0); }
}

.settle { animation: settle var(--duration-slow) var(--easing-standard) both; }

@media (prefers-reduced-motion: reduce) {
  .settle { animation: none; }
}

/* ---- utility ---- */

.visually-hidden {
  position: absolute;
  /* job 18: with no left/top, an absolutely positioned element resolves
     at its own static position against the nearest positioned ancestor
     (or the initial containing block if there is none), rather than
     against a fixed point. Inside a horizontally scrolling row that
     static position can sit thousands of pixels out, and because
     nothing between it and <html> establishes a containing block, the
     scroller's own clipping never applies to it: the document's scroll
     width grows to match. Fixed here, on the general utility, rather
     than only on .chart-figures, so the same failure cannot recur the
     next time this class is reached for. */
  left: 0;
  top: 0;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
}

.skip-link {
  /* fixed, not absolute (predates job 12, corrected alongside it): an
     absolutely positioned skip link is only ever placed correctly at
     scroll 0, since it positions against the document rather than the
     viewport. Fixed keeps it, and its z-index:60 exception over the
     header, correct at any scroll position. */
  position: fixed;
  left: var(--space-3);
  top: calc(-1 * var(--space-8)); /* clear of the link's own height, now 48px with the 44px tap-target padding below */
  background: var(--brand);
  color: var(--on-brand);
  padding: var(--space-3) var(--space-4);
  border-radius: var(--radius-md);
  /* the only exception to z-index: 50 being the site's one stacking
     value (job 12): the skip link must clear the sticky header when
     focused, or the first tab stop lands invisibly behind it. */
  z-index: 60;
  transition: top var(--duration-fast) var(--easing-standard);
}
.skip-link:focus { top: var(--space-3); }
@media (prefers-reduced-motion: reduce) { .skip-link { transition: none; } }

.error-page {
  padding-block: var(--space-9);
  text-align: left;
}

/* ---- v3: breadcrumbs, on every non-home page ---- */

.breadcrumbs {
  padding-block: var(--space-4);
}
.breadcrumbs ol {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2);
  font-size: var(--size-caption);
  color: var(--ink-muted);
}
.breadcrumbs li { display: flex; align-items: center; gap: var(--space-2); }
.breadcrumbs li:not(:last-child)::after { content: "/"; color: var(--hairline-strong); }
.breadcrumbs a { text-decoration: none; }
.breadcrumbs a:hover { color: var(--ink); }
.breadcrumbs li[aria-current] { color: var(--ink); }

/* ---- v3: FAQ, native <details>/<summary>, no JS required ---- */

.faq-list { display: flex; flex-direction: column; }
.faq-item {
  border-bottom: 1px solid var(--hairline);
  padding-block: var(--space-4);
}
.faq-item:first-child { border-top: 1px solid var(--hairline); }
.faq-item summary {
  cursor: pointer;
  font-weight: 600;
  list-style: none;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-4);
}
.faq-item summary::-webkit-details-marker { display: none; }
.faq-item summary::after {
  content: "+";
  flex: none;
  font-weight: 400;
  color: var(--ink-muted);
}
.faq-item[open] summary::after { content: "\2212"; }
.faq-item summary:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
.faq-item p { margin-top: var(--space-3); color: var(--ink-muted); max-width: 42em; }

/* ---- v3: roundup / ItemList cards (the couples flagship, and comparisons) ----
   Alignment law: cards in a row share one top edge, one height, and their
   captions start on the same baseline below the row. Grid's default
   align-items: stretch gives every card the row's tallest height for free;
   captions live inside a flex column pinned to the bottom via margin-top:auto,
   so a shorter card's caption still lands on the same line as a taller one. */
.roundup-grid {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--space-5);
  margin-top: var(--space-7);
}
@media (min-width: 46rem) {
  .roundup-grid { grid-template-columns: repeat(2, 1fr); }
}

.roundup-card {
  display: flex;
  flex-direction: column;
  height: 100%;
}
.roundup-card .canvas { display: flex; flex-direction: column; height: 100%; }
.roundup-card--us .canvas { border-color: var(--brand); background: var(--brand-quiet); }
.roundup-rank { font-size: var(--size-caption); color: var(--ink-muted); font-weight: 600; }
.roundup-name { font-size: var(--size-title); font-weight: 600; margin-top: var(--space-1); }
.roundup-verdict { margin-top: var(--space-3); color: var(--ink-muted); }
.roundup-facts {
  margin-top: var(--space-5);
  padding-top: var(--space-5);
  border-top: 1px solid var(--hairline);
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  font-size: var(--size-caption);
}
.roundup-facts dt { color: var(--ink-muted); }
.roundup-facts dd { font-weight: 500; }
.roundup-facts > div { display: flex; justify-content: space-between; gap: var(--space-3); }
.roundup-asof {
  margin-top: auto;
  padding-top: var(--space-4);
  font-size: var(--size-caption);
  color: var(--ink-muted);
}

/* ---- v3: money-flow visualisation family, and the categorical charts in
   section 7. Inline SVG, tabular figures, and an accessible text
   description alongside every figure. Colour never travels alone: labels
   and signs carry the meaning too. The node/axis/flow-line classes below
   are the shared vocabulary for every chart built after this point, so a
   new chart's markup reads the same way as the six money-flow pieces. */
.mf-figure { width: 100%; }
.mf-figure svg { width: 100%; height: auto; display: block; }
.mf-figure text {
  font-family: 'InterVariable', system-ui, -apple-system, 'Segoe UI', sans-serif;
  font-variant-numeric: tabular-nums;
}
.mf-desc {
  margin-top: var(--space-5);
  padding-top: var(--space-5);
  border-top: 1px solid var(--hairline);
  color: var(--ink-muted);
  font-size: var(--size-caption);
}
.mf-flow-line { fill: none; stroke-linecap: round; }
.mf-node-label { font-size: 15px; font-weight: 600; fill: var(--ink); }
.mf-node-sub { font-size: 13px; fill: var(--ink-muted); }
.mf-axis-label { font-size: 12px; fill: var(--ink-muted); }

/* section 10.6: the title states the finding, not the topic. This sits
   above a chart's own SVG, inside its canvas, so the chart is legible on
   its own if it is ever shared or screenshotted alone; the existing
   .mf-desc below it carries the method. */
.mf-finding {
  font-size: var(--size-title);
  line-height: var(--line-title);
  font-weight: 600;
  color: var(--ink);
  margin-bottom: var(--space-5);
}

/* ---- section 9/10.6 envelope-progress anatomy, from foundations-v1/
   ChartAnatomy.dc.html: a coloured dot and name, a track and fill, then
   amount and status, values always outside the fill, direct labels, never
   a legend. Below 40rem, a row is two lines (name plus figures, then the
   track full width) so the fixed-width name and tabular figures never
   have to shrink to fit a phone; at 40rem and up it becomes the
   reference's single horizontal row, four flex items in a line. Used
   first by the envelope flow rebuild; generic enough for any future
   envelope-progress chart to reuse rather than re-inventing the row. ---- */
.budget-alloc {
  display: flex;
  height: 22px;
  border-radius: var(--radius-md);
  overflow: hidden;
  margin-bottom: var(--space-6);
}
.budget-list { display: flex; flex-direction: column; gap: var(--space-5); }
.budget-row { display: flex; flex-direction: column; gap: var(--space-2); }
.budget-row-head { display: flex; align-items: baseline; justify-content: space-between; gap: var(--space-4); }
.budget-row-label {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  font-size: var(--size-caption);
  font-weight: 600;
  color: var(--ink);
}
.budget-dot { width: 10px; height: 10px; border-radius: 3px; flex-shrink: 0; }
.budget-row-figures { display: flex; gap: var(--space-4); flex-shrink: 0; }
.budget-track {
  height: 20px;
  border-radius: var(--radius-md);
  position: relative;
  overflow: hidden;
}
.budget-track--dashed { border: 1.5px dashed var(--hairline-strong); }
.budget-fill { position: absolute; inset: 0; border-radius: inherit; }
.budget-amount {
  text-align: right;
  font-size: var(--size-caption);
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}
.budget-status {
  text-align: right;
  font-size: var(--size-caption);
  font-variant-numeric: tabular-nums;
}
.budget-status--muted { color: var(--ink-muted); }
.budget-status--negative { color: var(--negative); font-weight: 600; }
@media (min-width: 40rem) {
  .budget-row { flex-direction: row; align-items: center; gap: var(--space-4); }
  .budget-row-head { display: contents; }
  .budget-row-label { width: 11rem; flex: 0 0 auto; }
  .budget-track { flex: 1 1 auto; }
  .budget-row-figures { display: contents; }
  .budget-amount { width: 8rem; flex: 0 0 auto; }
  .budget-status { width: 8rem; flex: 0 0 auto; }
}

/* job 10 section 2: motion draws the data, it is never an effect
   applied on top of it. motion.js gives a [data-scroll-in] chart
   .mo-hidden immediately (a plain, unanimated collapsed state, so
   nothing below the viewport is ever seen fully drawn before its turn)
   and never removes it; the actual reveal, once the chart crosses 35%
   visibility, is a Web Animation (element.animate()) that starts from
   that same collapsed value and interpolates to the drawn one, holding
   there. A CSS transition was tried first for the reveal itself and did
   not reliably interpolate: swapping or layering a class and reading
   the resulting style back in the same tick gives the browser nothing
   it can prove is a genuine before/after change to animate between,
   where explicit keyframes have no such ambiguity. With JavaScript
   disabled, or with prefers-reduced-motion set (motion.js exits before
   adding .mo-hidden at all in that case), none of this applies, so every
   bar, ribbon and label simply sits at its markup's own true, fully-drawn
   values from first paint. --duration-draw and --easing-standard are
   read at runtime from these same tokens rather than duplicated as
   numbers in the script.

   Three shapes, three meanings:
   - .mo-bar: a value with a baseline. Grows from its own bottom edge
     (transform-box: fill-box makes "bottom" mean each bar's own bottom,
     not the SVG's), never past 100% and never back.
   - .mo-mask: the clip rect behind a ribbon or a line, revealing it
     from its source edge (left) toward its destination, so the shape
     itself never stretches or distorts, only becomes visible.
   - .mo-annot: a label. Fades in only once its own shape has finished,
     via an animation delay set to the same --duration-draw the shape
     used, not an arbitrary stagger. */
[data-scroll-in] .mo-bar,
[data-scroll-in] .mo-mask { transform-box: fill-box; }
[data-scroll-in] .mo-bar { transform-origin: bottom; }
[data-scroll-in] .mo-mask { transform-origin: left; }

[data-scroll-in].mo-hidden .mo-bar { transform: scaleY(0); }
[data-scroll-in].mo-hidden .mo-mask { transform: scaleX(0); }
[data-scroll-in].mo-hidden .mo-annot { opacity: 0; }

/* ---- v3: calculator variants (rent, weighted rows) ---- */
.calc-weight-input {
  width: 6rem;
  border: 1px solid var(--hairline-strong);
  border-radius: var(--radius-md);
  background: var(--bg);
  color: var(--ink);
  padding: var(--space-3) var(--space-3);
  font-size: var(--size-body);
  text-align: right;
}
/* the name input's flex:1 (from .calc-person-row input) would also make
   this field grow to the row's full width once .calc-person-row wraps it
   onto its own line below 360px; flex-grow:0 keeps it at its own 6rem
   instead. min-width restores a floor of its own width, since the
   generic min-width:0 above (needed so the name field can shrink) would
   otherwise let this field collapse too, rather than wrap, when the row
   is tight. */
.calc-person-row .calc-weight-input { flex-grow: 0; min-width: 6rem; }
.calc-weight-input:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
.calc-person-row .calc-weight-label { font-size: var(--size-caption); color: var(--ink-muted); white-space: nowrap; }

/* ---- v3: step list (guides, HowTo) ---- */
.step-list { display: flex; flex-direction: column; gap: var(--space-6); counter-reset: step; }
.step-list li { padding-left: var(--space-8); position: relative; counter-increment: step; }
.step-list li::before {
  content: counter(step);
  position: absolute;
  left: 0;
  top: 0;
  width: var(--space-6);
  height: var(--space-6);
  border-radius: var(--radius-pill);
  border: 1px solid var(--hairline-strong);
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: var(--size-caption);
  font-weight: 600;
}
.step-list h3 { font-size: var(--size-body); font-weight: 600; }
.step-list p { margin-top: var(--space-2); color: var(--ink-muted); }

/* ---- v3: comparison / spec table, used from checkpoint two on ---- */
.spec-table-wrap { overflow-x: auto; }
.spec-table { width: 100%; border-collapse: collapse; font-size: var(--size-caption); }
.spec-table th, .spec-table td {
  text-align: left;
  padding: var(--space-3) var(--space-4);
  border-bottom: 1px solid var(--hairline);
  white-space: nowrap;
}
.spec-table th { color: var(--ink-muted); font-weight: 600; }
.spec-table td:first-child, .spec-table th:first-child { white-space: normal; }

/* the /vs/ pages' tables sit in the 38rem section-inner column, the same
   width prose uses, per the alignment law: a table's edges match every
   other section's edges rather than widening to fit its own content.
   Longer cell text (a competitor's plan name and price together) wraps
   instead of forcing a horizontal scroll a reader would not expect on
   a page with no other sideways-scrolling content. */
.spec-table--wrap th, .spec-table--wrap td { white-space: normal; }

/* ---- v3.1: chart legend and table-equivalent ---- */
.chart-legend {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2) var(--space-5);
  margin-top: var(--space-5);
  font-size: var(--size-caption);
}
.chart-legend li { display: flex; align-items: center; gap: var(--space-2); }
.chart-table-toggle { margin-top: var(--space-5); }
.chart-table-toggle summary { cursor: pointer; font-size: var(--size-caption); color: var(--ink-muted); font-weight: 600; }
.chart-table-toggle table { width: 100%; margin-top: var(--space-4); border-collapse: collapse; font-size: var(--size-caption); }
.chart-table-toggle th, .chart-table-toggle td { text-align: left; padding: var(--space-2) var(--space-3); border-bottom: 1px solid var(--hairline); }
.chart-tooltip {
  position: absolute;
  pointer-events: none;
  background: var(--ink);
  color: var(--bg);
  font-size: var(--size-caption);
  padding: var(--space-2) var(--space-3);
  border-radius: var(--radius-md);
  transform: translate(-50%, -100%);
  white-space: nowrap;
  opacity: 0;
  transition: opacity var(--duration-fast) var(--easing-standard);
  z-index: 5;
}
.chart-tooltip.is-visible { opacity: 1; }
.mf-figure { position: relative; }

/* ==== section 12/13: the home page spine system ====
   Scoped entirely to the home page rebuild: every class below is
   prefixed .spine- (or is a placeholder-slot helper) so nothing here
   can touch the .hero/.announce/.canvas-row system every other page
   still uses. Measurements are the owner's own demo, taken at a 1440
   viewport with a 1300 container, per section 12. Do not tighten the
   1150-ish section height this produces: that is one idea per screen,
   on purpose. */

.spine-shell {
  max-width: 1300px;
  margin-inline: auto;
}
@media (max-width: 85rem) { /* 1360px */
  .spine-shell { width: 100%; box-sizing: border-box; padding-inline: 24px; }
}

.spine-section { padding-block: var(--space-9); } /* 96px, measured */
.spine-section--feature { padding-block: var(--space-10) var(--space-9); } /* beat 2, the Bridge: more air above */

.spine-grid {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--space-6);
}
@media (min-width: 60rem) {
  .spine-grid { grid-template-columns: 47fr 53fr; gap: var(--space-8); align-items: start; }
}

.spine-heading {
  font-size: var(--size-display-xl);
  line-height: var(--line-display-xl);
  font-weight: 600;
  letter-spacing: -0.01em;
  margin-bottom: var(--space-5);
}
.spine-section--feature .spine-heading { max-width: 16ch; }

/* The gap below a heading is a property of the heading, not of whatever
   follows it (job 7, addendum item 7): a list, a table, a paragraph, a
   card row all get the same space above them for free, from
   .spine-heading's own margin-bottom, rather than each block carrying
   its own margin-top. --stacked is for a second .spine-heading inside
   the same container, which needs extra room above it, a separate
   concern from the gap below either heading. */
.spine-heading--stacked { margin-top: var(--space-7); }

.spine-kicker {
  font-size: var(--size-caption);
  font-weight: 600;
  color: var(--ink-muted);
  text-transform: uppercase;
  letter-spacing: 0.06em;
}
.spine-body {
  margin-top: var(--space-3);
  font-size: var(--size-body-lg);
  line-height: var(--line-body-lg);
  color: var(--ink-muted);
  max-width: 42ch;
}
.spine-aside .spine-body + .spine-body { margin-top: var(--space-5); }

.spine-media-wrap { margin-top: var(--space-10); } /* 128px, heading to media */

/* the canvas: 450 tall, ALWAYS (section 12); width comes from the board
   and varies, set per instance with --canvas-w, a bare number (defaults
   to 800, which is what every board in boards-v2 happens to use today,
   but the rule is the height, not the number). Below the reference
   width the whole canvas scales down together via aspect-ratio, which
   is correct. --canvas-w is unitless so it can feed aspect-ratio
   directly; the width itself is that number times 1px. */
.spine-media {
  --canvas-w: 800;
  width: calc(var(--canvas-w) * 1px);
  max-width: 100%;
  aspect-ratio: var(--canvas-w) / 450;
  border-radius: var(--radius-canvas);
  border: 1px solid var(--hairline);
  background: var(--canvas-ground);
  overflow: hidden;
}

/* job 17: the compact chart system (COMPACT-CHART-SPEC-v2.md). Both
   drawings sit in the markup always, switched here by the same 860px
   the header and nav already step at, never a mark type change, so a
   reader who opens the same page on a laptop and a phone sees the same
   chart. The wide .spine-media keeps its own canvas proportions and
   --radius-canvas above 860px; below it, .chart-compact replaces it
   with the compact card's own 340px width and --radius-lg, sized to
   its own SVG's intrinsic aspect ratio rather than a shared one, since
   compact cards run to different heights (a highlight card's sentence
   wraps to one or two lines; a detail card's plot is a fixed height
   regardless). */
.chart-compact { display: none; }
@media (max-width: 860px) {
  /* :has(), not a blanket rule: a .spine-media with no compact
     companion yet (the rollout is chart by chart, not site-wide in one
     pass) must keep showing its wide SVG scaled down, never vanish.
     Only a .spine-media immediately followed by a real .chart-compact
     hands off to it. */
  .spine-media:has(+ .chart-compact) { display: none; }
  .chart-compact {
    display: block;
    width: 340px;
    max-width: 100%;
    background: var(--canvas-ground);
    border: 1px solid var(--hairline);
    border-radius: var(--radius-lg);
    overflow: hidden;
  }
  .chart-compact svg { display: block; width: 100%; height: auto; }

  /* a .spine-media with no compact companion yet keeps its wide SVG at
     its own true pixel size instead of the aspect-ratio scale-down
     above, which shrinks a diagram's text past legible: an 11px label
     on the 800-wide artboard renders under 5px once scaled to a
     342px-wide phone canvas. Read horizontally, at true size, the same
     way an overflowing table already reads on this site
     (.spec-table-wrap), rather than illegibly small. */
  .spine-media:not(:has(+ .chart-compact)) {
    width: 100%;
    max-width: 100%;
    aspect-ratio: auto;
    height: 450px;
    overflow-x: auto;
  }
  .spine-media:not(:has(+ .chart-compact)) svg {
    width: calc(var(--canvas-w) * 1px);
    max-width: none;
    height: 100%;
  }
}

/* job 19, STAT-GRID-SPEC.md: the compact form of any scene that is a
   set of named amounts and a total, with no shape worth comparing by
   eye. Real HTML text, not a scaled SVG artboard: it reflows, its type
   stays on the type scale at every width, and nothing can clip against
   a viewBox edge because there is no viewBox. Markup carries a category
   key, a name and an amount; every pixel of presentation lives here.
   Colour placement is CLAUDE.md section 28.4, the owner's decision
   after four variants: the VALUE wears the category's --cat-*-text
   colour (never a mark, section 28.6), the label stays in --ink-muted,
   nothing else on the row is coloured. The category name printed
   directly above the value is the redundant channel section 16.2 and
   28.6 require, so colour survives greyscale and colour blindness. */
.stat-grid {
  margin: 0;
  background: var(--canvas-ground);
  border: 1px solid var(--hairline);
  border-radius: var(--radius-lg);
  overflow: hidden;
}
/* .chart-compact already draws this card's ground, hairline and radius
   (above); a stat-grid nested inside it does not draw its own again. */
.chart-compact .stat-grid { background: none; border: 0; border-radius: 0; }
.stat-grid-head { padding: var(--space-5) var(--space-5) var(--space-4); }
.stat-grid-kick {
  margin: 0 0 var(--space-3);
  font-size: var(--size-caption);
  font-weight: 600;
  letter-spacing: 0.09em;
  color: var(--ink-muted);
}
.stat-grid-say { margin: 0; font-size: 17px; line-height: 25px; color: var(--ink); }
/* auto-fit is the whole responsive story: two columns wherever two
   fit, one below that, decided by the card's own width and never a
   viewport query (measured: two columns at 340 and 264, one at 224).
   job 20, item 1: the hairline between rows used to be a border-top on
   each .stat, which draws only as wide as that cell's own column, so a
   row holding a single cell in a two-column grid drew its rule across
   half the card. A grid's row-gap track runs the FULL width of the
   grid regardless of how many columns a given row's cells occupy, so
   painting the rule there instead (the container's own background,
   showing through a 1px gap, with every cell opaque) draws one
   continuous line under every row, full width, by construction. The
   head-to-body seam is not a row gap, so it keeps its own border. */
.stat-grid-body {
  margin: 0;
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(136px, 1fr));
  row-gap: 1px;
  background: var(--hairline);
  border-top: 1px solid var(--hairline);
}
.stat {
  padding: var(--space-4) var(--space-5);
  min-width: 0;
  background: var(--canvas-ground);
  /* job 20, item 2: two labels in a row can wrap to a different number
     of lines; since a grid row stretches every cell to the row's own
     tallest sibling by default, the cells already match height, but
     content flowing from the top left a shorter label's value sitting
     higher than a wrapped label's value beside it. Anchoring the whole
     dt+dd block to the bottom of the cell puts every value in a row on
     one shared baseline regardless of how its own label wrapped. */
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
}
.stat--lede { grid-column: 1 / -1; }
/* an odd count of regular cells leaves the last one alone in a two
   column row: the row-gap hairline above still needs a fully occupied
   grid to paint against (the container's own background otherwise
   shows through the second, empty column as a filled block, not a
   line), so a card whose trailing item is genuinely alone marks it
   full width too, same mechanism as .stat--lede, no emphasis implied. */
.stat--full { grid-column: 1 / -1; }
.stat dt {
  margin: 0 0 2px;
  font-size: var(--size-caption);
  line-height: 18px;
  font-weight: 400;
  color: var(--ink-muted);
}
.stat dt span { overflow-wrap: anywhere; }
.stat dd {
  margin: 0;
  font-size: 22px;
  line-height: 28px;
  font-weight: 600;
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}
.stat--lede dd { font-size: 28px; line-height: 34px; color: var(--brand); }
/* the overspend state, section 9's rule: shown as a real negative, in
   the reserved warning colour, never a category hue (line 405). */
.stat--negative dd { color: var(--negative); }
/* the only per-category lines on the whole site: the value's colour,
   never a mark. Income, and anything still unassigned, is brand and is
   never a category (line 405). A category with no generated text tier
   (the "Other" fold) is left uncoloured rather than falling back to
   --cat-other, which is grey, and grey is never a data colour (14.5). */
.stat[data-cat=food] dd { color: var(--cat-food-text); }
.stat[data-cat=transport] dd { color: var(--cat-transport-text); }
.stat[data-cat=health] dd { color: var(--cat-health-text); }
.stat[data-cat=groceries] dd { color: var(--cat-groceries-text); }
.stat[data-cat=shopping] dd { color: var(--cat-shopping-text); }
.stat[data-cat=rent] dd { color: var(--cat-rent-text); }
.stat[data-cat=entertainment] dd { color: var(--cat-entertainment-text); }
.stat[data-cat=bills] dd { color: var(--cat-bills-text); }
.stat[data-cat=income] dd { color: var(--brand); }

/* a labelled placeholder: honest about what is missing, never a fake
   chart or a fake screenshot standing in for one. Sizing is left to
   whichever box these classes are combined with (.spine-media,
   .spine-hero-media): setting width/height here too would tie in
   specificity with .spine-media's own 800x450 rule and, on the source
   order this file happens to have, win, silently stretching every
   canvas but the one already protected by a more specific selector.
   Caught by measuring every canvas's rendered size, not by eye. */
.canvas-slot, .stock-slot {
  display: flex;
  align-items: center;
  justify-content: center;
  text-align: center;
  padding: var(--space-6);
  box-sizing: border-box;
}
.canvas-slot { border: 1.5px dashed var(--hairline-strong); border-radius: inherit; }
.stock-slot { border: 1.5px dashed var(--hairline-strong); border-radius: inherit; background: var(--bg-sunken); }
.placeholder-note {
  font-size: var(--size-caption);
  color: var(--ink-muted);
  max-width: 32ch;
}

.spine-card-title {
  margin-top: var(--space-6); /* 32px, canvas to title */
  font-size: var(--size-card-title);
  line-height: var(--line-card-title);
  font-weight: 600;
}
.spine-card-caption {
  margin-top: var(--space-3); /* 12px, title to caption */
  color: var(--ink-muted);
}

/* beat 3: a row of cards that may run past the container's right edge,
   on purpose, "to signal that more follows" (12). Every card stays in
   the DOM and reachable by scroll; there is no hidden slide and no
   prev/next control. */
.spine-row-wrap { width: 100%; overflow-x: auto; }
.spine-row {
  display: flex;
  gap: var(--space-7); /* 48px, card gap */
  width: max-content;
  padding-inline-start: max(24px, calc((100% - 1300px) / 2));
  padding-inline-end: 24px;
}
/* card width matches a canvas at the same width everywhere: both are
   min(their reference size, the space a 24px-padded shell actually has)
   rather than two different formulas (800 flat vs 86vw) that used to
   disagree at 390 (342 wide inside .spine-shell, 335 inside a card).
   Below 1360, the card sits inside two nested 24px paddings, .spine-shell's
   own and .spine-row's, 96px total, not 48: subtracting only 48px left
   the card exactly as wide as the row's own left padding, flush with the
   viewport's right edge with no gutter there at all. */
.spine-card { width: min(calc(100vw - var(--space-9)), var(--card-w, 800px)); flex-shrink: 0; }

/* below 860 (the same breakpoint the header and nav already switch on)
   the row that deliberately runs past the container's right edge on a
   1440 desktop has nowhere to run: the next card is a full screen away
   rather than half visible, so the "more follows" affordance the
   horizontal scroll depends on is invisible and the row reads as a dead
   end. Stacking keeps every card in the DOM and in normal reading order,
   which the no-hidden-content rule above already requires; it also means
   a card's scroll-in chart draws on an ordinary read instead of needing
   an explicit sideways swipe first. */
@media (max-width: 860px) {
  .spine-row-wrap { overflow-x: visible; }
  .spine-row {
    flex-direction: column;
    width: auto;
    padding-inline-end: 24px;
  }
  .spine-card { width: 100%; }
}

/* Job 10, section 1: the hero field. HeroFlow.svg is full bleed behind
   the hero, anchored right with the artwork's own left third left
   clear for the wordmark; blurred and scaled up slightly so the blur's
   own soft edge falls outside the frame rather than inside it. This is
   the one place on the site the 450-tall canvas rule does not apply,
   because the artwork is read through a blur rather than read as a
   chart, and it stays that one exception rather than a precedent. */
.spine-hero {
  position: relative;
  overflow: hidden;
  isolation: isolate;
  min-height: 640px;
}
@media (max-width: 30rem) {
  .spine-hero { min-height: 520px; }
}

/* home page only (job 12): sits at the hero's bottom edge, observed by
   header-sticky.js to reveal the header wordmark once the hero (which
   already carries the wordmark at 90px) has genuinely scrolled past. */
.hero-sentinel {
  position: absolute;
  bottom: 0;
  left: 0;
  width: 100%;
  height: 1px;
}

.spine-hero-media {
  position: absolute;
  inset: 0;
  z-index: 0;
}
.spine-hero-media img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: right center;
  filter: blur(38px);
  transform: scale(1.06);
}
/* Measured against the rendered blur, both themes: dark clears 4.5:1
   everywhere without help. In light mode the muted description line
   falls short (3.53:1 at 1440, 4.26:1 at 390) against the field behind
   it, so a scrim lifts only that zone; the fan stays unobstructed past
   where the text sits. Dark mode needs none and gets none. */
.spine-hero-scrim {
  position: absolute;
  inset: 0;
  z-index: 0;
  background: linear-gradient(90deg, rgba(255, 255, 255, 0.72) 0%, rgba(255, 255, 255, 0.72) 42%, rgba(255, 255, 255, 0) 68%);
  pointer-events: none;
}
@media (max-width: 30rem) {
  /* the single-column layout puts the text across nearly the full
     width at this size, so the fade has nothing left to protect */
  .spine-hero-scrim { background: rgba(255, 255, 255, 0.72); }
}
@media (prefers-color-scheme: dark) {
  .spine-hero-scrim { display: none; }
}
.spine-hero-content { position: relative; z-index: 1; padding-block: var(--space-9); text-align: left; }
.wordmark-hero { width: min(70vw, 720px); height: auto; }
/* section 12 measures the hero tagline at 21/25, between our body-lg
   (18/27) and display-xl (60/60, the section-heading size this line
   was wrongly running at). 21/25 is not itself a token; rather than
   add a third near-duplicate step for a 3px difference, this uses
   body-lg, the closer and already-existing match. Flagged, not silent. */
.spine-hero-tagline {
  margin-top: var(--space-7);
  font-size: var(--size-body-lg);
  line-height: var(--line-body-lg);
  font-weight: 600;
}
.spine-hero-desc {
  margin-top: var(--space-5);
  font-size: var(--size-body-lg);
  line-height: var(--line-body-lg);
  color: var(--ink-muted);
  max-width: 42ch;
}
.spine-hero-door {
  position: relative;
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  margin-top: var(--space-6);
  font-weight: 600;
  text-decoration: none;
  color: var(--ink);
}
/* plain text at line-height 24; padding here would look like a button
   this text link was never designed to be, so the tap area grows
   invisibly instead. */
.spine-hero-door::before {
  content: "";
  position: absolute;
  inset-block: calc(-1 * var(--space-3));
  inset-inline: calc(-1 * var(--space-2));
}
.spine-hero-door .arrow { transition: transform var(--duration-fast) var(--easing-standard); }
.spine-hero-door:hover .arrow, .spine-hero-door:focus-visible .arrow { transform: translateY(2px); }
@media (prefers-reduced-motion: reduce) {
  .spine-hero-door .arrow { transition: none; }
  .spine-hero-door:hover .arrow { transform: none; }
}

.spine-door-canvas { max-width: 34rem; }

/* footer close: an oversized, muted wordmark, per 12's "structure worth
   borrowing" note. Chrome, not a tenth beat: it carries no argument. */

/* ================================================================
   Code drawn charts (charts.js, 2 September 2026). One card, one data
   block, drawn at the width the card actually has, so type stays on
   the type scale at every width and no artboard is ever scaled. The
   header is HTML text (kicker, figure, scope, sentence, in that order
   and never reordered, COMPACT-CHART-SPEC-v2 section 1); only the
   plot is SVG. The card ground, hairline and radius are the compact
   card's own. With scripting off .chart-ready never arrives and the
   figures list is shown as ordinary text instead of the empty plot.
   ================================================================ */
.chart {
  margin: 0;
  width: 100%;
  /* THE ALIGNMENT LAW (CLAUDE.md 12 and 31.4): every canvas on this site
     is the same height, which is why the artboards were all 800 by 450.
     A code drawn chart holds to it: the CARD is a fixed 450 tall at every
     width, the header takes what its words need, and the plot absorbs
     whatever is left. Two cards side by side, or one above another, can
     never disagree by a pixel, whatever their headers say. */
  height: 450px;
  display: flex;
  flex-direction: column;
  background: var(--canvas-ground);
  border: 1px solid var(--hairline);
  border-radius: var(--radius-lg);
  padding: var(--space-5);
  box-sizing: border-box;
  overflow: hidden;
}
.chart-head { flex: 0 0 auto; }
.chart-periods { flex: 0 0 auto; }
.chart-head { display: flow-root; }
.chart-kicker {
  margin: 0;
  font-size: var(--size-caption);
  line-height: var(--line-caption);
  font-weight: 600;
  letter-spacing: 0.09em;
  text-transform: uppercase;
  color: var(--ink-muted);
}
.chart-figure {
  margin: 0;
  font-size: var(--size-display);
  line-height: var(--line-display);
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  color: var(--ink);
}
.chart-figure-unit {
  font-size: var(--size-body);
  line-height: var(--line-body);
  font-weight: 400;
  color: var(--ink-muted);
}
.chart-scope {
  margin: 0;
  font-size: var(--size-caption);
  line-height: var(--line-caption);
  color: var(--ink-muted);
  /* two lines are reserved whether or not the scope needs both, so a
     header that wraps and one that does not still hand the plot the
     same height: every plot on the site starts and ends on one line */
  min-height: calc(var(--line-caption) * 2);
}
.chart-sentence {
  margin: var(--space-5) 0 0;
  font-size: var(--size-body);
  line-height: var(--line-body);
  color: var(--ink);
  max-width: 60ch;
}
.chart-sentence [data-amt] { font-variant-numeric: tabular-nums; }
.chart-head:not(:empty) + .chart-periods:not([hidden]),
.chart-head:not(:empty) + .chart-periods[hidden] + .chart-plot {
  margin-top: var(--space-4);
  border-top: 1px solid var(--hairline);
  padding-top: var(--space-4);
}
.chart-periods { display: flex; gap: var(--space-1); }
.chart-periods:not([hidden]) + .chart-plot { margin-top: var(--space-4); }
.chart-period {
  flex: 1 1 0;
  min-height: 44px; /* the hit area, section 27.5 tap targets */
  padding: var(--space-2) var(--space-3);
  border: 0;
  border-radius: var(--radius-md);
  background: none;
  color: var(--ink-muted);
  font: inherit;
  font-size: var(--size-caption);
  font-weight: 600;
  cursor: pointer;
}
.chart-period[aria-selected="true"] { background: var(--surface-callout); color: var(--ink); }
.chart-period:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
.chart-plot { width: 100%; flex: 1 1 auto; min-height: 0; }
.chart-plot svg { display: block; width: 100%; overflow: visible; }
.chart-plot text { font-variant-numeric: tabular-nums; }
/* wide: the chart's own width is the card's; the canvas column caps it */
@media (min-width: 861px) {
  .chart { max-width: var(--card-w, 800px); }
}
/* scripting off: the numbers as a plain list, never an empty card */
.chart:not(.chart-ready) .chart-figures.visually-hidden {
  position: static; width: auto; height: auto; margin: 0; padding: 0;
  clip: auto; clip-path: none; overflow: visible; white-space: normal;
  list-style: none;
  font-size: var(--size-body); line-height: var(--line-body); color: var(--ink);
}
.chart:not(.chart-ready) .chart-figures li { display: flex; justify-content: space-between; gap: var(--space-4); padding: var(--space-2) 0; border-bottom: 1px solid var(--hairline); font-variant-numeric: tabular-nums; }
/* the plot is focusable so the callout works from a keyboard */
.chart-plot svg:focus-visible { outline: 2px solid var(--focus); outline-offset: 4px; border-radius: var(--radius-sm); }
.chart-plot svg { touch-action: pan-y; }

/* Below 360 a kicker, a figure with its unit, and a scope can each wrap
   onto an extra line, which would hand each plot a different height
   inside cards that are all 450 tall. At that width the reserve is one
   line larger, so the plots line up there too. Nothing above 360 pays
   for it. */
@media (max-width: 360px) {
  .chart-kicker { min-height: calc(var(--line-caption) * 2); }
  .chart-figure { min-height: calc(var(--line-display) * 2); }
  .chart-scope { min-height: calc(var(--line-caption) * 3); }
}
