/* styles.css — casa-datos site layout.
   Stack-authored, NOT a vendored @spine-kit file. Every color/radius/font
   value below reads a token from tokens.css (Context A, Night Shift) or,
   for chart-adjacent chrome and the page's own light, Context D's
   --tp-* set. There is no hex, rgb(), or named colour anywhere in this
   file, and nothing here redefines a --tp-* or Night Shift value
   (check-token-fidelity.mjs). Used by index.html / es/index.html /
   404.html / es/404.html — NOT by tool.html, which is a single,
   self-contained file with its own inline styles by design (see
   README.md, "tool.html vs. the example site"); tool.html carries the
   base half of the same elevation scale, and says so in its own header.

   ── WHICH CONTEXT, AND WHY ───────────────────────────────────────────
     CHROME is Context A.  Near-black ground, --text / --muted copy,
                           --accent on calls to action.
     DATA is Context D.    --tp-cat-1..8, in order, per DATAVIZ-RULES.md.
     ART is Context D too. The page's light sources, the 1px card edges,
                           the section ribbons.

   The rule that governs the boundary: a Rainbow hue may carry light,
   never language. No --tp-* value is ever a `color:` on body copy here.
   Chart marks are not language — they are marks, and DATAVIZ-RULES.md
   rule 2 makes sure none of them is the only carrier of its own value.

   ── THE CHART-VERSUS-RIBBON TENSION, AND HOW IT IS RESOLVED ──────────
   The reference stack closes every section with a 54–76px seeded
   spectral ribbon. This stack cannot simply copy that: a page whose
   whole job is data ink already spends the Rainbow on --tp-cat-1..8, and
   a second full-strength spectral object sitting under a chart competes
   with the chart for the same reading. Data ink wins.

   So the ribbon's HEIGHT is set by what the section it closes contains:

     a section with a chart      -> 22px. Core filament plus a narrow
                                    halo. A rule, not an event: enough to
                                    end the section, not enough to read
                                    as a second chart.
     a section without a chart   -> the full clamp(44px, 4.4vw, 60px).
                                    The hero and the tool callout are the
                                    only two, and neither has any data
                                    ink to compete with.

   The polarity of the @supports is deliberate and is the opposite of
   what looks natural. The QUIET height is the base value, and :has()
   RAISES it on chart-free sections — so an engine without :has() gets
   quiet ribbons everywhere rather than loud ones everywhere. On a data
   page the safe direction to be wrong in is "too restrained."

   Even the full height here is capped under the reference's (60px vs
   76px): this page is denser, and seven boundaries at the reference's
   floor would spend more of the scroll on ornament than on charts. */

:root {
  /* Fluid section rhythm, capped under the vendored --section-rhythm
     (96px). The gap a reader sees between two sections is rhythm + the
     ribbon + the ribbon's own top margin; holding the full 96px made
     that read as an unfinished page rather than a generous one. */
  --rhythm: clamp(48px, 5.6vw, 76px);

  /* ── THE ELEVATION SCALE — BASE LAYER ───────────────────────────────
     ⚠ THIS BLOCK IS THE FALLBACK, AND IT IS THE ONE THAT MUST BE RIGHT.
     The richer values live in the @supports block below.

     ── why a fallback exists at all: the failure mode is asymmetric ────
     color-mix() written DIRECTLY in a declaration is invalid at PARSE
     time and thrown away, so an earlier declaration for the same
     property still wins — graceful. color-mix() written into a CUSTOM
     PROPERTY parses fine and fails later, at SUBSTITUTION: the *using*
     declaration becomes `unset`, and no earlier declaration rescues it,
     because it was never the loser of a cascade. So
     `background-image: linear-gradient(var(--plane-1), var(--plane-1))`
     does not fall back to a darker card — it falls back to NO CARD, and
     every chart on this page loses its surface. That is why the whole
     scale gets a base layer here rather than a guard at each use site.

     ── the original bug this scale fixes, measured in THIS stack ──────
     .chart-card used --surface (#111118) on a --bg (#0a0a0f) ground.
     Measured on rendered pixels that was 1.05:1, dL* +2.44 — a card
     carrying a chart, on a page about charts, with no surface a reader
     could see. A chart on an invisible card is the same defect as a
     paragraph on an invisible card, and it is worse here, because the
     card's edges are the only thing telling a reader where one chart
     stops and the next begins.

     ── the arithmetic ceiling, stated up front ───────────────────────
     On a near-black page these two requirements FIGHT:

       body text (--text) on a plane >= 15:1   =>  plane Y <= 0.011625
       plane vs ground              >= 1.25:1  =>  plane Y >= 0.0125

     No value satisfies both — WCAG adds 0.05 to both luminances, and
     near black that constant swamps the signal. We hold the text floors
     and take the ceiling those allow. CIE L* is the honest metric here.

     Levels: ground (--bg) < band < plane-1 (cards) < plane-2 (hover),
     with --plane-sunk for chips recessed INTO a card. Built from the
     neutral ramp tokens.css already ships, in the order it ships it:
     --bg < --bg-alt < --surface < --raised. The card takes --raised,
     which is what that token is FOR.

     --plane-2 deliberately EQUALS --plane-1 in this layer: nothing in
     the neutral ramp sits above --raised, and inventing a lighter value
     would be forking the design system for a hover state. */
  --plane-base: var(--surface);
  --plane-band: var(--bg-alt);
  --plane-1:    var(--raised);
  --plane-2:    var(--raised);
  --plane-sunk: var(--bg);

  /* Edges. A flat `1px solid var(--border)` cannot vary along its
     length, so every card edge on a dark page reads identically and the
     page goes flat — which on a four-chart page is the difference
     between four framed plots and one long grey column.

     The base layer keeps the warm→cool character rather than collapsing
     to grey, because tokens.css already ships pre-alpha'd hues for
     exactly this: the `--tp-*-soft` family. They are plain token
     references, so they need no colour function at all. */
  --edge-warm:        var(--tp-sun-soft);
  --edge-neutral:     var(--border-strong);
  --edge-cool:        var(--tp-cyan-soft);
  --edge-warm-hot:    var(--tp-cream);
  --edge-neutral-hot: var(--border-strong);
  --edge-cool-hot:    var(--tp-cyan);

  --edge: linear-gradient(148deg,
      var(--edge-warm) 0%,
      var(--edge-neutral) 30%,
      var(--edge-neutral) 62%,
      var(--edge-cool) 100%);
  --edge-hot: linear-gradient(148deg,
      var(--edge-warm-hot) 0%,
      var(--edge-neutral-hot) 28%,
      var(--edge-neutral-hot) 60%,
      var(--edge-cool-hot) 100%);

  /* the lit lip along the top of the banded section — the band's fill is
     deliberately a whisper, and this line is what the eye catches. */
  --band-line: var(--border-strong);

  /* atmosphere gradient for banded sections — a warm radial glow that
     lifts the band fill subtly so it reads as a lit surface rather than
     a flat plane. Base: the same `-soft` token used by the light rig. */
  --band-atmos: var(--tp-ember-soft);

  /* --subtle (#6e6e80) is 3.96:1 on --bg at its very best — already
     under AA-normal BEFORE this pass, and lifting the card plane takes
     it to 3.49:1 on a card. It was this stack's colour for chart axis
     numbers, the chart caption, the footer line and the 404 code. All of
     those now read --quiet, which sits between --subtle and --muted:
     quiet enough to keep a real third step in the text hierarchy, over
     4.5:1 on every plane in this file (5.09:1 on --plane-1 measured).
     charts.js / choropleth.js read the same variable — see their own
     cssVar() calls — so an axis label and a caption cannot drift apart.
     The base value is simply --muted: erring toward MORE contrast, which
     is the only safe direction for a fallback to err. */
  --quiet: var(--muted);

  /* depth: a 1px inner top highlight + one soft, far-offset ambient
     shadow. Two shadows, not five — a raised surface on a dark ground
     needs a lit edge and a shadow that reads as distance; more just
     turns to smoke. */
  --lift:
    inset 0 1px 0 0 var(--border-strong),
    0 24px 48px -30px var(--tp-black);
  --lift-hover:
    inset 0 1px 0 0 var(--tp-cream),
    0 30px 60px -28px var(--tp-black);

  /* One variable so the SVG, the CSS fallback and the small-screen
     override can never drift apart. See this file's header for why the
     base value is the QUIET one on this stack. */
  --ribbon-h: 22px;

  /* The full spectral sweep, used at the top lip of the banded section.
     Raw hues at full saturation: this is a 3px line of LIGHT, never a
     text colour, which is the boundary this file holds. */
  /* ── THEME-SWITCHED PAINTS AND PLATES ─────────────────────────────
     Everything a theme has to re-aim, pulled out of its rule and into a
     variable so the rule itself is written once. The theme layer at the
     foot of this file overrides these five groups and nothing else
     touches a selector.

     Base values are the plain-token fallbacks; the @supports block
     below upgrades them; the light theme (also inside @supports)
     replaces them. */
  --rig-1: var(--tp-orange-soft);   /* warm key, high left      */
  --rig-2: var(--tp-cyan-soft);     /* cool counter, top right  */
  --rig-3: var(--tp-ocean-soft);    /* cool bounce, from below  */
  --rig-4: var(--tp-ember-soft);    /* warm spill, lower right  */
  --grain-op: 0.34;                 /* see the grain plate below */
  --bloom-cool: var(--tp-ocean-soft);

  /* The three places a gradient is poured into type or a control. Named
     rather than inlined because on paper none of the three survives:
     --grad-title is white→lavender, --grad and --grad-cta run through a
     1.56:1 amber and a 3.91:1 purple. */
  --brand-paint: var(--grad);       /* eyebrow + brand dot */
  --title-paint: var(--grad-title); /* h1 */
  --cta-fill:    var(--grad-cta);   /* primary button fill */
  --cta-ink:     var(--bg);         /* primary button text */
  --cta-glow:    var(--tp-ember-soft);

  --band-spectrum: linear-gradient(90deg,
      transparent 0%,
      var(--tp-sun)   14%,
      var(--tp-ember) 34%,
      var(--tp-red)   50%,
      var(--tp-teal)  72%,
      var(--tp-cyan)  88%,
      transparent 100%);
}

