/* casa-viva — styles.css (stack-authored, not a vendored kit file)
   Loads after tokens.css, motion.css, params-panel.css. Every color,
   radius, and gradient below reads a custom property from those files
   (spine/README.md rule 5) — the three exceptions are --wrap-narrow,
   --section-rhythm, --header-height, which are tokens.css's OWN
   stack-local examples, reused as-is (not redefined here; see this
   stack's CLAUDE.md for why nothing is added inside tokens.css itself).
   Any further layout variable this stack needs lives in the :root
   block immediately below — never inside a vendored file.

   There is no hex, rgb(), or named colour anywhere in this file, and
   nothing here redefines a --tp-* or Night Shift value. (Four rgba()
   literals used to sit in .field-fallback, .site-header and .eyebrow —
   they duplicated --tp-sun / --tp-cyan / --tp-emerald / --bg / --gated
   by hand, which is a fork of the design system written in a notation
   the token-fidelity gate cannot see. They are gone.)

   ── WHICH CONTEXT, AND WHY ───────────────────────────────────────────
   Same split casa-estatica holds, and for the same reason colors.md
   gives:

     CHROME is Context A.  Near-black ground, --text / --muted copy,
                           --accent and --grad-cta on calls to action,
                           --grad on the progress bar.
     ART is Context D.     The signal field's constellation, the section
                           ribbons, the page's light sources, and the
                           hairline card edges read the Rainbow ramp.
                           The five layer hues stay --tp-cat-1..8, which
                           is the categorical scale's own job.

   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.

   ── THE BACKDROP STACK, AND THE BUG IT FIXES ─────────────────────────
   This stack's centrepiece is a generative canvas. It was invisible.

   `.field-fallback` declared `background: <three gradients>, var(--bg)`.
   The trailing var(--bg) in a `background` shorthand is parsed as the
   layer's background-COLOR, so the element was OPAQUE — and it sits
   AFTER <canvas id="signal-field"> in tree order at the same z-index,
   which is what decides paint order among negative-z siblings. So the
   "fallback" painted over the thing it was a fallback for, every time,
   in every engine. Measured: hiding the canvas outright changed 0 of
   3,456,000 subpixels. Not "dim" — literally zero contribution.

   The order below is now explicit and layered by z-index rather than by
   accident of tree order, and every layer has one job:

     -3  .field-fallback   flat --bg seat. The no-canvas / JS-off ground.
     -2  section bands     opaque lifted plane under prose-only chapters.
     -1  body::before      the light rig (warm key, cool counter, bounce).
     -1  #signal-field     the canvas. Transparent except its own marks,
                           so the band and the rig read THROUGH it —
                           which is what makes the two systems one
                           composition instead of two fighting for the
                           same pixels.
     -1  body::after       the grain plate, `overlay`, over all of it.
      0  content

   body carries `isolation: isolate` so every one of those means "behind
   body's CONTENT", not "behind body's background", and html carries the
   ground so body's background can never propagate to the viewport
   canvas and paint above them all. */