/* ── THE ELEVATION SCALE — ENHANCEMENT LAYER ─────────────────────────
   Everything above is complete and correct on its own. This block makes
   it richer where the engine can afford it, and it is the ONLY place
   color-mix() touches a custom property — the one door with the
   catastrophic failure mode described above.

   Warmed toward the Rainbow ramp (--tp-peach) rather than lifted as flat
   grey, because the key light is warm and a neutral-grey lift under a
   warm light reads as a hole. The blue cast of --surface is pulled out
   against --tp-black first, so the warmth lands as warmth, not violet.
   The edges are opaque and composed off the plane they sit on, so every
   stop clears 1.5:1 against its own surface by construction.

   ── do not "tidy" the feature test back to `red 50%, blue` ────────────
   Every @supports colour test in this file mixes currentColor with
   transparent. The idiomatic probe is `color-mix(in srgb, red 50%, blue)`
   — and it would put two named colours in a stylesheet whose standing
   constraint is zero hex, zero rgb(), zero named colours. currentColor
   and transparent are CSS-wide keywords rather than named colours, they
   probe exactly the same function, and they keep the file honest. */
@supports (color: color-mix(in srgb, currentColor 50%, transparent)) {
  :root {
    --plane-base: color-mix(in srgb, var(--surface) 70%, var(--tp-black));
    --plane-band: color-mix(in srgb, var(--tp-peach)  3.5%, var(--bg));
    --plane-1:    color-mix(in srgb, var(--tp-peach)  6.5%, var(--plane-base));
    --plane-2:    color-mix(in srgb, var(--tp-peach) 11.5%, var(--plane-base));
    --plane-sunk: color-mix(in srgb, var(--tp-ember)    3%, var(--tp-black));

    --edge-warm:        color-mix(in srgb, var(--tp-cream) 38%, var(--plane-1));
    --edge-neutral:     color-mix(in srgb, var(--tp-paper) 18%, var(--plane-1));
    --edge-cool:        color-mix(in srgb, var(--tp-cyan)  32%, var(--plane-1));
    --edge-warm-hot:    color-mix(in srgb, var(--tp-cream) 46%, var(--plane-2));
    --edge-neutral-hot: color-mix(in srgb, var(--tp-paper) 30%, var(--plane-2));
    --edge-cool-hot:    color-mix(in srgb, var(--tp-cyan)  50%, var(--plane-2));

    --band-line: color-mix(in srgb, var(--tp-cream) 22%, var(--plane-band));
    --band-atmos: color-mix(in srgb, var(--tp-peach) 26%, transparent);
    --quiet:     color-mix(in srgb, var(--muted) 62%, var(--subtle));

    --lift:
      inset 0 1px 0 0 color-mix(in srgb, var(--tp-cream) 22%, transparent),
      0 24px 48px -30px color-mix(in srgb, var(--tp-black) 96%, transparent);
    --lift-hover:
      inset 0 1px 0 0 color-mix(in srgb, var(--tp-cream) 34%, transparent),
      0 30px 60px -28px color-mix(in srgb, var(--tp-black) 98%, transparent);

    --rig-1: color-mix(in srgb, var(--tp-orange) 30%, transparent);
    --rig-2: color-mix(in srgb, var(--tp-cyan)   26%, transparent);
    --rig-3: color-mix(in srgb, var(--tp-ocean)  32%, transparent);
    --rig-4: color-mix(in srgb, var(--tp-ember)  14%, transparent);
    --bloom-cool: color-mix(in srgb, var(--tp-ocean) 20%, transparent);
    --cta-glow: color-mix(in srgb, var(--tp-ember) 60%, transparent);
  }
}

* { box-sizing: border-box; }
html {
  scroll-behavior: auto; /* motion.css turns this on only under html.js:not(.no-motion) */
  /* html carries the ground too, so body's background never propagates
     to the viewport canvas — which would otherwise paint ABOVE body's
     own z-index:-1 pseudo-elements and hide the light rig entirely. */
  background: var(--bg);
}
body {
  margin: 0;
  background: var(--bg);
  color: var(--text);
  font: 16px/1.6 var(--font-sans);
  position: relative;
  isolation: isolate;   /* makes z-index:-1 below mean "behind body's
                           content", not "behind body's background" */
  -webkit-font-smoothing: antialiased;
}
img, svg { max-width: 100%; height: auto; display: block; }
a { color: var(--accent); }
a:focus-visible, button:focus-visible, input:focus-visible, textarea:focus-visible {
  outline: 3px solid var(--accent);
  outline-offset: 2px;
}

/* ── the light rig ────────────────────────────────────────────────────
   Two fixed, viewport-anchored layers under everything: a set of radial
   sources here, and a grain plate below. Fixed rather than folded into
   body's background shorthand so the light stays put while the page
   scrolls — a glow that scrolls away reads as a picture, not as lighting.
   Both are pointer-events:none and neither carries information. Both sit
   BEHIND every chart card, so no chart's plot area is ever lit unevenly:
   the plane is opaque, and that is the whole point of it being opaque. */
body::before,
body::after {
  content: "";
  position: fixed;
  inset: 0;
  z-index: -1;
  pointer-events: none;
}
/* A warm key light high on the left, a cool counter-source top-right,
   and a cool bounce from below.

   Alpha is not free — every unit of it lifts the GROUND, and the ground
   is one half of the surface-separation ratio the elevation scale above
   is fighting for. So the spend is deliberately uneven: the lobes are
   tight and pushed off-canvas, and the COOL sources take the bigger
   raise. --tp-ocean and --tp-cyan carry roughly a third of --tp-orange's
   relative luminance per unit alpha (the coefficient on blue is 0.0722,
   on green 0.7152), so a cool glow buys about three times as much
   visible colour per point of ground lift.

   Four lobes, one rule: the GEOMETRY is fixed and the COLOUR is a
   variable (--rig-1..4), because that is the axis every layer of this
   file varies on - the plain-token fallback, the color-mix() upgrade and
   the light theme all want these same four ellipses in these same four
   places, filled differently. Base values are the `-soft` tokens
   (0.15/0.16 alpha), dimmer than the mixed ones - for a fallback that is
   the right direction to be wrong in, since a dimmer rig lifts the
   ground less and the elevation ladder gets MORE of the contrast budget,
   not less. */
body::before {
  background:
    radial-gradient(72% 48% at 0% -14%,   var(--rig-1), transparent 66%),
    radial-gradient(58% 42% at 100% -6%,  var(--rig-2), transparent 64%),
    radial-gradient(92% 50% at 50% 114%,  var(--rig-3), transparent 70%),
    radial-gradient(70% 44% at 96% 78%,   var(--rig-4), transparent 72%);
}
/* Grain. feTurbulence rendered through a data URI: no request, nothing
   to cache-bust, works with JS off. Desaturated in the filter chain so
   it textures without tinting.

   THE BLEND MODE IS LOAD-BEARING, not cosmetic. fractalNoise is centred
   on 0.5, and `overlay` against a base below 0.5 resolves to
   2 x base x blend — so a plate centred on 0.5 is the IDENTITY in the
   mean: it modulates the ground up and down in equal measure and adds
   nothing to it. In NORMAL blending the same plate adds mid-grey to a
   near-black ground, lifting the GROUND to meet the card and inverting
   the elevation the scale above just bought. Worse, a normal-blended
   grey plate over a fixed light rig composites DIFFERENTLY in Gecko than
   in Blink/WebKit, and the divergence lands entirely on the ground —
   which is exactly what "looks fine over there, wrong over here" is.
   Overlay removes the divergence rather than papering over it.

   The plate is at z-index -1, i.e. BELOW every chart card. A chart's
   plot area never has grain over it, and a data mark's colour is never
   modulated by a noise tile. On a data-viz stack that is not a nicety —
   it is the difference between texture and a corrupted swatch.

   Support: mix-blend-mode ships everywhere since 2015 — strictly earlier
   than color-mix(), so any engine old enough to need the base layer
   above still has blend modes. The guard below is belt-and-braces for
   the one client that has neither: without it, a normal-blended plate at
   0.34 would lift the ground straight back into the old bug.

   ── ON PAPER THE BRANCH FLIPS. THE MEAN SURVIVES; THE GAIN DOES NOT ──
   Overlay picks its formula from the BASE, not the blend: 2·b·s below
   0.5, and 1 − 2(1−b)(1−s) at or above it. The light theme moves the
   ground from b≈0.043 to b≈0.90, so paper takes the second branch. Two
   consequences, and they are NOT the same consequence:

     the mean SURVIVES — and this is the load-bearing half. At s = 0.5
       the upper branch gives 1 − 2(1−b)(0.5) = b, the identity, exactly
       as the lower branch gives 2·b·0.5 = b. A plate centred on 0.5
       still adds no luminance on either side, so the whole
       engine-independence argument above holds unchanged in light mode.
       Worth stating plainly, because "the branch flipped" reads like
       "the maths broke" and the part that mattered did not.

     the GAIN does not survive. d/ds is 2b below and 2(1−b) above:
       ~0.098 at the near-black ground, ~0.21 on paper, i.e. 2.1× more
       swing per unit of plate. On top of that a unit of sRGB buys more
       L* near white than near black (measured at the two grounds:
       ~89 vs ~70 per unit), and 8-bit quantisation is coarser up there
       too (0.34 L* per step vs 0.27), so the same number of flipped
       pixels reads louder.

   Theory gets the direction and about half the magnitude; the plate was
   then swept and measured rather than trusted. Grain-only standard
   deviation of L* over an 8300-pixel detrended ground strip, plate on
   minus plate off, casa-estatica at 1280px:

     dark  op 0.34  ->  0.108        light  op 0.055  ->  0.074
                                     light  op 0.060  ->  0.096
                                     light  op 0.065  ->  0.107   <- match
                                     light  op 0.070  ->  0.122
                                     light  op 0.130  ->  0.141
                                     light  op 0.340  ->  0.266

   So light runs at 0.065 — a 5.2× cut, not the 2.7× the branch algebra
   alone predicts. Equal PERCEIVED texture, not equal opacity, and the
   number came off the pixels. */
body::after {
  opacity: var(--grain-op);
  mix-blend-mode: overlay;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='180' height='180' filter='url%28%23g%29'/%3E%3C/svg%3E");
}
/* No blend modes: drop the plate rather than let it add grey to the
   ground. Texture is worth having; it is not worth the elevation scale. */
@supports not (mix-blend-mode: overlay) {
  body::after { opacity: 0; }
}

/* ── visually-hidden: the accessible-table utility ──────────────────
   Standard clip-based sr-only pattern — present in the DOM and read by
   assistive tech, never display:none/visibility:hidden (which would
   remove it from the accessibility tree too, defeating the point). */
.visually-hidden {
  position: absolute;
  width: 1px; height: 1px;
  margin: -1px; padding: 0; border: 0;
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  overflow: hidden;
  white-space: nowrap;
}
/* ── the phantom horizontal scrollbar, and why the fix is not here ─────
   Measured at 375px BEFORE this pass: document scrollWidth 479 against a
   375 viewport on the EN page, 597 on the ES page. Attributed by walking
   the tree and skipping anything inside a scroll container, the ONLY
   unclipped contributors were the four accessible tables — #table-multiples
   at 439px wide was the worst, and Spanish is the longer half because its
   column headers are.

   Three plausible fixes were tried and MEASURED, and two of them do
   nothing. Recording that, because each looks obviously right:

     `contain: paint` on this utility — applies (computed style confirms
       it), and is irrelevant: paint containment clips what is INSIDE the
       box. It cannot shrink the box, and the box was the problem.
     `width: 1px` at higher specificity + `table-layout: fixed` — the
       specificity part is real (`table.data-table { width: 100% }` further
       down this file out-specifies `.visually-hidden`'s `width: 1px`, so
       every hidden table was 1px tall and FULL WIDTH). Fixing that still
       left the table at its content width: a table's used width is
       max(specified, min-content), and the <caption> — inheriting
       `white-space: nowrap` from the rule above — sets that min-content.
     `contain: size` — the textbook answer, and it silently does nothing:
       Chromium does not apply size containment to a table box. Measured
       identical scrollWidth with and without.

   What actually works is to stop treating it as a property of the hidden
   element at all. The table is `position: absolute` and body is the
   nearest positioned ancestor, so its width lands in the DOCUMENT's
   scrollable overflow. Give it a nearer positioned ancestor that clips,
   and the question never reaches the document — see .chart-card below. */