:root {
  --progress-h: 3px;
  --field-max-w: 1120px;

  /* Was var(--section-rhythm, 96px) flat. The ribbon at each chapter's
     foot now brings its own height, so the gap a reader actually sees
     between two chapters is rhythm + ribbon + the ribbon's top margin.
     Holding 96px here made that ~190px, which reads as an unfinished
     page rather than a generous one. */
  --chapter-gap: clamp(56px, 6.4vw, 88px);
  --card-pad: 22px;

  /* ── 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.
     Every plane, edge and lift below goes through that second door,
     which 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 ──────
     Cards used --surface (#111118) on a --bg (#0a0a0f) ground: 1.05:1
     in token math. Measured on rendered pixels it was WORSE than
     invisible — it was INVERTED. The .field-fallback wash lifted the
     ground to rgb(17,21,24) while the card stayed rgb(17,17,24), so a
     "raised" surface measured dL* -1.17 against the page around it.
     The card was darker than its own ground. That is the same defect
     casa-estatica had, with the sign flipped, and it is why this stack
     read flat no matter how much motion was layered on top.

     ── 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
     (they are what a reader actually needs) and take the ceiling those
     allow. CIE L* is the honest metric down 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. Hover
     differentiates through --edge-hot and --lift-hover instead. */
  --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. These compose a hairline that catches warm light at
     the top-left and cools at the bottom-right.

     The base layer keeps that 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 a banded chapter — the band's fill is
     deliberately a whisper, and this line is what the eye catches. */
  --band-line: var(--border-strong);
  --bloom-cool: var(--tp-ocean-soft);
  --band-atmos: var(--tp-ember-soft);

  /* --subtle (#6e6e80) is 3.96:1 on --bg — under AA-normal BEFORE this
     pass. This stack already avoided it (see CLAUDE.md's judgment call
     on --muted for captions); --quiet keeps a real third step in the
     text hierarchy that is over 4.5:1 on every plane in this file. 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; stacking 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);

  /* ── the Rainbow's vertical presence ──────────────────────────────
     The chapter divider is a ribbon with real height — see .thread. One
     variable so the SVG, the CSS fallback and the small-screen override
     can never drift apart. */
  --ribbon-h: clamp(54px, 5.6vw, 76px);

  /* The full spectral sweep, used at the top lip of a banded chapter.
     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 */

  /* 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));
    --bloom-cool: color-mix(in srgb, var(--tp-ocean) 20%, transparent);
    --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);
    --cta-glow: color-mix(in srgb, var(--tp-ember) 60%, transparent);
  }
}

* { box-sizing: border-box; }
html {
  scroll-behavior: auto;
  /* 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);
}
html.js:not(.no-motion) { scroll-behavior: smooth; }
body {
  margin: 0;
  background: var(--bg);
  color: var(--text);
  font: 16px/1.6 var(--font-sans);
  position: relative;
  isolation: isolate;   /* makes every z-index:-1 below mean "behind
                           body's content", not "behind its background" */
  -webkit-font-smoothing: antialiased;
}
h1, h2, h3, p, ul, ol, dl, figure { margin: 0; }
img, svg { display: block; max-width: 100%; }
a { color: inherit; }
main { display: block; }
ul { list-style: none; padding: 0; }

/* ── skip link ─────────────────────────────────────────────────── */
.skip-link {
  position: absolute;
  left: 12px;
  top: -48px;
  background: var(--accent);
  color: var(--bg);
  font: 600 14px var(--font-sans);
  padding: 10px 16px;
  border-radius: var(--r-badge);
  z-index: 100;
  transition: top 160ms ease;
}
.skip-link:focus { top: 12px; }

/* ── 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.

   In this stack the rig also does a job the reference stack does not
   need it for: it is the CSS-only atmosphere that stands alone when the
   canvas never paints (JS off, or getContext('2d') returning null). That
   is what lets .field-fallback stop being a wash — see below. */
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.

   In this stack the plate also sits over the canvas, which is the point:
   one grain over rig + band + constellation is what ties three
   independently-generated layers into one surface.

   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; }
}

/* ── decorative backdrop: canvas + its seat ────────────────────────────
   aria-hidden, never load-bearing (rule 8 / capability 2).

   .field-fallback used to carry three radial washes AND an opaque --bg
   background-colour, and it painted over the canvas (see this file's
   header for the measurement). It is now what its name always claimed:
   a seat. Flat --bg, at z-index -3, below every generated layer. The
   coloured, CSS-only atmosphere that stands in when the canvas never
   paints is body::before above — which needs neither JS nor a 2D
   context, so the no-canvas page is still a lit page, not a black one.
   Removing the element instead would have been the smaller diff, but a
   fixed opaque seat is what guarantees the ground colour under the
   canvas's own clearRect transparency on an engine where html's
   background does not propagate as expected. */
.signal-field { z-index: -1; }
.field-fallback { z-index: -3; background: var(--bg); }
.signal-field,
.field-fallback {
  position: fixed;
  inset: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
}

/* ── the canvas's luminance budget ─────────────────────────────────────
   ⚠ THIS IS THE OTHER HALF OF THE OCCLUSION FIX, AND IT IS NOT OPTIONAL.

   The field's per-mark alphas (core 0.85, glow 0.16–0.26) were never
   calibrated, because for as long as they existed nothing could see
   them. Un-occluding the canvas without capping it trades an invisible
   centrepiece for an illegible page: measured with every glyph made
   transparent so the pure backdrop could be sampled, the worst ground
   pixel under this page's own h2 was a near-solid --tp-cat-1 core, and
   the heading's contrast against it was 1.39:1. Half a percent of the
   body paragraph's area sat under 3.9:1.

   The arithmetic that sets the cap is the same one that sets the plane
   ceiling, run in the other direction. Holding --text at 15:1 and
   --muted at 6:1 puts a ceiling on the BACKDROP's relative luminance of
   Y <= 0.0109 / 0.0103. The ground under the rig measures Y ~ 0.0035, so
   the canvas may add at most ~0.007 — which for the brightest hue in the
   categorical scale (--tp-cat-1) is about 0.08 of composited alpha. That
   is the whole budget, and 0.85 is ten times it.

   ── but the budget only binds where a glyph can land ─────────────────
   Which is the same insight the light rig above is built on: spend the
   light where it costs nothing. In the page's margins there is no text,
   so the field can run an order of magnitude brighter there than it may
   under the measure. So the cap is applied as a MASK, not as a flat
   dimming — full strength at the viewport edges, knocked back to 12%
   across the reading column, with the transition landing in the gutter
   where nothing is read.

   Base / enhancement, for the usual reason: if mask-image does not
   resolve, the mask silently does nothing and the field would run at
   full strength across the text again — the exact failure this rule
   exists to prevent. So the BASE is the safe flat cap that needs no
   feature at all, and the mask is what buys back the margins.

   currentColor, not a colour: only a mask's ALPHA channel is read, so
   `currentColor` and `color-mix(…, transparent)` express "opaque here,
   12% there" without putting a colour value in the file. */
.signal-field {
  opacity: 0.085;
}
@supports ((mask-image: linear-gradient(currentColor, transparent)) or (-webkit-mask-image: linear-gradient(currentColor, transparent))) {
  .signal-field {
    opacity: 0.55;
    /* The knock-back spans the measure plus 40px of fade on each side.
       Below ~960px the two stops cross and the whole width is knocked —
       which is correct: a phone has no gutters, so it gets the flat cap
       everywhere, exactly as the base layer would have given it. */
    -webkit-mask-image: linear-gradient(90deg,
        currentColor 0,
        color-mix(in srgb, currentColor 12%, transparent) calc(50% - var(--wrap-narrow, 880px) / 2 - 40px),
        color-mix(in srgb, currentColor 12%, transparent) calc(50% + var(--wrap-narrow, 880px) / 2 + 40px),
        currentColor 100%);
    mask-image: linear-gradient(90deg,
        currentColor 0,
        color-mix(in srgb, currentColor 12%, transparent) calc(50% - var(--wrap-narrow, 880px) / 2 - 40px),
        color-mix(in srgb, currentColor 12%, transparent) calc(50% + var(--wrap-narrow, 880px) / 2 + 40px),
        currentColor 100%);
  }
}

/* ── header + scroll progress ──────────────────────────────────── */
.site-header {
  position: sticky;
  top: 0;
  z-index: 10;
  /* Base: OPAQUE. A translucent header without a blur behind it is not a
     softer header, it is content 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(10px)) or (-webkit-backdrop-filter: blur(10px))) {
  .site-header {
    background: color-mix(in srgb, var(--bg) 78%, transparent);
    backdrop-filter: blur(10px);
    -webkit-backdrop-filter: blur(10px);
  }
}
.progress-track { height: var(--progress-h); background: transparent; overflow: hidden; }
.progress-fill {
  height: 100%;
  width: 100%;
  transform-origin: 0 50%;
  transform: scaleX(0);
  background: var(--brand-paint);
}
.nav-inner {
  max-width: var(--wrap);
  margin: 0 auto;
  padding: 14px 20px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  flex-wrap: wrap;
}
.wordmark { font: 700 16px var(--font-sans); text-decoration: none; letter-spacing: 0.01em; }
.stack-tag {
  font: 600 11px var(--font-mono);
  color: var(--muted);
  border: 1px dashed var(--edge-neutral);
  border-radius: var(--r-chip);
  padding: 2px 8px;
  margin-left: 8px;
  letter-spacing: 0.04em;
}
.nav-links { display: flex; align-items: center; gap: 20px; font-size: 14px; }
.nav-links a:not(.lang-link) { text-decoration: none; color: var(--muted); }
.nav-links a:not(.lang-link):hover { color: var(--text); }
.lang-pair { display: inline-flex; align-items: center; gap: 6px; font: 600 12px var(--font-mono); color: var(--muted); }
.lang-link { text-decoration: none; color: var(--accent); }
.lang-sep { opacity: 0.5; }

/* ── hero ──────────────────────────────────────────────────────── */
.hero { min-height: min(92vh, 760px); display: flex; align-items: center; padding: 48px 20px; }
.hero-inner { max-width: var(--wrap-narrow, 880px); margin: 0 auto; }
/* The eyebrow is a chip here rather than a bare tracked label, so it
   joins the elevation scale instead of floating: --plane-sunk is below
   the page ground's own read, which is what makes a chip look set INTO
   the page. The dashed edge is what keeps it an aside rather than a
   button. --gated is a Night Shift status colour, not a Rainbow hue —
   the boundary rule holds. */
.eyebrow {
  display: inline-block;
  font: 600 12.5px var(--font-mono);
  letter-spacing: 0.05em;
  color: var(--gated);
  background: var(--plane-sunk);
  border: 1px dashed var(--edge-warm);
  border-radius: var(--r-chip);
  padding: 6px 14px;
  margin-bottom: 22px;
}
/* ── 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 is
   known to work. */
.hero-title {
  font: 800 clamp(2.1rem, 5.4vw, 3.6rem)/1.1 var(--font-sans);
  color: var(--text);
  margin-bottom: 20px;
}
@supports ((background-clip: text) or (-webkit-background-clip: text)) {
  .hero-title {
    background: var(--title-paint);
    -webkit-background-clip: text;
    background-clip: text;
    color: transparent;
  }
}
.hero-sub { max-width: 56ch; color: var(--muted); font-size: 1.05rem; margin-bottom: 34px; }
.scroll-cue {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  text-decoration: none;
  color: var(--text);
  font: 600 13px var(--font-mono);
  letter-spacing: 0.06em;
  border: 1px solid transparent;
  border-radius: var(--r-chip);
  padding: 10px 18px;
  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);
  transition: box-shadow 200ms ease;
}
.scroll-cue:hover,
.scroll-cue:focus-visible {
  box-shadow: var(--lift-hover);
  background-image: linear-gradient(var(--plane-2), var(--plane-2)), var(--edge-hot);
}