.skip-link {
  position: absolute;
  left: 12px; top: -48px;
  background: var(--accent);
  color: var(--bg);
  border: 1px solid transparent;
  border-radius: var(--r-badge);
  padding: 10px 16px;
  z-index: 100;
  transition: top 160ms ease;
}
.skip-link:focus { top: 12px; }

/* padding-INLINE, not the `padding: 0 24px` shorthand this replaces. That
   shorthand also set padding-block: 0 — and because <nav> here carries
   BOTH classes (`class="nav wrap"`), the 420px override's
   `.wrap { padding: 0 16px }` silently won over `.nav`'s own
   padding-block, so the nav's vertical rhythm was declared, documented,
   and dead below 420px. Measured: the header stood at 106px on a phone
   with 0 of its own vertical padding, held up only by min-height. */
.wrap { max-width: var(--wrap); margin: 0 auto; padding-inline: 24px; }
.wrap-narrow { max-width: var(--wrap-narrow); margin: 0 auto; padding-inline: 24px; }

/* ── header / nav ───────────────────────────────────────────────── */
.site-header {
  position: sticky; top: 0; z-index: 20;
  /* Base: OPAQUE. A translucent header without a blur behind it is not a
     softer header, it is a chart scrolling visibly through the nav — so
     the translucency is gated on the blur that makes it work, not merely
     on the colour function that expresses it. */
  background: var(--bg);
  /* --border (0.08 alpha white) over near-black is a 1.28:1 line — the
     header floated with no bottom at all. --border-strong is the same
     token family at twice the alpha and reads as an actual edge. */
  border-bottom: 1px solid var(--border-strong);
}
@supports ((backdrop-filter: blur(8px)) or (-webkit-backdrop-filter: blur(8px))) {
  .site-header {
    background: color-mix(in srgb, var(--bg) 84%, transparent);
    backdrop-filter: blur(8px);
    -webkit-backdrop-filter: blur(8px);
  }
}
.nav {
  display: flex; align-items: center; justify-content: space-between;
  gap: 16px; padding-block: 16px;
  min-height: var(--header-height);
}
.nav-brand { display: flex; align-items: baseline; gap: 8px; font-family: var(--font-mono); font-size: 15px; }
/* Same trap as the h1 below — solid base, gradient only where the clip
   works. Losing the clip while `color: transparent` stands does not give
   an ungradiented wordmark, it gives no wordmark. */
.nav-brand strong { color: var(--text); }
@supports ((background-clip: text) or (-webkit-background-clip: text)) {
  .nav-brand strong {
    background: var(--brand-paint);
    -webkit-background-clip: text;
    background-clip: text;
    color: transparent;
  }
}
.nav-brand span { color: var(--quiet); }
.nav-links { display: flex; align-items: center; gap: 20px; list-style: none; margin: 0; padding: 0; flex-wrap: wrap; }
.nav-links a { color: var(--muted); text-decoration: none; font-size: 14px; }
.nav-links a:hover { color: var(--text); }
.lang-link {
  border: 1px solid var(--edge-neutral);
  border-radius: var(--r-chip);
  padding: 4px 12px;
  font-family: var(--font-mono);
  font-size: 12.5px;
  text-decoration: none;
  color: var(--text) !important;
}
.lang-link:hover { border-color: var(--accent); }
.lang-pair { display: inline-flex; align-items: center; gap: 6px; font-family: var(--font-mono); font-size: 12.5px; color: var(--muted); }
.lang-sep { opacity: 0.5; }

/* ── hero ───────────────────────────────────────────────────────── */
.hero { padding-block: 56px var(--rhythm); }
.eyebrow {
  display: inline-block;
  font-family: var(--font-mono); font-size: 12.5px; letter-spacing: 0.08em;
  text-transform: uppercase; margin-bottom: 16px;
  /* Solid base, gradient only where the clip works — and --accent, not a
     --tp-* hue, so the fallback never puts a Rainbow value on language. */
  color: var(--accent);
}
@supports ((background-clip: text) or (-webkit-background-clip: text)) {
  .eyebrow {
    background: var(--brand-paint);
    -webkit-background-clip: text;
    background-clip: text;
    color: transparent;
  }
}
/* ── gradient text, and why it is the most dangerous rule in this file ──
   `background-clip: text` + `color: transparent` is the standard way to
   pour a gradient into type. It is also the standard way to ship an
   INVISIBLE HEADLINE: the two declarations are independent, so an engine
   that drops the clip still honours the transparent colour, and the
   result is not "an ungradiented h1" — it is nothing at all, on the one
   element the page cannot afford to lose. This rule shipped unguarded.
   Base is solid --text; the gradient is added only where the clip works. */
.hero h1 {
  font-size: clamp(32px, 5vw, 52px); line-height: 1.08; margin: 0 0 18px;
  color: var(--text);
}
@supports ((background-clip: text) or (-webkit-background-clip: text)) {
  .hero h1 {
    background: var(--title-paint);
    -webkit-background-clip: text;
    background-clip: text;
    color: transparent;
  }
}
.hero p.lede { font-size: 18px; color: var(--muted); max-width: 62ch; margin: 0 0 24px; }

.badge-synthetic {
  display: inline-flex; align-items: center; gap: 10px;
  background: var(--tp-ocean-soft);
  border: 1px solid var(--tp-ocean);
  color: var(--text);
  border-radius: var(--r-badge);
  padding: 10px 16px;
  font-size: 13.5px;
  max-width: fit-content;
}
@supports (color: color-mix(in srgb, currentColor 50%, transparent)) {
  .badge-synthetic { border-color: color-mix(in srgb, var(--tp-ocean) 55%, transparent); }
}
.badge-synthetic .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--tp-ocean); flex-shrink: 0; }

.pill-nav { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 28px; }
.pill-nav a {
  border: 1px dashed var(--edge-neutral);
  border-radius: var(--r-chip);
  padding: 8px 16px;
  font-size: 13px;
  color: var(--muted);
  text-decoration: none;
  background: var(--plane-sunk);
}
.pill-nav a:hover { border-color: var(--accent); color: var(--text); }

/* ── sections ───────────────────────────────────────────────────────
   The divider is the ribbon at the section's foot, not a border — see
   .thread below. The 1px border-top this replaces was --border, a
   0.08-alpha white line measuring about 1.28:1 against the page: it was
   declared, and it was not visible. */
section { padding-block: var(--rhythm); }
section[id] { scroll-margin-top: calc(var(--header-height) + 14px); }
.section-head { max-width: 68ch; margin-bottom: 28px; }
/* --accent, not var(--tp-cyan) as this shipped. A section kicker is
   language, and the boundary rule this file holds is that a Rainbow hue
   may carry light and never language — the cyan was the one place in the
   four stacks where a --tp-* token was a `color:` on type. It was legible
   (6.79:1 measured against the worst ground pixel behind it), so no gate
   caught it: check-a11y-contrast's hard fail is scoped to raw WARM hues
   on a LIGHT ground, and this was a cool hue on a dark one. The rule is
   still the rule. --accent is the Context A equivalent and measures
   11.5:1; the hero's eyebrow keeps the --grad clip, so the two labels
   stay distinguishable by treatment rather than by hue. */
.section-head .kicker { font-family: var(--font-mono); font-size: 12px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--accent); }
.section-head h2 { font-size: clamp(22px, 3vw, 30px); margin: 8px 0 10px; }
.section-head p { color: var(--muted); margin: 0; }

/* ── section cadence: the band ──────────────────────────────────────
   Sections used to differ only by a hairline, so the page read as one
   undifferentiated column. One section now sits on --plane-band with a
   lit lip along the top.

   Full-bleed WITHOUT touching the markup and without `100vw`: a spread
   box-shadow paints the band outward past the .wrap measure, and
   clip-path trims that spread back to the element's own top and bottom.
   box-shadow never contributes to scrollable overflow, so unlike the
   `left:50%; width:100vw` idiom this cannot produce a horizontal
   scrollbar on a platform with classic scrollbars, and needs no
   `overflow: clip` anywhere — which would have risked the sticky header.

   ── which sections, and why the parity clause is dropped ───────────
   The reference bands alternate sections. Here that would put every
   band under a chart, which is exactly backwards: a band lifts the
   ground under everything in its section, and on a near-black page the
   whole card-to-ground budget is about 0.16 of a ratio point — a band
   under a chart card spends a quarter of the budget that card needs to
   read as a surface at all. Data ink wins, again.

   So the rule states the PRINCIPLE instead of a parity: bands go where
   nothing raised competes. On index.html that resolves to exactly one
   section — the hero — and it stays correct if a prose section is added
   later.

   The whole block is inside @supports selector(:has(*)) and
   (clip-path: inset(…)). Strictly it need not be — a selector an engine
   cannot parse takes its rule down with it — but making the dependency
   explicit is what lets the next reader see, without testing, that the
   fallback page is a page with NO band rather than a page with a broken
   one. clip-path is in the condition for a harder reason than tidiness:
   the full-bleed trick is a 100vmax spread shadow TRIMMED back to the
   element's own top and bottom, and without the trim the shadow still
   paints — 100vmax in every direction — and the whole page turns
   --plane-band, erasing the distinction the band exists to create. */
@supports selector(:has(*)) and (clip-path: inset(0 -1px)) {
  main section:not(:has(.chart-card, .params-panel, .tool-callout)) { position: relative; }
  main section:not(:has(.chart-card, .params-panel, .tool-callout))::before,
  main section:not(:has(.chart-card, .params-panel, .tool-callout))::after {
    content: "";
    position: absolute;
    left: 0;
    right: 0;
    z-index: -1;
    pointer-events: none;
    clip-path: inset(0 -100vmax);
  }
  main section:not(:has(.chart-card, .params-panel, .tool-callout))::before {
    top: 0;
    bottom: 0;
    background:
      /* A subtle atmosphere gradient — warm towards the centre of the
         band, cooling at the edges. Follows the same axis as the light
         rig, so the band reads as a lit surface rather than a flat fill.
         Low opacity — it's atmosphere, not another colour stop. */
      radial-gradient(56% 68% at 20% 30%, var(--band-atmos), transparent 72%),
      var(--plane-band);
    box-shadow: 0 0 0 100vmax var(--plane-band);
  }
  /* The lit lip is 3px and spectral, not 1px and cream. Two colours in
     one element, on purpose: the ELEMENT's own background paints the
     spectrum and stops at the .wrap measure, because that is how wide
     the section actually is; the box-shadow spread that carries the line
     out to the viewport edges can only be a flat colour, so it stays the
     quiet --band-line. */
  main section:not(:has(.chart-card, .params-panel, .tool-callout))::after {
    top: 0;
    height: 3px;
    background: var(--band-spectrum);
    box-shadow: 0 0 0 100vmax var(--band-line);
  }
}

/* ── section ornament — THE RIBBON ─────────────────────────────────
   A seeded spectral ribbon closing each section. app.js redraws each one
   from the SAME seed the four charts are generated from, plus a
   per-section offset — so one press of "new seed" re-rolls the data AND
   re-cuts every divider together, which is what makes the page read as
   one parametric artifact rather than charts with decoration bolted on.

   Read this file's header first for the height rule: the base is the
   QUIET 22px, and only a chart-free section is raised to full presence.

   Built in three depths, because a big shape made of one flat gradient
   reads as a printed bar — the opposite of what a seeded ornament should
   say:
     halo   100% of the height, `-soft` alpha — the reach.
     body   38% of the height, `-soft` again — the second wash doubles
            alpha where the two overlap, so saturation RAMPS toward the
            middle instead of stepping.
     core   3px, raw hue at full strength — the filament.

   Everything below is the JS-OFF state, and it is a complete ribbon, not
   a placeholder: same three depths, same per-section palettes, same
   height. Decoration degrading to simpler decoration — never
   load-bearing, per Motion Rules 0/0a. */
.thread {
  --t1: var(--tp-sun);
  --t2: var(--tp-ember);
  --t3: var(--tp-teal);
  --t4: var(--tp-navy);
  --t1s: var(--tp-sun-soft);
  --t2s: var(--tp-ember-soft);
  --t3s: var(--tp-teal-soft);
  --t4s: var(--tp-navy-soft);
  display: block;
  width: 100%;
  height: var(--ribbon-h);
  /* The negative bottom margin puts the ribbon ON the boundary between
     two sections — it eats the section's own padding-bottom, so it lands
     where the old 1px border-top used to, rather than adding a third
     band of space to the stack. */
  margin-block: clamp(10px, 1.6vw, 22px) calc(var(--rhythm) * -1 + 2px);
  background:
    /* core — the saturated filament */
    linear-gradient(90deg,
      transparent 3%, var(--t1) 18%, var(--t2) 42%, var(--t3) 66%, var(--t4) 85%, transparent 97%)
    left center / 100% 3px no-repeat,
    /* body — the ramp */
    linear-gradient(90deg,
      transparent 0%, var(--t1s) 15%, var(--t2s) 42%, var(--t3s) 68%, var(--t4s) 86%, transparent 100%)
    left center / 100% 38% no-repeat,
    /* halo — the reach */
    linear-gradient(90deg,
      transparent 0%, var(--t1s) 20%, var(--t2s) 45%, var(--t3s) 70%, var(--t4s) 88%, transparent 100%)
    left center / 100% 100% no-repeat;
}
/* The presence rule, stated once. Base = quiet (above); a section with
   no chart in it is raised. See the header for why the polarity is this
   way round and not the intuitive one. */
@supports selector(:has(*)) {
  main section:not(:has(.chart-card)) .thread { --ribbon-h: clamp(44px, 4.4vw, 60px); }
}
/* Per-section palettes. Every hue here has BOTH a raw and a `-soft` form
   in tokens.css, which is why each palette carries four pairs and not
   three — a fourth deep stop anchors the spectrum on the cool side so
   the warmer three don't all drift mid-range. */
main section:nth-of-type(2) .thread {
  --t1: var(--tp-orange); --t2: var(--tp-red); --t3: var(--tp-cyan); --t4: var(--tp-navy);
  --t1s: var(--tp-orange-soft); --t2s: var(--tp-red-soft); --t3s: var(--tp-cyan-soft); --t4s: var(--tp-navy-soft);
}
main section:nth-of-type(3) .thread {
  --t1: var(--tp-sun);   --t2: var(--tp-lime);    --t3: var(--tp-ocean); --t4: var(--tp-ink);
  --t1s: var(--tp-sun-soft);   --t2s: var(--tp-lime-soft);    --t3s: var(--tp-ocean-soft); --t4s: var(--tp-ink-soft);
}
main section:nth-of-type(4) .thread {
  --t1: var(--tp-ember); --t2: var(--tp-emerald); --t3: var(--tp-ocean); --t4: var(--tp-navy);
  --t1s: var(--tp-ember-soft); --t2s: var(--tp-emerald-soft); --t3s: var(--tp-ocean-soft); --t4s: var(--tp-navy-soft);
}
main section:nth-of-type(5) .thread {
  --t1: var(--tp-sun);   --t2: var(--tp-red);     --t3: var(--tp-teal); --t4: var(--tp-navy);
  --t1s: var(--tp-sun-soft);   --t2s: var(--tp-red-soft);     --t3s: var(--tp-teal-soft); --t4s: var(--tp-navy-soft);
}
/* app.js swaps the class once it has drawn the real thing */
.thread-live { background: none; }
.thread svg { width: 100%; height: var(--ribbon-h); display: block; }