/* ── chapters: scroll-driven narrative ─────────────────────────────
   Fallback chain (documented once, applies to every .chapter):
     1. Baseline (also the JS-off state): fully visible, no animation.
     2. @supports (animation-timeline) — native CSS scroll-driven
        animation. No JS involved at all once this rule matches.
     3. html.js.observing .chapter — IntersectionObserver fallback
        (TPMotion.reveals, wired in app.js only when #2 is unsupported).
     4. no-motion / prefers-reduced-motion — both #2 and #3 collapse to
        the same static, fully-visible end state (see the override
        block near the bottom of this file). */
.chapter-section { padding: var(--chapter-gap) 20px; max-width: var(--wrap-narrow, 880px); margin: 0 auto; }
.chapter { opacity: 1; transform: none; }
.chapter-no {
  display: block;
  font: 700 13px var(--font-mono);
  color: var(--muted);
  letter-spacing: 0.12em;
  margin-bottom: 10px;
}
.chapter h2 { font: 700 clamp(1.5rem, 3.2vw, 2.1rem)/1.2 var(--font-sans); margin-bottom: 16px; }
.chapter p { color: var(--muted); font-size: 1.02rem; max-width: 64ch; }
.chapter p + p { margin-top: 14px; }

@supports (animation-timeline: view()) or (animation-timeline: scroll()) {
  html.js:not(.no-motion) .chapter {
    animation: chapter-in linear both;
    animation-timeline: view();
    animation-range: entry 0% cover 40%;
  }
}
@keyframes chapter-in {
  from { opacity: 0; transform: translateY(28px); }
  to   { opacity: 1; transform: none; }
}
html.js.observing:not(.no-motion) .chapter {
  opacity: 0;
  transform: translateY(28px);
  transition: opacity var(--motion-dur) var(--motion-ease), transform var(--motion-dur) var(--motion-ease);
}
html.js.observing:not(.no-motion) .chapter.in { opacity: 1; transform: none; }

@supports (animation-timeline: scroll()) {
  html.js:not(.no-motion) .progress-fill {
    animation: progress-grow linear both;
    animation-timeline: scroll(root block);
  }
}
@keyframes progress-grow { from { transform: scaleX(0); } to { transform: scaleX(1); } }