/* ── chart card — the raised surface ────────────────────────────────
   The border is 1px of TRANSPARENT, and --edge shows through it as the
   second background layer (background-clip: padding-box, border-box).
   That is the whole trick: a flat border cannot vary along its length,
   so a gradient hairline is what gives four stacked chart cards four
   distinguishable edges instead of one repeated grey rectangle.

   The fill is FLAT, not a top-lit vertical gradient. A gradient would be
   the obvious way to make a plane look lit — but --plane-1 already sits
   on the ceiling that keeps --text at 15:1, and on this stack --text is
   what every bar's printed value is drawn in. A lighter top band would
   put the tallest bars' own labels on the one strip of the card that
   fails the floor. The 1px inner highlight in --lift does that job
   instead: it lights the edge, which is where a real lit surface shows
   it, and it never crosses the plot area. */
.chart-card,
.tool-callout,
.params-panel {
  border: 1px solid transparent;
  border-radius: var(--r-card);
  background-image: linear-gradient(var(--plane-1), var(--plane-1)), var(--edge);
  background-origin: border-box;
  background-clip: padding-box, border-box;
  box-shadow: var(--lift);
}
/* position + overflow here are the fix for the phantom horizontal
   scrollbar documented at .visually-hidden above, and they belong here
   rather than there: the card is the right owner of "nothing inside me
   reaches the document." `position: relative` makes the card the
   containing block for its own absolutely-positioned accessible table,
   and `overflow: hidden` then clips that table to the card instead of
   letting its 439px land in body's scrollable overflow. Nothing in a
   chart card is supposed to escape it anyway — the wide charts scroll
   inside .chart-scroll, which is its own scroll container. Measured at
   375px: document scrollWidth 471 -> 375 (EN) and 589 -> 375 (ES).
   `overflow: hidden` rather than `overflow: clip` on purpose: clip is
   Chrome 90 / Firefox 81 / Safari 16, and this is a correctness fix, not
   a progressive enhancement. */
.chart-card {
  padding: 24px;
  position: relative;
  overflow: hidden;
  transition: box-shadow 200ms ease;
}
.chart-card:hover {
  box-shadow: var(--lift-hover);
  background-image: linear-gradient(var(--plane-2), var(--plane-2)), var(--edge-hot);
}
.chart-host { width: 100%; }
.chart-caption { margin-top: 14px; font-size: 13px; color: var(--quiet); }
.chart-scroll { overflow-x: auto; }
.chart-title {
  display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
  font-size: 14px; font-weight: 600; color: var(--muted);
  margin: 0 0 16px;
}
/* recessed INTO the card, not another raised thing on top of it —
   --plane-sunk is below --plane-1, so the chip reads as a slot. */
.chart-status {
  font-family: var(--font-mono); font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.06em;
  color: var(--live);
  background: var(--plane-sunk);
  border: 1px solid var(--live);
  border-radius: var(--r-chip);
  padding: 2px 8px;
}
@supports (color: color-mix(in srgb, currentColor 50%, transparent)) {
  .chart-status { border-color: color-mix(in srgb, var(--live) 45%, transparent); }
}

/* small multiples can run wide at small viewports — the panel count
   is fixed (4 programs), so we let the SVG's own viewBox scale down
   instead of reflowing columns; a horizontal scroll is the fallback
   for very narrow screens rather than illegible, squashed panels. */
#chart-multiples svg { min-width: 480px; }

/* ── accessible data tables ────────────────────────────────────────
   Only reachable via .visually-hidden above in the live pages; styled
   anyway so the same markup is legible if ever shown directly (e.g. a
   print stylesheet, or a future stack that surfaces it). */
table.data-table { border-collapse: collapse; width: 100%; font-size: 14px; }
table.data-table caption { text-align: left; color: var(--muted); margin-bottom: 8px; }
table.data-table th, table.data-table td { border: 1px solid var(--edge-neutral); padding: 6px 10px; text-align: left; }
table.data-table th[scope="row"] { color: var(--text); font-weight: 600; }

/* ── tool callout ───────────────────────────────────────────────── */
.tool-callout {
  display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 20px;
  padding: 28px;
}
.tool-callout h2 { margin: 0 0 6px; font-size: 21px; }
.tool-callout p { margin: 0; color: var(--muted); max-width: 52ch; }
.tool-callout code {
  font-family: var(--font-mono);
  font-size: 0.92em;
  background: var(--plane-sunk);
  border-radius: var(--r-badge);
  padding: 1px 6px;
}

.btn {
  display: inline-flex; align-items: center; gap: 8px;
  border-radius: var(--r-chip);
  padding: 12px 22px;
  font-size: 14.5px; font-weight: 600;
  text-decoration: none;
  border: 1px solid transparent;
  white-space: nowrap;
  transition: filter 160ms ease, border-color 160ms ease, color 160ms ease;
}
/* color: var(--bg), not a literal — this rule used to carry the one hex
   value in the file (#1a1206), a hand-mixed near-black that no token
   defines. The choice is made against the WORST stop of --grad-cta's
   three-colour sweep (the purple end), which a token-name-only audit
   cannot see since it never resolves a gradient's individual stops.
   Verified by hand: --bg vs each stop is 13.14:1 (amber) / 7.15:1 (pink)
   / 4.66:1 (purple) — all >=4.5:1 AA-normal at this button's 14.5px/600,
   which does not qualify for the 3:1 large-text threshold (>=18.66px
   bold). */
.btn-primary {
  background: var(--cta-fill);
  color: var(--cta-ink);
  box-shadow: 0 14px 30px -18px var(--cta-glow);
}
.btn-primary:hover { filter: brightness(1.06); }
.btn-secondary { border-color: var(--edge-neutral); color: var(--text); }
.btn-secondary:hover { border-color: var(--accent); }

/* ── params panel host ─────────────────────────────────────────── */
.params-host { padding-block: var(--rhythm); }
.params-panel { padding: 22px 20px; }
.params-note { max-width: 62ch; }

/* ── footer ─────────────────────────────────────────────────────── */
.site-footer { border-top: 1px solid var(--border-strong); padding-block: 32px; }
.site-footer .wrap { display: flex; flex-wrap: wrap; gap: 16px; align-items: center; justify-content: space-between; }
.footer-links { display: flex; gap: 18px; list-style: none; margin: 0; padding: 0; flex-wrap: wrap; }
.footer-links a { color: var(--muted); text-decoration: none; font-size: 13.5px; }
.footer-links a:hover { color: var(--text); }
.site-footer small { color: var(--quiet); font-size: 12.5px; }

/* ── 404 page ───────────────────────────────────────────────────── */
.error-page { padding-block: 96px; text-align: center; }
.error-page .code { font-family: var(--font-mono); color: var(--quiet); letter-spacing: 0.08em; }
.error-page h1 { font-size: clamp(28px, 4vw, 40px); margin: 12px 0 14px; }
.error-page p { color: var(--muted); max-width: 46ch; margin: 0 auto 28px; }
.error-actions { display: flex; gap: 14px; justify-content: center; flex-wrap: wrap; }

/* ── reduced motion ────────────────────────────────────────────────
   motion.css already carries a global guard, but 404.html links only
   tokens.css + styles.css — so without this, the transitions declared
   above would run unguarded on exactly the page that has no motion kit.
   Scoped to this file's own properties rather than a blanket `*`, so it
   never fights motion.css's rules on the pages that load both. */
@media (prefers-reduced-motion: reduce) {
  html:not(.force-motion) .skip-link,
  html:not(.force-motion) .btn,
  html:not(.force-motion) .chart-card {
    transition-duration: 0.01ms !important;
  }
}

/* ── responsive ─────────────────────────────────────────────────── */
@media (max-width: 700px) {
  .nav { flex-wrap: wrap; }
  .nav-links { gap: 14px; }
  .tool-callout { flex-direction: column; align-items: flex-start; }
}
/* ── small-screen: 375px, and the LONGER language half ───────────────
   This nav wraps, and Spanish labels run materially longer than English,
   so these rules are sized against the Spanish nav, not the English one.
   Check the ES twin at 375px, not just the EN page. */
@media (max-width: 480px) {
  /* ── the theme toggle does not fit here, and that is measured ───────
     The toggle mounts inside the language flipper, which is a flex item
     in a nav that is already at its limit on a phone. Measured at 375px,
     with the button in place:

       casa-estatica ES   nav row 2 needs 268px of 246 -> the "get in
                          touch" button wraps to a third row and the
                          headline drops 115px -> 150px. Baseline had
                          THREE pixels of slack.
       casa-viva EN       header row needs 357px of 343 -> the nav wraps
                          under the wordmark, header 48px -> 76px,
                          headline 162px -> 190px.

     casa-datos and taller-abierto happen to have room, but the rule is
     uniform across the palette on purpose: these are templates, and a
     stack copied from one of them should not behave differently from a
     stack copied from another because of a few pixels of nav copy.

     Hiding is the honest resolution rather than shrinking the control to
     11px or spending the nav's remaining slack, because nothing is lost
     that a phone user did not already have: `prefers-color-scheme` still
     governs, which is the baseline this whole layer is built on, and a
     phone's own theme switch is the control people actually reach for.
     The toggle was always the enhancement half. */
  .theme-toggle { display: none; }

  .wrap, .wrap-narrow { padding-inline: 16px; }
  .hero { padding-block: 32px 26px; }
  /* Six nav links will wrap to two lines at 375px in either language —
     that is a content fact, not a styling failure. What is sizeable is
     how much vertical space those two lines cost, so the padding and the
     line height are set here against the SPANISH nav, which is the
     longer half. min-height goes to 0 because --header-height (72px) is
     a desktop number, and holding it on a phone reserves space the nav
     no longer needs once it is allowed its own padding again. */
  .nav { padding-block: 9px; min-height: 0; gap: 8px; }
  .nav-brand { font-size: 13px; }
  .nav-links { gap: 9px 11px; line-height: 1.35; }
  .nav-links a { font-size: 12.5px; }
  .lang-link { padding: 3px 9px; font-size: 12px; }
  .chart-card { padding: 16px; }
  /* Chart sections already carry the quiet ribbon; the two full-height
     ones come down as well, because two dividers at the desktop 44px
     floor is a real fraction of a short screen. One variable, so the
     SVG follows automatically. */
  main section:not(:has(.chart-card)) .thread { --ribbon-h: 34px; }
  .thread { --ribbon-h: 18px; }
  /* The eyebrow is the ONLY thing between the sticky header and the
     headline, so at 375px it is measured, not styled by feel.
     Re-measure BOTH halves after any change here — Spanish is the
     binding case. */
  .eyebrow {
    font-size: 11px;
    letter-spacing: 0.06em;
    line-height: 1.35;
    margin-bottom: 12px;
  }
}

/* ══════════════════════════════════════════════════════════════════════
   THE THEME LAYER — light
   ──────────────────────────────────────────────────────────────────────
   tokens.css re-binds the CANONICAL tokens (--bg, --text, --accent …).
   This block re-derives what this stack composes ON TOP of them — the
   elevation ladder, the edges, the shadows, the light rig, the grain
   plate and the four painted gradients. Those are stack-local names, so
   they belong here and not in the kit; the split is exactly the one the
   vendoring contract already draws.

   Dark needs no counterpart block: forcing dark removes these rules
   (:not([data-theme="dark"])) and the file falls back through to the
   base values above, which is the same result with one copy of the
   numbers instead of two.

   Gated on the same color-mix() probe as the rest of the file, for the
   reason tokens.css states: --tp-paper is the lightest value the design
   system has, so a light elevation ladder cannot be built out of plain
   token references — the ground has to come down, and coming down means
   mixing. An engine without color-mix() gets the dark theme in light
   mode: a complete, measured page rather than a broken approximation.

   ── WHAT ACTUALLY HAD TO INVERT, AND WHAT THE NUMBERS SAY ────────────

   1 · THE LADDER'S SIGN. Dark lifts the card off the ground by mixing
       --tp-peach INTO a dark base. Paper has nothing above it to mix
       toward, so the GROUND drops instead and the card stays near paper:
       ground = ink 7% into paper (L* 91.94), card = ink 1% (L* 96.50),
       hover = paper itself (L* 97.26). dL* card−ground = +4.57, and
       hover finally lifts for real (+0.76 over the card) where on the
       dark page it could not move at all.

   2 · THE CEILING, WHICH IS THE MIRROR OF THE DARK ONE AND WORSE.
       The base layer above records that near black, --text ≥ 15:1 and a
       visible card fight. Near white they fight harder:

         --text (--tp-ink) on ground ≥ 15:1  =>  ground Y ≥ 0.90067
                                             =>  ground L* ≥ 96.00
                                             =>  dL* card−ground ≤ 1.26

       So on paper, "text at 15:1" and "a 4.5 dL* step" are not merely
       tight, they are mutually exclusive — WCAG's +0.05 is negligible up
       here, but L* is compressed instead (dL* per unit Y ≈ 40 near white vs 903
       in the near-black linear segment), which is the same trap wearing
       the other face. The ratified pairing is --tp-paper over --tp-ink
       (PIP-114, 2026-07-22) and it is held: ink stays ink. What gives is
       the 15:1 floor on OPEN GROUND, where it lands at 13.50:1. On the
       card — where the page's dense text actually sits — it is 15.18:1.
       Flagged rather than quietly traded: see the build report.

   3 · THE RIG. 26–32% alpha over near-black is a whisper; the same
       alpha over paper is mud, and it eats the ladder from the other
       end (ocean at 16% costs the ground 8.34 L*). Cut to 4–7%, which
       holds the worst overlap to about −1.5 L* — the rig still tints the
       corners and the ground stays inside the budget above.

   4 · THE SHADOWS. --tp-black at 96–98% alpha is soot on paper. Re-tinted
       to --tp-ink and softened to 20–46%, and the inset top highlight is
       dropped: a 1px light line reads as a lit edge on a dark ground and
       as nothing at all on a white one. On paper the ambient shadow does
       the whole job, which is the reverse of the dark page.

   5 · THE PAINTS. --grad-title is white→lavender (1.06:1 on paper),
       --grad runs through a 1.56:1 amber, --grad-cta through a 3.91:1
       purple. None survives. Each is re-aimed at the `*-deep` family,
       which is the design system's own answer for a light ground: the
       eyebrow reads red-deep→teal-deep→ocean-deep (5.44 / 6.46 / 7.56:1
       on ground), the h1 ink→teal-deep→ocean-deep, and the CTA becomes a
       DARK fill with paper text (4.86 / 6.23 / 8.67:1) instead of a
       bright fill with dark text. No raw warm hue carries text anywhere
       in this theme — that is the colors.md rule the a11y gate hard-fails
       on, and it is held by construction, not by luck.
   ══════════════════════════════════════════════════════════════════════ */