/* ── chapter cadence: the band ─────────────────────────────────────
   Chapters used to differ only by their own vertical padding, so the
   scroll read as one undifferentiated column and the eye had nothing to
   count. Prose-only chapters now sit on --plane-band with a lit lip
   along the top; the value structure the scroll moves THROUGH is what
   makes each chapter arrive rather than merely appear.

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

   ── z-index -2, and why that number and not -1 ─────────────────────
   This is the one place this stack's port diverges from the reference on
   MECHANISM rather than on selectors. In the reference the band sits at
   -1 among the rig and the grain, and tree order does the rest. Here a
   full-viewport canvas also sits at -1, and it comes BEFORE main in tree
   order — so a band at -1 would paint OVER the canvas and blank the
   stack's centrepiece across every prose chapter. Opaque is not
   negotiable (a translucent band over a moving canvas is a plane whose
   value changes as the constellation drifts under it, which is exactly
   the instability the "planes are opaque" rule exists to prevent). So
   the band goes DOWN instead: -2 puts it above the seat and below the
   rig and the canvas, and the constellation reads through it. Band, rig,
   canvas and grain end up as four stacked layers of one composition
   rather than two systems fighting for the same pixels.

   ── which chapters ────────────────────────────────────────────────
   Bands go where there is nothing raised to compete with. A band lifts
   the ground under everything in its section — including any card on it
   — and the whole card-to-ground budget on a near-black page is about
   0.16 of a ratio point, so a band under a card row spends a quarter of
   it on decoration. Chapters carrying signal items, field notes or the
   params panel keep the darkest possible ground so their surfaces get
   the full budget. On index.html that leaves the three prose chapters.

   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 bands rather than a page with broken
   ones. 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.

   Losing the band costs this page less than it looks: the chapter
   cadence is also carried by the ribbon at each chapter's foot (see
   .thread), which needs no :has() and no colour function at all. */
@supports selector(:has(*)) and (clip-path: inset(0 -1px)) {
  main section.chapter-section:not(:has(.signal-item, .signal-list-fallback, .fieldnote, .fieldnote-fallback-item, .chip-row)) {
    position: relative;
  }
  main section.chapter-section:not(:has(.signal-item, .signal-list-fallback, .fieldnote, .fieldnote-fallback-item, .chip-row))::before,
  main section.chapter-section:not(:has(.signal-item, .signal-list-fallback, .fieldnote, .fieldnote-fallback-item, .chip-row))::after {
    content: "";
    position: absolute;
    left: 0;
    right: 0;
    z-index: -2;
    pointer-events: none;
    clip-path: inset(0 -100vmax);
  }
  main section.chapter-section:not(:has(.signal-item, .signal-list-fallback, .fieldnote, .fieldnote-fallback-item, .chip-row))::before {
    top: 0;
    bottom: 0;
    background:
      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 chapter's measure, because that is how
     wide the chapter 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. The read is a spectral segment inside
     the content measure with a hairline running off both sides of it. */
  main section.chapter-section:not(:has(.signal-item, .signal-list-fallback, .fieldnote, .fieldnote-fallback-item, .chip-row))::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 chapter. app.js redraws each one
   from the SAME session seed the signal field uses, plus a per-section
   offset — so one press of "new seed" re-cuts the constellation and
   every ribbon together. That shared seed is the point: it is what makes
   the canvas and the dividers read as one generated page rather than two
   unrelated generators that happen to share a stylesheet.

   Height is the cheap half. The expensive half is that a big shape made
   of one flat gradient reads as a printed bar, which is the opposite of
   what a seeded ornament should say. So the ribbon is built in three
   depths, and each depth does a different job:

     halo   100% of the height, `-soft` alpha  — the colour a reader
            feels before they look at it, and what makes the divider
            occupy space rather than mark a line.
     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. The eye
            lands here; the halo tells it how far the colour reaches.

   Everything below is the JS-OFF state, and it is a complete ribbon, not
   a placeholder for one: same three depths, same per-chapter palettes,
   same height. With JS on, app.js replaces it with a curved, ticked,
   seeded version of the same idea. 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 chapters — it eats the chapter's own padding-bottom, so it lands
     where a border-bottom would, rather than adding a third band of
     space to the stack. */
  margin-block: clamp(12px, 1.9vw, 26px) calc(var(--chapter-gap) * -1 + 2px);
  background:
    /* core — the saturated filament */
    linear-gradient(90deg,
      transparent 3%, var(--t1) 17%, var(--t2) 38%, var(--t3) 60%, var(--t4) 81%, transparent 97%)
    left center / 100% 3px no-repeat,
    /* body — the ramp */
    linear-gradient(90deg,
      transparent 0%, var(--t1s) 15%, var(--t2s) 36%, var(--t3s) 58%, var(--t4s) 79%, transparent 100%)
    left center / 100% 38% no-repeat,
    /* halo — the reach */
    linear-gradient(90deg,
      transparent 0%, var(--t1s) 20%, var(--t2s) 40%, var(--t3s) 61%, var(--t4s) 82%, transparent 100%)
    left center / 100% 100% no-repeat;
}
/* The hero deliberately gets NO ribbon, which is the one place this
   stack's ribbon placement differs from the reference's. casa-viva's
   hero is a centring flex container (`display:flex; align-items:center`)
   holding a single child — a ribbon dropped into it becomes a second
   flex ITEM and lands beside the copy rather than under it. Forcing
   `flex-direction: column` to make room would re-do the hero's layout
   for a divider, and the hero already has two boundary cues a reader
   can see: the scroll cue, and the band that opens chapter 01. Five
   chapters, five ribbons.
   Per-chapter palettes. Every hue here has BOTH a raw and a `-soft` form
   in tokens.css, which is why these four triples and not another four —
   the fallback needs the soft variant to exist without mixing one. */
main section:nth-of-type(2) .thread {
  --t1: var(--tp-orange); --t2: var(--tp-red);     --t3: var(--tp-cyan);
  --t1s: var(--tp-orange-soft); --t2s: var(--tp-red-soft); --t3s: var(--tp-cyan-soft);
  --t4: var(--tp-navy); --t4s: var(--tp-navy-soft);
}
main section:nth-of-type(3) .thread {
  --t1: var(--tp-sun);   --t2: var(--tp-lime);    --t3: var(--tp-ocean);
  --t1s: var(--tp-sun-soft);   --t2s: var(--tp-lime-soft);    --t3s: var(--tp-ocean-soft);
  --t4: var(--tp-navy); --t4s: var(--tp-navy-soft);
}
main section:nth-of-type(4) .thread {
  --t1: var(--tp-ember); --t2: var(--tp-emerald); --t3: var(--tp-ocean);
  --t1s: var(--tp-ember-soft); --t2s: var(--tp-emerald-soft); --t3s: var(--tp-ocean-soft);
  --t4: var(--tp-navy); --t4s: var(--tp-navy-soft);
}
main section:nth-of-type(5) .thread {
  --t1: var(--tp-sun);   --t2: var(--tp-red);     --t3: var(--tp-teal);
  --t1s: var(--tp-sun-soft);   --t2s: var(--tp-red-soft);     --t3s: var(--tp-teal-soft);
  --t4: var(--tp-navy); --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; }

/* ── chips ─────────────────────────────────────────────────────── */
.chip-row { display: flex; flex-wrap: wrap; gap: 10px; margin: 28px 0 22px; }
.chip {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  border: 1px solid transparent;
  border-radius: var(--r-chip);
  color: var(--muted);
  font: 600 13px var(--font-sans);
  padding: 9px 16px;
  cursor: pointer;
  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);
  transition: box-shadow 200ms ease, color 160ms ease;
}
.chip:hover {
  color: var(--text);
  box-shadow: var(--lift-hover);
  background-image: linear-gradient(var(--plane-2), var(--plane-2)), var(--edge-hot);
}
.chip.is-active {
  color: var(--text);
  box-shadow: var(--lift-hover);
  background-image: linear-gradient(var(--plane-2), var(--plane-2)), var(--edge-hot);
}
.chip .count { font: 600 11px var(--font-mono); color: var(--quiet); }
.chip-shape { width: 11px; height: 11px; flex: none; }

/* ── signal list (FLIP re-layout target) ──────────────────────────
   #signal-list is JS-rendered; :empty keeps it from taking any space
   before app.js fills it — a structural CSS rule, not a JS-off content
   hide (there is nothing inside it to hide until JS actually runs). */
#signal-list:empty { display: none; }
.signal-list { display: grid; gap: 10px; }
.signal-item,
.signal-list-fallback li {
  border: 1px solid transparent;
  border-radius: var(--r-badge);
  background-image: linear-gradient(var(--plane-1), var(--plane-1)), var(--edge);
  background-origin: border-box;
  background-clip: padding-box, border-box;
}
/* The layer hue is an INSET shadow, not a 3px left border. The plane
   recipe above needs the border box for --edge, so a coloured border on
   one side would be painted over by the edge gradient; an inset shadow
   lands inside the padding box, keeps the categorical stripe at full
   strength, and composes with --lift in one declaration. Colour is still
   never the only signal — the shape glyph beside it carries the same
   information (CONTEXTS.md computed rule 5). */
.signal-item {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 4px 14px;
  padding: 14px 16px 14px 19px;
  box-shadow: inset 3px 0 0 0 var(--hue, var(--accent)), var(--lift);
}
.signal-item.is-hidden { display: none; }
.signal-tag { display: inline-flex; align-items: center; gap: 6px; grid-row: 1 / 3; align-self: start; }
.signal-tag-label { font: 600 11px var(--font-mono); color: var(--muted); letter-spacing: 0.03em; }
.signal-name { font: 700 14.5px var(--font-sans); }
.signal-detail { color: var(--muted); font-size: 13.5px; grid-column: 2; }