/* ══════════════════════════════════════════════════════════════════════
   LIGHT THEME — SAME VALUES appear TWICE, by CSS limitation
   ──────────────────────────────────────────────────────────────────────
   The declarations below are IDENTICAL to the `:root[data-theme="light"]`
   block further down this file. They must be kept in sync by hand.

   Why two copies: a selector list cannot cross an `@media` boundary,
   so this `@media (prefers-color-scheme) { :root:not([data-theme]) }`
   path (JS-off fallback) and the `:root[data-theme="light"]` path
   (explicit toggle + JS-stored choice) must be separate rules even
   though every declaration is identical. There is no pure-CSS way to
   share a declaration block across these two conditions without a
   preprocessor, which this palette explicitly does not use.

   Edit ONE, grep for the identically-named properties in the other
   and mirror the change. The gate will catch a drifted hex value
   (via check-token-fidelity.mjs) but NOT a drifted color-mix() —
   that check is on you.  */
@supports (color: color-mix(in srgb, currentColor 50%, transparent)) {
  @media (prefers-color-scheme: light) {
    :root:not([data-theme="dark"]) {
      --plane-base: var(--surface);
      --plane-band: var(--bg-alt);
      --plane-1:    var(--raised);
      --plane-2:    var(--tp-paper);
      --plane-sunk: color-mix(in srgb, var(--tp-ember) 4%, var(--bg));

      --edge-warm:        color-mix(in srgb, var(--tp-ember) 52%, var(--plane-1));
      --edge-neutral:     color-mix(in srgb, var(--tp-ink)   22%, var(--plane-1));
      --edge-cool:        color-mix(in srgb, var(--tp-ocean) 32%, var(--plane-1));
      --edge-warm-hot:    color-mix(in srgb, var(--tp-ember) 62%, var(--plane-2));
      --edge-neutral-hot: color-mix(in srgb, var(--tp-ink)   34%, var(--plane-2));
      --edge-cool-hot:    color-mix(in srgb, var(--tp-ocean) 48%, var(--plane-2));

      --band-line: color-mix(in srgb, var(--tp-ink) 20%, var(--plane-band));
      --band-atmos: color-mix(in srgb, var(--tp-peach) 18%, transparent);
      --quiet:     color-mix(in srgb, var(--tp-ink) 76%, var(--tp-paper));

      --lift:
        0 1px 2px -1px   color-mix(in srgb, var(--tp-ink) 20%, transparent),
        0 16px 30px -20px color-mix(in srgb, var(--tp-ink) 38%, transparent);
      --lift-hover:
        0 2px 5px -2px   color-mix(in srgb, var(--tp-ink) 26%, transparent),
        0 24px 44px -20px color-mix(in srgb, var(--tp-ink) 46%, transparent);

      --rig-1: color-mix(in srgb, var(--tp-orange) 7%, transparent);
      --rig-2: color-mix(in srgb, var(--tp-cyan)   6%, transparent);
      --rig-3: color-mix(in srgb, var(--tp-ocean)  7%, transparent);
      --rig-4: color-mix(in srgb, var(--tp-ember)  4%, transparent);
      --grain-op: 0.065;
      --bloom: color-mix(in srgb, var(--tp-ember) 16%, transparent);
      --bloom-cool: color-mix(in srgb, var(--tp-ocean) 13%, transparent);

      --brand-paint: linear-gradient(135deg, var(--tp-red-deep) 0%, var(--tp-teal-deep) 52%, var(--tp-ocean-deep) 100%);
      --title-paint: linear-gradient(120deg, var(--tp-ink) 0%, var(--tp-teal-deep) 46%, var(--tp-ocean-deep) 100%);
      --cta-fill:    linear-gradient(135deg, var(--tp-ember-deep) 0%, var(--tp-red-deep) 45%, var(--tp-ocean-deep) 100%);
      --cta-ink:     var(--tp-paper);
      --cta-glow:    color-mix(in srgb, var(--tp-ink) 34%, transparent);
    }
  }
  /* Same declarations, no media condition: the reader's explicit choice
     wins over the OS. Duplicated because a selector list cannot cross an
     @media boundary — keep the two halves identical. */
  :root[data-theme="light"] {
    --plane-base: var(--surface);
    --plane-band: var(--bg-alt);
    --plane-1:    var(--raised);
    --plane-2:    var(--tp-paper);
    --plane-sunk: color-mix(in srgb, var(--tp-ember) 4%, var(--bg));

    --edge-warm:        color-mix(in srgb, var(--tp-ember) 52%, var(--plane-1));
    --edge-neutral:     color-mix(in srgb, var(--tp-ink)   22%, var(--plane-1));
    --edge-cool:        color-mix(in srgb, var(--tp-ocean) 32%, var(--plane-1));
    --edge-warm-hot:    color-mix(in srgb, var(--tp-ember) 62%, var(--plane-2));
    --edge-neutral-hot: color-mix(in srgb, var(--tp-ink)   34%, var(--plane-2));
    --edge-cool-hot:    color-mix(in srgb, var(--tp-ocean) 48%, var(--plane-2));

    --band-line: color-mix(in srgb, var(--tp-ink) 20%, var(--plane-band));
    --band-atmos: color-mix(in srgb, var(--tp-peach) 18%, transparent);
    --quiet:     color-mix(in srgb, var(--tp-ink) 76%, var(--tp-paper));

    --lift:
      0 1px 2px -1px   color-mix(in srgb, var(--tp-ink) 20%, transparent),
      0 16px 30px -20px color-mix(in srgb, var(--tp-ink) 38%, transparent);
    --lift-hover:
      0 2px 5px -2px   color-mix(in srgb, var(--tp-ink) 26%, transparent),
      0 24px 44px -20px color-mix(in srgb, var(--tp-ink) 46%, transparent);

    --rig-1: color-mix(in srgb, var(--tp-orange) 7%, transparent);
    --rig-2: color-mix(in srgb, var(--tp-cyan)   6%, transparent);
    --rig-3: color-mix(in srgb, var(--tp-ocean)  7%, transparent);
    --rig-4: color-mix(in srgb, var(--tp-ember)  4%, transparent);
    --grain-op: 0.065;
    --bloom: color-mix(in srgb, var(--tp-ember) 16%, transparent);
    --bloom-cool: color-mix(in srgb, var(--tp-ocean) 13%, transparent);

    --brand-paint: linear-gradient(135deg, var(--tp-red-deep) 0%, var(--tp-teal-deep) 52%, var(--tp-ocean-deep) 100%);
    --title-paint: linear-gradient(120deg, var(--tp-ink) 0%, var(--tp-teal-deep) 46%, var(--tp-ocean-deep) 100%);
    --cta-fill:    linear-gradient(135deg, var(--tp-ember-deep) 0%, var(--tp-red-deep) 45%, var(--tp-ocean-deep) 100%);
    --cta-ink:     var(--tp-paper);
    --cta-glow:    color-mix(in srgb, var(--tp-ink) 34%, transparent);
  }

  /* The ribbon is the one ORNAMENT that does not survive a re-binding,
     because its problem is compositing rather than colour. Its halo and
     body washes are the `-soft` tokens — 0.15 alpha — and a colour
     LIGHTER than the ground laid over it at 0.15 alpha is, on paper,
     almost exactly nothing: --tp-sun at 0.15 over --tp-paper moves the
     pixel 1.06:1. Multiply is the fix and it is the physically right
     one: it is what translucent ink on paper actually does, subtracting
     the channels the hue lacks (sun's blue) instead of averaging toward
     white. Same three depths, same section palettes, same seeded SVG —
     the element just stops washing out. Decoration only; nothing here is
     load-bearing with JS off or with motion disabled. */
  @media (prefers-color-scheme: light) {
    :root:not([data-theme="dark"]) .thread { mix-blend-mode: multiply; }
  }
  :root[data-theme="light"] .thread { mix-blend-mode: multiply; }
}