/* fallback list (JS-off / pre-boot) — plain, legible, no chip filter.
   Same plane as the live list, so the page a JS-off visitor gets is the
   same page, not a flatter one. */
.signal-list-fallback { display: grid; gap: 10px; margin: 28px 0; }
.signal-list-fallback li { padding: 14px 16px; box-shadow: var(--lift); color: var(--muted); font-size: 13.5px; }
.signal-list-fallback strong { color: var(--text); display: block; margin-bottom: 3px; }
.signal-list-fallback .tag { font: 600 11px var(--font-mono); color: var(--quiet); }

/* ── field notes ───────────────────────────────────────────────── */
#fieldnotes-dynamic:empty { display: none; }
.fieldnotes { display: grid; gap: 16px; margin-top: 26px; }
/* 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, so a lighter top band would
   put the note's teaser 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. */
.fieldnote, .fieldnote-fallback-item {
  border: 1px solid transparent;
  border-radius: var(--r-card);
  padding: var(--card-pad);
  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);
  transition: box-shadow 200ms ease;
}
.fieldnote:hover, .fieldnote-fallback-item:hover {
  box-shadow: var(--lift-hover);
  background-image: linear-gradient(var(--plane-2), var(--plane-2)), var(--edge-hot);
}
.fieldnote-role { font: 600 12px var(--font-mono); color: var(--quiet); letter-spacing: 0.02em; margin-bottom: 10px; }
.fieldnote-teaser { font: 600 16px/1.5 var(--font-sans); color: var(--text); margin-bottom: 0; }
.fieldnote-detail {
  overflow: hidden;
  max-height: none; /* JS-off / pre-boot default: fully open */
}
html.js .fieldnote-detail { max-height: var(--detail-h, none); }
.fieldnote-detail p { color: var(--muted); font-size: 14px; padding-top: 12px; }
/* Recessed INTO the note, not another raised thing on top of it —
   --plane-sunk is below --plane-1, so the toggle reads as a slot. */
.fieldnote-toggle {
  margin-top: 12px;
  background: var(--plane-sunk);
  border: 1px dashed var(--edge-neutral);
  color: var(--accent);
  font: 600 12.5px var(--font-mono);
  border-radius: var(--r-chip);
  padding: 7px 14px;
  cursor: pointer;
  transition: border-color 160ms ease;
}
.fieldnote-toggle:hover { border-color: var(--edge-warm-hot); }

/* ── handoff ───────────────────────────────────────────────────── */
.cta-row { display: flex; flex-wrap: wrap; gap: 14px; margin-top: 22px; align-items: center; }
/* color: var(--bg), not a literal — chosen for contrast 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: text 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 14px/700, which does not qualify for the
   3:1 large-text threshold (that needs >=18.66px bold). */
.cta {
  display: inline-block;
  text-decoration: none;
  font: 700 14px var(--font-sans);
  color: var(--cta-ink);
  background: var(--cta-fill);
  border-radius: var(--r-chip);
  padding: 12px 22px;
  box-shadow: 0 14px 30px -18px var(--cta-glow);
  transition: filter 160ms ease;
}
.cta:hover, .cta:focus-visible { filter: brightness(1.06); }
.cta-secondary { font: 600 13px var(--font-mono); color: var(--muted); text-decoration: none; }
.cta-secondary:hover { color: var(--text); }

/* ── footer ────────────────────────────────────────────────────── */
.site-footer { max-width: var(--wrap); margin: 0 auto; padding: 40px 20px 64px; border-top: 1px solid var(--border-strong); }
.demo-note { color: var(--muted); font-size: 13.5px; max-width: 64ch; margin-bottom: 26px; }
.footer-links { display: flex; flex-wrap: wrap; gap: 18px; align-items: center; margin-bottom: 30px; font-size: 13.5px; }
.footer-links a { color: var(--muted); text-decoration: none; }
.footer-links a:hover { color: var(--text); }
.params { margin-bottom: 26px; }
.copyright { color: var(--quiet); font-size: 12.5px; }

/* ── parámetros panel: stack-level polish over the vendored kit ─────
   params-panel.css is vendored and must not be edited in place; its own
   header says a stack should override in ITS OWN stylesheet, which is
   what this is. Only surface treatment — no behavior, no colour that
   isn't already a token. */
.params-panel {
  border: 1px solid transparent;
  border-radius: var(--r-card);
  padding: 20px 18px;
  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);
}
.params-note { max-width: 62ch; }

/* ── reveal helper for smaller elements (vendored .reveal mechanism —
   used here for chips/signals/field-notes entrances; chapters use
   their own bespoke rules above so the two mechanisms never overlap
   on the same element) ── */
.reveal { transition-delay: calc(var(--d, 0) * 1ms); }

/* ── reduced motion / no-motion: this stack's own bespoke animated
   elements, following motion.css's documented "decorative motion"
   pattern (its own file defines none of these selectors on purpose —
   a consuming stack owns them). !important is load-bearing for the
   same cascade reason MOTION-RULES.md rule 7 calls out: the
   @supports rule above carries an extra html.js:not(.no-motion)
   prefix and would otherwise out-specify a plain override. ── */
/* ── the hidden-document freeze (Motion Rules 0/0a) ─────────────────
   A page loaded into a BACKGROUND tab — a prerender, "open link in new
   tab", a restored session — has a suspended DocumentTimeline and no
   scroll progression, so a chapter's `animation-timeline: view()`
   animation is created and then never advances. Measured on this stack
   before this pass: all five chapters sat at computed `opacity: 0`, and
   getAnimations() reported every one of them `running`. The entire prose
   of the page was invisible, waiting on an animation that could not run.

   The motion kit already ships the mechanism for exactly this: the
   inline head snippet sets .motion-failed the instant it sees
   document.hidden, and motion.js is careful NOT to remove that class
   while the document is still hidden (see its own comment — removing it
   early "would only recreate the exact freeze the failsafe exists to
   prevent"). What was missing is this stack's half of the contract: the
   inline failsafe rule covers .reveal, and .chapter deliberately does
   not carry .reveal (CLAUDE.md, "the two mechanisms never overlap on the
   same element"). So .chapter had no rescue at all.

   This is the rescue. It self-heals: the moment the document becomes
   visible motion.js drops .motion-failed, the view() timeline resolves
   against a real scroll position, and chapters below the fold go back to
   revealing normally.

   .progress-fill is deliberately NOT in this rule. Its frozen value in a
   hidden document is scaleX(0) — which is the CORRECT value for a page
   at scroll 0, not a stuck one. Forcing it to scaleX(1) here would make
   an aria-hidden ornament claim the reader had reached the bottom of a
   page they have not scrolled. */
html.motion-failed .chapter {
  animation: none !important;
  opacity: 1 !important;
  transform: none !important;
}

html.no-motion .chapter,
html.no-motion .progress-fill {
  animation: none !important;
}
html.no-motion .chapter { opacity: 1 !important; transform: none !important; }
html.no-motion .progress-fill { transform: scaleX(1) !important; }
@media (prefers-reduced-motion: reduce) {
  html:not(.force-motion) .chapter,
  html:not(.force-motion) .progress-fill {
    animation: none !important;
  }
  html:not(.force-motion) .chapter { opacity: 1 !important; transform: none !important; }
  html:not(.force-motion) .progress-fill { transform: scaleX(1) !important; }
  html:not(.force-motion) .skip-link,
  html:not(.force-motion) .chip,
  html:not(.force-motion) .cta,
  html:not(.force-motion) .scroll-cue,
  html:not(.force-motion) .fieldnote,
  html:not(.force-motion) .fieldnote-toggle {
    transition-duration: 0.01ms !important;
  }
}

/* ── focus ─────────────────────────────────────────────────────── */
:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; border-radius: 2px; }

/* ── responsive ────────────────────────────────────────────────── */
@media (max-width: 640px) {
  .nav-links { gap: 14px; font-size: 13px; }
  .signal-item { grid-template-columns: 1fr; }
  .signal-detail { grid-column: 1; }
  .hero { min-height: auto; padding: 88px 18px 56px; }
}

/* ── small-screen: 375px, and the LONGER language half ───────────────
   Overflow is not the only way a header fails at 375px. 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; }

  .nav-inner { padding: 12px 16px; gap: 10px; }
  .nav-links { gap: 12px; font-size: 12.5px; }
  .wordmark { font-size: 15px; }
  .stack-tag { margin-left: 6px; padding: 2px 6px; font-size: 10px; }
  /* The ribbon keeps real presence on a phone but not desktop presence:
     six dividers at the desktop 54px floor would spend 324px of a short
     screen on decoration. One variable, so the SVG follows
     automatically. */
  .thread { --ribbon-h: 44px; }
  .chapter-section { padding-inline: 16px; }
  /* The eyebrow chip 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.04em;
    line-height: 1.35;
    padding: 5px 11px;
    margin-bottom: 16px;
  }
  .hero { padding: 56px 16px 44px; }
}

/* ══════════════════════════════════════════════════════════════════════
   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 at the foot of 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; }
}
