/* ══════════════════════════════════════════════════════════════════════════
   OLBRA — v3. Built 2026-08-31 against https://nexura.framer.ai

   Every value below marked ⟨measured⟩ was read from nexura's LIVE CSS, not
   estimated from a screenshot. That distinction is the whole reason the three
   previous attempts failed (HANDOFF-2026-08-31 § 1, § 7 rule 1).

     h1            54px  / 1.2em / −0.04em / w500     ⟨measured⟩
     body          18px  / 1.4em / −0.03em / w400     ⟨measured⟩
     container     1200px                             ⟨measured: 1199/1250⟩
     radius        12px cards · 100px pills           ⟨measured⟩
     weights       400 and 500 ONLY — never 600+      ⟨measured⟩
     line-height   1.4em everywhere except display    ⟨measured: 76 of 90⟩

   ── What we take from nexura, and what we don't ─────────────────────────────
   TAKE:  the tight-grotesk display, −0.03/−0.04em tracking, weight 500 as the
          heaviest thing on the page, 12px cards on hairlines, 100px pills,
          the airy near-white ground, a left-aligned hero.
   LEAVE: its blue #0099ff (that is Stripe/Bridge territory — see the brand
          audit), its SaaS dashboard mocks, its avatar social-proof.

   ── The brand, which is ours and not nexura's ───────────────────────────────
   INK READS. OXBLOOD MARKS. Confirmed twice: by Kacperek ("it's an accent,
   not used everywhere") and by aura-flow, whose brand colour appears exactly
   three times on its first screen while every word of text is near-black.

   Oxblood is permitted on: the wordmark, primary buttons, the badge dot,
   the countersignature underline on hover, and figure ink. Nothing else.
   ══════════════════════════════════════════════════════════════════════════ */

@import url('https://fonts.googleapis.com/css2?family=Inter+Tight:wght@400;500&family=Fragment+Mono&display=swap');

:root{
  /* ── ground ───────────────────────────────────────────────────────────────
     Lightened 2026-08-31 (Kacperek). The page ground itself had almost no
     headroom — it was already 97.8%, i.e. 2.2% off pure white — so what
     actually read as heavy were the RAISED and SUNKEN steps beneath it.
     Those moved most: 91.0 → 94.3% and 83.9 → 88.5%.

       ground  #FFFCFA 97.8%  →  #FFFDFC 98.5%   warmth +5 → +3
       raised  #F8F4F1 91.0%  →  #FBF8F6 94.3%
       sunken  #F1EBE7 83.9%  →  #F5F1EE 88.5%

     ⚠ The warmth DIRECTION is preserved, only reduced. That is the whole
     lesson of the three failed grounds: #F7F2EC (warm paper) and #F4F2EE
     (bone) were the same warmth at much LOWER luminance, and warm-plus-dark
     is what reads as old paper. Lighter is safe; warmer is not.

     ⚠ Surfaces are now separated by 4.2% and 5.8% luminance rather than ~7%.
     With no borders and no shadows, that separation IS how a panel is seen —
     do not lighten further without checking the steps are still visible. */
  --ground:#FFFDFC;   /* 98.5% */
  --raised:#FBF8F6;   /* 94.3% */
  --sunken:#F5F1EE;   /* 88.5% */

  /* ── ink ─────────────────────────────────────────────────────────────────
     Ratios recomputed against the ground, not carried forward from a table.
     ⚠ Recompute these whenever the ground moves — a ratio quoted against a
     colour that has changed is worse than no ratio at all. */
  --ink:#16141A;            /* 18.02 on ground · 16.27 on sunken */
  --ink-2:#413C44;          /* 10.59 · 9.57  — secondary prose */
  --ink-3:#6B6870;          /*  5.39 · 4.87  — THE FLOOR for small text */
  /* ⚠⚠ 2.90:1 — a TAD below the 3.0 large-text floor, and that is a DECISION,
     not an oversight. Kacperek, 2026-09-01: the gap between filled and unfilled
     was too slight to read as a wave, and "we can go a tad beyond the floor".
     Lightened from #8A8791 (3.48). ⚠ 2.45 (#A6A3AD) was tried first and is too
     far — it trips the contrast gate outright; 2.90 is the intended margin.

     What makes it defensible rather than reckless: this colour is NEVER a
     resting state. The wave settles 140ms after the last scroll and everything
     behind the edge resolves to ink, so a reader who stops mid-passage is not
     left with low-contrast prose — they are left with ink. And the whole
     effect is off under prefers-reduced-motion, where the script bails before
     splitting and the text renders at --ink throughout.

     ⚠ SCROLL-FILL RESTING STATE ONLY. It is not a text colour: never reuse it,
     and never let a static element inherit it. If .fill's type ever drops
     below 24px, or if the settle ever stops resolving to ink, this has to go
     back to --ink-3. */
  --fill-rest:#98959F;

  /* ── oxblood: the mark, never the message ───────────────────────────────── */
  --ox:#6B1F32;             /* 11.09 on ground */
  --ox-deep:#2A1016;        /* button hover, dark band ink */
  /* ⚠ The dither's ink is DEEPER than --ox, and that is not a mistake to
     tidy. dither.py's own comment: "at low dot coverage pure #6B1F32 reads
     pink on bone". A dithered figure is mostly ground, so it needs the darker
     ink to land at the same apparent colour as a solid --ox element.
     ⚠ SAME VALUE AS dither.py's INK — the PNGs and the live canvas must
     match, so changing one means changing both. */
  --dither-ink:#5E1B2C;
  /* ── up: the balance delta's green, and NOTHING ELSE ────────────────────
     ⚠⚠ ONE USE, ON PURPOSE (Kacperek, 2026-09-10: "the percent above the
     balance should be green"). The phone screen otherwise spends a single
     accent, and green is banned on it generally because a fact encoded in a
     hue is one no text gate can read.
     ⭐ THE HISTORY MATTERS, because it is what fixes the boundary: the full
     green/red convention WAS built the same session, applied to the activity
     amounts, and Kacperek reverted it on sight — "the numbers on recent
     should stay grey as they did before". So --down was deleted rather than
     left unused, and this token has exactly one consumer.
     ⛔ IT MUST NEVER COLOUR A TOKEN'S STATE. PLNY and USDY have zero supply;
     a green marker beside either reads as "in issue", the claim-as-markup
     defect /docs shipped. A balance MOVING is not a token being live.
     ⚠ NOT iOS's #34C759 — measured 2.19:1 on --ground, far under the 4.5
     floor. #177D3A reads as the same green and measures 5.14:1. ⛔ Do not
     "correct" it back to the system colour. */
  --up:#177D3A;             /* 5.14 on ground */
  /* ── the hero plate's own palette (Kacperek, 2026-09-02) ─────────────────
     Sampled from hero-plate.png, which is a hard FIVE-colour dither: white
     plus these four. Listed deepest-last because that is how the plate uses
     them — colour there is DEPTH, not decoration. Measured mean vertical
     position: --plate-pale 0.77 (highest, most dissolved) -> --plate-warm
     0.83 -> --plate-deep 0.905 -> --plate-rose 0.943 (lowest, densest).
     #credit's figure borrows that logic, mapping colour to LOCAL DENSITY so
     its packed core reads deep and its scattered edges pale.
     ⚠ --plate-warm is --coin-orange, the same hex the coins and the scroll
     fill already use. Aliased, not restated, so the relationship stays
     visible — do not fork them.
     ⚠ --plate-rose is NOT --bloom (#F5C1C5). Close, different, and from
     different sources: bloom is ramp A from card-bloom.html, these are read
     off the plate's own pixels. Do not "unify" them. */
  --plate-pale:#FFDEDC;
  --plate-warm:var(--coin-orange);
  --plate-deep:#FFC5D2;
  --plate-rose:#FA81A7;
  /* ⚠⚠ THE FIGURE NEEDS A DEEPER FLOOR THAN THE PLATE HAS, and this is the
     one place it legitimately departs from the sampled palette.
     The plate is a SOLID dithered field — its dots sit shoulder to shoulder,
     so #FA81A7 across a packed area reads as a strong pink mass. The credit
     figure is sparse dots on cream at ~10-20% coverage, and the identical hex
     over that much ground washes out to a tint: built with the plate's four
     tones alone, every state read pale and weightless.
     So the ramp gains a fifth step BELOW the plate's darkest, used only for
     the densest cores. It is the plate's rose carried down toward the
     dither's own ink, not a new colour — the figure stays in the plate's
     family while regaining the mass the sparseness costs it. */
  --plate-core:#C0456B;
  /* bloom ramp A, from card-bloom.html — oxblood at low saturation.
     Decoration only: it never carries text, so it has no contrast duty. */
  --bloom:#F5C1C5;
  /* The plates' orange, sampled at full resolution from Kacperek's artwork and
     already used by the coins' ramp. Decoration only — 1.63:1 on the ground,
     so like --bloom it never carries text the reader must rest on.
     ⚠ NOT the excluded saturated orange: measured ΔE 54.0 from cinnabar/M0 and
     59.8 from Chainalysis. See DECISIONS.md § 4.11.
     ⚠ THE SAME VALUE IS ALSO HARD-CODED in index.html's #coin-ramp stops — an
     SVG gradient stop cannot read a custom property without extra plumbing, so
     there are deliberately two copies. If this changes, change both. */
  --coin-orange:#FFBB87;

  /* ── dark band ───────────────────────────────────────────────────────────
     Lifted verbatim from the live site's .ol-band-dark (src/olbra.css:1362)
     so the footer we are reusing sits on exactly the ground it was drawn for.
     Not flat black: a deep blue-black gradient carries the dot grid. */
  --dark-a:#0b0c0f;
  --dark-b:#0d0f14;
  --dark-glow:#14161d;

  /* ── state: says what a token IS, never what category it belongs to ─────── */
  --in-issue:#287C47;       /* 5.10 on ground · 4.60 on sunken — EURY only */
  --deployed:#896828;       /* 5.08 · 4.59 — PLNY, USDY (supply 0) */

  /* The grid rules. Raised from .14 → .22: at .14 over the hero plate the
     dashes measured as a 3-4 unit difference from their background — present
     in the pixels, invisible to the eye. The grid states the page's measure,
     so it has to be legible on every surface it crosses. */
  --rule:rgba(22,20,26,.22);
  --rule-2:rgba(22,20,26,.08);

  /* ── type ⟨measured from nexura⟩ ─────────────────────────────────────────
     Body 18px is nexura's own and sits inside the 16–18 band all five
     references share. Our earlier 19px was above every one of them, which is
     why the first build read oversized and blog-like. */
  --t-mono:.8125rem;                                    /* 13 */
  --t-sm:.875rem;                                       /* 14 */
  --t-body:clamp(1rem,.97rem + .15vw,1.125rem);         /* 16 → 18 */
  --t-lead:clamp(1.125rem,1.07rem + .27vw,1.375rem);    /* 18 → 22 */
  --t-h3:clamp(1.25rem,1.18rem + .35vw,1.5rem);         /* 20 → 24 */
  --t-h2:clamp(1.75rem,1.45rem + 1.5vw,2.375rem);       /* 28 → 38 */
  /* The scroll statement: below h2 so the heading still leads, above lead so
     it still reads as a statement (Kacperek, 2026-09-01).
     ⚠ Its floor is 1.5rem = 24px, exactly the WCAG large-text threshold that
     --fill-rest depends on. ⚠ --fill-rest measures 2.90:1, NOT the 3.48 this
     comment carried until 2026-09-04 — it is below the large-text floor too,
     knowingly (see --fill-rest). The 24px floor still matters: it is what
     keeps the exception a large-text one rather than a body-text one.
     Do NOT lower it. */
  --t-fill:clamp(1.5rem,1.28rem + 1.1vw,2rem);          /* 24 → 32 */
  --t-h1:clamp(2.25rem,1.6rem + 3.2vw,3.375rem);        /* 36 → 54 */

  /* ── space ───────────────────────────────────────────────────────────────
     Sections ~120px, matching the reference band. Ours was 160px, which read
     as drift rather than calm. */
  --s1:.25rem; --s2:.5rem;  --s3:.75rem; --s4:1rem;
  --s5:1.5rem; --s6:2rem;   --s7:3rem;   --s8:4rem;  --s9:6rem;
  /* Reduced from 72→120 (Kacperek, 2026-09-01) — sections read loose.
     ⚠ This is only HALF the space between two sections: `main` adds a
     row-gap of its own (clamp 56→126), so the real separation is
     pad + gap + pad = 294px at 1440, not 176. Tune this before the gap:
     the gap is the break BETWEEN sections, this is breathing room WITHIN one. */
  --section:clamp(3.5rem,2.6rem + 3.6vw,5.5rem);        /* 56 → 88 */
  /* The break BETWEEN sections. A token because .is-joined cancels it with a
     negative margin and the two must not drift apart. */
  --main-gap:clamp(3.5rem,2rem + 6vw,7.875rem);         /* 56 → 126 */

  --r-card:12px;                                        /* ⟨measured⟩ */
  --r-pill:100px;                                       /* ⟨measured⟩ */
  /* ⚠ 1300, WIDENED FROM 1200 on 2026-09-10 (Kacperek: "the sections should
     be a bit wider on the whole page … the whole dotted box about 100px").
     The 1200 was ⟨measured⟩ off nexura; this is a deliberate departure from
     that reference, not a re-measurement — ⛔ do not "restore" it to 1200 on
     the grounds that the source says so.
     ⭐ ONE TOKEN MOVES THE WHOLE PAGE. Every grid rule, plate, section art and
     .wrap is expressed against --container, so the nav, the hero plate, the
     dotted box and every section widen together and stay registered. ⛔ Never
     hard-code a width to match it.
     ⚠ THE `min(100% - 2.5rem, …)` IN .wrap IS WHAT KEEPS THIS SAFE at narrow
     widths — below ~1340px the viewport clamp wins and the box simply tracks
     the window, so this does not need its own breakpoint. Measured after:
     1280 gives a 20px side margin (was 40), 1100 is unchanged at 20px. */
  --container:1300px;                                   /* was 1200 ⟨measured⟩ */
  --nav-h:68px;             /* the sticky nav's height; the hero subtracts it */
  /* breathing room between the grid rules and any content inside them, so
     nothing touches a rule (Kacperek). Applied by .wrap. */
  --gutter:clamp(1rem,.6rem + 1.6vw,2rem);
  /* The hero plate's inset from the grid box, on all four sides. Its own
     token rather than --gutter: the gutter is the measure that keeps CONTENT
     off the rules and is tuned for text, while this is a frame around an
     image and reads better tighter. Changing one should not move the other. */
  --plate-inset:clamp(.5rem,.38rem + .5vw,.75rem);
  /* The dash period, measured off nexura's rendered pixels: 4px on, 1px off.
     Held as tokens so the verticals, the horizontals and the nav's own rules
     cannot drift apart — every dashed line on the page reads from these. */
  /* Kacperek asked for bigger gaps, 2026-08-31. Nexura's measured period is
     4 on / 1 off; ours opens to 3 on / 4 off, which reads as dotted rather
     than as a broken solid line. Every dashed rule on the page — the
     verticals, the horizontals and the nav's — reads from these two tokens,
     so they cannot drift apart. */
  --dash-on:3px; --dash-off:4px;

  /* ── scroll reveal ────────────────────────────────────────────────────────
     THE FIRST MOTION TOKENS IN THIS FILE. Everything else times itself with a
     literal (.18s links, .22s buttons, .38s bento tracks, .6s filmstrip), and
     those stay as they are — they are per-component feel, not a system. These
     four exist because the reveal is applied ~40 times across ten sections and
     a stagger that drifts between sections reads as a bug.

     ⚠⚠ THIS IS THE SECOND DELIBERATE BREAK OF DESIGN.md § 5, whose ⛔ list
     names "translate slide-ins" by name, after #credit's sticky-scrub. On
     Kacperek's instruction 2026-09-03: the rulebook is to be rewritten once
     the redesign lands. See DECISIONS.md § 4.12 for the full reasoning.
     ⛔ Do not "restore" compliance on a tidy-up pass.

     Values read out of nexura.framer.ai's live markup, not guessed:
       reveal      opacity 0.001 + translateY(40px), 800ms cubic-bezier(.2,0,.2,1)
       hero copy   translateY(80px), 800ms, DELAYED 0.6-0.8s
       hero title  per-character blur(5px) + translateY(10px)
       micro       400ms cubic-bezier(.44,0,.56,1)  ← already our button curve

     ⚠ RETUNED 2026-09-03 (Kacperek: "most of the animation is too fast and
     happens before you even get to see it, I want nice slow movements").
     First pass ran 620ms / 14px / 70ms and was measurably quicker than the
     reference it was copying — nexura is 800ms and, in the hero, waits 600ms
     before starting. The lesson: the effect was tuned DOWN from the reference
     for restraint, and restraint applied to duration reads as haste, not calm.
     Now 900ms — past nexura, because our rise is smaller and a short travel
     over a long duration is what "slow and smooth" actually means. */
  --rv-dur:900ms;
  --rv-ease:cubic-bezier(.2,0,.2,1);
  /* ⚠ 22px, against nexura's 40px. Nexura is a wide-open SaaS template with
     empty gutters; this page is dense and ruled, and at 40px a .pcard visibly
     crosses the dotted section rules on its way up — the layout assembling
     late rather than content arriving. 22px is the compromise: enough travel
     to read as movement at 900ms, short enough to stay inside its own row.
     ⚠ Rise and duration are a PAIR. A small rise over a long duration reads
     as a drift; a large one over a short duration reads as a snap. Change one
     and re-judge the other, on sight, in Safari. */
  --rv-rise:22px;
  --rv-step:110ms;
  /* ⚠⚠ NO BLUR ON THE PAGE REVEALS — REMOVED 2026-09-03, DELIBERATELY.
     It was on every .rv for one pass and it was wrong twice over: on body
     copy a reader is already trying to read, a filter is a readability cost
     rather than a style; and repeated 38 times it stopped reading as an
     effect at all and started reading as the page failing to focus.
     ⭐ The blur survives in exactly ONE place — the hero title — where being
     unique is what makes it land. See .hero-rv--title.
     ⚠ Blur is also the one property here that does NOT get the free ride
     transform/opacity do: it forces a repaint of the blurred box. One element
     doing it once is free; 38 is not.
     ⛔ Do not reintroduce a --rv-blur token. If a section ever needs it, give
     that section its own keyframes the way the hero title has. */
}

*,*::before,*::after{box-sizing:border-box}

html{-webkit-text-size-adjust:100%;scroll-behavior:smooth}
@media (prefers-reduced-motion:reduce){html{scroll-behavior:auto}}

body{
  margin:0;background:var(--ground);color:var(--ink);
  font-family:'Inter Tight',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
  font-size:var(--t-body);line-height:1.4;letter-spacing:-.03em;
  -webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;
}

/* Display sizes track tighter than body, per every reference measured. */
h1,h2,h3,h4{margin:0;font-weight:500;color:var(--ink);text-wrap:balance}
h1{font-size:var(--t-h1);line-height:1.06;letter-spacing:-.04em}
h2{font-size:var(--t-h2);line-height:1.12;letter-spacing:-.04em}
h3{font-size:var(--t-h3);line-height:1.25;letter-spacing:-.03em}
p{margin:0;color:var(--ink-2)}
a{color:inherit;text-decoration:none}
ul,ol{margin:0;padding:0;list-style:none}
img,svg{display:block;max-width:100%}
table{border-collapse:collapse;width:100%}

:where(a,button,summary,input,[tabindex]):focus-visible{
  outline:2px solid var(--ox);outline-offset:3px;border-radius:4px}

/* Mono is reserved for data that must be transcribable — contract addresses,
   register numbers, tickers. Fragment Mono was chosen because it renders
   `0x31A7` correctly: Host Grotesk turns it into `0×31A7`, which is fatal on
   a page whose whole argument is "go and check these addresses". */
.mono{font-family:'Fragment Mono',ui-monospace,SFMono-Regular,monospace;
  letter-spacing:0;font-variant-ligatures:none}

/* ── the measure ──────────────────────────────────────────────────────────
   --container is the GRID's width — where the dotted rules are drawn. .wrap
   sits inside it and adds --gutter, so section content never touches a rule.
   Both the nav and every section use .wrap, so all of them align to the same
   two lines. That is the whole point of the device: it states the measure the
   page is actually built on. */
.wrap{width:min(100% - 2.5rem,var(--container));margin-inline:auto;
  padding-inline:var(--gutter)}
@media (max-width:40rem){
  .wrap{width:min(100% - 2rem,var(--container))}
}

/* The page-wide fixed grid was here. Removed once each section grew its own
   rules: a continuous fixed pair cannot break between sections, and the break
   is the point (Kacperek). Sections own their rules, so the space between them
   IS the gap — no arithmetic, and it cannot drift from the content. */

/* Section padding, and the BREAK between sections.

   ⚠ The break cannot be margin-block: adjacent margins COLLAPSE, so two 63px
   margins give a 63px gap, not 126. `main` is a flex column instead — row-gap
   does not collapse, so the value written here is the gap you actually get.
   Nexura's measured break is 126px ⟨measured⟩.

   The gap is also what breaks the vertical rules: the rules belong to each
   section, so where a section ends its rules end. Nothing to keep in sync. */
.section{padding-block:var(--section)}
main{display:flex;flex-direction:column;row-gap:var(--main-gap)}
/* ⚠ The hero must NOT take the flex gap above it — it sits directly under the
   nav, and any gap there both pushes it past the fold and breaks the
   100svh − nav arithmetic. Pulling it up by the gap is wrong (it would
   double-count on resize); instead the hero is the flex container's first
   item and the gap only ever falls BETWEEN items, so the 25px came from
   `main` having a text node before it. Removed at the source in the markup;
   this guard keeps it correct if one returns. */
main > .hero:first-child{margin-top:0}

.lead{font-size:var(--t-lead);line-height:1.35;color:var(--ink-2);letter-spacing:-.03em}
.small{font-size:var(--t-sm);color:var(--ink-3);line-height:1.5}
.muted{color:var(--ink-3)}
.sr{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;
  clip:rect(0 0 0 0);white-space:nowrap;border:0}
/* ⚠ Off-screen via clip, NOT left:-9999px — a far-left absolute element still
   contributes to scrollWidth. And it must stay position:absolute: as a static
   inline link it took 21px of layout at the top of the page, pushed the nav to
   y=25, and made the hero's `100svh − nav-h` overflow the fold by exactly that
   amount at every viewport. */
.skip{position:absolute;width:1px;height:1px;overflow:hidden;
  clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap}
.skip:focus{width:auto;height:auto;overflow:visible;clip:auto;clip-path:none;
  left:var(--s5);top:var(--s5);z-index:100;background:var(--ox);
  color:#fff;padding:var(--s3) var(--s4);border-radius:8px}

/* ── label ───────────────────────────────────────────────────────────────────
   The eyebrow above every section heading. Sentence case, not mono caps: it is
   a label, and a label should not shout louder than the heading it labels. */
.label{display:block;font-size:var(--t-mono);letter-spacing:-.01em;
  color:var(--ink-3);margin-bottom:var(--s4)}

/* ── badge — the pill. Removed from the hero 2026-08-31 (Kacperek); the
   component stays because it is the section-eyebrow device and is still
   available where a label needs a container. ──────────────────────────────── */
.badge{display:inline-flex;align-items:center;gap:var(--s2);
  padding:.4rem .85rem .4rem .65rem;border-radius:var(--r-pill);
  border:1px solid var(--rule);font-size:var(--t-mono);
  letter-spacing:-.01em;color:var(--ink-2);line-height:1.4;background:var(--ground)}
.badge::before{content:"";width:6px;height:6px;border-radius:50%;
  background:var(--ox);flex:none}

/* ── buttons ────────────────────────────────────────────────────────────────
   Pill, flat. Solid primary + hairline ghost side by side — the pattern in
   aura, soundra and heymessage alike.

   ⭐ THE LABEL SWAP. Read from nexura's DOM, not guessed: it renders the label
   TWICE inside a wrapper with overflow:clip, offsets the pair by ±24px, and
   swaps which copy is in flow on hover. Ours does the same with one transform
   on a stack, so it animates a single compositor-friendly property.
   The second copy is aria-hidden: a screen reader must hear the label once. */
.btn{display:inline-flex;align-items:center;justify-content:center;gap:var(--s2);
  min-height:46px;padding:.7rem 1.4rem;border:0;border-radius:var(--r-pill);
  font-family:inherit;font-size:var(--t-body);font-weight:500;letter-spacing:-.02em;
  cursor:pointer;background:var(--ox);color:#fff;
  position:relative;
  box-shadow:0 2px 10px rgba(107,31,50,.22);
  transition:background-color .22s cubic-bezier(.44,0,.56,1)}
.btn:hover{background:var(--ox-deep)}
.btn:active{transform:translateY(1px)}

/* ⭐ THE HALO — hover only, pale pink (Kacperek). Geometry from nexura: a ring
   at inset:-4px ⟨measured⟩. The pink is bloom ramp A #F5C1C5 from Kacperek's
   own card-bloom.html — oxblood at low saturation. */
.btn::before{content:"";position:absolute;inset:0;z-index:-1;
  border-radius:inherit;background:var(--bloom);opacity:0;
  transition:opacity .2s cubic-bezier(.44,0,.56,1),inset .2s cubic-bezier(.44,0,.56,1)}
.btn:hover::before{inset:-4px;opacity:1}
.dark .btn::before{background:rgba(245,193,197,.34)}

/* The clip window. 1lh tall, so .btn-sm inherits with no second rule. */
.btn__label{display:block;position:relative;overflow:clip;height:1lh;
  overflow-clip-margin:0}
@supports not (height:1lh){.btn__label{height:1.4em}}
.btn__label span{display:block;height:1lh;line-height:1lh;
  transition:transform .34s cubic-bezier(.34,.02,.2,1)}
@supports not (height:1lh){.btn__label span{height:1.4em;line-height:1.4em}}
.btn:hover .btn__label span,
.btn:focus-visible .btn__label span{transform:translateY(-100%)}

/* Keyboard focus sits OUTSIDE the halo — the halo is on every button at rest,
   so focus must not look like it. */
.btn:focus-visible{outline:2px solid var(--ox);outline-offset:6px}
.btn-ghost{background:transparent;color:var(--ink);
  box-shadow:inset 0 0 0 1px var(--rule);
  transition:box-shadow .22s cubic-bezier(.44,0,.56,1)}
.btn-ghost:hover{background:transparent;box-shadow:inset 0 0 0 1px var(--ink)}
/* The secondary takes a GREY ring, not pink (Kacperek): pink is the primary
   action's signal. Background cleared on the BASE rule — clearing it only on
   :hover left --bloom underneath and the ghost flashed pink on mouse-out. */
.btn-ghost::before{background:transparent;box-shadow:0 0 0 0 var(--rule-2);
  transition:opacity .2s cubic-bezier(.44,0,.56,1),
             box-shadow .2s cubic-bezier(.44,0,.56,1)}
.btn-ghost:hover::before{inset:0;box-shadow:0 0 0 4px var(--rule-2)}
/* ⚠⚠ AND `background:transparent` HERE TOO, for the same reason one line of
   reasoning above: `.dark .btn::before` (~line 423) paints the BLOOM
   (rgba(245,193,197,.34)) and wins on source order, so the ghost hovered to a
   pink haze inside a white ring. The base `.btn-ghost::before` clears it and
   its own note says why — "clearing it only on :hover left --bloom underneath
   and the ghost flashed pink on mouse-out" — but the .dark variant did not
   carry the clear across.
   ⭐ The halo on a ghost is the soft RING, never a fill. */
.dark .btn-ghost::before{background:transparent;
  box-shadow:0 0 0 0 rgba(255,255,255,.14)}
.dark .btn-ghost:hover::before{box-shadow:0 0 0 4px rgba(255,255,255,.14)}
.btn-sm{min-height:38px;padding:.5rem 1.05rem;font-size:var(--t-sm)}
.dark .btn:focus-visible,.dark .btn-ghost:focus-visible{outline-color:#fff}

@media (prefers-reduced-motion:reduce){
  .btn__label span{transition:none}
  .btn:hover .btn__label span,
  .btn:focus-visible .btn__label span{transform:none}
}

/* ── nav ─────────────────────────────────────────────────────────────────────
   The nav uses .wrap like every section, so its logo and CTA line up with the
   grid (Kacperek).

   ⚠ It sits ABOVE the page grid and draws its OWN vertical rules in the same
   places. Kacperek: "the rules don't print above the navbar but rather there's
   additional rules on the navbar that matches the ones below". That is the
   right construction — the sticky bar stays opaque as it scrolls over content,
   and the measure still reads as continuous from the very top of the page.
   Painting the fixed grid over the bar would leave dashes floating on top of
   whatever scrolled beneath it. */
.nav{position:sticky;top:0;z-index:40;background:var(--ground)}
.nav .wrap{position:relative;display:flex;align-items:center;gap:var(--s5);
  height:var(--nav-h)}

/* the nav's two verticals — same period, same colour, same x as .ol-grid,
   because .wrap IS the grid's width */
.nav .wrap::before,.nav .wrap::after{content:"";position:absolute;
  top:0;bottom:0;width:1px;pointer-events:none;
  background-image:repeating-linear-gradient(to bottom,
    var(--rule) 0 var(--dash-on),
    transparent var(--dash-on) calc(var(--dash-on) + var(--dash-off)))}
.nav .wrap::before{left:0}
.nav .wrap::after{right:0}

/* The horizontal under the nav — ALWAYS present, not only once scrolled
   (Kacperek). It spans the CONTAINER, not the window: it is part of the grid,
   and the grid is the container's measure. Full-bleed was wrong — Kacperek:
   "the navbar underscore is there on the full width of the page where it
   shouldn't". Drawn on .wrap so it inherits that width exactly. */
.nav .wrap::before{border-bottom:0}
/* The nav's corner rings. BOTTOM TWO ONLY (Kacperek: "no circles at the top
   corners of the navbar") — and that also resolves the doubling: the nav's
   bottom pair and the hero's top pair both marked y≈68, overlapping by 10px
   and rendering as two rings at one corner. One owner per edge, so the hero
   drops its top pair below. */
.nav__rule::after{content:"";position:absolute;z-index:3;pointer-events:none;
  left:-5px;right:-5px;top:-5px;bottom:-5px;
  background-image:
    url("data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%2712%27 height=%2712%27%3E%3Ccircle cx=%276%27 cy=%276%27 r=%275%27 fill=%27%23FFFDFC%27 stroke=%27rgba(22,20,26,0.22)%27 stroke-width=%271%27/%3E%3C/svg%3E"),
    url("data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%2712%27 height=%2712%27%3E%3Ccircle cx=%276%27 cy=%276%27 r=%275%27 fill=%27%23FFFDFC%27 stroke=%27rgba(22,20,26,0.22)%27 stroke-width=%271%27/%3E%3C/svg%3E");
  background-size:12px 12px;
  background-position:0 100%,100% 100%;
  background-repeat:no-repeat}

.nav__rule{position:absolute;left:0;right:0;bottom:0;height:1px;
  pointer-events:none;
  background-image:repeating-linear-gradient(to right,
    var(--rule) 0 var(--dash-on),
    transparent var(--dash-on) calc(var(--dash-on) + var(--dash-off)))}
/* The supplied lockup contains its own drawn wordmark, so nothing here is set
   in type. Previously the mark sat beside the word "Olbra" in Inter Tight —
   a second wordmark competing with the real one. Height, not width: the
   lockup's own aspect ratio decides the rest. */
.nav__mark{display:flex;align-items:center;color:var(--ox)}  /* ← accent #3 of 3 */
.nav__mark img{height:21px;width:auto}
.nav__links{display:flex;gap:var(--s6);margin-left:auto;align-items:center}
.nav__links a{font-size:var(--t-sm);color:var(--ink-2);letter-spacing:-.02em}
.nav__links a:hover{color:var(--ink)}
.nav .btn{margin-left:var(--s5)}
@media (max-width:56rem){
  .nav__links{display:none}
  .nav .btn{margin-left:auto}
}

/* ── the mobile menu ──────────────────────────────────────────────────────
   ⭐ Added 2026-09-04. Below 56rem .nav__links is display:none and NOTHING
   replaced it — eleven sections, ~30,000px of scroll, and no way to jump, on
   the device PRODUCT.md puts the consumer audience on.
   ⚠ A <details>, not a JS drawer. It opens with no script, so a JS failure
   cannot strand the page's only navigation. Same reasoning as the team
   filmstrip and the markup-split hero words.
   ⚠ HIDDEN ABOVE 56rem — the desktop links are the navigation there, and two
   navigations visible at once is a duplicate tab stop. */
.navm{display:none;position:relative;margin-left:var(--s3)}
@media (max-width:56rem){.navm{display:block}}

/* ⚠ list-style:none AND ::-webkit-details-marker — Safari draws the disclosure
   triangle through the -webkit pseudo-element and ignores list-style alone.
   Safari is the review browser, so both are required, not belt-and-braces. */
.navm__btn{list-style:none;display:flex;align-items:center;justify-content:center;
  width:44px;height:44px;margin-right:calc(var(--s3) * -1);cursor:pointer;
  border-radius:var(--r-pill);-webkit-tap-highlight-color:transparent}
.navm__btn::-webkit-details-marker{display:none}
.navm__btn:focus-visible{outline:2px solid var(--ox);outline-offset:2px}

/* Three bars that become a cross. Transform only — no layout property moves,
   so it composites. */
.navm__bars{position:relative;width:20px;height:14px}
.navm__bars i{position:absolute;left:0;right:0;height:1.5px;background:var(--ink);
  border-radius:2px;transition:transform .22s var(--rv-ease),opacity .16s linear}
.navm__bars i:nth-child(1){top:0}
.navm__bars i:nth-child(2){top:6.25px}
.navm__bars i:nth-child(3){top:12.5px}
.navm[open] .navm__bars i:nth-child(1){transform:translateY(6.25px) rotate(45deg)}
.navm[open] .navm__bars i:nth-child(2){opacity:0}
.navm[open] .navm__bars i:nth-child(3){transform:translateY(-6.25px) rotate(-45deg)}

/* The panel. --ground, not translucent: DESIGN § 8 forbids backdrop-filter on
   a full-bleed band, and the nav sits over a LIVE SHADER — the one combination
   the rule was written about. A solid ground costs nothing here. */
.navm__panel{position:absolute;top:calc(100% + var(--s3));right:0;
  min-width:11rem;display:flex;flex-direction:column;
  background:var(--ground);border:1px solid var(--rule-2);
  border-radius:var(--r-card);padding:var(--s2);
  box-shadow:0 8px 28px rgba(22,20,26,.09)}
/* ⚠ 44px min-height, not padding alone: these are the touch targets the audit
   found at 19px in the footer. A menu built to the same 15px habit would ship
   the same defect in a new place. */
.navm__panel a{display:flex;align-items:center;min-height:44px;
  padding:0 var(--s3);font-size:var(--t-body);color:var(--ink-2);
  letter-spacing:-.02em;border-radius:calc(var(--r-card) - 4px)}
.navm__panel a:hover{color:var(--ink);background:var(--sunken)}
.navm__panel a:focus-visible{outline:2px solid var(--ox);outline-offset:-2px}

@media (prefers-reduced-motion:reduce){
  .navm__bars i{transition:none}
}

/* ══ HERO ══════════════════════════════════════════════════════════════════
   FULL VIEWPORT HEIGHT. Three bands, stacked:

     ┌─────────────────────────────┐
     │            (nav)            │
     ├─────────────────────────────┤
     │      h1 · lead · buttons    │  ← copy, centred in the space
     │   ┌───────────────────────┐ │
     │   │   the plate (image)   │ │  ← sits BEHIND the copy, and STOPS
     │   └───────────────────────┘ │     above the fine print
     ├─────────────────────────────┤
     │  fine print, container wide │  ← on clean ground, not on the image
     └─────────────────────────────┘

   The licence line is inside the hero and pinned to its bottom (Kacperek,
   2026-08-31), at the same width as every other section and the navbar, so it
   reads as a footnote to the hero rather than the first line of the page.

   ⚠ Type sits ON the image, so legibility is no longer given by the ground.
   A scrim protects it, tuned against pixel sampling rather than by eye. A real
   photograph will be busier than this placeholder — re-sample when it lands,
   do not assume the scrim still carries it. */
/* ⚠ 100svh MINUS THE NAV. The nav is sticky and in flow, so the hero starts
   69px down the page; a plain 100svh therefore ended 69px BELOW the fold and
   pushed the fine print off-screen, leaving a half-empty first screen. The
   nav height is a variable so the two can never drift apart. */
/* ⚠ NO text-align:center. The hero was centred until 2026-09-10; it is now a
   two-column split with the copy left-aligned. See .hero__inner. */
.hero{position:relative;isolation:isolate;overflow:hidden;
  min-height:calc(100svh - var(--nav-h));display:flex;flex-direction:column;
  padding-top:clamp(2rem,1rem + 3vw,3.5rem);padding-bottom:var(--s6)}
@supports not (min-height:100svh){.hero{min-height:calc(100vh - var(--nav-h))}}

/* ⭐⭐ THE HERO IS A TWO-COLUMN SPLIT: copy top-left, phone right
   (Kacperek, 2026-09-10, from a reference layout).

   ⚠⚠ THIS OVERTURNS DESIGN.md § 3 RULE 3 — "the hero is centred, everything
   else is left-aligned … centring is the hero's device so it reads as
   arrival". The hero was the page's ONE sanctioned use of centring, which is
   what made it a system rather than a habit. With it left-aligned, the only
   centred block left is #app's heading — which § 3 rule 3 allows as a narrow
   carve-out for "a section built symmetrically around a central object".
   ⭐ The device that replaces centring is the SPLIT itself: the fold is now
   the only two-column composition on the page, so it still reads as arrival
   rather than as another section. DESIGN.md § 3 rule 3 is amended to match —
   if the two ever disagree again, the built page is not the authority.

   ⭐⭐ VERTICALLY CENTRED IN THE PLATE as of 2026-09-10 evening (Kacperek:
   "lets move titles on the right side to the vertical center of the hero
   plate"). It was TOP-ANCHORED before, and the argument for that is kept
   below rather than deleted, because half of it is still load-bearing.

   ⚠ What top-anchoring was for: the copy is one column of a split and was
   made to align with the TOP of the phone beside it. Measured when it was
   last centred: h1 top 126 -> 262px at 1000px tall — the copy drifts down as
   the window grows, because centring ties its position to the FOLD'S HEIGHT.
   ⭐ That drift is now the POINT rather than the defect: top-anchored, the
   copy measured 12.8% from the plate's top and 62.2% from its bottom at
   1728x1117 — sitting in a corner of a tall fold with most of the plate
   empty under it. The two columns no longer register head-to-head; each is
   centred in the frame it lives in.
   ⚠⚠ THE PHONE IS STILL BOTTOM-WEIGHTED AND THAT IS NOT AN INCONSISTENCY.
   The plate's artwork lives entirely in its lower half, so the device is
   pushed down into the ink deliberately (see .hero__phone — profiled: nothing
   behind the glass for the top three quarters when it was centred). ⛔ Do not
   "harmonise" the two by centring the figure as well.
   ⚠ The padding floor survives as a floor ONLY: on a short window it keeps
   the headline clear of the nav, where true centring would ride it up. */
.hero__inner{position:relative;z-index:2;flex:1;
  /* ⚠⚠ THE PHONE'S COLUMN IS SIZED, NOT `auto`. With `auto` the copy column
     took every pixel the figure did not — measured at 1440: an 821px copy
     column holding a 534px lead, i.e. a 287px dead zone in the middle of the
     fold, which is what read as "badly positioned". The split is now a stated
     proportion and the empty space is distributed by the gap instead.
     ⚠ The right column is a fraction, not a fixed width, so it keeps tracking
     the container; the FIGURE inside it is what carries the size clamp. */
  /* ⚠ ONE track now: the phone left the grid (see .hero__phone) and a second
     column with nothing in it would still reserve its gap. The copy's own
     max-width is what stops it running under the figure. */
  display:grid;grid-template-columns:minmax(0,1fr);
  /* ⭐ center, not start — the copy is centred in the plate box (see the head
     of this block). One track holds only the copy, so `center` on the grid IS
     the centring; the free space above and below splits by construction.
     ⛔⛔ NOT translateY(-50%). `.hero-rv`'s @keyframes hero-rise SETS
     `transform` (translateY(--rv-rise) -> none), so a centring transform on
     anything inside here is overwritten the instant the reveal runs. Same
     trap DESIGN § 5.1 records for .rv-y and .appx__fig, and the one
     .hero__phone already hit (measured: -28% bottom inset). Grid alignment is
     a property the animation does not own. */
  align-items:center;
  column-gap:clamp(1.5rem,4vw,4rem);
  margin-top:calc(var(--plate-inset) - clamp(2rem,1rem + 3vw,3.5rem));
  /* ⚠ A FLOOR, NOT A POSITION. With align-items:center this no longer places
     the copy — it only stops a short window pulling the headline up under the
     sticky nav. ⚠ Symmetrical with padding-bottom so the padding itself
     cannot bias the centring it sits inside. */
  padding-top:clamp(1.5rem,4vh,3rem);
  padding-bottom:clamp(1.5rem,4vh,3rem)}

/* The copy column. ⚠ A grid ITEM, so it needs its own column flow — the h1,
   lead and buttons were direct children of a column flexbox before. */
.hero__copy{display:flex;flex-direction:column;align-items:flex-start;
  /* ⚠ A MEASURE CAP, so the column stops where the words do. The h1 and lead
     already carry ch caps (15ch / 44ch); without one here the COLUMN still
     stretched to the grid track and the gap to the phone was whatever was
     left over, which made the fold read as two things drifting apart rather
     than as a composition. 34rem is just past the lead's own 44ch. */
  /* ⚠ 44rem, not 34. At 34 the copy stopped so far short of the figure that
     the fold had a ~300px canyon down the middle; the cap is there to stop the
     column SPRAWLING, not to squeeze it. 44rem is a touch past the lead's own
     44ch measure, so the words still wrap where they did — the cap only bites
     on the empty track, never on the text.
     ⚠ 47rem, RAISED WITH --container ON 2026-09-10. The grid went 1200 → 1300
     and this cap did not, so the whole +100px landed in the GAP — recreating
     the canyon the 34→44 raise had just fixed, one token change later.
     ⚠⚠ 47, NOT 50, AND THE NUMBER WAS SWEPT RATHER THAN PICKED. Measured at
     1440, gap between copy and figure:
         44rem -> 121px   46 -> 89   47 -> 73   48 -> 57   50 -> 25
     50 gave the column the whole +100 and left the two columns nearly
     touching, which is the opposite failure to the canyon. 47 splits it:
     the column takes ~half and the gap lands at 73px, near the 121 the 1200
     layout had. ⛔ Do not push this to 50 to "use the space" — the space
     between the columns IS the composition.
     ⭐ THE TWO MOVE TOGETHER. This cap is a share of the measure, not an
     independent number: ⛔ if --container changes again, change this in the
     same commit, or the fold silently re-opens down the middle.
     ⚠ It still only bites on the EMPTY TRACK — the h1 (15ch) and lead (44ch)
     carry their own caps, so the words wrap exactly where they did. Verified
     after: both keep their measured widths.
     ⚠ The h1 and lead keep their own ch caps; this governs the COLUMN. */
  max-width:47rem;min-width:0}
.hero h1{max-width:15ch}
/* ⚠ margin-inline:auto REMOVED with the centring. Left in place it would push
   a lead narrower than its column toward the middle, breaking the flush left
   edge the split depends on. */
.hero .lead{margin-top:var(--s5);max-width:44ch}
.hero__acts{margin-top:var(--s7);display:flex;gap:var(--s3);flex-wrap:wrap;
  justify-content:flex-start}

/* The plate. Bleeds left and right, but stops ABOVE the fine print.
   Kacperek: "so the bg plate lives above that".

   ⚠ Its bottom is driven by the PRINT'S MEASURED HEIGHT (--hero-print, set by
   the inline script), not by a hand-picked rem. A fixed value was wrong the
   moment the text wrapped differently: 8.5rem cleared it on desktop and
   overlapped by 4px at 390px, where the sentence runs to three lines. The
   fallback here applies only before the script runs. */
/* ── SECTION RULES · BREAKS · CORNER MARKERS ────────────────────────────────
   Three related devices, all measured off nexura's rendered page.

   ⭐ THE BREAK. The vertical rules do not run unbroken down the document: at a
   section boundary they STOP, leaving a gap, then resume. Measured at page
   y≈1078 — a **126px** gap ⟨measured⟩. That gap is what gives the page air
   between sections, and it is why the grid reads as structure rather than as
   graph paper laid over everything.

   Implemented by giving each section its own pair of rules rather than one
   continuous fixed pair: a rule that belongs to a section naturally stops
   where the section does, and the margin between sections IS the break. No
   arithmetic, and it cannot drift out of step with the content.

   ⭐ THE HORIZONTAL. A dotted rule across each section's top edge, same period
   as the verticals, so the boundary is stated in both axes.

   ⭐ THE CORNER MARKERS. Small circles where the rules meet — Kacperek asked
   for circles rather than nexura's stepped square, at the same weight as the
   dotted line. 5px, hairline stroke, sitting ON the intersection. They mark
   the corners of the measure; a circle reads as a registration mark, which is
   what this is. */
.section,.hero{position:relative}

/* the two verticals, owned by the section so the gap between sections is the
   break. inset:0 on the block axis; the section's own margin does the rest. */
.section::before,.hero::before{content:"";position:absolute;top:0;bottom:0;z-index:1;
  left:50%;transform:translateX(-50%);
  width:min(100% - 2.5rem,var(--container));
  pointer-events:none;
  border-left:1px dashed transparent;border-right:1px dashed transparent;
  background-image:
    repeating-linear-gradient(to bottom,var(--rule) 0 var(--dash-on),
      transparent var(--dash-on) calc(var(--dash-on) + var(--dash-off))),
    repeating-linear-gradient(to bottom,var(--rule) 0 var(--dash-on),
      transparent var(--dash-on) calc(var(--dash-on) + var(--dash-off))),
    repeating-linear-gradient(to right,var(--rule) 0 var(--dash-on),
      transparent var(--dash-on) calc(var(--dash-on) + var(--dash-off))),
    repeating-linear-gradient(to right,var(--rule) 0 var(--dash-on),
      transparent var(--dash-on) calc(var(--dash-on) + var(--dash-off)));
  /* FOUR layers: two verticals, then a horizontal at the top AND one at the
     bottom. The bottom rule was missing — the box was open at the foot, so a
     section's grid never closed and its two lower corner rings sat on nothing. */
  background-size:1px 100%,1px 100%,100% 1px,100% 1px;
  background-position:left top,right top,left top,left bottom;
  background-repeat:no-repeat}
/* ⚠ The hero sits directly under the nav, whose rule already marks that edge.
   Without this the two stack and render as a 2px double line — measured as a
   2px inked band at page y≈92-94. The hero keeps its verticals, drops its top
   horizontal. */
/* ⚠ THE HERO'S GRID BOUNDS THE PLATE, NOT THE HERO (Kacperek): "our grid
   should end below the bg plate picture and the fine print lives below it."
   So the pseudo stops where the plate stops, and the fine print sits beneath
   it on open ground — outside the box.

   It also drops its TOP horizontal: the nav's rule already marks that edge,
   and drawing both rendered a 2px double line. It keeps a BOTTOM horizontal,
   which closes the box under the plate. */
/* ⛔⛔ NEUTRALISED 2026-09-14 — display:none, not deleted. These rules are
   SHARED with .section, which still draws its dotted box; deleting them here
   would mean unpicking a selector list that other sections depend on.
   The box existed to frame a plate INSET inside the grid. The hero is now a
   full-bleed photograph, so there is no box edge for a dotted rule to sit on,
   and the corner rings are hard-coded fill:#FFFDFC — cream circles on a dark
   photo. ⛔ If the plate ever returns to the hero, restore BOTH this and the
   containment in .hero__shots, never one alone. */
.hero::before{display:none;bottom:calc(var(--hero-print,7rem) + var(--s6));
  background-image:
    repeating-linear-gradient(to bottom,var(--rule) 0 var(--dash-on),
      transparent var(--dash-on) calc(var(--dash-on) + var(--dash-off))),
    repeating-linear-gradient(to bottom,var(--rule) 0 var(--dash-on),
      transparent var(--dash-on) calc(var(--dash-on) + var(--dash-off))),
    repeating-linear-gradient(to right,var(--rule) 0 var(--dash-on),
      transparent var(--dash-on) calc(var(--dash-on) + var(--dash-off)));
  background-size:1px 100%,1px 100%,100% 1px;
  background-position:left top,right top,left bottom;
  background-repeat:no-repeat,no-repeat,no-repeat}

/* ⭐ THE CORNER MARKERS — measured properly off nexura this time.

   ⚠ I had the geometry wrong for four rounds. Nexura's marker is NOT centred
   on the corner: it is a hollow ~10px outline sitting just INSIDE it, and the
   dashed rule runs up to the marker's edge and stops there. Read at 4x from
   its live page ⟨measured⟩. Every attempt to centre a mark ON the corner was
   solving the wrong problem — which is why each fix moved the drift around
   instead of removing it.

   Ours is the circle equivalent at the same size and placement: an 11px ring,
   1px stroke, inset so it sits inside the corner exactly as the square does.
   Kacperek asked for circles rather than squares, and much bigger than the
   5px first tried.

   SVG rather than a gradient because a 1px stroke at this size cannot survive
   colour-stop interpolation; the ground-coloured fill punches the rule out
   beneath so the ring reads as a ring. */
.section::after,.hero::after{content:"";position:absolute;z-index:3;
  left:50%;transform:translateX(-50%);
  /* ⚠ The ring tiles sit at -5px, i.e. 5px OUTSIDE this box on every side —
     and a background never paints beyond its own box, so those 5px were
     simply missing. It is not a clipping problem: every ancestor here is
     overflow:visible. The box is inflated 5px instead, and the tiles moved to
     0 / 100% so they land in the same place on screen while staying inside
     the paint area. */
  width:calc(min(100% - 2.5rem,var(--container)) + 10px);
  top:-5px;bottom:-5px;pointer-events:none;
  background-image:
    url("data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%2712%27 height=%2712%27%3E%3Ccircle cx=%276%27 cy=%276%27 r=%275%27 fill=%27%23FFFDFC%27 stroke=%27rgba(22,20,26,0.22)%27 stroke-width=%271%27/%3E%3C/svg%3E"),
    url("data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%2712%27 height=%2712%27%3E%3Ccircle cx=%276%27 cy=%276%27 r=%275%27 fill=%27%23FFFDFC%27 stroke=%27rgba(22,20,26,0.22)%27 stroke-width=%271%27/%3E%3C/svg%3E"),
    url("data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%2712%27 height=%2712%27%3E%3Ccircle cx=%276%27 cy=%276%27 r=%275%27 fill=%27%23FFFDFC%27 stroke=%27rgba(22,20,26,0.22)%27 stroke-width=%271%27/%3E%3C/svg%3E"),
    url("data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%2712%27 height=%2712%27%3E%3Ccircle cx=%276%27 cy=%276%27 r=%275%27 fill=%27%23FFFDFC%27 stroke=%27rgba(22,20,26,0.22)%27 stroke-width=%271%27/%3E%3C/svg%3E");
  background-size:12px 12px;
  /* INSIDE the corner, not centred on it — the marker's own box is tangent to
     both rules, which is what nexura draws. */
  background-position:0 0,100% 0,0 100%,100% 100%;
  background-repeat:no-repeat}

/* ⚠ Suppresses BOTH pseudo-elements — the dashed box AND the corner rings are
   four background layers on ::before and four on ::after, so hiding one leaves
   the other painting.

   Used by #faq ONLY (Kacperek, 2026-09-01): the question list draws its own
   horizontal rules, and the section grid on top of them reads as two competing
   rule systems. #solution had this modifier once and losing ITS rules broke the
   page's continuous measure — the difference is that #faq's neighbours each
   close their own box (#team above; below it, since #early was folded into the
   footer, the dark footer itself). Do not reach for this modifier again without
   checking that the neighbours still close. */
.section--norules::before,.section--norules::after{display:none}
/* A section without rules should also BREATHE more than one with them
   (Kacperek, 2026-09-01). Without this it occupies exactly the same vertical
   space as a boxed section and reads as a hole in the run rather than a
   deliberate break — the rules were the only thing distinguishing it.
   The extra is on the block padding, not a margin: a margin would collapse
   against the neighbour's and give back half of what it adds.
   ⚠ #faq is --norules too and deliberately does NOT get this — it sits
   directly above the dark footer, where extra space reads as a gap before a
   hard edge rather than as air around the section. */
/* ⚠ #app was here until 2026-09-02 and no longer is: it took --norules back
   off when the orbit was replaced. The modifier was on it because "the orbit
   is a centred radial composition and the dotted box puts a rectangle around
   a circle" — the new layout IS a rectangle, so the reason expired with the
   figure it was written for.
   Side effect worth knowing: #app now closes its own box, which is exactly
   the condition the comment above asks for in #solution's LOWER neighbour. */
#solution.section--norules{padding-block:calc(var(--section) + var(--s7))}

/* ── #stablecoins' plate ──────────────────────────────────────────────────
   The same device as the hero's, and deliberately the same construction — see
   .hero__art for the reasoning behind every number here. ⚠ THAT RULE NO
   LONGER EXISTS — the hero became a full-bleed photograph on 2026-09-14 and
   the plate's containment went with it. The reasoning still governs THIS
   section; see § THE HERO PHOTOGRAPHS for what replaced it and why.

   ⚠ The section must become a containing block for it. .section already has
   position:relative (it needs one for the dotted-box pseudo-elements), so the
   plate can be absolute against it without adding anything.

   ⚠ INSET BY --plate-inset ON THREE SIDES, exactly like the hero: the dotted
   grid is the box and content sits INSIDE it, never on it. The fourth side —
   the bottom — is not an inset but a stop; see below.

   ⚠ z-index 0, and the copy above it is lifted to 1. Without that the plate
   paints over the heading: it comes first in the DOM, but so does the hero's,
   and .hero__inner carries a z-index for exactly this reason. */
.sc__art{position:absolute;z-index:0;pointer-events:none;line-height:0;
  overflow:hidden;
  left:50%;transform:translateX(-50%);
  width:calc(min(100% - 2.5rem,var(--container)) - var(--plate-inset) * 2);
  top:var(--plate-inset);
  /* ⚠ THE BOTTOM IS A MEASURED STOP (Kacperek: "it ends above the separators
     above the big numbers section"). The .stmts row's top rules are those
     separators, and the distance from the section's foot up to them depends on
     how the heading and lead wrapped — there is no CSS expression for it, so
     --sc-plate-stop is written by the inline script in index.html. The
     fallback keeps the plate short rather than letting it run under the
     figures if the script never runs.

     ⛔ Do NOT let the plate run the section's full height. That was tried and
     reverted: making the box ~1.07:1 against a 1.79:1 source meant `cover`
     showed only 60% of the width and blew the art up to fill the height, so it
     stopped being a field the section sits on and became a picture competing
     with the copy. The plate's SIZE and CROP are right as they are — the only
     thing the fade changes is how its lower edge ends. */
  bottom:var(--sc-plate-stop,60%)}
@media (max-width:40rem){
  /* ⚠ Restates the FULL calc, gutter included — an override that restates a
     property inherits nothing from the rule it overrides. The hero's twin
     silently lost its inset below 640px exactly this way. */
  .sc__art{width:calc(min(100% - 2rem,var(--container)) - var(--plate-inset) * 2)}
}
/* ⚠ object-position 0% 0% — TOP-LEFT anchored. With the plate back at its
   original ~3.5:1 band, cover shows the FULL width and the top half of the
   source, so the art lands where it was drawn: bands sweeping in from the
   top-right, empty at the left where the heading and lead sit.

   ⚠ COVER, and the crop is deliberate (Kacperek, 2026-09-01 evening). This
   slot is ~3.55:1 while the source is 1.79:1, so cover shows the full width
   and the top ~51% of the height: the burst fills the band and its rays bleed
   off the bottom. `contain` was the alternative and was rejected — it fits the
   whole artwork but then occupies only the right half of the band, and the
   plate stops looking like a field the section sits on.
   Bleeding off an edge is also what DESIGN.md § 4 asks of these forms:
   "Forms bleed off the edge. They do not sit neatly inside a box." */
/* ⭐ FADES OUT AT THE FOOT (Kacperek, 2026-09-01 evening) — "make it fade to
   the bottom so it ends smoothly below". Without it the plate stops on a hard
   horizontal edge a fixed distance above the figures, and that edge reads as a
   crop rather than as art settling into the page.

   ⚠ A MASK on the image, not a gradient overlay. An overlay would have to be
   painted in --ground, which only works while the plate sits on --ground — the
   moment this section's surface changes, a cream rectangle appears over the
   art. Masking removes the pixels instead, so whatever is behind shows through
   and the fade is correct on any ground.

   ⚠ This is the OPPOSITE decision from the hero, deliberately. There the plate
   is a framed picture that "should end where it ends", and a fade was removed
   for bleeding its edges into the page. Here the art is a FIELD the section
   sits on: it runs the full height of the box, through the figures, and has to
   stop being present by the time the eye reaches the portfolio list. A hard
   edge across the middle of a section reads as a crop; a dissolve reads as
   depth.

   ⚠ The ramp only softens the plate's LOWER EDGE — it does not reposition or
   resize anything. Full strength to 55%, gone by 100%, i.e. the fade occupies
   the bottom 45% of a band that already stops above the figures. The art's own
   density has fallen off by then, so the ramp has little left to remove and
   never eats into the part that reads.

   ⚠ Percentages of the PLATE's own height, not the section's — and the plate's
   height is set by --sc-plate-stop, so the ramp keeps its proportions when the
   copy above the figures rewraps. That is the reason to express it this way
   rather than in px. */
.sc__art img{width:100%;height:100%;object-fit:cover;object-position:0% 0%;
  -webkit-mask-image:linear-gradient(to bottom,#000 0%,#000 55%,transparent 100%);
  mask-image:linear-gradient(to bottom,#000 0%,#000 55%,transparent 100%)}
/* The live plate for this section. Same shader as the hero (plate.js reads
   data-plate off the host), but TOP-anchored and with no hover.
   ⚠⚠ THE MASK MUST BE REPEATED HERE, and it is not redundant. It sits on
   .sc__art img, not on .sc__art, so the canvas is not covered by it — without
   this the shader's bottom edge would cut hard across the section while the
   still it replaces dissolves. Keep the two gradients identical: if one is
   retuned, retune both, or the art shifts as the canvas fades in. */
.sc__art canvas{position:absolute;inset:0;width:100%;height:100%;
  display:block;opacity:0;transition:opacity .5s ease;
  -webkit-mask-image:linear-gradient(to bottom,#000 0%,#000 55%,transparent 100%);
  mask-image:linear-gradient(to bottom,#000 0%,#000 55%,transparent 100%)}
.sc__art.is-live canvas{opacity:1}
/* ⚠⚠ HIDE THE STILL ONCE THE CANVAS IS LIVE, and only then.
   Both layers carry the same bottom-fade mask, so the canvas becomes
   transparent toward the foot and the PNG underneath showed THROUGH it —
   two different fields visible at once, which read as "a static image baked
   into" the animation. Fading the still out fixes it without touching the
   fallback: is-live is only ever set after a context, a compiled shader and
   the tone field have all been proved, so every failure path still shows the
   still at full opacity.
   ⛔ Do not use display:none — it would collapse the <img> and with it the
   layout the canvas is absolutely positioned against. */
.sc__art.is-live img{opacity:0;transition:opacity .5s ease}
@media (prefers-reduced-motion:reduce){ .sc__art canvas{display:none} }

/* ⚠ The copy sits ABOVE the plate. .wrap has no z-index of its own, so in the
   same stacking context the plate — an earlier sibling — would still paint
   under it, but any positioned descendant inside .wrap would not. Lifting the
   whole wrap is the one-line version of the hero's .hero__inner z-index. */
#stablecoins > .wrap{position:relative;z-index:1}

/* ── statements ───────────────────────────────────────────────────────────
   Three figures replacing #stablecoins' comparison table (Kacperek,
   2026-09-01): a large figure with a label beneath it, one row of three.

   <dl>, not <ol>: each item is a term and its description, which is what a
   definition list is for — and it means the figure and its label are bound
   together for a screen reader instead of reading as six loose strings. The
   wrapping <div> is required; dt/dd cannot be grid items directly if the pair
   is to stay together.

   The figure is NOT --t-h1: the section already has an h2 above it, and a
   figure that outsizes the heading inverts the page's hierarchy. --t-h2 puts
   them on the same step, which is the intent — these ARE the argument. */
.stmts{display:grid;gap:var(--s5);margin-top:var(--s8);
  grid-template-columns:repeat(3,minmax(0,1fr))}
.stmt{display:flex;flex-direction:column;gap:var(--s3);
  padding-top:var(--s5);border-top:1px solid var(--rule)}
.stmt__fig{font-size:var(--t-h2);line-height:1;letter-spacing:-.04em;
  color:var(--ink);font-weight:500;
  /* tabular figures so 100% / 24/7 sit on a consistent rhythm */
  font-variant-numeric:tabular-nums}
.stmt__lb{margin:0;font-size:var(--t-sm);line-height:1.45;
  color:var(--ink-3);letter-spacing:-.01em;text-wrap:pretty;max-width:30ch}

@media (max-width:44rem){
  .stmts{grid-template-columns:1fr;gap:0}
  .stmt{padding:var(--s5) 0}
}

/* ── joined sections ──────────────────────────────────────────────────────
   #stablecoins → #assets → #credit read as ONE block (Kacperek, 2026-09-01).

   ⚠ Joined does NOT mean the boundary rule disappears. That was my first
   reading and it was wrong: nexura keeps a dashed separator with corner
   markers at EVERY section boundary. Adjacent sections join by SHARING one
   rule instead of each closing its own box — the run reads continuous
   because there is no gap and no doubled line, not because the line is gone.

   So two things happen, and only two:

   1. the flex row-gap between them goes to zero, so the two rules that would
      sit either side of the gap collapse onto one boundary;
   2. the lower section drops its own top rule and top corner rings, leaving
      the upper section's bottom rule to serve as the shared separator.

   Everything else — verticals, the outer horizontals, the separator's own
   corner markers — stays exactly as it is on an unjoined section. */
.is-joined + .is-joined{margin-top:calc(-1 * var(--main-gap))}

/* Drop only the DUPLICATE top rule; the section above supplies the boundary.
   ⚠ Three layers, not four: two verticals + the bottom horizontal. Counting
   layers matters here — an override that restates a multi-layer property and
   gets the count wrong silently opens the box at one edge. */
.is-joined + .is-joined::before{background-image:
    repeating-linear-gradient(to bottom,var(--rule) 0 var(--dash-on),
      transparent var(--dash-on) calc(var(--dash-on) + var(--dash-off))),
    repeating-linear-gradient(to bottom,var(--rule) 0 var(--dash-on),
      transparent var(--dash-on) calc(var(--dash-on) + var(--dash-off))),
    repeating-linear-gradient(to right,var(--rule) 0 var(--dash-on),
      transparent var(--dash-on) calc(var(--dash-on) + var(--dash-off)));
  background-size:1px 100%,1px 100%,100% 1px;
  background-position:left top,right top,left bottom}

/* and the duplicate top rings with it — the boundary keeps the upper
   section's pair, so the markers still land on the separator. */
.is-joined + .is-joined::after{background-position:0 100%,100% 100%;
  background-image:
    url("data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%2712%27 height=%2712%27%3E%3Ccircle cx=%276%27 cy=%276%27 r=%275%27 fill=%27%23FFFDFC%27 stroke=%27rgba(22,20,26,0.22)%27 stroke-width=%271%27/%3E%3C/svg%3E"),
    url("data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%2712%27 height=%2712%27%3E%3Ccircle cx=%276%27 cy=%276%27 r=%275%27 fill=%27%23FFFDFC%27 stroke=%27rgba(22,20,26,0.22)%27 stroke-width=%271%27/%3E%3C/svg%3E")}

/* on a dark band the punch-out has to be the dark ground, not the page's */
.dark.section::after{background-image:
    url("data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%2712%27 height=%2712%27%3E%3Ccircle cx=%276%27 cy=%276%27 r=%275%27 fill=%27%230b0c0f%27 stroke=%27rgba(255,255,255,0.22)%27 stroke-width=%271%27/%3E%3C/svg%3E"),
    url("data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%2712%27 height=%2712%27%3E%3Ccircle cx=%276%27 cy=%276%27 r=%275%27 fill=%27%230b0c0f%27 stroke=%27rgba(255,255,255,0.22)%27 stroke-width=%271%27/%3E%3C/svg%3E"),
    url("data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%2712%27 height=%2712%27%3E%3Ccircle cx=%276%27 cy=%276%27 r=%275%27 fill=%27%230b0c0f%27 stroke=%27rgba(255,255,255,0.22)%27 stroke-width=%271%27/%3E%3C/svg%3E"),
    url("data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%2712%27 height=%2712%27%3E%3Ccircle cx=%276%27 cy=%276%27 r=%275%27 fill=%27%230b0c0f%27 stroke=%27rgba(255,255,255,0.22)%27 stroke-width=%271%27/%3E%3C/svg%3E")}

/* and its rules need to be visible on dark too.
   ⚠ FOUR layers, matching the light rule. This override was written when the
   base had three and kept only three, so every dark band's grid was open at
   the foot while the light ones closed — a difference invisible unless you
   count layers per section. Raised to .22 to match --rule's weight. */
.dark.section::before{background-image:
    repeating-linear-gradient(to bottom,rgba(255,255,255,.22) 0 var(--dash-on),
      transparent var(--dash-on) calc(var(--dash-on) + var(--dash-off))),
    repeating-linear-gradient(to bottom,rgba(255,255,255,.22) 0 var(--dash-on),
      transparent var(--dash-on) calc(var(--dash-on) + var(--dash-off))),
    repeating-linear-gradient(to right,rgba(255,255,255,.22) 0 var(--dash-on),
      transparent var(--dash-on) calc(var(--dash-on) + var(--dash-off))),
    repeating-linear-gradient(to right,rgba(255,255,255,.22) 0 var(--dash-on),
      transparent var(--dash-on) calc(var(--dash-on) + var(--dash-off)));
  background-size:1px 100%,1px 100%,100% 1px,100% 1px;
  background-position:left top,right top,left top,left bottom}

/* ⚠ Must come AFTER the base .section::after,.hero::after rule, which sets
   bottom:0. Declared before it, this had equal specificity and lost, so the
   circles sat at the hero's bottom (y=900) instead of the plate's (789) —
   below the visible box, which is why they could not be seen. */
/* The hero shows only its BOTTOM two rings: the nav's bottom pair already
   marks the edge they share, and drawing both put two rings on one corner. */
.hero::after{display:none;bottom:calc(var(--hero-print,7rem) + var(--s6) - 5px);
  background-position:0 100%,100% 100%;
  background-image:
    url("data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%2712%27 height=%2712%27%3E%3Ccircle cx=%276%27 cy=%276%27 r=%275%27 fill=%27%23FFFDFC%27 stroke=%27rgba(22,20,26,0.22)%27 stroke-width=%271%27/%3E%3C/svg%3E"),
    url("data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%2712%27 height=%2712%27%3E%3Ccircle cx=%276%27 cy=%276%27 r=%275%27 fill=%27%23FFFDFC%27 stroke=%27rgba(22,20,26,0.22)%27 stroke-width=%271%27/%3E%3C/svg%3E")}

@media (max-width:40rem){
  .section::before,.section::after,
  .hero::before,.hero::after{width:min(100% - 2rem,var(--container))}
}

/* ══ THE HERO PHOTOGRAPHS ═════════════════════════════════════════════════
   ⭐⭐ REPLACED THE DITHER PLATE IN THE HERO, 2026-09-14 (Kacperek, from
   plasma.org). The plate itself is NOT retired — #stablecoins, #assets and
   the card still carry it. This is the hero only.

   ⚠⚠ WHAT THIS ABANDONS, DELIBERATELY: the plate was CONTAINED WITHIN THE
   GRID, inset on all four sides by --plate-inset, "so the rules read as the
   box holding it rather than lines drawn over a full-bleed image" (Kacperek,
   2026-09-01). A photograph that stops short of the window edge is not the
   pattern being asked for, so the containment goes and the dotted box goes
   with it — see the .hero::before/::after neutralisation below. ⛔ Do not
   restore one without the other; a dotted frame around a full-bleed photo
   draws a box that has no edge to sit on.

   ⚠ inset:0 on the SHOTS, not the section: .hero is the flex column that
   still sizes the copy and the licence. */
/* ⚠⚠ .hero.dark, NOT .hero — THE SAME TRAP THE DOTS HIT, and here it was
   worse because it failed SILENTLY. `.dark > *{position:relative}` is
   declared ~2100 lines later at equal specificity, so it won: the container
   computed position:relative, `inset:0` stopped anchoring, and the box
   collapsed to HEIGHT 0. The photographs never painted at all — and the hero
   still looked dark and plausible, because .dark's own gradient background
   was showing through. ⭐ Measured, not seen: getBoundingClientRect() gave
   height:0 while the page looked finished.
   ⛔ Do not reduce either of these to one class. */
.hero.dark .hero__shots{position:absolute;inset:0;z-index:0;overflow:hidden;
  pointer-events:none;line-height:0;
  /* ⚠⚠ THE SINGLE SOURCE FOR THE SLIDE DURATION. It drives @keyframes
     hero-pull below, and it MUST equal `var HOLD` in the carousel IIFE in
     index.html. Two numbers, two files, no gate between them — ⛔ change
     both or neither. */
  --hero-dwell:3000ms}

/* ⚠ ALL THREE ARE STACKED AND ALWAYS PRESENT — a cross-fade, not a slider.
   Nothing translates, so nothing reflows, and the hero's height cannot change
   when the photograph does. A layout that moves every six seconds is worse
   than no carousel. */
.hero.dark .hero__shot{position:absolute;inset:0;margin:0;opacity:0;
  /* ⚠ 600ms, not 900 (2026-09-15, with HOLD 6000 -> 3000). At a 3s cycle a
     900ms dissolve is 30% of every slide's life — the carousel reads as
     permanently mid-fade rather than as showing photographs. 600ms is 20%,
     which still crosses softly. ⛔ If HOLD changes again, re-check this
     ratio rather than leaving it. */
  transition:opacity 600ms var(--rv-ease)}
.hero.dark .hero__shot.is-current{opacity:1}
.hero.dark .hero__shot img{width:100%;height:100%;object-fit:cover;object-position:50% 50%}

/* ── ⭐ THE SLOW PULL BACK (2026-09-15) ────────────────────────────────────
   A Ken Burns zoom-out, done in CSS on the still rather than as generated
   footage. It is the same effect a video model was asked for and returned at
   ~4.4 MB with a garbled UI — assets/PROMPT-hero-video.html § 2 records that
   attempt in full, and the prompts are kept for when footage is worth
   revisiting.

   ⚠⚠ IT MUST SCALE DOWN TO 1, NEVER UP FROM 1. The image is object-fit:cover
   at inset:0, so scale(1) exactly fills the box. Any value BELOW 1 would show
   the hero's background at the edges; so the animation starts ZOOMED IN and
   settles to 1. 1.08 -> 1 reads as a pull back while never exposing an edge.

   ⚠ transform on the <img>, not on .hero__shot — the figure is the fade layer
   and the two must not fight over the same property. Compositor-only
   (transform + opacity), so the pull back costs no layout.

   ⛔ NOT on .hero__shots: that box has overflow:hidden and is the container
   the scrim sits in. Scaling it would scale the scrim with it, and the scrim
   is the one thing holding the headline's contrast. */
.hero.dark .hero__shot img{
  /* ⚠⚠ THE BASE IS scale(1), NOT 1.08 — see the outgoing-slide note below.
     This is the value an img falls back to the instant .is-current drops, so
     it must be the END of the pull back, not its start. The 1.08 lives in the
     keyframe only. */
  transform:scale(1);
  transform-origin:52% 46%;   /* ⚠ biased toward the phone, not the frame's
                                 centre: the subject sits just right of and
                                 above middle in all three shots, so a 50% 50%
                                 origin drifts it downward as the frame opens. */
  will-change:transform}

/* ⭐ The zoom runs only while the slide is current, and RESTARTS each time it
   becomes current again — the class toggle is what re-triggers the animation,
   which is why this is an animation rather than a transition on .is-current.
   A transition would ease back to 1.08 while fading OUT, visibly reversing. */
.hero.dark .hero__shot.is-current img{
  /* ⚠⚠ --hero-dwell, NOT --hero-hold. `--hero-hold` already exists ~2400
     lines below, scoped to .hero-rv, where it is the 520ms reveal stagger.
     Reusing the name would have run this zoom for 520ms if the variable ever
     reached here — a silent, plausible-looking bug of exactly the kind this
     file keeps recording. ⛔ Do not "tidy" the two names together.
     ⚠ It must match HOLD in the carousel IIFE. index.html sets both. */
  animation:hero-pull var(--hero-dwell,3000ms) linear forwards}

@keyframes hero-pull{from{transform:scale(1.08)} to{transform:scale(1)}}

/* ⚠⚠ THE OUTGOING SLIDE MUST NOT SNAP BACK, AND THE FIRST FIX DID NOT WORK.
   Measured (probes/hero-pull.mjs): at the moment the class drops, slide 0 went
   scale 1.0007 -> 1.0800 WHILE STILL AT OPACITY 0.35 — a visible jolt behind
   the incoming photograph, for two frames, every three seconds.

   ⭐ Why the obvious fix fails: `animation: … forwards` only holds its end
   value while the animation is still APPLIED. The moment .is-current goes, the
   rule stops matching, the animation is removed, and the img falls straight
   back to its base transform of scale(1.08). A `transition` on
   :not(.is-current) cannot catch it either — there is no transition between
   "animated" and "not animated", the value simply reverts.

   ⭐ So the BASE value is the end state, not the start. The img rests at
   scale(1) — where it also exactly fills the box — and the animation supplies
   the 1.08 it starts FROM. Dropping the class now reverts to scale(1), which
   is where the pull back had just arrived: no jump, and the fade-out happens
   at the scale the reader was already looking at. */
.hero.dark .hero__shot:not(.is-current) img{transform:scale(1)}

/* ⛔ NO PULL BACK UNDER REDUCED MOTION. A 6-second continuous zoom is exactly
   the sustained motion this query exists for, and unlike the cross-fade it
   cannot be argued as essential — the photograph reads identically still. The
   carousel's own script already bails here, so the slide simply rests at 1. */
@media (prefers-reduced-motion:reduce){
  .hero.dark .hero__shot img{transform:none;animation:none;will-change:auto}
  .hero.dark .hero__shot.is-current img{animation:none}
}

/* ⚠⚠ THE SCRIM IS LOAD-BEARING. White type over a photograph is the one case
   nothing in this repo can check automatically: an <img> has no
   backgroundColor, so verify.mjs reports the hero clean whatever the art
   does. Contrast is sampled from RENDERED PIXELS per slide, and the WORST of
   the three photographs governs — they all sit under the same headline.

   ⭐ WEIGHTED LEFT, because the copy is left-aligned (.hero__copy is
   align-items:flex-start). The horizontal pass darkens the copy column; the
   vertical pass protects the licence line at the foot, which is the smallest
   type on the darkest-headroom string.
   ⚠ If a photograph fails the probe, darken THIS before touching the type —
   but if it has to go past ~.85 on the left, regenerate the photograph
   instead. A scrim heavy enough to rescue a bright photo has already thrown
   the photograph away. */
.hero.dark .hero__scrim{position:absolute;inset:0;
  /* ⚠⚠ THE TWO PASSES MULTIPLY. The first pair of values tried (.82 left over
     .42 top) left the photograph's light side at ~14% of its own luminance —
     a dark rectangle, not a photograph. The horizontal pass does the real
     work for the copy; the vertical one only protects the licence line at the
     foot and is now nearly clear across the middle. */
  /* ⚠⚠ RE-TUNED 2026-09-14 FOR BRIGHT PHOTOGRAPHS. The first two shots came back
     far too dark because § 2 of the prompt file asked for a dark left third —
     wrong: the reference photographs are warm, bright rooms and the DARKNESS
     IS THE SCRIM'S JOB. Measured, their left fifth is ~0.166 mean luminance
     against our first attempt's 0.003, i.e. 55x brighter, WITH their scrim on.
     ⭐ Tested against a simulated bright room: at the previous values the lead
     landed on EXACTLY 4.5:1 — passing with zero margin, which is not a margin.
     These values give it room.
     ⛔ THE SCRIM IS THE LEVER, NOT THE PHOTOGRAPH. If a bright shot ever fails
     the probe, come here — do not ask for a darker picture. That is what
     produced the first two. */
  /* ⚠ RAISED AGAIN 2026-09-14 when the second (bright) pass landed: the café
     shot's lead measured 3.88:1 against the 4.5 floor. ⭐ THAT IS THE SYSTEM
     WORKING — the photograph is right and the scrim is the lever, exactly as
     § 4.45 says. ⛔ The fix is never a darker photograph.
     ⚠ The horizontal pass carries the copy (which ends at 43.5% across), so
     it takes the increase; the right-hand stops are untouched so the
     photograph still opens up where nothing sits on it. */
  background:
    linear-gradient(90deg,rgba(10,9,12,.86) 0%,rgba(10,9,12,.76) 32%,
      rgba(10,9,12,.44) 62%,rgba(10,9,12,.12) 100%),
    linear-gradient(180deg,rgba(10,9,12,.38) 0%,rgba(10,9,12,.10) 22%,
      rgba(10,9,12,.10) 62%,rgba(10,9,12,.58) 100%)}

/* ── the dots ────────────────────────────────────────────────────────────
   ⚠ Positioned against --hero-print, the SAME measured distance the plate
   used to track. The observer survives this rework unchanged and this is now
   its only consumer — see the note on .hero__licence below. */
/* ⚠⚠ .hero.dark, NOT .hero — AND THAT IS NOT DECORATION. `.dark > *` sets
   position:relative on every direct child, it is declared ~2100 lines later,
   and both selectors were (0,1,0) — so it won and the dots computed
   `position:relative`, which made `left:50%` and the translate do nothing and
   dropped them at the hero's bottom-LEFT. Measured: x=0, width=1440.
   ⭐ Two classes is the minimum that outranks it without !important.
   ⛔ Do not "tidy" this back to a single class. */
.hero.dark .hero__dots{position:absolute;z-index:3;
  left:50%;transform:translateX(-50%);
  bottom:calc(var(--hero-print,7rem) + var(--s4));
  display:flex;gap:.25rem;align-items:center}
/* ⚠⚠ A 6px MARK IN A 20px HIT AREA, and the two numbers are independent.
   Kacperek, 2026-09-14: "smaller, less spaced". The visible dot went 8px → 6px
   and the row gap .5rem → .25rem, so the group reads as one quiet indicator
   rather than three controls.
   ⛔ THE HIT AREA DOES NOT SHRINK WITH IT. A 6px tap target is unusable; the
   padding carries the difference and the visible mark is drawn by ::after.
   ⚠ 20px is already below the 24px WCAG 2.2 target-size minimum — acceptable
   only because the dots are an enhancement over a carousel that also advances
   on its own, and because they sit well clear of any other control. ⛔ Do not
   take it lower. */
.hero__dot{width:20px;height:20px;padding:7px;border:0;margin:0;
  border-radius:50%;cursor:pointer;background:transparent;
  display:flex;align-items:center;justify-content:center;
  -webkit-appearance:none;appearance:none}
.hero__dot::after{content:"";display:block;width:6px;height:6px;
  border-radius:50%;background:rgba(255,255,255,.42);
  transition:background 220ms var(--rv-ease)}
.hero__dot.is-on::after{background:#fff}
.hero__dot:hover::after{background:rgba(255,255,255,.72)}
.hero__dot.is-on:hover::after{background:#fff}
.hero__dot:focus-visible{outline:2px solid #fff;outline-offset:0}

/* ⚠⚠ WHITE NOW, BECAUSE IT SITS OVER THE PHOTOGRAPH (2026-09-14). It was
   --ink-3 on cream. .dark's own paragraph colour (rgba(255,255,255,.65)) is
   NOT reused here: this is the smallest type on the page over the least
   predictable ground, so it gets a higher alpha than body prose does.
   ⛔ Re-sample it from pixels after any photograph or scrim change — it is
   the string that fails first, and no gate can see it.
   ⚠ Still flex:none and still in flow at the hero's foot, which is what keeps
   the --hero-print observer meaningful. ⛔ Do not make it absolute. */
.hero__licence{position:relative;z-index:2;flex:none;
  margin-top:var(--s6);
  font-size:var(--t-mono);line-height:1.55;color:rgba(255,255,255,.76);
  text-align:center;text-wrap:pretty}   /* centred — Kacperek, 2026-08-31 */
.hero__licence strong{color:#fff;font-weight:500}



@media (max-width:56rem){
  .hero h1{max-width:16ch}
}

/* The register card was here. Removed 2026-08-31 when the hero became
   centred — the licence claim now reads as one line under the image
   (.hero__licence), and the FAQ and footer carry the full identification.
   The countersignature below survives and is still used on the FT reference. */


/* ⭐ the countersignature — the page's signature device.
   Any checkable claim carries a hairline underline; hover OR KEYBOARD FOCUS
   reveals which register answers for it. Keyboard is not optional: a
   hover-only disclosure is invisible to anyone not using a mouse. */
.cs{position:relative;display:inline-block;border-bottom:1px solid var(--rule);
  cursor:help;transition:border-color .18s}
.cs:hover,.cs:focus-visible{border-bottom-color:var(--ox)}
/* Centred on its anchor. It was right-anchored for the old hero register card
   (which sat at the page's right edge); with the licence line now centred, a
   centred tooltip is what stays inside the viewport. Clamped so it cannot
   reach either edge. */
.cs__src{position:absolute;left:50%;bottom:calc(100% + 9px);z-index:50;
  opacity:0;visibility:hidden;transform:translateX(-50%) translateY(3px);
  transition:opacity .18s,transform .18s,visibility .18s;
  background:var(--ox-deep);color:#fff;padding:var(--s3) var(--s4);
  border-radius:8px;width:max-content;max-width:min(17rem,72vw);
  font-size:var(--t-mono);line-height:1.5;letter-spacing:-.01em;text-align:left;
  pointer-events:none}
.cs__src b{display:block;font-weight:500;margin-bottom:2px;color:#fff}
.cs__src span{color:rgba(255,255,255,.7)}
.cs:hover .cs__src,.cs:focus-visible .cs__src{opacity:1;visibility:visible;
  transform:translateX(-50%) translateY(0)}
@media (max-width:40rem){
  .cs__src,.cs:hover .cs__src,.cs:focus-visible .cs__src{
    position:fixed;left:1rem;right:1rem;bottom:1rem;top:auto;
    max-width:none;width:auto;transform:none}
}

/* ── section head ───────────────────────────────────────────────────────── */
.shead{max-width:56ch}
.shead h2{max-width:20ch}
.shead .lead{margin-top:var(--s4)}
.shead--wide{max-width:none}
/* Centred head — the problem section (Kacperek, 2026-09-01, off aura-flow).
   ⚠ .shead h2 caps at 20ch, which inside a centred box leaves the heading
   left-set within its own line box and the centring reads as broken. The
   override is required, not tidy-up. */
.shead--center{max-width:min(100%,42rem);margin-inline:auto;text-align:center}
.shead--center h2{max-width:none}

/* two-column: heading left, content right — the concept's own layout */
.two{display:grid;grid-template-columns:minmax(0,.85fr) minmax(0,1.15fr);
  gap:clamp(2rem,1rem + 4vw,5rem);align-items:start}
@media (max-width:56rem){.two{grid-template-columns:1fr;gap:var(--s7)}}

.stack{display:flex;flex-direction:column;gap:var(--s5)}

/* ── scroll fill: the solution's prose ────────────────────────────────────
   The nexura device, on our ground and in our colours. Each word rests
   dimmed, passes through bloom as the section scrolls, and settles at ink.
   Three states, not two — the pink is the middle of the ramp, not the end
   (Kacperek, 2026-09-01).

   ⚠ Bloom is the BUTTON HALO colour and the system's rule is "ink reads,
   oxblood marks". This is the one place bloom touches type, and it is
   deliberate: no word STAYS pink. It is a transition state on the way to
   ink, so nothing is ever left to be read at #F5C1C5 — which is nowhere
   near a legible contrast on the off-white ground.

   The type is stepped up to --t-lead: at body size the fill reads as a
   rendering bug rather than a device. */
/* Width: 65% of the container, NOT the full measure (Kacperek, 2026-09-01 —
   "not full width of the page so its more like 60-70% section width"). A
   percentage rather than a ch cap, because the point is the proportion of the
   section it occupies; a ch measure would drift with the type size.

   line-height 1.4, not the 1.12 of a heading. Over three-plus lines, heading
   leading sets them too close to read as a passage — the design detector
   caught that and it was a real defect, not a false positive. */
.fill{font-size:var(--t-fill);line-height:1.4;letter-spacing:-.03em;
  margin-top:var(--s7);max-width:65%}
/* Below the two-column breakpoint a 65% cap would leave the statement a
   narrow ribbon; it takes the full width there, as every other section does. */
@media (max-width:56rem){.fill{max-width:none}}
.fill p{color:var(--fill-rest)}

/* ⭐ The resting grey is its OWN token, lighter than --ink-3, so the change
   as a word fills is actually visible (Kacperek, 2026-09-01).
   ⚠⚠ 2.90:1 — MEASURED, and the figure this comment carried until 2026-09-04
   (3.48:1) was WRONG. It is below BOTH the 4.5 body floor and the 3.0 large-
   text floor. Kacperek went past the floor knowingly (rev 16: "a tad beyond
   the floor is the whole point of the effect"), and that is defensible ONLY
   because this is never a RESTING state: the wave settles 140ms after the last
   scroll and everything behind the edge resolves to ink.
   ⛔ That defence depends on the reduced-motion path below actually working.
   It did not until 2026-09-04 — see the note there. If .fill's type ever drops
   below 24px, or the settle stops resolving to ink, this goes back to --ink-3. */
.fill .w{color:var(--fill-rest);transition:color .18s linear}

/* Mid-ramp and settled. Set by the script as it advances, so the ends of the
   ramp live here rather than in JS strings.
   ⭐ THREE STEPS, NOT TWO (Kacperek, 2026-09-01): the leading edge of the wave
   is ORANGE and the words behind it cool to bloom pink before resolving to
   ink. Those are the two accent colours sampled from the plates, met in the
   order the eye meets them — a word warms as the wave arrives and cools as it
   passes. The orange is #FFBB87, the same value the coins use.
   ⚠ .is-lead MUST come after .is-mid. Both are single classes at the same
   specificity, so source order is the only thing deciding the winner, and a
   lead word also carries .is-mid. */
.fill .w.is-mid{color:var(--bloom)}
.fill .w.is-lead{color:var(--coin-orange)}
.fill .w.is-on{color:var(--ink)}

/* ⚠ Reduced motion does not mean "no effect" — it means no scroll-coupled
   ANIMATION. The text simply arrives fully filled, which is also what the
   no-JS path renders.

   ⚠⚠ THIS RULE MATCHED NOTHING UNTIL 2026-09-04, AND THE PAGE LOOKED FINE.
   It targeted `.fill .w` — but the script RETURNS on prefers-reduced-motion
   (index.html, the scroll-fill module) BEFORE it splits any text into .w
   spans. So under reduced motion there are zero .w elements, this rule applied
   to nothing, and `.fill p{color:var(--fill-rest)}` above stayed in force:
   the prose sat at 2.90:1 PERMANENTLY for exactly the reader who asked for
   less motion. Measured: reduced → wordsTotal 0, stuck at 2.9:1; default +
   scrolled → every word resolves to ink at 18:1.
   ⭐ It also inverted rev 16's own defence, which argues 2.90 is acceptable
   BECAUSE the effect is off under reduced motion — that is precisely where it
   became a resting state.
   ⛔ Target `.fill p`, not `.fill .w`. The no-JS path needs it too, and it has
   no .w spans either. Keep .w in the selector list for the case where a script
   HAS split the words and the OS setting changes afterwards. */
@media (prefers-reduced-motion:reduce){
  .fill p,.fill .w{color:var(--ink);transition:none}
}
/* The statement is one thought in two parts; --s5 spaced them like body
   paragraphs, which at h2 scale reads as two unrelated blocks. */
.fill.stack{gap:var(--s6)}


/* ── pcards: the problem's three named forces ─────────────────────────────
   Was .modes — three stacked paragraphs in the right column of a .two. The
   section now runs centred head + three cards, so #problem stops reading as
   the same shape as #solution directly below it. Still NOT numbered: the
   three forces are simultaneous, not a sequence (DESIGN.md § 3c). */
/* ⚠ --s7, not --s8. The larger gap was set when each card opened with a 4:3
   image, which put its own visual mass under the heading; with the cards now
   opening on a RULE, --s8 left the first rule floating and the head detached
   from what it introduces. Measured at 390 the gap read as ~64px of blank
   between the h2 and the first line. */
.pcards{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));
  gap:var(--s5);margin-top:var(--s7)}
/* minmax(0,1fr), not 1fr: grid children default to min-width:auto, so one long
   unbreakable word widens its track past its share — the same trap already
   commented at .team > *. */
@media (max-width:56rem){.pcards{grid-template-columns:1fr}}

/* No border, no background, no padding: the card is a column of content, not a
   container (Kacperek, 2026-09-01 — matches the reference, where the image
   slot is the only visible boundary). It is deliberately NOT built on .card
   for that reason. Nothing here is interactive, so there is no hover state
   either — a border-lift on a non-link invites a click that goes nowhere. */
/* ⭐⭐ THE BENTO'S CARD, BORROWED WHOLE (Kacperek, 2026-09-08 — "use similar
   card styling to the bento grid"). Supersedes the top-rule pass earlier the
   same day, which gave the cards structure but still read as loose columns.

   This is .bt's recipe, matched deliberately rather than approximated:
     border   1px solid --rule-2      (.08 — the hairline .afeat and .bt share)
     radius   --r-card                (12px)
     ground   transparent             (the tile is an OUTLINE, not a fill)
     padding  --s4
     icon     absolute, bottom-right, so long copy cannot push it out

   ⚠ --rule-2 (.08), NOT the --rule (.22) the top-rule pass used. .bt's own
   comment is the authority: nine outlined tiles at .22 "would read as a
   table". Three read the same way at this size — the lighter hairline is what
   makes a set of cards a set rather than a grid.
   ⚠ box-sizing is not set here for the same reason .bt does not set it: the
   page sets it globally, and without it the 1px border grows every card. */
.pcard{position:relative;display:flex;flex-direction:column;gap:var(--s2);
  border:1px solid var(--rule-2);border-radius:var(--r-card);
  background:transparent;padding:var(--s4);
  /* ⚠ The bottom reserve went with the mark when it moved to the top corner
     (§ 4.25c). The clearance the icon needs is now HORIZONTAL and it belongs on
     the title — see .pcard h3 below. */
  min-width:0;overflow:hidden}
/* The title binds to its prose, not to the image above it: gap on the flex
   parent spaces all three equally, which left the heading floating between
   two things it belongs to unequally. */
/* ⚠⚠ THE min-height AND padding-right THAT USED TO BE HERE ARE GONE with the
   corner mark (2026-09-15). They were NOT decoration: the icon was absolutely
   positioned and the h3 was in normal flow, so nothing stopped them sharing a
   line — at 1440 "Permission" ran under the hand with 0px between them, and
   with the title alone inset the paragraph's FIRST LINE still ran into the
   icon's box on all three cards.
   ⭐ That was found with Range.getClientRects, never by looking: the glyph is
   narrower than its 2.5rem box, so the collision was invisible in a screenshot
   while being real in the layout.
   ⛔ IF A MARK EVER RETURNS, BOTH COME BACK WITH IT — min-height:2.5rem and
   padding-right:calc(2.5rem + var(--s3)), matching .pcard__ic's box. They were
   ONE measurement in three places. Leaving them in with no icon just indents
   every title against nothing. */
.pcard h3{font-size:var(--t-body);font-weight:500;letter-spacing:-.03em;
  margin-bottom:calc(var(--s4) - var(--s3))}
/* ⚠ --ink-2 (10.59), not --ink-3 (5.39 — the contrast floor). At 14px the
   muted step has no headroom left. */
.pcard p{font-size:var(--t-sm);color:var(--ink-2);line-height:1.5;margin:0}

/* ⭐ THE FIGURE SLOT IS GONE (2026-09-08). It held three dithered placeholders
   (rails / vault / ledger, interim from 2026-09-04) and they were removed for
   the reason #credit's were, measured the same way:

     fig-problem-1/2/3   3.3% / 3.4% / 4.9% ink
     #credit padlock     11.9% ink  -> already REJECTED as "thin, floats"
     #credit coin        36.4% ink  -> the one that worked

   So these were 2-4x sparser than a figure that had already failed, while
   occupying 69.2% of each card at 1440 and 69.8% at 390. The cause is
   structural and is recorded in HANDOFF-2026-09-07-v2 § 1: our dither maps
   TONE, so a picture with little ink has little to band. The hero plate
   escapes it by being a FIELD; an icon on blank paper is the opposite shape
   of problem.
   ⛔ Do not reintroduce a --sunken tile or a background field behind the icon.
   That was tested last session and declined: it changes what the --sunken tile
   is FOR, and drifts toward the hero plate's register, which was deliberately
   scoped to the hero.
   ⚠ fig-problem-{1,2,3}.png are left on disk, referenced by nothing — the same
   treatment fig-credit-{1..4}.png got. */
/* ⚠ Phosphor Regular are FILLED compound paths. A stroke rule does nothing to
   them and fill:none erases them outright — the same note .afeat__ic svg
   carries. ⛔ Do not paste the "raw/" cut from the Phosphor repo: those are
   stroked outlines with fill="none", and they vanish under this rule. */
/* ⚠ FIXED BOX, ART CENTRED IN IT. The three icons do not fill the 256 viewBox
   equally — measured drawn height / top offset:

     hand-palm        224 tall, y=16
     clock-countdown  208 tall, y=24
     scissors         176 tall, y=40

   The BOX is what the layout aligns to, so pinning it keeps the three marks on
   one top and one bottom whatever the glyph does. The marks themselves still
   read at slightly different optical weights, which is inherent to the set.
   ⚠ Do not read a staircase in a screenshot as a layout bug without checking
   the transform: .pcard carries --d 0/1/2, so a shot taken mid-reveal shows a
   9 / 15 / 21px stagger that is the ANIMATION, not the grid. At rest all three
   cards measure top 1290 and title 1350 at 1440. */
/* ⚠ `.pcard__ic` (the top-right mark, 2.5rem, --ink-3) was REMOVED 2026-09-15
   along with its three icons — see index.html § the pcards. The cards are type
   only now. ⛔ Restoring it means restoring .pcard h3's reserve too. */

/* Under the cards. .mode__close carried a top rule to separate it from the
   stacked prose above it; the card boundary does that job now, and a
   full-width rule here would fight the section's own dotted grid rules. */
.pcards__close{text-align:center;margin-top:var(--s7);color:var(--ink-3);
  max-width:60ch;margin-inline:auto}

/* ⚠ STACKED, THE CARD BECOMES A ROW — not a full-width column.
   Measured at 390px before this rule: the 4:3 slot rendered 326x244 and took
   67.2% of every card's height, against a 16px title and 14px body. Three of
   those plus the head ran the section to 1507px — nearly two viewports for
   three short paragraphs, with a placeholder as the dominant object on each
   screen. At desktop the same ratio is one third of a column and reads as an
   accent; the shape does not survive the stack, the width does.
   ⭐ Same resolution as .tmstrip at 62.5rem (§ team): picture left, text
   right. The three forces stay scannable as a list instead of becoming three
   separate screens. 4:3 is kept — the slot's own proportion is a decision
   (Kacperek, 2026-09-01), and it is the CARD's proportion that was wrong. */
/* ⚠ THE TWO-COLUMN STACK IS GONE WITH THE FIGURE. This block used to turn each
   card into a 38%/1fr grid (picture left, text right) — a rule whose entire job
   was stopping the 4:3 slot from taking 67.2% of every card's height and
   running the section to 1507px, "nearly two viewports for three short
   paragraphs". With no slot there is nothing to hold, so the card goes back to
   the plain column it already is at desktop, and the gap tightens.
   ⛔ Do not restore the grid "for symmetry" — it would put a 44px icon in a
   38% column beside two lines of prose. */
/* ⚠⚠ THE CORNER MARK GOES BACK BESIDE THE TITLE BELOW 40rem, and this is a
   measurement, not a preference. The bento's bottom-right mark works there
   because .bt carries a TICKER and a short name; .pcard carries a paragraph.
   Reserving --s4 + 2rem + --s2 under three paragraphs cost ~64px per card and
   pushed the section to 897px — PAST the 816px it measured with the figures
   still in, i.e. the change would have made the thing it was fixing worse.
   It also left the cards unequal (190/169/169) because only the first card's
   prose runs to three lines, so the reserve showed as slack on the other two.
   ⭐ Inline beside the title costs one row that the title already occupies.
   ⛔ Do not "unify" this back to the corner for consistency with desktop. The
   card is the same card; only where a 2rem mark can afford to sit changes. */
@media (max-width:40rem){
  .pcards{gap:var(--s4)}
  /* ⚠⚠ THE TWO-COLUMN GRID WENT WITH THE ICON (2026-09-15). It was
     `auto minmax(0,1fr)` so a 1.75rem inline mark could sit beside the title;
     with no mark the first track is an EMPTY auto column and the title sat in
     column 2 against nothing. The 40rem block also used to release the desktop
     reserve (min-height + padding-right) — both are gone too, so the h3 needs
     no per-breakpoint handling at all now.
     ⛔ All of this comes back together if a mark ever returns. */
  .pcard{padding-bottom:var(--s4)}
  .pcard h3{margin-bottom:var(--s2)}
  .pcards__close{margin-top:var(--s6)}
}


/* ── cards ──────────────────────────────────────────────────────────────── */
.card{border:1px solid var(--rule);border-radius:var(--r-card);padding:var(--s5);
  background:var(--ground);transition:border-color .18s ease}
.card:hover{border-color:rgba(22,20,26,.28)}

/* ── token rows ─────────────────────────────────────────────────────────────
   ⭐ COIN LEFT, TEXT RIGHT (Kacperek, 2026-09-01) — three full-width rows
   rather than three columns.

   Three cards ACROSS, each one CENTRED — coin above, then ticker, name,
   status and contract address stacked under it on one axis.

   ⚠ Coin-left / text-right was tried and reverted (Kacperek: "it looks messy").
   Two reasons it did not hold: the coin is a symmetrical medallion, so an
   asymmetric card fights its own artwork; and the "To follow" row below is
   centred, so the two rows disagreed on their axis and the section read as
   two unrelated blocks. Centred, the eight coins share one vertical rhythm.
   ⚠ The side-by-side version needed a .tok__body wrapper. It is KEPT — it
   groups the four text fields so their spacing is independent of the coin,
   which is still what we want. */
.toks{display:grid;gap:var(--s4);grid-template-columns:repeat(3,1fr)}
@media (max-width:56rem){.toks{grid-template-columns:1fr}}

/* auto for the coin so it keeps its own clamp; 1fr for the text.
   align-items:center — the coin is a symmetrical disc and centring it on the
   text block is what keeps the row from looking top-heavy. */
.tok{display:flex;flex-direction:column;align-items:center;text-align:center;
  gap:var(--s3)}

/* The text stack. Its own grid so the four fields keep a consistent rhythm
   independent of the coin's height. */
.tok__body{display:grid;gap:var(--s1);justify-items:center}

.tok__sym{font-size:1.5rem;line-height:1;color:var(--ink-3);font-weight:400}
/* A step above the prose, still below the section's h2 (Kacperek: "a tad
   bigger") so the page's hierarchy is unchanged. */
.tok__tic{font-size:var(--t-h3);font-weight:500;letter-spacing:-.03em}
.tok__nm{font-size:var(--t-sm);color:var(--ink-3)}
.tok__addr{font-size:var(--t-mono);color:var(--ink-3);margin-top:var(--s2);
  border-bottom:1px solid var(--rule);justify-self:center;transition:.18s}
.tok__addr:hover{color:var(--ink);border-bottom-color:var(--ink)}

/* ⚠ Below 34rem the coin and the text stop fitting side by side — the coin's
   own clamp floor is 6.5rem and the text needs ~11rem before the address
   wraps. Stack and centre rather than letting the address break. */
/* Below 56rem the grid is one column, so each card has the full measure and
   the side-by-side arrangement holds — no stacking needed. */

/* ⚠ THE STATUS DOT IS A FACTUAL CLAIM EXPRESSED AS MARKUP.
   check-forbidden-terms scans TEXT, so it cannot see this. That is exactly
   how a green "live" dot shipped beside two zero-supply tokens. Green is
   permitted ONLY where totalSupply() > 0.
   Verified on-chain 2026-08-31: EURY 5,000 · PLNY 0 · USDY 0. */
.status{display:inline-flex;align-items:center;gap:.4rem;font-size:var(--t-mono);
  margin-top:var(--s2);justify-self:center}
.status::before{content:"";width:6px;height:6px;border-radius:50%;flex:none}
.status--issue{color:var(--in-issue)}
.status--issue::before{background:var(--in-issue)}
.status--deployed{color:var(--deployed)}
.status--deployed::before{background:var(--deployed)}

/* ── the coins ────────────────────────────────────────────────────────────
   Five orbit rings turning around an open centre that holds the currency
   glyph. From Kacperek's coin-ref-2.mp4 (2026-09-01).

   ⚠⚠ THIS OVERRULES DESIGN.md § 8, WHICH EXCLUDES "a coin". Kacperek was
   shown the exclusion and its reason (Circle rebuilt USDC's 2025 identity on
   the guilloché/coin reference, and the note says "a coin, but different" is
   inside it) and chose to build it anyway. Recorded in DECISIONS.md § 4.9 with
   the date. Do NOT silently "restore" the exclusion by deleting this — and do
   not drift it toward the two neighbouring exclusions it sits between:
   NO struck rim, NO relief or engraving, NO arc inscription (Swedbank), and
   the glyph stays a free-floating character rather than a monogram set into a
   ring (a mint's mark — name withheld: it is on the forbidden-terms list, and
   this file is tracked). Hairlines and open space are what keep it clear of
   both.

   ⚠ THE RINGS CARRY NO STATE. All three coins are identical and animate
   identically — Kacperek's call, 2026-09-01: motion is decoration and the text
   status carries the distinction. That is only safe while the coin contains no
   colour that encodes anything, so the stroke is --rule and NEVER --in-issue.
   The green dot beside EURY is still the only thing on the page saying which
   token is in issue, and § the status dot above explains why that matters.
   ⛔ Do not "improve" this by tinting EURY's rings green.

   ⚠ WIDE ELLIPSES, NOT THIN ONES. The open centre is the region no ellipse
   INTERIOR reaches, so it grows as ry approaches rx: at ry 32/rx 46 the arcs
   bow outward and leave a rounded pentagon of clear ground for the glyph.
   Measured against the reference frame-by-frame. Thin ellipses (ry 7-11) were
   tried first and are exactly wrong — every one of them passes through the
   centre, so they converge on the glyph and the figure reads as an atom.

   ⚠ FIVE rings, an ODD count. Six at 30° steps is mirror-symmetric about two
   axes, and the envelope reads as one wobbly circle rather than as orbits.
   Five at 36° has no mirror and keeps the lace legible. */
.coin{position:relative;display:grid;place-items:center;
  /* UNCHANGED from the centred layout — the coin did not need to shrink for
     the side-by-side arrangement (Kacperek: "we should be fine at the original
     size", and measurement agrees).
     ⚠ Measured in-browser at 897/900/940/1024/1200/1440: the coin lands at
     104..130px, the text body keeps 144..234px, and the status line is 18px
     tall — ONE LINE — at every width. 897 is the narrowest the grid is ever
     3-across, since it collapses to one column at 56rem (896).
     ⚠ MEASURE this, do not compute it. Two fixed-width estimates were wrong
     here: one shrank the coin to 72px, and both leaned on a guessed ~150px for
     "Issuance commencing" when the element actually measures 50px. */
  width:clamp(6.5rem,9vw,8.5rem);aspect-ratio:1;
  /* the glyph's own box is smaller than the orbit; the grid centres both */
  margin-bottom:var(--s2)}

/* ⚠ max-width:none IS LOAD-BEARING — the same trap the app ring hit twice on
   2026-09-01. The global `img,svg{max-width:100%}` reset at the top of this
   file caps any svg at its containing block, and this one is stretched to its
   parent by inset. Without it the box measures narrower than the insets
   describe and nothing reports anything. See § THE OUTLINE IS STATIC. */
.coin__orb{position:absolute;inset:0;width:100%;height:100%;max-width:none;
  pointer-events:none;overflow:visible}
/* ⭐ THE STROKE IS A GRADIENT PAINT SERVER, AND IT ROTATES (Kacperek).
   `stroke` takes an SVG paint server, never a CSS gradient — `linear-gradient()`
   in a stroke is simply ignored, which is why this is a <linearGradient> in a
   <defs> at the top of index.html rather than anything in this file.
   ⚠ The ROTATION lives there too, as SMIL <animateTransform> on
   gradientTransform: CSS cannot animate an SVG presentation attribute. So the
   coin has TWO independent motions — the rings turn (CSS, below) and the colour
   sweeps across all of them (SMIL, in the markup). They are deliberately not in
   step; a shared period would read as one rigid object. */
/* ⚠ NO `stroke` HERE. Each ellipse names its own gradient as a presentation
   attribute in the markup, and a CSS `stroke` would override ALL of them —
   that is exactly what happened: a stale `stroke:url(#coin-ramp)` survived the
   rewrite, pointed at a gradient that no longer existed, and silently flattened
   all eleven rings to one paint. Verified after removing it: 7 distinct
   computed strokes. Style the stroke's WIDTH and EFFECT here, never its paint. */
/* ⚠ ELLIPSE BELONGS IN THIS LIST. SVG shapes default to fill:black, and
   leaving <ellipse> out of the selector paints nine solid black discs over
   the coin — seen, in the prototype for exactly this build. Any shape type
   added to the rosette must be added here too. */
.coin__orb polygon,.coin__orb circle,.coin__orb ellipse{fill:none;stroke-linejoin:miter;
  /* ⚠ non-scaling-stroke, or the 1-unit stroke is scaled by the viewBox->box
     ratio and changes weight with the viewport — same reason as the app
     ring's. This pins every coin's hairline to 1 device px, which is also what
     keeps it matching the dotted section rules it sits inside. */
  /* 1.25 rather than 1: at clamp(6.5rem,9vw,8.5rem) a single device pixel is
     thin enough that the pale end of the ramp nearly disappears on the warm
     ground. Still non-scaling, so it does not drift with the viewport. */
  vector-effect:non-scaling-stroke;stroke-width:.9}
/* The inner ring, from Kacperek's reference: a heavier band inside the lace,
   which is what turns a texture into a medallion. Same travelling ramp — it is
   part of the figure, not a frame around it.
   ⭐ r20 with a stroke of 3 (Kacperek). ⚠ non-scaling-stroke means 3 DEVICE
   px, not 3 viewBox units — at the coin's 104..136px box that is 2.2..2.9
   units, so the band is roughly 18.6..21.4 and the numbers below are in units.
   ⚠ BOTH EDGES ARE LOAD-BEARING: the outer face (~21.4) must clear zone 2's
   hole at 24.7, and the inner face (~18.6) is the glyph's field. Raising the
   radius and the stroke together moves the two faces in OPPOSITE directions,
   so check each end — never just the radius.
   ⚠ It is a clean, separate circle by design (Kacperek) — the gap is the point. */
/* ⚠⚠ `.coin__orb circle` BEATS `.coin__inner` ON SPECIFICITY, and that is how
   this shipped wrong once: the shared shape rule above is (0,2,1) — two
   classes and a type — while a bare `.coin__inner` is (0,1,0), so the hairline
   .9 won no matter which came later and the "heavy band" rendered identical to
   the lace. Measured: computed stroke-width was 0.9px where 6 was intended.
   Scoping this to `.coin__orb` makes it (0,2,1) too and the later rule wins.
   ⚠ Any future per-shape override in this figure needs the same scope. */
.coin__orb .coin__inner{stroke-width:3}

/* ⭐ STATIC TILT ON THE WRAPPER, SPIN ON THE CHILD — and they must stay on two
   separate nodes. Safari 16 animates the individual `rotate:`/`scale:`
   properties on SVG children nondeterministically, so the combined shorthand
   is the only reliable form; putting tilt and spin on one node would mean one
   `transform` carrying both, and the spin keyframe would overwrite the tilt.
   Splitting them lets each node own one whole transform. */
.coin__ring{transform-box:fill-box;transform-origin:center}
/* ⚠ transform-box:fill-box + transform-origin:center on the ELLIPSE too, or
   the rotation resolves against the viewBox origin instead of the shape and
   each ring swings around the corner of the box. */
/* ⚠ The shapes carry NO animation: their geometry is baked into the markup and
   the parent <g> supplies all the motion. */
/* ⭐ GEOMETRIC, not elliptical (Kacperek, 2026-09-01 late, with a reference).
   Three octagons at ONE radius, each turned by a third of a facet, so their
   edges cross and interlace — that crossing is the drawing.
   ⚠ EIGHT sides, and the count is not free. A polygon's visible flatness is
   its sagitta, r(1-cos(pi/n)): at r46 an 8-gon sits 3.50 units inside its
   circumradius, a 12-gon 1.57 and a 16-gon 0.88 — below about 1.5 the facets
   stop reading and the figure is just a rough circle. Earlier passes used
   10-14 sides and looked round for exactly this reason. Raise the side count
   only if you also raise r, and recompute.
   ⭐⭐ THREE CONCENTRIC ZONES (Kacperek, 2026-09-01 late): geometric mesh
   outside, the elliptical rosette inside it, then the clean circle — a coin,
   read from the rim inwards. Each zone is a <g> so the three can be tuned
   independently, and all three ride the same rotation and the same ramp.
     zone 1 .coin__mesh   3 octagons, ONE shell at r46        -> spans 42.5..46
     zone 2 .coin__weave  9 ellipses rx40 ry24.7 at 180/9 deg -> spans      ..40
     zone 3 .coin__inner  1 circle r20, stroke 6
   ⚠ NO ZONE TOUCHES ANOTHER (Kacperek). All three clearances, computed:
     · zone 1 inner PAINTED edge  r46*cos(pi/8) - 0.45 = 42.05
       vs zone 2 outer painted face  rx40 + 0.45 = 40.45
       -> 1.6 units. Deliberately tight (Kacperek: "almost touching") — the
       two zones read as one continuous field rather than as a rim with a
       separate medallion inside it. ⚠ Do not close it further: below ~1 unit
       the octagon vertices start cutting the weave's outer arcs at the eight
       points where they pass closest, which reads as a rendering error.
     · zone 2 inner clear radius = 21.00 EXACTLY, and the exact value matters:
       for centred ellipses the closest approach is along each one's own minor
       axis, so the hole is simply ry. (A 0.62*ry rule of thumb was used once
       and is wrong — it under-reports by a third.)
       zone 2's hole grew with it: ry is now 24.7
       vs zone 3 outer face r20 + half its 6-wide stroke = 23.0
       -> 1.7 units of clear ground
   ⚠ Zone 3 is a HEAVY band (stroke 6, i.e. 3 units either side of r20), and
   both edges are load-bearing: the outer face at 23.0 must clear the weave's
   24.7, and the inner face at 17.0 is the glyph's field. Raising the radius
   and the stroke together moves BOTH faces — check each end, not the radius.
   ⚠ THE HALF STROKE-WIDTH COUNTS. The circle was r20 and appeared to clear 21
   on radius alone, but its painted outer face reached 21.5 and it crossed the
   weave. Recompute all three if any radius or stroke width moves.
   ⚠ The SPACING is the whole trick and it is tight on purpose. Each shell's
   own band is r(1-cos(pi/8)) = 3.5 units deep, so shells 2 apart OVERLAP and
   fuse; at a step of 4+ they separate into visibly concentric rings and the
   figure stops being one object. ⚠ Nesting makes the lace zone DEEPER overall
   (38.8..46 = 7.2 units against 3.5 for a single shell) — what makes it read
   thinner is that the strokes are finer and the pattern denser, not that the
   band is narrower. Do not "fix" that by spreading the shells.
   ⚠ Stroke drops to 1.0 with nine polygons: at 1.2 the crossings merge into a
   solid tone and the lace stops being legible as line work. */

/* ⭐⭐ THE ROSETTE IS ONE RIGID BODY (Kacperek, 2026-09-01 late).
   Replaces seven independently-spinning ellipses. The eleven ellipses are
   STATIC — their tilts are baked into the markup as SVG `transform` attributes
   — and a single <g> turns the whole assembly.

   ⚠ Why this is a different drawing, not just a slower one: when each ellipse
   span its own way the figure had no fixed shape, so it read as a tangle of
   loose strands. Rotating the assembly keeps the woven rosette INTACT and
   turns it, which is what makes it read as an object rather than as motion.
   The interference pattern between the eleven tilts is the drawing; it only
   exists if the tilts hold still relative to one another.

   ⚠ Eleven at 180/11° steps, not 360: an ellipse is symmetric about both axes,
   so a tilt of t and t+180 draw the SAME shape. Stepping over 360 would draw
   every ellipse twice and halve the apparent density for the same node count.

   ⚠ The tilts live in the MARKUP (transform="rotate(a 50 50)"), not here. They
   are geometry, fixed at authoring time; only the body's spin is animation.
   Putting them in CSS would mean 11 rules that must not drift from the markup.

   ⚠ ONE ANIMATED NODE PER COIN, down from seven. 3 running animations on the
   page instead of 21. */
.coin__rose{transform-box:fill-box;transform-origin:center;
  animation:coin-spin var(--coin-dur,54s) linear infinite;
  animation-direction:var(--coin-dir,normal)}
@keyframes coin-spin{from{transform:rotate(0)}to{transform:rotate(360deg)}}

/* ⚠ Each COIN turns at its own rate and direction, so the three are never in
   step with one another — the same "never aligned" requirement as before, now
   at the level of the coin rather than the ring. Irrational ratios
   (54 · 54√2 · 54/√3 ≈ 31.2) so no common period exists at all.
   ⚠ The middle one runs BACKWARDS. With all three the same way the row reads
   as a mechanism with a drive belt. */
.tok:nth-child(1) .coin__rose{--coin-dur:54s}
.tok:nth-child(2) .coin__rose{--coin-dur:76.37s;--coin-dir:reverse}
.tok:nth-child(3) .coin__rose{--coin-dur:31.18s}

/* The glyph. Real text, not a path: zl is two characters, it has to stay
   selectable and it should scale with the page's own font stack. z-index so it
   sits above the rings — both are grid items in the same cell. */
.coin__sym{position:relative;z-index:1;
  /* tracks the coin's own clamp so the aperture keeps its proportion */
  font-size:clamp(1.75rem,2.4vw,2.25rem);
  line-height:1;color:var(--ink-2);font-weight:400}

/* ⚠⚠ THE GLOBAL REDUCED-MOTION RULE AT THE FOOT OF THIS FILE IS NOT ENOUGH,
   AND LEFT ALONE IT IS WRONG. It forces animation-duration:.01ms and
   iteration-count:1 on everything, so an infinite rotation does not stop — it
   RUNS ONCE, instantly, and lands on its final frame. Measured: every ring's
   transform collapses to `none`, i.e. all five snap back to tilt-only, the
   spin-phase offsets that separate them are lost, and the figure flattens into
   a more symmetric shape than the one designed.
   So the rings are given an explicit resting phase here: the mark stays, the
   travel stops. The coin is how the token is DRAWN — removing it would leave
   three bare glyphs and a section that has visibly lost something.
   (This used to cite the app ring's own reduced-motion block as the precedent.
   That ring went with the orbit on 2026-09-02 — the principle is unchanged,
   the cross-reference just no longer points anywhere.) */
@media (prefers-reduced-motion:reduce){
  /* One node to stop now. The rosette is a fixed figure whose resting state IS
     the drawing, so it simply holds at its authored orientation — there is no
     phase to restore and nothing collapses. The global !important rule at the
     foot of this file would run the spin once to its end frame (rotate(360deg)
     ≡ rotate(0)), which happens to be correct here, but this is explicit
     rather than accidental. */
  .coin__rose{animation:none;transform:rotate(0)}
}

/* ── "to follow" — the currencies that are not issued yet ──────────────────
   ⭐ Mini coins (Kacperek, 2026-09-01), replacing five text pills. Same
   octagon mesh as the real coins so the family is obvious, but stripped to
   the OUTER RING ONLY — no weave, no inner band — and painted flat in the
   dotted grid's own colour so they read as inactive.

   ⚠ THE COLOUR IS THE WHOLE POINT AND IT IS ALSO A CLAIM. These five have no
   contract, no whitepaper and no supply; the live three carry the travelling
   pink/orange ramp. Painting these in --rule (the same value as the section's
   dotted boxes) is what says "not yet" without a word of copy. ⛔ Never give
   them the ramp, and never add a status dot — see § the status dot above on
   why a claim expressed as markup is the dangerous kind.

   ⚠ The stroke override needs the .coin__orb scope to beat the shared shape
   rule (0,2,1) — the same specificity trap that silently kept the inner ring
   at a hairline. A bare .coin--soon polygon would lose. */
/* ⚠ Tighter than the issued row (Kacperek: "more stacked"). 1fr tracks spread
   five small marks across the whole container, so each sat in a lot of its own
   space and the row read as five separate items rather than one group.
   ⚠⚠ FLEX, NOT GRID. `repeat(auto-fit,minmax(min-content,max-content))` was
   tried and collapses to ONE COLUMN — auto-fit needs a definite track size to
   work out how many fit, and a min-content minimum gives it none, so all five
   stacked vertically. Flex wrap with a fixed item width packs them to their
   own size and centres the row, which is what was wanted. */
.soons{display:flex;flex-wrap:wrap;justify-content:center;
  gap:var(--s4) var(--s5);
  list-style:none;margin:0;padding:0}
/* A fixed basis so the five items are equal and the labels have a measure to
   wrap in; narrow enough that they pack rather than spread. */
.soon{flex:0 0 8.5rem}
/* ⚠ auto-fit + minmax, not repeat(5,1fr): five fixed columns leave the last
   row of a wrapped set stretched across the full width at narrow viewports.
   auto-fit lets them reflow to 3+2 or 2+2+1 and keeps every cell the same. */

.soon{display:flex;flex-direction:column;align-items:center;gap:var(--s2);
  text-align:center}
.soon__nm{font-size:var(--t-sm);color:var(--ink-3);text-wrap:balance}

/* Smaller than the issued coins, and deliberately so — a third of the visual
   weight, because they are a footnote to the portfolio rather than part of it. */
.coin--soon{width:clamp(3.25rem,4.5vw,4rem);margin-bottom:0}
.coin--soon .coin__orb polygon,
.coin--soon .coin__orb ellipse{stroke:var(--rule)}
/* The glyph follows the mesh: same colour, so the whole mark reads as one
   inactive object rather than a live symbol in a grey frame. */
.coin--soon .coin__sym{font-size:var(--t-sm);color:var(--ink-3)}
/* ⚠ Static. These do not turn: motion is what the issued coins use to say
   they are alive, so a rotating "coming soon" mark would contradict the
   colour that was just chosen to say the opposite. */
.coin--soon .coin__rose{animation:none}

/* ── assets row: copy left, bento right ───────────────────────────────────
   Kacperek, 2026-09-01. NOT .two — that is a .85/1.15 split tuned for prose
   against prose. The bento carries five tiles and needs the wider half, so the
   ratio is inverted: the copy takes the narrower column.

   align-items:start so the bento sits at the top of the row rather than
   stretching to the copy's height — a bento that grows to match a paragraph
   would change its own tile proportions. */
.assets-row{display:grid;grid-template-columns:minmax(0,.9fr) minmax(0,1.1fr);
  gap:clamp(2rem,1rem + 4vw,4rem);align-items:start}
@media (max-width:64rem){.assets-row{grid-template-columns:1fr;gap:var(--s7)}}

.assets__copy .stack{margin-top:var(--s5)}


/* ── bento: the tokenised-asset tiles ─────────────────────────────────────
   Was .chips — five pills in a wrapping row, which read as metadata rather
   than as products. Staggered spans per the reference (Kacperek, 2026-09-01),
   label top-left, geometric mark bottom-right.

   Four columns so the spans have somewhere to stagger: two tiles take two
   columns, one takes two rows, and the mosaic closes without a hole. Five
   items in a 3-col grid can only ever be 3+2.

   Every tile is placed by grid-template-areas — no span classes. Adding one
   back on top of an area assignment fights the map rather than adjusting it;
   change the map instead. */
/* ⚠ Two columns, not four. In half the container a 4-track grid gives ~130px
   tiles — narrower than the product names they carry, so every one wrapped.
   The stagger is kept: it just runs over a 2×3 field instead of 4×2. */
/* THREE tracks, not two: an even grid can only split a row 50/50, and the
   metals row is deliberately uneven — gold takes two thirds, silver one
   (Kacperek, 2026-09-01). The other rows still read as halves because their
   spans are 3 and 2+1 of the same three columns. */
/* ⚠ Plain 1fr, NOT minmax(0,1fr). The hover rules below animate to values like
   `1.34fr .83fr .83fr`, and a transition only interpolates between tracks of
   the SAME type — minmax(0,1fr) → 1.34fr is a type change, so the browser
   snapped straight to the end value in both directions. Measured: at +60ms the
   grid was already at its final size, with nothing in between. What looked
   like an animation was only the tile's background fading.
   (min-width:0 is the default for a plain fr, so nothing else changes.) */
.bento{display:grid;grid-template-columns:1fr 1fr 1fr;
  /* superseded by the explicit grid-template-rows in § bento hover — kept as
     the fallback for the ≤44rem flow, where the areas are released. */
  grid-auto-rows:minmax(8rem,auto);gap:var(--s3);
  /* ⚠ Explicit placement, not auto-flow. Left to itself the tall tile landed
     in column 4 and the two rows closed around a hole. Five tiles across a
     4×2 field only tessellates in one arrangement:
        EUR EUR USD PLN
        Au  Ag  Ag  PLN   */
  /* Five tiles over three rows of three columns:
        EUR EUR EUR
        USD PLN PLN
        Au  Au  Ag
     Bonds take two thirds of the middle row, gold two thirds of the last
     (Kacperek, 2026-09-01) — so the wide tile alternates side, and the
     asymmetry reads as the grid's rhythm rather than one odd row. Written
     out rather than left to auto-flow — auto-flow reorders and opens a gap. */
  grid-template-areas:"eur eur eur" "usd pln pln" "au  au  ag"}
/* ⚠⚠ nth-of-type-STYLE COUNTING VIA .bt, NOT :nth-child. The grid gained a
   non-tile first child in 2026-09-08 (.bento__art, the shared plate), and
   :nth-child counts ALL children — so every tile shifted one area along, EUR
   rendered in usd's cell, and Ag fell off the map entirely and auto-placed.
   `.bt:nth-of-type(n)` would not help either: the host is a <div> and so are
   the tiles, so they share a type.
   ⭐ Counting from the end is stable against anything added at the FRONT, and
   the tile count is fixed at five by the copy. ⛔ If a sixth tile is ever
   added, or a second non-tile child appears, re-derive these. */
.bt:nth-last-child(5){grid-area:eur}
.bt:nth-last-child(4){grid-area:usd}
.bt:nth-last-child(3){grid-area:pln}
.bt:nth-last-child(2){grid-area:au}
.bt:nth-last-child(1){grid-area:ag}
/* Below the row's own breakpoint the bento regains the full width, so it
   spreads to four columns.
   ⚠ Two rows here, and the map must not reference a row-span: PLN stopped
   being the tall tile when the desktop grid went to three rows, so a map
   still spanning it would leave a hole. Ag closes the second row instead. */
@media (max-width:64rem){
  /* Same three-row map, only taller: below the row breakpoint the bento has
     the full width, so the tiles can afford more height.

     ⚠ It does NOT rearrange. An earlier version squeezed the five tiles into
     two rows here, which forces EUR to share a row and destroys both 2:1
     splits — 1:2 and 2:1 in one grid need a track count divisible by three,
     and a row shared with EUR does not leave one. Changing the row count
     changes the ratios; change the height instead. */
  .bento{grid-auto-rows:minmax(9.5rem,auto)}
}
@media (max-width:44rem){
  /* The stagger does not survive two columns: a span-2 tile becomes a full
     row and the mosaic reads as a mistake. Everything equal here — and the
     areas must be released too, or tiles stay pinned to a wider map. */
  .bento{grid-template-columns:1fr 1fr;grid-template-areas:none}
  .bt:nth-child(n){grid-area:auto}
}
@media (max-width:26rem){.bento{grid-template-columns:1fr}}

/* The tile. --sunken against the section's --ground: the tiles sit a step
   INTO the page rather than on top of it, which is why this section had to
   stop being .dark — the surface pair only exists on the light side. */
.bt{position:relative;border-radius:var(--r-card);
  /* ⭐ OUTLINED AT REST (Kacperek, 2026-09-02): the same hairline the #app
     feature cards carry, rather than a filled --sunken tile. The hover
     behaviour is unchanged — the pink bloom and the white fill both stay.
     ⚠ The border is --rule-2 (.08), matching .afeat exactly. Nine outlined
     tiles at --rule (.22) would read as a table, which is the same reason
     .afeat uses the lighter one.
     ⚠ box-sizing is not set here because the page already sets it globally;
     without that the 1px border would grow every tile and the pinned grid
     height below would no longer divide evenly. */
  border:1px solid var(--rule-2);
  /* ⚠ 82%, not transparent — the 18% is the dither showing through
     (Kacperek, 2026-09-08 asked for 90%; measured, 10% of the field over cream
     is a 25/255 shift and reads as almost nothing, so it went to 18%).
     Was `transparent` when there was nothing behind the grid to show.
     ⛔ Do not go below ~.78: the ticker is --ink-3 at 13px and § 4.26e
     measured it failing the contrast floor over an undimmed field. */
  background:rgba(255,253,252,.82);
  padding:var(--s4);display:flex;flex-direction:column;
  min-width:0;overflow:hidden}
.bt__tic{font-family:'Fragment Mono',monospace;font-size:var(--t-mono);
  color:var(--ink-3);letter-spacing:-.01em}
.bt__nm{font-size:var(--t-sm);color:var(--ink);margin-top:2px;
  letter-spacing:-.02em;text-wrap:balance}

/* ⚠⚠ `.bt__st` IS UNUSED, AND SO IS EVERY OTHER QUALIFIER ON THIS SECTION.
   As of 2026-09-15 the page says NOTHING about these five being unreleased —
   the per-tile label and the lede sentence were both removed, the second on an
   explicit decision after the trade-off was put (Kacperek: "im sure of it").

   ⛔ THIS RULE IS KEPT ON PURPOSE AND IS NOT DEAD CODE. It is the cheapest way
   back: one span per tile, already styled, if this is ever revisited — by
   counsel, at promotion, or the next time somebody notices that EUR/USD
   money-market funds, PLN government bonds, Gold and Silver are presented with
   no contract, no whitepaper and no supply behind any of them.

   ⚠ Where the qualifier lived, in order: tiles (to 2026-09-07) -> lede ->
   tiles again -> lede again -> nowhere. index.html § the .stack above the
   bento holds the full record and the reason. ⛔ Read it before re-adding a
   label here; the absence is a decision, not an omission.

   ⚠ If it does come back: "Forthcoming", --t-mono at --ink-3 matching
   .bt__tic — a status, not a heading. ⛔ Never --t-body: a qualifier as loud
   as the product name is the 2026-09-07 "stated five times" complaint
   arriving from the other direction. */
.bt__st{font-family:'Fragment Mono',monospace;font-size:var(--t-mono);
  color:var(--ink-3);margin-top:var(--s1);letter-spacing:-.01em}

/* Bottom-right, per the reference. Absolutely positioned so a long product
   name cannot push it out of the corner, and it never enters the text's flow. */
.bt__ic{position:absolute;right:var(--s4);bottom:var(--s4);
  width:2.5rem;height:2.5rem;fill:var(--ink);pointer-events:none}
/* ⚠⚠ THE COUNTER RULE IS BACK (2026-09-15) WITH THE ABSTRACT SHAPES.
   It was removed earlier the same day when the marks were briefly Phosphor
   icons — single compound paths whose counters are holes, needing no
   recolouring. The shapes are two-tone again: a filled form with a counter
   punched out in the TILE's colour, declared inline as fill="var(--ground)".
   ⛔ Without this rule the punch-out paints --sunken and shows as a grey ghost
   inside the glyph on an unfilled tile.
   ⚠⚠ AND IT MUST SURVIVE THE HOVER RAMP BELOW. `fill` on .bt__ic sets the
   FORM; this sets the COUNTER, and the counter has to stay the tile's ground
   in both states or the shape fills in solid the moment it warms. That is why
   the hover rule targets .bt__ic alone and never `.bt__ic *`. */
.bt__ic path[fill]{fill:var(--ground)}
.bento .bt:hover .bt__ic path[fill],
.bento .bt:focus-within .bt__ic path[fill]{fill:var(--ground)}

/* ── the mark warms and grows on hover ─────────────────────────────────────
   ⭐ Kacperek, 2026-09-15: "on hover they change color to our pale pink/orange
   gradient and become a bit larger than on the stale state".

   ⚠ TWO PROPERTIES, TWO REASONS THEY ARE SAFE:
   · `fill` to a paint server — a gradient cannot be transitioned (there is no
     interpolation between a solid and a url()), so the COLOUR change is a
     step. That is correct here: the ramp arriving instantly under a cursor
     reads as a state, whereas a fading gradient reads as a load.
   · `transform: scale()` — this one IS transitioned, and it is what carries
     the motion. transform-box:fill-box + transform-origin:center so the mark
     grows about its own centre; without fill-box the origin is the SVG
     viewport and the glyph flies out of its corner. ⛔ Same trap as the
     dashboard's card reveal.

   ⚠ 1.18, not more. The mark is absolutely positioned in the tile's corner at
   2.5rem with var(--s4) of inset — at ~1.25 it starts to crowd the corner
   radius, and on the two SHORT tiles (Au, Ag) it reaches the product name.
   ⛔ Re-measure before raising it; the constraint is the small tiles, not the
   large one.

   ⚠ :focus-within as well as :hover — the tiles are not focusable themselves,
   but this keeps the behaviour if one ever gains a link, and costs nothing. */
/* ⚠⚠ THE COLOUR CROSS-FADES, AND IT TAKES TWO LAYERS TO DO IT.
   The first version transitioned `fill` straight from --ink to url(#bt-warm)
   and the change was INSTANT (Kacperek: "the change of the color is sudden").
   ⛔ That is not a tuning problem and no duration fixes it: CSS cannot
   interpolate between a solid colour and a paint server — there is no
   intermediate value between rgb() and a gradient, so the computed style
   simply flips at the end of the transition.

   ⭐ SO THE MARK IS DRAWN TWICE AND THEIR OPACITY CROSS-FADES: a <g> holding
   the shapes (ink, always painted) and a <use> of that same <g> filled with
   the ramp, resting at opacity 0. `opacity` IS interpolable, so the warm copy
   fades up and back down over the ink. One geometry, referenced — ⛔ do not
   duplicate the shapes in the markup, or the two halves drift the first time
   one is edited.

   ⚠ THE INK LAYER STAYS PAINTED UNDERNEATH. Fading it out as the warm one
   fades in would dip the mark's density mid-transition and read as a flicker;
   the ramp is opaque, so it simply covers the ink when fully up.
   ⚠ --rv-ease, the page's own curve. This was written as var(--ease, …) with
   a fallback and `--ease` DOES NOT EXIST in this file — the fallback would
   have applied silently and nothing would have looked wrong, which is exactly
   how a wrong token survives review. Checked with grep, not by looking. */
.bt__ic{transition:transform .34s var(--rv-ease)}
.bt__ic-warm{fill:url(#bt-warm);opacity:0;
  transition:opacity .34s var(--rv-ease)}
.bento .bt:hover .bt__ic,
.bento .bt:focus-within .bt__ic{
  transform-box:fill-box;transform-origin:center;
  transform:scale(1.18)}
.bento .bt:hover .bt__ic-warm,
.bento .bt:focus-within .bt__ic-warm{opacity:1}

/* ⛔ NO GROWTH UNDER REDUCED MOTION — the colour still changes, because that
   is the state, but nothing moves and nothing fades. */
@media (prefers-reduced-motion:reduce){
  .bt__ic,.bt__ic-warm{transition:none}
  .bento .bt:hover .bt__ic,
  .bento .bt:focus-within .bt__ic{transform:none}
}

/* ── bento hover ──────────────────────────────────────────────────────────
   Hovering a tile grows it and the others give up the space (Kacperek,
   2026-09-01).

   ⚠ GRID TRACKS, not transform. The first attempt scaled the tiles, and it was
   wrong for this: a scaled tile keeps its original grid cell, so it OVERLAPS
   its neighbours and spills past the container's padding — the grid stopped
   being a grid. Animating the tracks instead means the fractions always sum to
   the same total, so the block's outer box never moves and the gutters stay
   exact. Tiles re-lay-out rather than overlap, which is the point.

   The cost is a real layout pass per frame instead of a composited transform.
   With five tiles and no text re-wrap at these sizes that is affordable; do
   not extend the same device to a grid of many tiles without re-measuring.

   The map, which the selectors below depend on:
        EUR EUR EUR      columns 1 2 3
        USD PLN PLN      rows    1 2 3
        Au  Au  Ag
   Column weights shift toward the hovered tile's column, row weights toward
   its row. A tile spanning two columns (pln, au) claims both. */
.bento{
  transition:grid-template-columns .38s cubic-bezier(.22,.9,.3,1),
             grid-template-rows    .38s cubic-bezier(.22,.9,.3,1)}
.bt{transition:background-color .2s ease,border-color .2s ease}

/* ⚠ min-width, NOT a release rule further down the file. Below 44rem the grid
   areas are gone and tiles flow in two columns, so these weights describe a
   map that does not exist — and a `height:auto` override placed AFTER them
   still lost, because these :has() rules re-set the fr rows. The result was a
   grid that collapsed 808px → 567px on hover and tiles that never settled
   (Playwright reported "element is not stable" and timed out). Guarding the
   whole device at the breakpoint is what actually scopes it. */
@media (min-width:44.0625rem){
  /* Columns — usd is col 1, ag is col 3; two-column tiles claim both. */
  .bento:has(.bt:nth-child(2):hover),
  .bento:has(.bt:nth-child(4):hover){grid-template-columns:1.34fr .83fr .83fr}
  .bento:has(.bt:nth-child(3):hover){grid-template-columns:.83fr 1.085fr 1.085fr}
  .bento:has(.bt:nth-child(5):hover){grid-template-columns:.83fr .83fr 1.34fr}

  /* Rows — eur is row 1, usd/pln row 2, au/ag row 3. */
  .bento:has(.bt:nth-child(1):hover){grid-template-rows:1.3fr .85fr .85fr}
  .bento:has(.bt:nth-child(2):hover),
  .bento:has(.bt:nth-child(3):hover){grid-template-rows:.85fr 1.3fr .85fr}
  .bento:has(.bt:nth-child(4):hover),
  .bento:has(.bt:nth-child(5):hover){grid-template-rows:.85fr .85fr 1.3fr}
}

/* ⚠ The rows need an explicit base for the fr values above to animate FROM:
   grid-auto-rows produces IMPLICIT tracks, and a transition between implicit
   and explicit tracks does not interpolate, it snaps.
   ⚠⚠ And that base must be a HEIGHT, not minmax(...,auto). With auto rows the
   1fr weights above resolve against the container instead of the content, and
   the whole block collapsed 408px → 239px the moment a tile was hovered
   (measured). A fixed row height makes 1fr mean "one third of the block", so
   the weights redistribute within a constant total — which is the entire
   point: the outer box must not move. */
/* ⚠⚠⚠ fr rows need a DEFINITE container height to divide. Without one the
   grid is content-sized, `1.3fr .85fr .85fr` has no total to share out, and
   every row falls back to its content — the block collapsed 408px → 239px on
   hover (measured twice: first with minmax(...,auto) rows, then with fixed
   rem rows, which does not help either because the CONTAINER is still auto).
   So the height is pinned here and the fr values redistribute inside it. */
@media (min-width:44.0625rem){
  /* ⚠ Written out, not repeat(3,1fr) — same reason the columns above are not
     minmax(): the hover rules animate to three explicit values, and a
     repeat() start value would not interpolate with them. The rows snapped
     while the columns eased, which is what isolated this. */
  .bento{height:calc(8rem * 3 + var(--s3) * 2);
    grid-template-rows:1fr 1fr 1fr}
}
@media (min-width:44.0625rem) and (max-width:64rem){
  .bento{height:calc(9.5rem * 3 + var(--s3) * 2)}
}

/* ⛔ NO FILL ON HOVER (Kacperek, 2026-09-02, "for now"). The tile used to wash
   to --raised; now only the BORDER responds, exactly as .afeat's does. The two
   card systems on this page therefore have the same hover in both places.
   ⚠ The punch-out's hover override went with it, and had to: it existed only
   to keep the mark's counter matching a hovered FILL, and with no fill it
   would have painted a --raised ghost inside the glyph on a transparent tile,
   which is the same bug in reverse.
   ⚠ The resting punch-out rule this refers to is ALSO gone now (2026-09-15) —
   the marks are Phosphor compound paths whose counters are holes, so nothing
   needs recolouring in either state. See the note at .bt__ic. */
.bento .bt:hover{border-color:var(--rule)}

/* ⭐ THE BLOOM (Kacperek, 2026-09-01 evening). Pale pink washing into the tile
   on hover — the same --bloom the icon's ring and the button halo use, so the
   page has ONE hover colour rather than a new one per component.

   ⚠ A LAYER, not a background-image on .bt. The tile's own `background` is
   already doing --sunken → --raised, and a background-image would have to be
   composited with that in one shorthand — which means the surface step and the
   bloom could no longer be tuned independently, and the punch-out rule above
   (which reads the tile's flat colour) would have a gradient underneath it.

   ⚠ z-index 0 and NO stacking above the content. .bt__ic is absolutely
   positioned and .bt__tic/.bt__nm are in flow, so a pseudo without a z-index
   would paint over the flowed text but under the absolute mark — the bloom
   would sit on the ticker and behind the glyph, which reads as a rendering
   fault. Pinned to 0 it is behind both.

   ⚠ It bleeds off the corner deliberately: the gradient's centre sits at the
   tile's bottom-right, where the mark is, so the colour appears to come off
   the mark rather than being a panel tint. `overflow:hidden` on .bt clips it
   to the rounded corner for free.

   ⛔⛔ DORMANT (Kacperek, 2026-09-02, "for now"). The layer is still built and
   still correct — only the line that revealed it is gone, so restoring the
   bloom is one declaration: `.bento .bt:hover::after{opacity:.5}`.
   ⚠ Kept rather than deleted BECAUSE of the four notes above: the z-index, the
   layer-not-background-image choice and the corner bleed were each arrived at
   by fixing a specific visible fault, and none of that is recoverable from a
   deleted rule. `_internal/` is gitignored, so there is no history to read it
   back out of either.
   ⚠ It is inert as written — opacity 0 with nothing raising it — so it costs a
   pseudo-element and no paint. */
.bt::after{content:"";position:absolute;inset:0;z-index:0;
  border-radius:inherit;pointer-events:none;
  background:radial-gradient(120% 110% at 100% 100%,
    var(--bloom) 0%, rgba(245,193,197,.55) 34%, rgba(245,193,197,0) 72%);
  opacity:0;transition:opacity .28s ease}

/* ⚠ The tile's own children must sit ABOVE the bloom. They have no z-index of
   their own, so within the same stacking context the LAST painted wins — and
   ::after is painted after in-flow content. Lifting them to 1 keeps the ticker
   and the product name readable through the wash.
   .bt__ic is NOT listed here: it is already absolutely positioned by its own
   rule above, and repeating `position` here would fight it. It gets the
   z-index alone. */
/* ⭐⭐ ONE SHADER BEHIND THE WHOLE GRID (Kacperek, 2026-09-08).
   "Add a dither shader underneath the cards, and add 90% opacity to the cards
   so it's a nice addition but way simpler to implement." It replaces a
   five-host WebGL version (§ 4.26) and then a 12-frame sprite (§ 4.26c-e),
   both of which chased the same problem from the wrong end.

   ⭐ Why this is the right shape and the others were not:
     · ONE plate host, so ONE GL context — not five, and no context-cap risk
       to the hero (§ 4.26's data-lazy existed only to dodge that).
     · The host is a normal full-size plate, so plate.js's cover maths gets the
       box it was designed for. No data-cell pin, no data-zoom, no sprite
       alignment, no steps() off-by-one — every bug in § 4.26a-e came from
       forcing the field into a 128px tile.
     · It is the SAME plate as the hero and #stablecoins, on the same terms.

   ⚠ The tiles sit ON it at 90%, so the dither reads THROUGH them rather than
   behind a hole. That is what makes it "a nice addition" rather than a panel:
   the grid still reads as five solid tiles, tinted by what is under them.
   ⚠ .bt's background was `transparent` (the outlined-tile decision, 2026-09-02);
   it has to become an actual colour for the opacity to mean anything. --ground
   at .9 is the same surface it always read as, now with 10% of the field
   coming through.
   ⛔ Do not raise the tiles above ~.92: the ticker is --ink-3 at 13px, and
   § 4.26e measured it failing the contrast floor over an undimmed field. */
/* ⚠ The section becomes the positioning context and its own stacking context,
   so the plate sits under everything in it and nothing leaks to neighbours.
   ⛔ isolation:isolate is required, not tidy-up: #assets is inside a joined run
   and its z-indexes must not compete with #stablecoins' or #credit's. */
#assets{position:relative;isolation:isolate}

/* ⚠⚠ THE PLATE IS INSET TO THE GRID, NOT BLED TO THE SECTION'S EDGES — the
   same decision the hero's .hero__art used to record (that rule is gone since
   2026-09-14 — see § THE HERO PHOTOGRAPHS): "content sits INSIDE the grid,
   never on it". Full-bleed here would run the field under the dotted rules and
   out to the window, which reads as a band rather than as a plate the section
   sits on.
   ⚠ Width matches .wrap exactly so it aligns with the copy and the bento. */
.assets__art{position:absolute;left:50%;transform:translateX(-50%);
  width:min(100% - 2.5rem,var(--container));
  top:var(--plate-inset);bottom:var(--plate-inset);
  z-index:0;pointer-events:none;line-height:0;overflow:hidden;
  border-radius:var(--r-card);
  /* ⭐ 85%, RAISED FROM 50% ON 2026-09-10 — and the raise is a CONSEQUENCE of
     the corner mask (§ 4.39), not a change of mind about it.
     ⚠ The .5 was set on 2026-09-08 for a stated reason: the plate "sits under
     reading copy and a bento", so it was dialled back rather than competing
     with them. ⭐ THAT REASON EXPIRED when the field was masked into the
     bottom-right corner: it no longer sits under the copy at all. Measured
     after the mask, at every opacity tried, ink under the copy is 3/255 —
     i.e. nothing. The constraint that set .5 is gone, so the value that
     served it should not outlive it.
     ⚠ NOT 1.0. Swept and looked at: .5 -> .7 -> .85 -> 1.0. At full strength
     the field washes over the TILES themselves (mid-band ink 126 against .85's
     50) and the bento stops reading as cards on a ground. .85 is presence
     without the wash.
     ⚠ ON THE HOST, not on the canvas or the <img> separately: those two
     cross-fade against each other (opacity 0 -> 1 as .is-live lands), so an
     opacity on either would multiply with that fade and the swap would
     flicker. The host is the one element whose opacity is constant through
     the swap.
     ⚠ Contrast was measured at FULL strength when this was written — --ink
     10.64:1, --ink-2 6.25:1 over the densest band — so .85 sits inside a
     margin that was already proved at 1.0. Re-measured after this change:
     the tile text is clear (see § 4.39d). */
  opacity:.85}
@media (max-width:40rem){
  /* ⚠ Restates the FULL expression. An override that restates a property
     inherits nothing — the same trap .hero__art's 40rem rule recorded before
     the hero became a photograph (2026-09-14). */
  .assets__art{width:min(100% - 2rem,var(--container))}
}
.assets__art img,.assets__art canvas{
  position:absolute;inset:0;width:100%;height:100%;display:block}

/* ⭐⭐ THE FIELD IS PULLED INTO THE BOTTOM-RIGHT CORNER (Kacperek, 2026-09-10:
   "going into the corner on the right so it's under the bento grid but doesn't
   cover the text on the left"). It ran the full width before, so its densest
   band crossed under the reading copy as well as the tiles.

   ⚠⚠ A MASK, NOT A NARROWER PLATE. Shrinking .assets__art to the bento's half
   would work today and break the moment the split changes — and it would also
   re-crop the field, which is what § "same file, same field, wrong source"
   above spent a session getting right. The plate keeps its full box and its
   anchoring; only what is PAINTED changes.

   ⚠ MEASURED, not eyeballed. At 1440 inside this section the copy ends at
   x=629 and the bento starts at x=693 — a 64px corridor. The mask's centre is
   at 100% 100% with its inner stop at 42%, which puts full strength under the
   tiles and reaches zero before x=629 at every width, because the stops are
   percentages of the plate's own box and the split is too.

   ⛔⛔ THE TWO GRADIENTS MUST STAY IDENTICAL — the same rule .sc__art records.
   The mask sits on the <img> AND the <canvas> separately (not on the host),
   because the two cross-fade against each other as .is-live lands; masking
   the host instead would be simpler but would also fade the mask with them.
   If one is retuned, retune both, or the art visibly shifts as the shader
   arrives.
   ⚠ -webkit- first, then standard. Safari still needs the prefix here. */
.assets__art img,.assets__art canvas{
  -webkit-mask-image:radial-gradient(78% 150% at 100% 100%,
    #000 0%,#000 46%,rgba(0,0,0,.30) 72%,transparent 92%);
  mask-image:radial-gradient(78% 150% at 100% 100%,
    #000 0%,#000 46%,rgba(0,0,0,.30) 72%,transparent 92%)}
/* ⚠⚠ BOTTOM-ANCHORED, matching data-anchor="bottom" on the host — the shader
   reproduces object-fit:cover from that attribute, and if the two disagree the
   art visibly jumps when the canvas fades in.
   ⚠⚠ AND THE FIELD IS plate-field.png, THE HERO'S OWN — not sc-field.png. The
   two are INVERTED and it is easy to miss: measured mean tone down the frame,
     plate-field  247 -> 238 -> 178 -> 92 -> 16   (dense at the FOOT)
     sc-field     146 -> 153 -> 186 -> 217 -> 230 (dense at the HEAD)
   Wired to sc-field with a bottom pin, the grid lands on the field's PALEST
   end — which is why the tiles read as empty however opacity and zoom were
   tuned. Same file, same shader, wrong source. */
.assets__art img{object-fit:cover;object-position:50% 100%}
/* The canvas fades in over the still, exactly as .hero__art did before the
   hero became a photograph (2026-09-14); .sc__art is now the reference. */
.assets__art canvas{opacity:0;transition:opacity .5s ease}
.assets__art.is-live canvas{opacity:1}
.assets__art.is-live img{opacity:0;transition:opacity .5s ease}
@media (prefers-reduced-motion:reduce){ .assets__art canvas{display:none} }

/* The section's own content must sit above the plate. */
.assets-row{position:relative;z-index:1}

/* ⚠ .bt__st IS IN THIS LIST. Every text layer on the tile has to clear the
   bloom that .bt::after paints on hover; a qualifier washed out by the hover
   state is a qualifier that disappears exactly when the reader is paying most
   attention to the tile. */
.bt__tic,.bt__nm,.bt__st{position:relative;z-index:1}
.bt__ic{z-index:1}

@media (prefers-reduced-motion:reduce){
  /* ⚠ The bloom KEEPS its hover state, it just stops easing into it. It is a
     colour change, not movement, so removing it entirely would take away the
     hover feedback rather than the motion — which is not what the setting
     asks for. The grid's track animation is the part that actually moves, and
     that is what `.bento{transition:none}` above stops. */
  .bento,.bt::after,.bt::before{transition:none}
}

/* divider with a centred label */
.divider{display:flex;align-items:center;gap:var(--s4);margin-block:var(--s7)}
.divider::before,.divider::after{content:"";height:1px;background:var(--rule-2);flex:1}
.divider span{font-size:var(--t-mono);color:var(--ink-3);flex:none}

/* ── steps: numbered BECAUSE the content is a sequence ────────────────────
   DESIGN.md § 3c — numbering encodes sequence or is not used. Credit keeps
   01–04 because you cannot repay before you pledge; the Problem section does
   NOT, because its three forces are simultaneous, not ordered. */
.steps{counter-reset:step;display:flex;flex-direction:column;gap:var(--s5)}
.steps li{counter-increment:step;display:grid;grid-template-columns:2.4rem 1fr;
  gap:var(--s4);align-items:baseline}
.steps li::before{content:counter(step,decimal-leading-zero);
  font-family:'Fragment Mono',monospace;font-size:var(--t-mono);color:var(--ink-3)}
.steps b{font-weight:500;color:var(--ink)}

/* ── app: the phone at the centre, five cards around it ───────────────────
   ⭐ REBUILT AGAIN 2026-09-02 (Kacperek, from a layout sketch). Replaces the
   figure-left / list-right arrangement of a few hours earlier. The device is
   now the section's centre with the five capabilities floating around it, and
   the phone is drawn FROM THE TOP — both corners, the Dynamic Island, the side
   buttons — dissolving toward the bottom.

   ⚠ THE HEADING IS CENTRED AGAIN, and this is the one carve-out, not a drift.
   DESIGN.md § 3 rule 3 makes centring a hero-only device with exactly one
   exception: "a section built symmetrically around a central object (the app
   section's phone) may centre its heading block". That sentence was written
   about THIS layout. It lapsed while the phone sat on the left; it applies
   again now. ⚠ The cards still read left-aligned within themselves — that is
   the other half of the carve-out.

   THE GRID, and why it is a grid rather than absolute positioning: every card
   holds a real sentence, so its height is its content's. Polar placement would
   need a measured radius per card and would re-break at every width. Three
   columns give the same read — two cards left, the phone, two cards right,
   one above — and reflow on their own. */
/* ⭐ THE ROW IS WIDER THAN THE SECTION (Kacperek, 2026-09-02): "the cards can
   go slightly off the section width... to create more space". The grid breaks
   out of .wrap's measure so the flanking cards sit outside where the dotted
   box would be drawn, which buys the phone room without shrinking anything.
   ⚠ NEGATIVE MARGIN, not a wider .wrap. .wrap's width IS the page's measure —
   every other section is aligned to it and the dotted rules are drawn on it.
   Widening it here would move the heading too and break that alignment; the
   margin lets ONLY this row escape.
   ⚠ Clamped so it never exceeds the viewport: at narrow widths the breakout
   goes to 0 before the grid collapses, so nothing can push the page sideways.
   verify.sh's hOvf check is what guards this. */
.appx{margin-top:var(--s7);display:grid;
  margin-inline:clamp(-5rem,-4vw,0rem);
  grid-template-columns:minmax(0,1fr) auto minmax(0,1fr);
  /* ⚠⚠ FOUR ROWS, AND THE PHONE STARTS AT ROW 2. The first build gave the top
     card row 1 and spanned the phone across rows 1-3 — so the two shared a
     cell and the card sat ON the device, covering the Dynamic Island, both top
     corners and the clock. That is precisely the part of the phone this layout
     exists to show. The top card now has row 1 to itself and the device spans
     2-4, which is also what puts it BETWEEN the two cards on each side rather
     than beside the first one. */
  grid-template-rows:auto auto auto auto;
  column-gap:clamp(1.5rem,3vw,3rem);
  /* ⚠ The row gap is small on purpose. The phone spans rows 2-4 and is much
     taller than any card, so the rows are sized by IT, not by the cards — a
     generous gap here does not space the cards, it just adds air the device
     has already provided. Measured: at 2rem the flanking pairs sat with ~200px
     of empty column between them. */
  row-gap:clamp(.75rem,1.2vw,1rem);
  align-items:center;justify-items:center}

/* ── the figure ──────────────────────────────────────────────────────────
   ⛔ NO plate, no ground, no radius. The phone went through a --sunken plate
   earlier today, and the plate existed to give a CROPPED drawing an edge to be
   cropped by. This drawing is not cropped: it is whole at the top and
   dissolves at the bottom, so it needs no frame — and a filled panel behind a
   centred figure would fight the cards floating around it.
   The fade is a MASK inside the SVG (see index.html), not a gradient overlay:
   an overlay must be painted in --ground and would show as a cream rectangle
   the moment this section's surface changes. */
.appx__fig{grid-column:2;grid-row:2 / span 3;
  position:relative;display:block;
  /* ⚠ Pushed down inside its own span (Kacperek). The device spans rows 2-4 and
     is centred in them by the grid's align-items, so this is the only way to
     move it without changing where the CARDS sit — a row-gap change would move
     both. A transform, not a margin: the figure is the tallest thing in the
     row, so a margin would grow the section by the same amount. */
  /* ⚠⚠ --rv-y IS COMPOSED IN, NOT REPLACED. This figure already carries a
     resting offset, so putting .rv on it would set `transform` outright and
     WIPE the 6.5rem — the phone would jump up into the cards, silently, and
     look like a layout mistake rather than a broken animation. That is the
     exact trap DESIGN § 5.1 records for .afeat, which is why those five cards
     use .rv-y too.
     ⛔ NEVER put .rv on .appx__fig. It takes .rv-y, and the travel adds to the
     resting offset instead of overwriting it. */
  --rv-y:0px;
  transform:translateY(calc(6.5rem + var(--rv-y)));
  /* ⚠ The width is the DEVICE's, and the figure's height follows from the
     viewBox — 1060x1180 of a device that is 1000 wide and 2172 tall, i.e. the
     top ~54% of the phone. Do not set a height here: the aspect ratio is the
     drawing's own and pinning it would either squash the device or letterbox
     it. */
  /* ⚠⚠ THE WIDTH IS A LEGIBILITY FLOOR, NOT A TASTE CHOICE. Everything inside
     is drawn to real proportions, so the figure's rendered width sets the size
     of the smallest thing in it. Measured at 280px the app labels came out at
     7.1px and the clock at 10.4px — below the size at which type resolves at
     all, which is what made the drawing read as mush however the geometry was
     adjusted. At ~400px they are ~10px and ~15px, which holds.
     ⚠ More detail is not an alternative to more size: the label is iOS's real
     11pt, so shrinking the phone to fit a column shrinks it too. If this figure
     ever must be smaller, DROP the labels rather than scale them under ~9px. */
  /* ⚠ 23rem (Kacperek, walked down 29 -> 27 -> 23 in one session). The cap is a
     composition call; the FLOOR and the vw term are not — they are set by label
     legibility, per the note above.
     ⚠ 23rem = 368px puts the app labels at 9.3px, still clear of the ~9px floor
     where they stop resolving. That is the number to check before going lower:
     below ~22rem the labels have to be dropped rather than shrunk. */
  /* ⚠ The FLOOR is 20rem, not 17. At 17 the mid-range widths fell through to
     the vw term and shrank the figure to 288px at 1200px — labels 7.3px, back
     under the threshold. The cap is the composition; the floor is legibility,
     and only the cap is Kacperek's to move. */
  width:clamp(20rem,24vw,23rem)}

/* ⚠ max-width:none — the global img,svg{max-width:100%} reset has cost two
   sizing bugs in this build already. Belt and braces; it costs nothing. */
.pmock{display:block;width:100%;height:auto;max-width:none}

/* ⭐ ONE DEVICE PIXEL, PINNED. non-scaling-stroke takes the viewBox->box ratio
   out of the decision, so the outline is the same weight as the page's own
   hairlines at every size. Without it a 1-unit stroke on a 1060-wide viewBox
   rendered at ~270px would paint at a quarter of a pixel and vanish. */
.pmock .stroke,.pmock .stroke-soft{
  fill:none;vector-effect:non-scaling-stroke;stroke-width:1;
  stroke-linecap:round;stroke-linejoin:round}
.pmock .stroke{stroke:var(--rule)}
.pmock .stroke-soft{stroke:var(--rule-2)}
/* ── the fills (2026-09-07) ────────────────────────────────────────────────
   ⭐ Added because the drawing was outline-only and read as a wireframe rather
   than as a device (Kacperek: "some fills so it feels richer").

   ⚠⚠ EVERY FILL IS ONE OF THE PAGE'S OWN THREE SURFACE STEPS. --raised for the
   body, --sunken for the screen, --ground for the icon tiles. DESIGN § 1.1:
   surfaces are told apart by ground colour alone, and the steps are 4.2% and
   5.8% apart — enough to read as bezel / screen / icon without a single new
   value entering the palette.
   ⛔ NO gloss, NO highlight, NO drop shadow, NO gradient. Those are § 8
   exclusions and they are what would tip this drawing into the crypto-native
   register PRODUCT.md names as the primary anti-reference.
   ⚠ The stroke stays on every filled shape: the hairline is what keeps the
   phone in the same drawing system as the dotted grid around it. A filled
   shape with no outline would read as a silhouette pasted in. */
/* ⭐⭐ FOUR CONCENTRIC STEPS HERE TOO, as of 2026-09-10 (Kacperek: "use the
   same richer mockup in the app section"). This phone had TWO — --raised body
   over --sunken screen — while the hero had four, and the two are meant to be
   one device with two screens.
   ⚠⚠ THE FILLS ARE OPAQUE HERE AND TRANSLUCENT IN THE HERO, and that is not
   an inconsistency to tidy. The hero's device sits over the dither plate and
   its whole point is that the artwork shows through the glass; this one sits
   on flat --ground, where an alpha would composite against nothing and only
   cost contrast. ⛔ Do not copy the hero's rgba() values across — copy its
   GEOMETRY, which is what was shared.
   ⚠⚠ .shell IS NOW A RING PATH (fill-rule:evenodd), not a rect: a plain fill
   would paint over the rail and bezel drawn inside it. The fill stays --raised
   so the body reads as the page's raised surface, exactly as before.
   ⛔ THE BEZEL IS THE ONE PLACE THIS DRAWING GOES DARK — built in --sunken it
   is LIGHTER than the ink beside it and the device washes out. Same value as
   the hero's, and the same warning. */
.pmock .shell{fill:var(--raised);stroke:var(--rule);
  vector-effect:non-scaling-stroke;stroke-width:1}
.pmock .rail{fill:none;stroke:var(--rule-2);
  vector-effect:non-scaling-stroke;stroke-width:1}
.pmock .bezel{fill:none;stroke:rgba(22,20,26,.34);
  stroke-width:12;vector-effect:none}
/* ⭐ The screen carries a pale warm wash rather than flat --sunken — the
   plate's palette, diagonally, at ~.4 alpha. See the <defs> note in the
   markup for why it is paler than the hero's bars and why it is diagonal.
   ⚠ The url() needs a fallback: if the def is ever renamed or dropped the
   fill computes to `none` and the screen renders TRANSPARENT, showing the
   section ground through the glass — a failure that looks like a z-index bug
   rather than a missing paint server.

   ⭐⭐ THE WASH IS GONE (Kacperek, 2026-09-15). The screen is flat --sunken.
   The gradient was designed when this phone showed a HOME SCREEN of outlined
   app tiles — a field of near-empty rectangles that needed something behind
   them. The screen is a dashboard now: it has a 107u balance figure, a delta
   and a 46-bar chart of its own, so the wash was colour competing with
   content rather than supporting it, and it tinted the whole device pink.
   ⛔ Do not reinstate it under the dashboard. If this phone ever goes back to
   an icon grid, the <defs> and this note are the record of why it existed. */
/* ⭐ WHITE (Kacperek, 2026-09-15). Was --sunken (88.5%), which read as a grey
   panel rather than as a lit display once the warm wash came off.
   ⚠⚠ THE CARD TILES AND THE RANGE PILL HAD TO MOVE WITH IT. Both were lighter
   than --sunken so they could sit ON it; against white they were invisible —
   a .88-alpha near-white rect on a white ground is nothing at all. They are
   --sunken now, i.e. the old relationship inverted: the screen is the light
   ground and the tiles are the recessed shapes. ⛔ Change one, check the other
   two; they are a set. */
.pmock .screen{fill:#fff;stroke:none}
.pmock .btn-side{fill:var(--sunken);stroke:var(--rule-2);
  vector-effect:non-scaling-stroke;stroke-width:1}
/* ⚠ The island is a CUTOUT, so it takes the darkest value on the page rather
   than an outline — an outlined island reads as a shape drawn on the glass.
   --ox-deep, not black: this palette has no true black. */
.pmock .island{fill:var(--ox-deep)}
/* ⚠ --ground, i.e. LIGHTER than the screen they sit on. An icon is a surface
   raised off the wallpaper, and inverting that (darker tiles on a lighter
   screen) reads as holes punched in the display.
   ⚠ They keep the hairline so the eleven empty apps stay clearly secondary to
   the one filled Olbra tile — see .tile below, which is the only oxblood
   object in the drawing and must remain the only place the eye lands. */
.pmock .apptile{fill:var(--ground);stroke:var(--rule-2);
  vector-effect:non-scaling-stroke;stroke-width:1}
.pmock .tile{fill:var(--ox)}
.pmock .mark{fill:var(--ground)}
/* ⚠ Real text on the page's own ground, so it carries a contrast duty the
   dark-plate version did not — and verify.mjs can see it. --ink-3 is the
   small-text floor (5.39), --ink-2 is secondary prose (10.59).
   ⚠ No font-family: it inherits the page's face deliberately. */
/* ⚠ The label is in viewBox units derived from points, like the rest of the
   drawing: iOS's ~11pt at 2.381 units/pt = 26. It was 44 (≈18pt), which is why
   the app names read as captions on a diagram rather than as part of a screen.
   ⚠ --ink-3 is the small-text floor (5.39:1). These are the ONLY real text
   left inside the device — see .ui below. */
.pmock .label{fill:var(--ink-3);font-size:26px;letter-spacing:.2px}

/* ⭐ THE STATUS BAR IS DRAWN, NOT SET. Same hairline as the shell, so the UI
   belongs to the drawing instead of sitting on top of it.
   ⚠ TWO EXPLICIT CLASSES, NOT :not(). The first attempt used a blanket
   `.ui rect{fill}` plus `.ui rect:not(.ui-fill){fill}` to carve out the battery
   shell, and the two rules simply restated each other — the shell filled solid
   and the battery rendered as a slab. Anything here is either solid ink or an
   outline; saying which, per element, is shorter than describing the exception
   and it cannot contradict itself. */
.pmock .ui rect,.pmock .ui circle{fill:var(--rule);stroke:none}
.pmock .ui .ui-line{fill:none;stroke:var(--rule);
  stroke-width:1;vector-effect:non-scaling-stroke;
  stroke-linecap:round;stroke-linejoin:round}

/* ══ THE HERO PHONE — REMOVED 2026-09-14 ══════════════════════════════════
   The hero's inline-SVG phone was deleted when the hero became a full-bleed
   photograph carousel. The DEVICE half of its vocabulary was always shared
   with #app's phone (.pmock .shell/.rail/.bezel/.screen/.island/.ui, above)
   and never went anywhere.

   ⭐ ITS SCREEN CAME BACK 2026-09-15, in #app rather than in the hero — see
   § THE DASHBOARD SCREEN below, scoped .pmock--dash. The balance, the delta
   and the 46-bar chart are live again; the range tabs, the 2x2 card grid and
   the activity rows are below that phone's crop and stayed behind in
   _pre-photo-hero/brand.css, which remains the provenance record.

   ⚠ The screen ALSO exists as artwork, redrawn at device resolution in
   assets/app-screens/ and composited into the hero photographs. ⛔ Those are
   real pixels at 3x and these are viewBox units at ~0.35 px each — the two
   scales must never be harmonised; that mistake produced 7.5px subtitles once.

   ⚠ probes/stack.mjs and gaps.mjs measured this drawing and were marked inert
   on 2026-09-14. They are live again for .pmock--dash. ⛔ probes/ref.json stays
   with them — it is the ONLY surviving copy of the dashboard reference and the
   documented regeneration command writes an EMPTY file. */

/* ══ THE DASHBOARD SCREEN — RESTORED 2026-09-15 ════════════════════════════
   The screen contents that were the HERO's until 2026-09-14, now drawn inside
   #app's phone (Kacperek, 2026-09-15): the section that says "all of this in
   one place" shows the product rather than a grid of other companies' app
   icons. Ported from _pre-photo-hero/brand.css, which stays on disk as the
   provenance record — ⛔ do not delete that folder.

   ⭐ SCOPED `.pmock--dash`, NOT `.pmock--hero`. The hero is a photograph
   carousel and has no phone in it; a rule named for it would send the next
   reader looking for something that does not exist.

   ⚠ ONLY THE RULES WHOSE ELEMENTS ARE ABOVE THE CROP CAME ACROSS. The hero
   drew this screen into a 1028x2174 viewBox; #app's is 1028x1080, the top
   ~54% of the device. The range tabs, the 2x2 card grid, the activity rows
   and the home indicator are below the fold of that box, so their ~15 rules
   (.pm-tab*, .pm-card*, .pm-ring*, .pm-c-*, .pm-div, .pm-a-*, .pm-home) were
   deliberately NOT copied. ⛔ Do not port them "for completeness" — they would
   style nothing. They remain in _pre-photo-hero/brand.css if the crop grows.

   ⚠ The sizes here are viewBox UNITS, ~0.35 rendered px each at this figure's
   width — not the real pixels the app-screen PNGs use. ⛔ The two scales must
   never be harmonised; that mistake produced 7.5px subtitles once already. */
.pmock--dash .pm-eyebrow{fill:var(--ink-2);font-size:33px;letter-spacing:.6px}
/* ⭐ The screen title — "Wallet", the reference's own header. It replaced an
   eyebrow-plus-avatar pair: the reference has NO avatar, and the glyph that
   was there existed only to fill the line beside a label.
   ⚠ --ink and a heavier weight than .pm-eyebrow: it is a title, not a label,
   and it is the first thing under the status bar. 60u renders ~17px at the
   new device size.
   ⛔ The account glyph rules (.pm-avatar / .pm-avatar-g) were DELETED with the
   markup, not orphaned. If an avatar ever returns, note that there is no
   person on this screen by rule — an outlined mark, never a photograph and
   never initials. */
.pmock--dash .pm-title{fill:var(--ink);font-size:42px;font-weight:500;
  letter-spacing:-.01em}

/* ── 2 · the balance ────────────────────────────────────────────────────── */
/* ⭐ The screen's focal point: display size, --ink, tabular figures so the
   decimal aligns with the holdings below it. */
.pmock--dash .pm-total{fill:var(--ink);font-size:107px;font-weight:500;
  letter-spacing:-.025em;font-variant-numeric:tabular-nums}
/* ⚠⚠ THE GREYED CENTS, AND THIS IS TYPOGRAPHY RATHER THAN STATUS COLOUR.
   The reference sets the whole number solid and the decimal light, so the eye
   lands on the figure that matters. It is one tspan on the same string, in
   the same ramp — ⛔ do not read it as the colour-carrying-meaning violation
   this file bans elsewhere and "correct" it. Nothing here encodes a fact.
   ⚠ The unit word ("PLN · total") went with the reference's layout: the
   reference carries no unit beside its figure, and the previous note about
   ambiguity is answered instead by the token cards below, each of which
   names its own currency. */
.pmock--dash .pm-total-c{fill:var(--ink-3)}
/* ⭐⭐ THE SCREEN'S ONE ACCENT, AND IT IS SPENT HERE. Oxblood at low alpha
   behind the movement figure — the only non-neutral on the screen.
   ⛔ It is on the TOTAL, not on a token row: an accent beside PLNY or USDY
   would read as a status for that token, which is the claim-as-markup defect
   this screen is built to avoid. A change in a total is arithmetic, not a
   supply claim.
   ⛔ rgba(), never rgb(R G B / A) — CSS Color 4 is banned in this file. */
/* ⚠ NO PILL BACKGROUND ANY MORE. The reference sets its delta inline beside
   the label rather than in a filled chip, so .pm-delta-bg went with the
   markup. The accent survives in the arrow and the figure — it is still the
   screen's one non-neutral.
   ⛔ The reference's delta is GREEN. Ours is oxblood: one accent per screen,
   and a status hue is a fact no text gate can read. */
/* ⚠ The arrow is now the CHARACTER ↑ inside the delta's own <text> — the
   nav's own device (`.ext`, U+2197 at .78em), not a drawn path. A hand-drawn
   arrow carries its own weight and joins, which is why it never sat right
   beside the figure. .78em is `.ext`'s ratio, kept so the two read as one
   family. ⛔ The old .pm-delta-i path rule was deleted with its markup. */
.pmock--dash .pm-delta-a{font-size:.78em}
/* ⚠ 29u AND --up, both measured off the reference (Kacperek, 2026-09-10:
   "the percent above the balance should be green and smaller"). Its "1.22%"
   caps 20.7u -> a 29u font; ours was 49u, which made a secondary figure
   compete with the total beside it. ⛔ Do not enlarge it back — the delta is
   a footnote to the balance, and the size is what says so. */
.pmock--dash .pm-delta-t{fill:var(--up);font-size:29px;font-weight:500;
  font-variant-numeric:tabular-nums}

/* ── 3 · the chart ──────────────────────────────────────────────────────── */
/* ⚠⚠ ILLUSTRATIVE, AND THE DRAWING MUST KEEP SAYING SO. 46 hairline bars with
   no axis, no scale and no period label — deliberately, so it cannot be read
   as a chart OF anything. ⛔ Do not label it and ⛔ do not source it from real
   figures: a reserve or volume number on the public site is a disclosure, and
   ROADMAP.md marks those as living under NDA.
   ⚠ --rule, the page's hairline ink. The bars are a TEXTURE behind the total,
   not a second focal point; giving them --ink-2 made the screen argue with
   itself about where to look. */
.pmock--dash .pm-bar{fill:var(--rule);stroke:none}
/* ⭐⭐ THE CHART WARMS LEFT TO RIGHT, and the colours are the PLATE'S OWN
   (Kacperek, 2026-09-10: "add some of our orange and pale pink for accents").
   --plate-deep → --plate-warm, i.e. the same four-colour dither the artwork
   behind the device is built from. ⛔ Do not substitute a picked pink or a
   generic orange: the point is that the screen borrows the plate's palette
   rather than introducing a fifth colour to the page.
   ⚠ WHY THE CHART AND NOT THE CARDS. A rising series reading as warming is
   a property of the SHAPE, not a fact about any token — whereas the same
   colours on the four cards would distinguish one token from another, which
   is the per-token-colour rule this screen exists to obey. The bars carry no
   claim, so they can carry colour.
   ⚠ It stays UNDER the oxblood in weight: the total is still the focal
   point, and these are a texture behind it. The alpha is what keeps that
   true — ⛔ do not raise it to make the chart "pop".
   ⚠ rgba(), never rgb(R G B / A) — CSS Color 4 is banned in this file. */
/* ⚠⚠ `pm-chart-warm`, AND IT IS THE CHART'S OWN GRADIENT NOW. Two separate
   things used to share `pm-warm-a`: the screen's background wash and these
   bars. The wash was removed 2026-09-15 (see .pmock .screen) and the bars
   kept their colour — so the def survives, renamed, owned by one consumer.
   ⛔ AN SVG FILL POINTING AT A MISSING PAINT SERVER DOES NOT ERROR, IT FALLS
   BACK TO BLACK. Leaving these on a `pm-warm-a` that no longer had a reason
   to exist was one rename away from 46 solid black bars — failing as a
   drawing rather than as a bug. This rule earned that warning once already:
   it arrived from the hero still referencing `pm-warm-h`.
   ⚠ It is HORIZONTAL again, like the hero's. The diagonal was for a wash
   across a whole screen; a series that warms as it rises reads along its own
   axis. ⚠ And it is no longer alpha'd down to sit under a wash that is gone —
   the stops are the plate's own colours at full strength, which is what keeps
   the bars visible on flat --sunken.
   ⛔ Still a TEXTURE, not a second focal point: the total is the focus. Do not
   raise these further to make the chart "pop". */
.pmock--dash .pm-chart .pm-bar{fill:url(#pm-chart-warm)}


/* ── the rest of the screen: tabs, the 2x2 grid, recent activity ──────────
   ⭐⭐ ADDED 2026-09-15 (Kacperek: "lets add the rest of the dashboard design
   in… the numbers below cards etc"). The viewBox grew 1080 -> 1900 to make
   room; the elements and these rules came from _pre-photo-hero/, where they
   had been left behind on purpose when only the top of the screen was shown.

   ⚠ THE TYPE DID NOT GET SMALLER. Scale is set by the figure's WIDTH
   (1028u -> ~346px), which did not change — a taller viewBox only makes the
   element taller. Measured at the smallest: .pm-tab 29u = 9.03px at 1280,
   the tightest thing on the screen and still clear of the ~9px floor.
   ⛔ Re-measure if .appx__fig's width clamp ever moves; that is what would
   push these under.

   ⚠ .pm-home (the home indicator, y=2078) is STILL not included: it sits
   below even the taller box. The drawing ends in the fade, which is the
   point — ⛔ do not grow the viewBox again just to land it. */
/* ⚠⚠ --raised, NOT the pale pink it arrived with. That pink was chosen when
   the screen carried a warm gradient wash and the pill had to sit ON it; the
   wash was removed 2026-09-15, and a pink chip on a flat neutral screen reads
   as the one coloured control on a monochrome UI — it pulled the eye harder
   than the balance. The active tab is told apart by GROUND and by weight,
   which is DESIGN § 1.1's own mechanism and what the original note here asked
   for before the wash era overrode it.
   ⛔ Do not give it the accent: the screen's one accent is spent on the total. */
.pmock--dash .pm-tab-bg{fill:var(--sunken);stroke:none}
/* ⚠⚠ THE INACTIVE TABS ARE SMALLER THAN THE ACTIVE ONE — measured off the
   reference, not invented: its "1D" caps 17.5u against "6M"'s 23.8u. The
   selected range is larger AND darker AND on a ground: three signals, which
   is why it reads instantly. ⛔ Do not equalise them for tidiness.

   ⛔⛔ BUT THE REFERENCE'S OWN VALUES CANNOT BE COPIED HERE, AND THIS IS THE
   ONE PLACE THIS REBUILD KNOWINGLY DEPARTS FROM IT. Measured off the
   screenshot: its inactive tab renders at 1.73:1 — grey #C2C2C2 on white,
   far under any floor. It gets away with it because it is a REAL PHONE
   SCREENSHOT at full size, where 11px of #C2C2C2 is a legible system control
   the user can also tap. Ours is a marketing drawing ~250px wide over a
   translucent plate: at 24u the tabs measured 6.03px and 4.22:1 at 1280x800,
   which fails BOTH the ~9px legibility floor and the 4.5 contrast floor.
   ⭐ So the SIZE RATIO is kept (inactive < active, the hierarchy the
   reference is teaching) and the absolute values are ours: 29u, --ink-2.
   ⛔ Do not "finish matching the reference" by taking this to 24u/--ink-3 —
   that trade was made deliberately, and the numbers above are why. */
.pmock--dash .pm-tab{fill:var(--ink-2);font-size:29px;letter-spacing:.3px}
.pmock--dash .pm-tab--on{fill:var(--ink);font-size:33px;font-weight:500}

/* ── 5 · the card grid ──────────────────────────────────────────────────── */
/* ⚠ Translucent, like the shell — an opaque card punches a hole of flat cream
   through the device exactly where the plate's densest band sits.
   ⚠ .88, a touch more opaque than the .82 around it: these cards carry the
   screen's smallest strings, and the step keeps them reading as raised
   surfaces rather than dissolving into the glass. */
/* ⭐ OUTLINED, NOT FILLED (Kacperek, 2026-09-15). The tiles were --sunken
   against the white screen — a grey panel each, four of them, which made the
   lower half of the display heavier than the balance above it. With the fill
   gone the hairline alone carries the card and the screen stays white
   throughout.
   ⚠ THE STROKE IS WHAT IS LEFT, so it may not go too: fill:none + no stroke is
   four invisible rectangles and the amounts would float in space.
   ⚠ This is now the same treatment as .bt and .afeat on the page itself —
   outlined at rest, no fill — which is the right coincidence rather than a
   borrowed one. */
.pmock--dash .pm-card{fill:none;stroke:var(--rule-2);
  vector-effect:non-scaling-stroke;stroke-width:1}
/* ⛔⛔ ONE RING TREATMENT FOR ALL FOUR TILES. The LETTER is the identity; the
   ring never carries a per-token colour. THE REFERENCE'S FOUR TILES ARE
   SATURATED — blue, orange, purple, black — and that is the one part of it
   this rebuild refuses. Four hues here would encode in-issue vs deployed as a
   palette: unreadable to check-forbidden-terms, and the exact defect /docs
   shipped. */
.pmock--dash .pm-ring{fill:none;stroke:var(--rule);
  vector-effect:non-scaling-stroke;stroke-width:1}
.pmock--dash .pm-ring-l{fill:var(--ink-2);font-size:44px;font-weight:500}
/* The card tile's glyph, where there is no ticker letter to use. */
.pmock--dash .pm-card-g{fill:none;stroke:var(--ink-3);
  vector-effect:non-scaling-stroke;stroke-width:1;stroke-linejoin:round}
/* ⚠ --ink-3 for the label and --ink for the amount: inside the card the
   AMOUNT leads, which is the reference's own hierarchy. The card is the more
   opaque surface, so --ink-3 measures clear of the floor here where it would
   not on bare glass (see the --ink-2 note above). */
/* ⚠⚠ --ink-2, NOT --ink-3, AND THE FADE IS WHY. --ink-3 is documented in the
   token block as "THE FLOOR for small text" — 5.39:1 on ground, 4.87 on
   sunken — so it has no headroom at all. These labels sit INSIDE the
   dissolve (u996, with the ramp starting at u900), which multiplies their
   effective contrast by the mask's alpha: at .73 they measured 3.37:1 against
   a 4.5 floor.
   ⭐ The lever is the INK, not the fade. --ink-2 is 10.59 and survives being
   faded to ~.73 with room to spare. ⛔ Do not push these back to --ink-3
   while the ramp starts above them — the two are coupled, and no gate outside
   probes/contrast.mjs measures type under a mask. */
.pmock--dash .pm-c-lab{fill:var(--ink-2);font-size:31px;letter-spacing:.2px}
.pmock--dash .pm-c-amt{fill:var(--ink);font-size:53px;font-weight:500;
  letter-spacing:-.015em;font-variant-numeric:tabular-nums}
/* The hairline between activity rows. ⚠ Full content width here, not inset:
   these rows sit on the glass rather than inside a card, so the rule divides
   the LIST rather than dividing something within one object. */
.pmock--dash .pm-div{stroke:var(--rule-2);stroke-width:1;
  vector-effect:non-scaling-stroke}

/* ⚠⚠ THE HOLDINGS CARD AND THE ACTION BUTTONS WERE DELETED, NOT ORPHANED.
   .pm-h-tok / .pm-h-sub / .pm-h-amt / .pm-h-cur (the three-row holdings card)
   and .pm-cta / .pm-cta-t / .pm-cta2 / .pm-cta2-t (Send / Receive) had no
   markup left once the reference's bands replaced the screen: the reference
   has neither a holdings list nor an action pair. The tokens they showed are
   now the four cards above, which is the same information in the reference's
   arrangement.
   ⚠ If Send/Receive ever return, they were the page's own button pair — one
   filled in oxblood (.btn), one outlined (.btn-ghost) — borrowed rather than
   reinvented, so the screen inherits the site's hierarchy. They were dropped
   for SPACE, not because they were wrong: see the 2-rows-vs-3 arithmetic in
   index.html. */

/* ── 5 · activity ───────────────────────────────────────────────────────── */
/* ⚠⚠ SCALED DOWN WITH THE REST OF THE SCREEN (2026-09-10). These were 56u
   and 48u — the pre-reference scale — and once the bands above came down to
   the reference's proportions this block was the only thing left oversized,
   which read as a different screen pasted under the cards.
   ⚠ The reference has no activity list to measure, so these are derived from
   the bands that DO have one: the verb takes the card amount's role (37u
   against .pm-c-amt's 53u display weight is wrong — it is a list row, not a
   figure), the subtitle matches .pm-c-lab's 31u.
   ⛔ --ink-2 on the subtitle, not --ink-3: these sit directly on the glass,
   where --ink-3 measured 4.61:1 against a 4.5 floor. Inside a card it is
   fine; here it is not. Same rule as .pm-eyebrow's note above. */
.pmock--dash .pm-a-v{fill:var(--ink);font-size:40px;font-weight:500}
.pmock--dash .pm-a-s{fill:var(--ink-2);font-size:31px}
/* ⛔⛔ ONE COLOUR FOR BOTH DIRECTIONS. Green-in / red-out is a fact encoded in
   a hue that no text gate can read, and on a page arguing that PLNY and USDY
   have zero supply, a green figure beside a token is precisely the misreading
   to avoid. The SIGN carries direction, in a character.

   ⚠⚠ THIS WAS REVERSED AND RESTORED WITHIN ONE SESSION (2026-09-10). Kacperek
   asked for the full green/red convention, it was built, and on seeing it
   asked for grey back: *"the numbers on recent should stay grey as they did
   before"*. ⭐ The rule now stands on BOTH grounds — the canon reason above
   and a look at the rendering — which is stronger than it stood this morning.
   ⛔ Do not re-add --up/--down here. The delta above is the one sanctioned
   green on this screen, and confining it there is what keeps a hue off
   anything token-shaped.
   ⚠ − is U+2212 MINUS, not a hyphen: at this weight a hyphen reads as a list
   dash rather than as arithmetic. */
.pmock--dash .pm-a-n{fill:var(--ink-2);font-size:40px;font-weight:500;
  font-variant-numeric:tabular-nums}
/* ⚠ The ticker: the subtitle's size, --ink-2, riding inside the amount's own
   <text> so it sits on the same baseline and moves with it.
   ⛔ NOT the direction colour. The AMOUNT carries direction; colouring the
   ticker would put a hue on a token's NAME — the line .pm-a-n's note above
   forbids crossing. */
.pmock--dash .pm-a-cur{fill:var(--ink-2);font-size:31px;font-weight:400;
  letter-spacing:.2px}

/* ── the dashboard reveal ──────────────────────────────────────────────────
   ⭐⭐ IT FIRES ON SCROLL, NOT ON LOAD (Kacperek, 2026-09-15), and that is the
   one substantive change from the hero version. In the hero this screen was
   the first thing on the page and a load-time cascade was right. Here the
   phone sits mid-page: a reveal that runs at load has finished long before
   anybody scrolls to it, so every reader would meet a static drawing.

   ⚠⚠ THE ORIGINAL SAID "⛔ DO NOT GATE ANY OF IT BEHIND A CLASS AN OBSERVER
   ADDS (the .ol-word trap)" AND THAT PROHIBITION IS DELIBERATELY LIFTED HERE
   — but only together with the thing that made it safe. The trap it names is
   an element RESTING at opacity:0 and depending on a script to reveal it: no
   script, no content. So the rest state below is the END state — visible, in
   place, unblurred — and `.is-in` ADDS the animation rather than undoing a
   hidden rest. With JS off nothing adds .is-in, no animation ever runs, and
   the screen renders complete. ⛔ Never give these a hidden rest state.

   ⭐ The hook already exists. `.appx__fig` carries `.rv-y` and the page's one
   IntersectionObserver adds `.is-in` to it — ⛔ no second observer, and no
   scroll listener.

   ⚠ --pm-hold IS 240ms HERE, NOT THE HERO'S 1100ms. That figure was an
   expression for the hero's own arithmetic: --hero-hold (520) + --hd:3 x
   140ms = a device landing at 1840ms, with the screen starting 160ms into its
   travel. None of those terms exists in #app — the figure arrives on .rv-y's
   own transition instead. 240ms keeps the same RELATIONSHIP (the screen
   begins just after the device is substantially there) against this section's
   timing. ⛔ Do not paste the 1100ms back: here it is most of a second of
   nothing after the phone has already settled. */
.pmock--dash{--pm-hold:240ms}

/* ⚠ THE REST STATE IS THE END STATE. See the note above — this is what makes
   JS-off correct without a noscript rule. */
.pm-rv-dig,.pm-rv-dig2,.pm-rv-bar,.pm-rv-up,.pm-rv-row,.pm-rv-card{
  opacity:1;transform:none;filter:none}

/* ── the balance: digit by digit, from below, blurred ──────────────────────
   ⚠⚠ THE SECOND AND LAST PLACE ON THE PAGE THAT BLURS, an explicit carve-out.
   ⛔ It is NOT a --rv-blur token and must never become one, and ⛔ it must
   never extend to the chart's 46 bars: blur forces a repaint and does not get
   the free ride transform and opacity do.
   ⚠⚠ THE TRAVEL IS IN viewBox UNITS, NOT SCREEN PIXELS, AND THAT IS WHAT
   BROKE THE FIRST VERSION. Inside an SVG a translateY(9px) is 9 USER UNITS.
   Every travel here is a FRACTION OF THE ELEMENT'S OWN FONT SIZE — 64u under
   a 107u digit is 0.6em — so it reads as a rise at any render size because it
   scales with the drawing. ⛔ Never copy a flat px travel from a CSS reveal
   elsewhere on the page; that number means something different in here.
   ⛔⛔ NO clip-path. It was tried as an odometer and read as the digits being
   CUT OFF mid-travel. They fade in while travelling instead. */
.appx__fig.is-in .pm-rv-dig{
  animation:pm-dig 720ms var(--rv-ease) both;
  animation-delay:calc(var(--pm-hold) + var(--i,0) * 45ms)}
@keyframes pm-dig{
  from{opacity:0;transform:translateY(64px);filter:blur(6px)}
  to{opacity:1;transform:none;filter:blur(0)}}

/* ── the chart: 46 bars growing from their own baseline ───────────────────
   ⚠ scaleY is a transform, so it is on the free path — 46 of them is fine
   where 46 blurs would not be. ⚠ Left to right at 11ms, so the series builds
   in the direction it reads. */
.appx__fig.is-in .pm-rv-bar{
  transform-box:fill-box;transform-origin:center bottom;
  animation:pm-bar 620ms var(--rv-ease) both;
  animation-delay:calc(var(--pm-hold) + 90ms + var(--i,0) * 11ms)}
@keyframes pm-bar{
  from{transform:scaleY(0);opacity:0}
  30%{opacity:1}
  to{transform:scaleY(1);opacity:1}}

/* ── the header and the delta: a short rise, no blur ───────────────────── */
.appx__fig.is-in .pm-rv-up{
  animation:pm-up 560ms var(--rv-ease) both;
  animation-delay:calc(var(--pm-hold) + var(--i,0) * 70ms)}
@keyframes pm-up{
  from{opacity:0;transform:translateY(24px)}
  to{opacity:1;transform:none}}


/* ── the card figures, the card surfaces and the activity rows ────────────
   ⭐ ADDED 2026-09-15 with the rest of the screen. These three classes were
   already ON the markup — 43 elements carrying .pm-rv-dig2 / .pm-rv-card /
   .pm-rv-row — but their rules had not come across, so they rendered static
   while everything around them animated. ⚠ That is the quiet half of the
   failure mode this file keeps warning about: a class with no rule does not
   error, it simply does nothing. verify.sh's class-coverage check is what
   names them. */

/* ⚠ NO FILTER HERE, deliberately: four figures blurring alongside the balance
   would spend the blur's uniqueness, and at this size the glyphs are small
   enough that a blur reads as mush rather than as focus.
   ⭐ --c is the card index and --i the digit, so each tile fills in turn
   rather than all four at once. */
.appx__fig.is-in .pm-rv-dig2{
  animation:pm-dig2 480ms var(--rv-ease) both;
  animation-delay:calc(var(--pm-hold) + 480ms + var(--c,0) * 70ms
                       + var(--i,0) * 26ms)}
@keyframes pm-dig2{
  from{opacity:0;transform:translateY(32px)}
  to{opacity:1;transform:none}}

/* ⭐ EACH CARD LANDS JUST BEFORE ITS OWN FIGURE — surface at 330ms + N*70,
   digits at 480ms + N*70 — so every tile is a surface arriving and then
   filling, rather than four surfaces and then four fills.
   ⚠ A SHORTER, SMALLER MOVE THAN THE TEXT: 20u and a 0.985 scale. A surface
   is a bigger object than a glyph, so the same distance reads as a lurch.
   ⛔ transform-box:fill-box is REQUIRED for the scale, as on the bars —
   without it the origin is the SVG viewport and the card scales from the
   drawing's corner, flinging it across the screen. */
.appx__fig.is-in .pm-rv-card{
  transform-box:fill-box;transform-origin:center;
  animation:pm-card 540ms var(--rv-ease) both;
  animation-delay:calc(var(--pm-hold) + 330ms + var(--c,0) * 70ms)}
@keyframes pm-card{
  from{opacity:0;transform:translateY(20px) scale(.985)}
  to{opacity:1;transform:none}}

/* ⚠ The activity rows come LAST and travel furthest (44u) — they are the foot
   of the screen, and they are also the part dissolving into the fade. */
.appx__fig.is-in .pm-rv-row{
  animation:pm-row 620ms var(--rv-ease) both;
  animation-delay:calc(var(--pm-hold) + 700ms + var(--i,0) * 75ms)}
@keyframes pm-row{
  from{opacity:0;transform:translateY(44px)}
  to{opacity:1;transform:none}}

/* ⚠⚠ AN EXPLICIT REDUCED-MOTION BRANCH, AND THE GLOBAL NUKE IS NOT ENOUGH.
   MEASURED, in the hero version: with only the global rule every animated
   element sat HIDDEN AND BLURRED under prefers-reduced-motion. The global rule
   forces animation-duration:.01ms but says NOTHING about animation-delay, so
   the duration collapsed while the DELAY survived and each element sat on its
   `from` frame — opacity 0, translated, blurred — on a setting whose entire
   purpose is to not do that.
   ⛔ Do not "simplify" this away by trusting the global rule: it is correct
   for an undelayed animation and silently wrong for a delayed one, which is
   this entire reveal. ⛔ And do not fix it by removing the delays.
   ⚠ animation:none, NOT a zero duration — with `both` fill gone the elements
   render from their own static rules, which is the end state. */
@media (prefers-reduced-motion:reduce){
  .appx__fig.is-in .pm-rv-dig,  .appx__fig.is-in .pm-rv-dig2,
  .appx__fig.is-in .pm-rv-bar,  .appx__fig.is-in .pm-rv-up,
  .appx__fig.is-in .pm-rv-row,  .appx__fig.is-in .pm-rv-card{
    animation:none!important;
    opacity:1!important;transform:none!important;filter:none!important;
    clip-path:none!important}
}


/* ── the split collapses ────────────────────────────────────────────────────
   ⚠ 62rem is MEASURED, not inherited. The hero already carries three different
   breakpoints (40rem for the plate and the dotted box, 56rem for the h1's
   measure) and #app collapses its phone at 66rem — so there was no "the"
   breakpoint to reuse. This one is where the SPLIT fails: below it the copy
   column falls under ~26rem and "Level up your money" starts breaking across
   three lines while the phone still needs its legibility floor.
   ⛔ Do not align this to 56rem or 66rem for tidiness; they measure different
   things and would move the collapse away from the failure it exists for. */
@media (max-width:62rem){
  /* One column, copy first. ⚠ Source order already has the copy first, so
     nothing needs reordering — the phone follows it naturally. */
  .hero__inner{grid-template-columns:minmax(0,1fr);
    justify-items:center;text-align:center}
  /* ⚠ The copy RE-CENTRES in the stack, and this is not a contradiction of
     the split. Left-aligned copy is one half of a two-column composition; with
     the second column below rather than beside it there is no edge for the
     copy to register against, and a flush-left block under a centred figure
     reads as a mistake. Same reasoning #app's stacked cards use. */
  .hero__copy{align-items:center}
  .hero .lead{margin-inline:auto}
  .hero__acts{justify-content:center}
  /* ⚠⚠ THE HEIGHT BUDGET SHRINKS BECAUSE THE COPY NOW SITS ABOVE IT, not
     beside it. Stacked, the phone must fit in what the headline, lead and
     buttons leave over — subtracting the fold alone would let it run past the
     licence line, which is exactly what the side-by-side rule's own note
     warns about. 22rem is #app's stacked figure width; here it is a CAP on
     height, with the fold still able to force it smaller. */
  /* ⚠⚠ A FLOOR, NOT JUST A CAP — AND THE FLOOR IS WHY THIS RULE IS SHAPED
     LIKE THIS. Written as a bare min() against the leftover fold, the phone
     shrank to whatever was going spare: measured at 900x800 it came out
     115px wide with 6.04px subtitles, against the ~9px floor. A figure that
     small is not a small phone, it is an unreadable one.
     ⭐ clamp() gives the floor teeth: 26rem of height (≈ 12.3rem wide, ~9.7px
     subtitles) is the smallest this drawing may ever render. Below that the
     40rem rule drops it instead — the two rules are one decision, and the
     handover between them is the point.
     ⚠ The consequence is that between 40rem and 62rem the phone can push the
     licence line below the fold on a SHORT window. That is the accepted
     trade: the line stays on the page and one scroll reaches it, whereas an
     illegible phone is wrong at every scroll position. */
  /* ⚠⚠ 28rem FLOOR, RAISED FROM 26 — measured, and the margin is the point.
     At 26rem the smallest glyph came out at 9.18px against a ~9px floor: it
     passes, but 0.18px is not margin, it is a rounding error away from
     failing. 28rem puts it near 10px, which survives a font-metric change or
     a subtitle getting one character longer.
     ⚠ margin-right is reset here: the side-by-side rule insets the figure from
     the plate's RIGHT edge, which is meaningless once it is centred in a
     single column and would push it off-centre by that amount. */
  /* ⚠ The phone's stacked rule lived here (position:static + a clamped
     height) and went with the SVG on 2026-09-14. Its lesson is kept because
     it generalises: an absolutely-positioned box is out of flow, so
     grid-column / justify-self / grid-row are all INERT on it until
     position:static puts it back. Measured before that line existed, the
     phone sat ON the copy at 760px, overlapping by 141x223px.
     ⭐ Nothing replaces it: the photograph is inset:0 and takes no part in
     the grid at any width. */
}

/* ⚠ The 40rem rule that DROPPED the phone went with it (2026-09-14). It was
   there because a figure below the copy pushed the licence line off the first
   screen at 390px — the fold problem open item 43 records.
   ⭐ THE PHOTOGRAPH DOES NOT HAVE THAT PROBLEM, and this is the one place the
   rework makes the fold BETTER rather than harder: an inset:0 background
   occupies no vertical space, so it cannot push anything. The hero at 390px
   now carries only the headline, lead, buttons, dots and licence.
   ⛔ Verify rather than assume — verify.mjs's fold check is the test. */

/* ── the cards ───────────────────────────────────────────────────────────
   ⚠ These are CARDS again, and they must not read like #assets' bento tiles.
   What separates them: the bento is a filled --sunken tile with no border and
   its icon in the bottom-right corner; these are --ground with a hairline
   border and the icon leading the title. Filled-vs-outlined is the strongest
   distinction available without changing the shape, and the shape is fixed by
   the layout — five things floating around a centre have to be bounded objects
   or they read as spillage.
   ⚠ The border is --rule-2 (.08), NOT --rule (.22): at .22 five boxes around a
   device read as a table and the phone stops being the centre. */
.afeat{position:relative;display:flex;flex-direction:column;gap:var(--s2);
  margin:0;padding:1rem 1.125rem;
  border:1px solid var(--rule-2);border-radius:var(--r-card);
  background:var(--ground);
  font-size:inherit;font-weight:inherit;
  /* ⚠ EVERY CARD IS THE SAME SIZE (Kacperek). The copy lengths differ, so
     equal height has to be declared rather than hoped for: min-height holds
     the short ones open and the flex column lets the text sit at the top
     instead of centring in the slack.
     ⚠ Measured before this: 86-129px across the five, which read as five
     different objects rather than one set. */
  width:100%;max-width:19rem;min-height:7.25rem;
  transition:border-color .25s ease,transform .25s ease}
.afeat:hover{border-color:var(--rule)}

/* Icon and title on ONE row — stacked, each card opens with two short rows
   before any content, and five of those read as a list of bullets. */
.afeat__top{display:flex;align-items:center;gap:var(--s3)}
/* ⚠ align-items:center, not baseline: the icon is a 1.5rem BOX, not a glyph. */
.afeat__ic{display:block;flex:none;width:1.375rem;height:1.375rem;
  color:var(--ink-3)}
/* ⚠ Phosphor Regular icons are FILLED compound paths. A stroke rule does
   nothing to them and fill:none erases them outright. */
.afeat__ic svg{width:100%;height:100%;display:block;fill:currentColor}
.afeat__ttl{font-size:var(--t-body);letter-spacing:-.02em;font-weight:500;
  color:var(--ink);margin:0}
.afeat p{font-size:var(--t-sm);line-height:1.55;color:var(--ink-2);margin:0;
  text-wrap:pretty}

/* ── placement ───────────────────────────────────────────────────────────
   One above the phone, then two down each side. The top card is centred over
   the device; the flanking four sit on rows 2 and 3, which the phone spans. */
/* ⚠ No max-width of its own: it had 17rem while the flanking four had 19, so
   the five were not the same size — which is the thing that was asked for. The
   top card is narrower ANYWAY because its grid column is the phone's, and that
   is the layout doing it rather than a second rule. */
.afeat--t {grid-column:2;grid-row:1}
/* The flanking pairs sit on rows 2-3 and 3-4 respectively, so they straddle
   the device's own span rather than lining up with its top and bottom. */
.afeat--l1{grid-column:1;grid-row:2;justify-self:end}
.afeat--l2{grid-column:1;grid-row:4;justify-self:end}
.afeat--r1{grid-column:3;grid-row:2;justify-self:start}
.afeat--r2{grid-column:3;grid-row:4;justify-self:start}

/* ⭐ THE FLOAT. The four flanking cards carry a static outward offset so the
   group reads as orbiting the phone rather than as a three-column table. It is
   a static offset, not motion — DESIGN.md § 5 allows no drift, and a card that
   floated on load would be exactly that.
   ⭐ EVERY CARD LIFTS ON HOVER (Kacperek, 2026-09-02: "I like how the send and
   earn cards slightly float while hovering, lets do that to all the other
   cards"). Before, only the four flanking cards moved, and they moved by
   changing their static offset — so the top card, which has no offset, did
   nothing at all.
   ⚠ The lift is its own declaration rather than a bigger offset, which is what
   lets it apply to all five: each card composes its resting transform with the
   same -4px. Writing it as "offset + 0.25rem" per card is what made the top
   one impossible. */
/* ⚠⚠ THE OFFSETS ARE STRONGLY ASYMMETRICAL, AND THEY ARE SOLVED, NOT GUESSED.
   Kacperek: "the cards need to sit on the top side of the phone and the mockup
   should fade below the cards" — so all four flanking cards cluster against the
   device's upper half and the phone carries on down past them into the fade.

   The grid places rows 2 and 4 a whole phone-height apart, which is far more
   than that arrangement wants, so the bottom pair is pulled up by most of it.
   Measured at 1440 to land it: the top pair spans -104..12 relative to the
   figure's top, and -15.7rem puts the bottom pair at 36..152 — directly beneath
   it, with both pairs inside the device's top ~40%.

   ⚠ These are px-ish offsets against a grid that is NOT px-based, so they hold
   at 1440 and drift a little either side. That is acceptable because the effect
   is a cluster, not an alignment — but if the figure's size, the row gap or the
   card height changes, RE-MEASURE rather than scaling these numbers. */
/* ⚠⚠ THE DEFAULT MUST COME FIRST, AND THIS BIT — silently, for several
   rounds of adjustment. `.afeat{--rest:0rem}` and `.afeat--l2{--rest:…}` are
   BOTH (0,1,0): equal specificity, so source order alone decides, and the
   default sitting after the modifiers reset every card to 0. Every offset
   "applied" while it was in that order did nothing, and nothing reported it —
   the cards simply never moved, which reads as "the value was too small"
   rather than as a bug.
   ⚠ Caught by dumping the COMPUTED --rest per card, not by looking at the
   page. The same lesson this file already records for .coin__inner and the
   nav's mobile rules: a broad selector beats a specific one whenever they
   tie, and custom properties give no warning when they do.
   ⛔ Do not move the default below the modifiers again. */
/* ⚠⚠ --rv-y IS THE REVEAL, COMPOSED IN — NOT A `transform` ON .afeat.
   The scatter offsets ARE this declaration. A reveal that sets `transform`
   here (which is what the generic .rv rule does) wipes all five cards back to
   the grid and the section silently loses its layout — the same class of bug
   as the --rest specificity tie recorded just above, and it would look like a
   taste problem for exactly as long.
   So .afeat cards are revealed WITHOUT .rv: they carry .rv-y instead, which
   animates this third variable and leaves --rest and --lift untouched.
   ⛔ Never put .rv on a .afeat. */
.afeat{--rest:0rem;--lift:0px;--rv-y:0px;
  transform:translateY(calc(var(--rest) + var(--lift) + var(--rv-y)))}

/* ⚠⚠ @property IS LOAD-BEARING, NOT DECORATION — WITHOUT IT THIS STEPS.
   An unregistered custom property is a token string to the animation engine,
   so it flips from 14px to 0px at the halfway mark instead of gliding. It
   would still "work" (the card lands correctly), which is exactly why this is
   worth a comment: the failure is a one-frame jump, easy to read as a stutter
   and impossible to find by looking at the CSS.
   Registering it as a <length> makes it interpolable. Baseline 2024, and
   Safari has had it since 16.4 — the browser this prototype is reviewed in.
   ⚠ This is the FIRST @property in the file. DESIGN.md § 5's own scroll-driven
   spec uses the same technique for `--dot`, so it is house-sanctioned. */
@property --rv-y{syntax:"<length>";inherits:false;initial-value:0px}

/* The .afeat-only reveal. Opacity behaves as it does everywhere else; the
   travel goes through --rv-y so it ADDS to the resting offset instead of
   replacing it. */
.rv-y{opacity:0}
.rv-y.is-in{
  animation:rv-in-y var(--rv-dur) var(--rv-ease) both;
  animation-delay:calc(var(--d,0) * var(--rv-step))}
@keyframes rv-in-y{
  from{opacity:0;--rv-y:var(--rv-rise)}
  to{opacity:1;--rv-y:0px}}
/* ⚠ RE-SOLVED once the offsets actually applied. -15.7rem was measured while
   the default was still overriding them, i.e. against a layout where nothing
   had moved — so it overshot and the two pairs landed 8px apart, overlapping.
   A number solved against a broken state is not a number.
   Measured working, at 1440, relative to the figure's top:
     Store         -176 .. -60   (pulled DOWN toward the device — Kacperek)
     Send/Earn      -76 ..  40
     Receive/Spend   96 .. 212
   All four flanking cards sit against the phone's upper half and the device
   carries on down past them into the fade.
   ⚠ The two flanking pairs are 56px apart, and that gap is set HERE rather
   than by the grid: rows 2 and 4 are a whole phone-height apart, so the row
   gap cannot express a 56px separation — only the difference between these two
   offsets can. Change them as a PAIR, or the gap moves when you meant to move
   the cluster. */
.afeat--t {--rest:3.75rem}
.afeat--l1,.afeat--r1{--rest:1.75rem}
.afeat--l2,.afeat--r2{--rest:-2.95rem}
.afeat:hover{--lift:-4px}

/* ── narrow ──────────────────────────────────────────────────────────────
   ⚠ Scoped AT the breakpoint, never released after it — source order decides
   between equal-specificity rules, and that has cost three separate bugs here.
   Below the breakpoint the three columns cannot hold a sentence either side of
   a phone: the cards fall to ~13rem and every description wraps.

   ⚠⚠ 66rem, NOT 60 — and the deciding number is the app LABELS, not the cards.
   The phone is drawn to real proportions, so the figure's width sets the size
   of the smallest type in it. Measured across the sweep: at 1024px the
   three-column layout squeezed the figure to 307px and the labels to 7.8px,
   while the SAME viewport stacked gives 352px and 8.9px. The three-column
   arrangement was actively worse than the stack there, so the collapse moves
   up to meet it. ⚠ Re-measure if the figure's clamp or the column ratio
   changes; the floor is ~9px of label. */
@media (max-width:66rem){
  .appx{grid-template-columns:minmax(0,1fr);grid-template-rows:none;
    row-gap:var(--s4);margin-inline:0}
  /* ⭐ THE PHONE GOES LAST (Kacperek, 2026-09-02): "theres title, 5 cards below
     each other and then the mockup at the bottom of the section".
     ⚠ `order`, not a DOM change. On desktop the phone must sit BETWEEN the two
     cards on each side, which is a grid placement; reordering the markup to
     suit the stack would break that and would also put the figure ahead of the
     copy for a screen reader. Source order stays copy-first; only the visual
     order changes, which is what `order` is for.
     ⚠ The five cards keep their natural order (they are 1-5 in the DOM), so
     only the figure needs a value — anything above 0 puts it after them. */
  .appx__fig{grid-column:1;grid-row:auto;order:1;
    width:min(100%,22rem);margin-inline:auto}
  /* ⚠ min-height is NOT released here. It was, and the stacked cards went back
     to 86/107px — the same unevenness the desktop rule exists to remove. A
     stack shows the difference more plainly than a scattered arrangement, not
     less. */
  .afeat,.afeat--t,.afeat--l1,.afeat--l2,.afeat--r1,.afeat--r2{
    grid-column:1;grid-row:auto;justify-self:center;order:0;
    max-width:26rem;--rest:0rem}
  /* ⚠ --rest released for all five rather than the transform itself, so the
     hover lift still works in the stack. Zeroing `transform` here would have
     killed it, because the lift is composed into the same declaration. */
}

/* ⚠ prefers-reduced-motion. Nothing here loops — the offsets are static and
   the only movement is a hover nudge — so what goes is the travel, not the
   resting arrangement. */
@media (prefers-reduced-motion:reduce){
  /* The resting arrangement stays; only the travel goes. --lift is what moves,
     so holding it at 0 leaves each card on its own static offset. */
  .afeat{transition:none}
  .afeat:hover{--lift:0px}
}

/* ── team: expanding filmstrip ────────────────────────────────────────────
   Nexura's device (Kacperek, 2026-09-01). One card open, the rest collapsed
   to portrait strips; clicking another switches which.

   The whole thing is grid-template-columns on the parent with a transition —
   NOT width on the children. Animating a grid track keeps every card in flow,
   so nothing overlaps mid-transition and the row height stays honest. */
.tmstrip{--tm-open:min(58vw, 46rem);
  /* ⚠ The portrait's fixed render width — see .tmc__shot img. It must be at
     least the widest the shot ever is, which is the OPEN card's 30%. The strip
     is min(100% - 2.5rem, 1200px) split 6fr/1fr/1fr/1fr with three --s3 gaps,
     so the open card is (strip - 3*gap) * 6/9 and its shot is 30% of that:
     measured 220px at 1440 and 179px at 1024. 15rem (240px) clears both with
     margin, and a little slack costs nothing — the surplus is simply cropped
     by the card, which is the whole point of the construction. */
  --tm-shot-w:15rem;
  display:grid;gap:var(--s3);
  grid-template-columns:6fr 1fr 1fr 1fr;
  /* align-items:stretch (the default) is doing real work: it makes every card
     as tall as the tallest, so the collapsed strips match the open card. */
  /* ⛔ NO SPRING (Kacperek, 2026-09-01 evening) — "remove spring from the
     animation and make it nice and smooth". It was cubic-bezier(.34,1.24,.5,1),
     which overshot ~4% and settled; the overshoot is what the detector was
     flagging as [bounce-easing], and on a card this wide the rebound reads as
     the layout wobbling rather than as liveliness.

     ⚠ THE SAME CURVE MUST BE ON .tmc__shot's width. The track and the portrait
     move as one object — if they ease differently the picture lags the card
     edge and a sliver of the text shows through mid-transition. Both are now
     this curve; change them together or not at all.

     A long-tailed ease-out: quick to leave, slow to arrive, no return. .6s
     rather than .5 because without the overshoot's snap the motion needs a
     little longer to read as deliberate instead of merely slow. */
  transition:grid-template-columns .6s cubic-bezier(.22,.61,.36,1)}
/* :has() sets the tracks from whichever card is open, so the open state lives
   in one place (the button) instead of being duplicated as a parent class. */
.tmstrip:has(.tmc:nth-child(2).is-open){grid-template-columns:1fr 6fr 1fr 1fr}
.tmstrip:has(.tmc:nth-child(3).is-open){grid-template-columns:1fr 1fr 6fr 1fr}
.tmstrip:has(.tmc:nth-child(4).is-open){grid-template-columns:1fr 1fr 1fr 6fr}

/* The card. A button, so the browser's own reset has to be undone. */
/* ⚠ A fixed height, NOT aspect-ratio. The cards change width as the strip
   opens, so a ratio would make the open card ~1200px tall while the collapsed
   strips stayed stubby — the row must keep ONE height through the whole
   transition, and only the widths move. */
.tmc{position:relative;display:flex;align-items:stretch;
  min-width:0;overflow:hidden;text-align:left;
  border:0;padding:0;margin:0;font:inherit;color:inherit;
  /* ⛔ NO border-radius (Kacperek, 2026-09-01 evening). The cards are almost
     entirely photograph, so rounding them rounds the PICTURES — the same call
     already made for the image placeholders in #problem and for the hero
     plate. A rounded corner belongs to a container the page draws, not to a
     picture it places. */
  background:var(--raised);
  /* ⚠ Fixed height, and the number is MEASURED, not chosen: the longest bio
     (Allan, 101 words) needs 23.4rem at 1440 and 26rem at the 1000px
     breakpoint below which the strip stacks. 26rem covers the whole range.
     Re-measure whenever a bio grows — nothing here reports a clip. */
  cursor:pointer;height:26rem;
  transition:background-color .3s ease}
.tmc:hover{background:var(--sunken)}
.tmc.is-open{background:var(--raised);cursor:default}
.tmc:focus-visible{outline:2px solid var(--ox);outline-offset:3px}

/* Placeholder portrait slot — same convention as .pcard__art and .phone: a
   filled box on a distinct ground with a mono label, so it reads as a
   placeholder rather than as a design decision. Real portraits exist as
   inline base64 in the concept (handoff open item #4). */
/* ⚠ ABOVE the panel and opaque, so the text emerges from UNDER the portrait
   instead of across it (Kacperek, 2026-09-01). The panel is absolutely
   positioned and right-aligned; as a card closes, its full-width text is
   briefly wider than the shrinking card, and without this it painted over
   the picture. z-index + a solid background make the portrait the mask. */
/* Holds the portrait. No padding — the image bleeds to the card's edge; the
   --sunken stays as the colour behind a photo that has not loaded yet. */
/* ⚠⚠ THE PORTRAIT DOES NOT TRANSITION ITS OWN WIDTH — and removing that
   transition is what kills the bounce that survived deleting the spring curve.

   The width is a PERCENTAGE OF THE CARD (100% closed, 30% open) while the
   card's width is itself animating. Transitioning both means the rendered
   width is card(t) x pct(t): two eased factors MULTIPLIED. Neither overshoots
   alone; their product does. Measured, the portrait swelled 122 -> 284 and
   fell back to 220 — a 64px rebound over 21 backward frames — while the card
   was perfectly monotonic. Modelling card(t)*pct(t) predicted peak 283 /
   overshoot 64 against the measured 284 / 64: mechanism confirmed, not guessed.

   ⭐ So the percentage now SNAPS and the card carries the motion. Because the
   percentage is of a smoothly-animating parent, the portrait's rendered width
   still eases — it just eases ONCE, following the card exactly, which is what
   the original comment about "the track and the portrait must move as one"
   was actually asking for. Transitioning it a second time was the bug.

   ⚠ Do not add `transition:width` back here. Fixing the easing curve cannot
   fix this — the spring was never the cause, the compounding was. */
/* ⚠⚠ max-width IS THE FIX for the "photo doesn't scale under the filter" bug,
   and it belongs on the BOX rather than on the picture.

   The shot is width:100% closed and 30% open, and that 30% is deliberately
   NOT transitioned (a width transition here is what caused the old zoom — see
   .tmc__shot img). So the instant `is-open` drops, the box jumps from 220px to
   the whole card and then narrows with it. The image is pinned at
   --tm-shot-w (240px) by design, so for the first ~240ms of every close the
   BOX WAS WIDER THAN THE PICTURE: measured at t~80ms, a 509px box around a
   240px image, leaving 134.5px of bare --sunken on each side — with the
   oxblood wash painted over all of it, which is what read as "only the filter
   is visible in some places".

   ⛔ The two obvious fixes are both wrong, and both were tried and measured:
     · widening the IMAGE to fill the box (`min-width:100%`) brought the zoom
       straight back — the rendered scale swung 0.671 -> 1.334, a 98.7% change,
       because object-fit:cover recomputes scale from the box every frame;
     · narrowing the wash to the image left the same bare gap, just cream
       instead of oxblood. The gap was never the wash's fault.
   Capping the BOX at the picture's own width fixes the cause: the box can
   never present area the image does not cover, so there is nothing for the
   wash to over-paint and nothing for the image to stretch into. The card's own
   overflow:hidden still crops everything as it closes. */
.tmc__shot{position:relative;z-index:1;
  flex:none;width:100%;max-width:var(--tm-shot-w,15rem);overflow:hidden;
  background:var(--sunken)}
/* ⚠ cover + a fixed object-position, because the four sources are different
   shapes (413×620, 492×620, 618×620 — measured). Left to itself `cover`
   centres the crop, which on the near-square source takes the top of the head
   off; 50% 30% keeps the face in frame across all four. Re-check this if a
   portrait is replaced. */
/* ⚠ ABSOLUTE, so the image cannot size its own container. In flow at
   height:100% with no definite height on .tmc__shot, it fell back to its
   INTRINSIC height and drove the whole card — measured 878px tall at 390, and
   four unequal stacked rows at 900 because the four sources are different
   shapes (413×620, 492×620, 618×620). Out of flow, the shot's width/ratio
   rules decide the box and the image just fills it. */
/* ⚠⚠ THE IMAGE IS PINNED TO THE OPEN CARD'S WIDTH AND CROPPED — it is NOT
   re-fitted to the box each frame. This is the "photograph scales violently
   while the card closes" fix.

   `object-fit:cover` scales by max(boxW/natW, boxH/natH). The source is
   PORTRAIT (413x620) and the card's height is constant (416px) while its width
   collapses 733 -> 122. So early in the close, WIDTH drives the scale
   (733/413 = 1.78x); as the box narrows past 277px, HEIGHT takes over and pins
   at 416/620 = 0.67x. Measured, the rendered image swept 1.78x -> 0.67x — the
   face shrinking to a third of its size mid-transition. That is a zoom, and
   nothing about the card's motion asks for one.

   ⭐ Fixed by taking the box out of the sizing decision. The image is given the
   OPEN card's width as a fixed left-anchored box and object-fit is dropped, so
   its scale is set once and never recomputed. As the card narrows, the card's
   own overflow:hidden crops the picture — which is what a closing panel should
   do to a photograph. The face stays exactly the size it was.

   ⚠ CENTRED, not left:0. A fixed-width image anchored left is cropped from its
   LEFT edge, so the closed strips showed the left 122px of a 240px portrait and
   every face was cut in half. left:50% + translateX(-50%) keeps the picture's
   centre on the box's centre at every width, so the crop closes in
   symmetrically on the face. It must NOT be inset:0 — anchoring both edges
   would make the width track the box again and bring the zoom straight back.
   ⚠ --tm-shot-w must be >= the widest the shot ever gets, or the narrow strip
   shows ground on its right. It is the OPEN card's shot width; the strip's
   grid is 6fr of a 1200px container, so this is measured, not guessed. */
.tmc__shot img{position:absolute;top:0;left:50%;transform:translateX(-50%);
  /* ⚠ max-width:none — the SAME global reset that caught the app icon's ring.
     `img,svg{max-width:100%}` at the top of this file caps the picture at its
     parent's width, so the fixed width above was silently clamped the moment
     the shot narrowed past it: measured, the image held 240px for the first
     half of the close and then shrank 240 -> 122 with the box, which is the
     zoom coming back for the second half. The pinned width only works if
     nothing is allowed to override it. */
  /* ⚠⚠ width IS A FLOOR, NOT A FIXED SIZE: `min-width:100%` on top of it.
     The pinned width alone left the picture NARROWER THAN ITS BOX for the
     first half of every close, because `.tmc.is-open .tmc__shot{width:30%}` is
     deliberately untransitioned — so the shot jumps from 220px to the card's
     full width the instant `is-open` drops, then narrows. Measured on the
     closing card: at t~80ms the box was 509px against a 240px image, leaving
     134.5px of bare --sunken on EACH side. That is the "photo doesn't scale
     under it" fault.
       box 509 -> image fills 509      (min-width wins: no gap)
       box 240 -> image 240            (they meet)
       box 128 -> image holds 240      (pinned width wins: cropped, no zoom)
     ⚠ This does NOT bring the old zoom back, and the distinction matters: the
     zoom came from `object-fit:cover` recomputing the SCALE from the box every
     frame once the box got narrower than the picture. Below 240px the width is
     still pinned, so that never happens. Above it, the image is larger than
     the crop the card shows anyway.
     ⚠ max-width:none is still load-bearing — the same global `img{max-width:100%}`
     reset that caught the app icon's ring would re-clamp this to the parent. */
  width:var(--tm-shot-w,15rem);max-width:none;height:100%;object-fit:cover;
  object-position:50% 30%;
  display:block;
  /* Closed cards read as context, the open one as the subject (Kacperek,
     2026-09-01): desaturated and pushed toward oxblood, full colour only when
     open. The tint is a --ox overlay on .tmc__shot::after rather than a
     sepia/hue-rotate chain — hue-rotate on a desaturated image lands on a
     muddy brown, not on our red, because it rotates whatever hue survives
     rather than mapping to a target. */
  filter:saturate(.15) brightness(1.12) contrast(1.02);
  transition:filter .45s ease}
.tmc.is-open .tmc__shot img{filter:none}

/* The oxblood wash. multiply keeps the portrait's own tonal range instead of
   flattening it the way a flat overlay would, and it fades out with the card
   opening. pointer-events:none so it never eats the button's click.

   ⚠⚠ IT IS SIZED TO THE PICTURE, NOT TO THE BOX — and `inset:0` was the bug
   Kacperek reported as "the photo doesn't scale under it and only the filter
   is visible in some places".

   The mechanism, measured on the CLOSING card: `.tmc.is-open .tmc__shot{width:30%}`
   is deliberately NOT transitioned (see the note above — a width transition
   here is what caused the old zoom), so the moment `is-open` is dropped the
   shot box jumps from 220px to the card's full width and then narrows with the
   card. The IMAGE, however, is pinned at --tm-shot-w (240px) by design. So for
   the first ~240ms of every close the box is WIDER than the picture inside it:
   at t~80ms the shot measured 509px against a 240px image, leaving 134.6px of
   bare --sunken on each side — and an `inset:0` wash painted oxblood over all
   of it. Filter with no photograph under it, exactly as described.

   Matching the picture's geometry — same width RULE, same centring — means the
   wash can only ever cover pixels the image also covers.
   ⚠⚠ Keep all four values identical to .tmc__shot img's: width, min-width,
   left and transform. `min-width:100%` is part of that contract, not an
   afterthought — with the image now growing to fill a wider box, a wash still
   pinned at 240px would leave the picture's edges UNTINTED during the close,
   which is the same bug wearing the other shoe. */
.tmc__shot::after{content:"";position:absolute;top:0;bottom:0;z-index:1;
  width:var(--tm-shot-w,15rem);left:50%;transform:translateX(-50%);
  background:var(--ox);mix-blend-mode:multiply;opacity:.34;
  pointer-events:none;transition:opacity .45s ease}
.tmc.is-open .tmc__shot::after{opacity:0}

@media (prefers-reduced-motion:reduce){
  .tmc__shot img,.tmc__shot::after{transition:none}
}
.tmc.is-open .tmc__shot{width:30%}

/* The bio panel. Only the open card shows it; the others keep it in the DOM
   (a screen reader and Find-in-page still reach it) but give it no width. */
/* ⚠ ABSOLUTE, with a width fixed to the OPEN card's panel — not flex.
   Measured on the closing card: as a flex child its panel shrank 417px → 5px
   during the switch and the bio reflowed from 9 lines to 78, thrashing the
   layout behind the fade. Out of flow, the text keeps the width it was laid
   out at and simply fades; nothing reflows.
   ⚠ The 70% is the COMPLEMENT of .tmc__shot's 30% — change the two together
   or the panel leaves a gap beside the portrait, or overlaps it. The portrait
   was narrowed from 38% to give the bio more width (Kacperek, 2026-09-01). */
.tmc__panel{position:absolute;top:0;bottom:0;
  left:calc(var(--tm-open) * 0.30);z-index:0;
  width:calc(var(--tm-open) * 0.70);
  display:flex;flex-direction:column;justify-content:space-between;
  gap:var(--s5);padding:var(--s6);
  opacity:0;pointer-events:none;
  transition:opacity .2s ease}
.tmc.is-open .tmc__panel{opacity:1;transition-delay:.2s}
/* Name and title lead at the top, bio at the foot — space-between on the
   panel puts the gap between them (Kacperek, 2026-09-01). */
.tmc__by{order:-1;font-size:var(--t-h3);line-height:1.2;letter-spacing:-.03em;
  color:var(--ink-3)}
.tmc__by b{display:block;font-weight:500;color:var(--ink)}
.tmc__role{display:block;font-size:var(--t-sm);color:var(--ink-3);
  margin-top:2px;letter-spacing:-.02em}
.tmc__bio{font-size:var(--t-sm);line-height:1.55;color:var(--ink-2);
  text-wrap:pretty;max-width:52ch}

/* ⚠ The closed card shows the PICTURE ONLY — no name (Kacperek, 2026-09-01).
   The spine stays in the DOM so a screen reader still has the name inside the
   control; it is hidden visually, not removed. */
.tmc__spine{position:absolute;width:1px;height:1px;overflow:hidden;
  clip-path:inset(50%);white-space:nowrap}

/* ⚠ prefers-reduced-motion: no spring, no slide. An overshoot is exactly the
   kind of motion the setting exists to switch off. The layout still changes;
   it just arrives without travel. */
@media (prefers-reduced-motion:reduce){
  .tmstrip,.tmc__shot,.tmc__panel{transition:none}
}

/* ⚠ Below the breakpoint the strip becomes a plain stack: four 4:5 cards in a
   row need ~1000px before the collapsed ones are narrower than their own
   corner radius. Every card is open here — there is nothing to click, and the
   script leaves it alone. */
/* ⚠ Below 1000px every card is open and they stack — but each stays a ROW:
   portrait left, text right, exactly as nexura behaves on a narrow screen.
   NOT a full-width stack with the picture above the text; that was the
   earlier reading and it is not what the reference does. */
@media (max-width:62.5rem){
  /* ⚠ Must out-specify EVERY desktop :has() rule, not just one. The originals
     are :has(.tmc:nth-child(N).is-open) — more specific than
     :has(.tmc.is-open) — so with card 2, 3 or 4 open the four-column layout
     won here and the cards rendered as narrow strips with wrapped text. It
     only looked fine when card 1 was open, which is what the first test
     checked. Matching nth-child(n) puts these on equal footing, and coming
     later in the sheet they win. */
  .tmstrip,
  .tmstrip:has(.tmc:nth-child(n).is-open){grid-template-columns:1fr;gap:var(--s4)}
  .tmc{height:auto;min-height:0;cursor:default;display:flex}
  /* the portrait keeps its share of the row instead of going full width */
  /* align-self:stretch gives the shot the ROW's height, which the panel sets —
     so the four rows differ with the bios (measured 304/283/239/348 at 900).
     A fixed height makes every stacked row the same, and the panel centres in
     it. The image is absolute, so it simply fills whatever this box is. */
  .tmc__shot,.tmc.is-open .tmc__shot{width:38%;aspect-ratio:auto;
    align-self:stretch;min-height:16rem}
  /* back INTO flow: absolute is what stops the closing card reflowing, and
     there is no switch here — every card is open — but out of flow it cannot
     give the row its height. */
  .tmc__panel,.tmc.is-open .tmc__panel{position:static;width:auto;flex:1 1 auto;
    left:auto;opacity:1;padding:var(--s5);gap:var(--s4);justify-content:center}
  .tmc__spine{display:none}

  /* ⭐⭐ EVERY PORTRAIT IN FULL COLOUR (Kacperek, 2026-09-04).
     The desaturate + oxblood wash says "this card is context, the open one is
     the subject". Below this breakpoint there IS no open one: every card is
     expanded, the script leaves the strip alone, and nothing is clickable — so
     the treatment was marking a distinction that no longer exists. Measured at
     390px before this rule: three of the four shots still carried
     saturate(0.15) and a 0.34 wash, while card 1 alone was in colour, purely
     because it happens to hold .is-open from the desktop layout. Four faces,
     one arbitrarily different.
     ⚠ Must name BOTH the plain and the .is-open selector: .tmc.is-open
     .tmc__shot img (two classes) out-specifies a bare .tmc__shot img, so
     resetting only the latter would leave card 1 governed by the desktop rule.
     Same for the ::after wash. */
  .tmc__shot img,.tmc.is-open .tmc__shot img{filter:none}
  .tmc__shot::after,.tmc.is-open .tmc__shot::after{opacity:0}
}

/* One column only when the row itself is too narrow to split: below ~30rem a
   38% portrait leaves the text about 12 characters wide. */
@media (max-width:30rem){
  .tmc{display:block}
  /* ⚠ An explicit height, not aspect-ratio. .tmc is display:block here, and
     with the image absolutely positioned there is nothing in flow to give the
     shot a height — the ratio resolved against a zero content box and every
     portrait collapsed to 0×0 (measured). */
  /* ⚠ display:block is the load-bearing bit. .tmc__shot is a <span>, and the
     rule above sets .tmc{display:block} — so the span stops being a flex item
     and reverts to INLINE, where width and height do not apply. Computed
     height read 240px while the rendered box measured 0.
     Explicit height rather than aspect-ratio: the image is absolute, so there
     is nothing in flow for a ratio to resolve against. */
  /* ⚠⚠ max-width:none is load-bearing, and its absence was the fault.
     .tmc__shot carries `max-width:var(--tm-shot-w,15rem)` — the 240px pin the
     desktop strip needs so the closing card crops rather than zooms. The
     `width:100%` here never reached the box, because a max-width caps a width:
     measured at 390px the shot rendered 240px inside a 326px card, leaving 86px
     of bare ground beside every portrait, and the same at 360 and 430. The
     picture read as a small inset thumbnail rather than as the card's subject.
     ⚠ Safe to lift ONLY in this branch: the pin exists for the open/closed
     transition, and below 30rem there is no transition — every card is expanded
     and the script leaves the strip alone. */
  .tmc__shot,.tmc.is-open .tmc__shot{display:block;width:100%;max-width:none;
    aspect-ratio:auto;height:15rem;min-height:0}
  /* The image is pinned to --tm-shot-w too (line ~2364) for the same reason.
     With the box now full-width it must fill it, or the gap simply moves from
     the box to the picture inside it. object-position stays at 50% 30% — the
     face sits high in these portraits. */
  .tmc__shot img,.tmc.is-open .tmc__shot img{width:100%}
  .tmc__shot::after,.tmc.is-open .tmc__shot::after{width:100%}
}

/* ⚠⚠ A STRAY `}` WAS HERE, closing nothing — a real syntax error that lived
   in this file unnoticed because BROWSERS SILENTLY RECOVER from an extra
   closer, so every verify run and every screenshot looked correct.
   PostCSS does not recover: it aborted the Astro build the moment this
   file was copied into src/. Removed 2026-09-04.
   ⭐ The lesson is the file's own: a check that only ever renders in one
   engine is not a check. */

/* ── faq ────────────────────────────────────────────────────────────────── */
.faq{border-top:1px solid var(--rule-2)}
.faq details{border-bottom:1px solid var(--rule-2)}
.faq summary{padding-block:var(--s5);cursor:pointer;list-style:none;
  display:flex;justify-content:space-between;align-items:center;gap:var(--s4);
  font-size:var(--t-h3);font-weight:500;letter-spacing:-.03em}
.faq summary::-webkit-details-marker{display:none}
.faq summary::after{content:"+";font-family:'Fragment Mono',monospace;
  font-size:var(--t-body);color:var(--ink-3);flex:none;transition:transform .2s}
.faq details[open] summary::after{content:"–"}
.faq .a{padding-bottom:var(--s5);max-width:62ch;display:flex;
  flex-direction:column;gap:var(--s3)}
.faq .a p:first-child{color:var(--ink)}
.faq .a p{font-size:var(--t-sm);line-height:1.55;color:var(--ink-3)}
.faq .a a{border-bottom:1px solid var(--rule);color:var(--ink-2)}
.faq .a a:hover{border-bottom-color:var(--ink)}

/* ── early access ───────────────────────────────────────────────────────── */
/* One pill, not two. The field and the button read as a single control —
   the button sits INSIDE the field's rounded box, which is why the wrapper
   carries the border and the input carries none.
   width:100% because it is a flex child of .foot__lead (align-items:
   flex-start), which would otherwise shrink it to its content. */
.ea__form{display:flex;align-items:center;gap:var(--s2);margin-top:var(--s5);
  width:100%;max-width:26rem;padding:5px 5px 5px var(--s2);
  background:rgba(255,255,255,.04);
  border:1px solid rgba(255,255,255,.14);border-radius:var(--r-pill);
  transition:border-color .22s ease,background-color .22s ease}
.ea__form:hover{border-color:rgba(255,255,255,.24)}
/* Focus moves to the WRAPPER, so the whole control reads as focused. The input
   drops its own ring — two nested focus rings on one control is noise. */
.ea__form:focus-within{border-color:rgba(255,255,255,.4);
  background:rgba(255,255,255,.06)}
.ea__form input{flex:1 1 8rem;min-width:0;min-height:38px;padding:0 var(--s2);
  font-family:inherit;font-size:var(--t-sm);color:#fff;
  background:transparent;border:0;border-radius:0}
.ea__form input::placeholder{color:rgba(255,255,255,.45)}
/* No ring on the input itself — .ea__form:focus-within carries it. */
.ea__form input:focus-visible{outline:none}

/* ── the form's announced message ─────────────────────────────────────────
   ⭐ Added 2026-09-04. The form is novalidate, so the native error bubble is
   off and nothing replaced it: a failed submit was silent, visually and to a
   screen reader.
   ⚠ EMPTY BY DEFAULT AND IT MUST STAY THAT WAY. The region is always in the
   DOM so aria-live has something to watch and aria-describedby has a stable
   target, but it carries no text until there is something to say. A live
   region pre-filled with placeholder text announces that text on load. */
.ea__msg{margin-top:var(--s3);font-size:.78125rem;line-height:1.5;min-height:0}
.ea__msg:empty{margin-top:0}
/* ⚠ On the dark footer ground, NOT the light page — these are the footer's
   own colours, and both are measured against --dark-a #0b0c0f:
   #FF9DAF 9.96:1 · #8FE3AE 12.82:1. Comfortable at .78125rem.
   ⚠ Deliberately NOT --plate-rose / --in-issue, though both would also pass
   there (8.16:1 / 11.22:1). --in-issue means "this token is in circulation"
   — it is a STATE token, and the § status dot rule is that a state colour is
   a factual claim. Borrowing it for a form's success message is how a colour
   with meaning quietly becomes decoration. */
.ea__msg.is-bad{color:#FF9DAF}
.ea__msg.is-good{color:#8FE3AE}
/* The border echoes the message, so the error is not carried by the text
   alone — but aria-invalid in the script is what actually announces it. */
.ea__form:has(input[aria-invalid="true"]){border-color:rgba(255,157,175,.55)}

/* ⚠ EVERY rule here is written .dark .ea__form … on purpose. The footer IS a
   .dark, so .dark .btn / .dark .btn:hover (two classes, and later in the file)
   beat a plain .ea__form .btn. Written the short way these rules simply never
   applied: the button kept the generic dark-band hover and the pink halo, and
   a computed-style probe reported the dark-band value while the file on disk
   said otherwise — which reads exactly like a stale cache and is not one.
   Match the weight; do not reach for !important. */
.dark .ea__form .btn{flex:none;color:var(--ink);
  min-height:38px;padding:.45rem 1.1rem;font-size:var(--t-sm);
  box-shadow:none}
/* Grey, not the pale-pink halo (Kacperek, 2026-09-01): this button is white,
   so the bloom ring read as a stray red glow. */
.dark .ea__form .btn:hover{background:rgba(255,255,255,.78)}
.dark .ea__form .btn::before{display:none}
.dark .ea__form .btn:focus-visible{outline:2px solid #fff;outline-offset:3px}

/* The label clips to height:1lh for the hover swap, and `line-height:normal`
   resolves per-font, so the clip window and the text box disagreed. An
   explicit line-height makes them match — and that is the WHOLE fix.

   ⚠ There was a translateY(-2px) "optical correction" here. Do not put it
   back. It was answering a measurement error, not a rendering one: a pixel
   scan reported the glyphs 9px low at 2x, but on this dark footer the ground
   (sum≈42-70) and the ink (sum≈68) are nearly the same brightness, so the
   threshold was reading the BACKGROUND as glyphs. With the transform removed
   the label box measures 10.25/10.25 inside the button — already centred. The
   nudge shifted a correct box to 8.25/12.25 and made it visibly low.
   To re-check: read gapTop/gapBottom off getBoundingClientRect, or scan
   pixels bounded by each row's own pill edges (see § the probe in the log). */
.ea__form .btn__label,
.ea__form .btn__label span{line-height:1.25}

/* ══ DARK BAND ═════════════════════════════════════════════════════════════
   Copied from the live site (src/olbra.css:1362) so the reused footer sits on
   exactly the ground it was drawn for. Not flat black — a deep blue-black
   gradient with a faint dot grid, so the surface has depth. */
.dark{position:relative;overflow:hidden;color:#fff;
  background:
    radial-gradient(900px 500px at 85% 0%,var(--dark-glow) 0%,transparent 60%),
    linear-gradient(180deg,var(--dark-a) 0%,var(--dark-b) 100%)}
.dark::before{content:"";position:absolute;inset:0;pointer-events:none;opacity:.35;
  background-image:radial-gradient(rgba(255,255,255,.04) 1px,transparent 1px);
  background-size:26px 26px;
  -webkit-mask-image:radial-gradient(120% 90% at 50% 0%,black,transparent 70%);
          mask-image:radial-gradient(120% 90% at 50% 0%,black,transparent 70%)}
.dark > *{position:relative;z-index:1}
.dark h1,.dark h2,.dark h3,.dark h4{color:#fff}
.dark p{color:rgba(255,255,255,.65)}
.dark .label,.dark .muted,.dark .small{color:rgba(255,255,255,.45)}
/* The divider label inherited --ink-3 on the dark band and measured 3.58:1 —
   under the 4.5 floor, and invisible by eye. Caught by the in-page audit. */
.dark .divider span{color:rgba(255,255,255,.55)}
.dark .divider::before,.dark .divider::after{background:rgba(255,255,255,.12)}
.dark .badge{color:rgba(255,255,255,.75);border-color:rgba(255,255,255,.18);
  background:transparent}
.dark .badge::before{background:#fff}
.dark .btn{background:#fff;color:var(--ink)}
.dark .btn:hover{background:rgba(255,255,255,.86)}
.dark .btn-ghost{background:transparent;color:#fff;
  box-shadow:inset 0 0 0 1px rgba(255,255,255,.25)}
/* ⚠⚠ `background:transparent` IS LOAD-BEARING HERE, AND ITS ABSENCE WAS A
   REAL BUG (found 2026-09-14). `.dark .btn:hover` two lines up sets
   `rgba(255,255,255,.86)` and is (0,2,0) — the SAME specificity as
   `.btn-ghost:hover`, which it beats on source order, being ~2800 lines
   later. Without the reassertion here the ghost button filled near-white on
   hover and stopped being a ghost.
   ⭐ Measured, not reasoned about: the hovered button's computed
   backgroundColor read rgba(255,255,255,0.86) against a resting rgba(0,0,0,0).
   ⚠ PRE-EXISTING — it predates the photo hero and affects every .dark band,
   not just this one. ⛔ Do not remove this declaration believing the base
   `.btn-ghost:hover` covers it; it does not win. */
.dark .btn-ghost:hover{background:transparent;box-shadow:inset 0 0 0 1px #fff}
.dark .steps li::before{color:rgba(255,255,255,.45)}
.dark .steps b{color:#fff}
.dark .mode__close{border-top-color:rgba(255,255,255,.12)}
.dark strong{color:#fff;font-weight:500}

/* ══ FOOTER — reused from the live site, deliberately ═══════════════════════
   Kacperek: "I like the footer on our live site so let's use exactly the same
   one here." This reproduces src/components/v2/V2Footer.astro: logo alone in a
   left column, link columns filling the rest, then the numbered disclosures,
   the whitepaper row, and one bottom bar.

   Its type hierarchy is the point and is preserved exactly — quietest to
   loudest, three steps only:
     column heading  13px  white/45   (a label, sentence case)
     link            15px  white      (the loudest thing here)
     legal note      12.5px white/45
   An earlier version used 10px mono caps for the headings, which shouted over
   the links beneath and inverted the hierarchy. Headings label; links are the
   content. */
/* ⬜ KNOWN, NOT FIXED: a cream stripe shows under the footer on OVERSCROLL —
   only in a window, not full screen (Kacperek, 2026-09-02).

   Cause, measured: <html> carries no background, so the browser propagates
   <body>'s --ground (cream) to the page CANVAS — the surface outside the
   document box. .dark's colour is a GRADIENT, which paints only inside its own
   element, so rubber-banding past the end of the page reveals cream. There is
   no layout gap: the footer's box ends exactly at the viewport bottom.

   ⛔ THREE FIXES WERE BUILT AND REVERTED. Recording them so they are not
   rediscovered one at a time:
     · `html{background:var(--dark-a)}` — works, but the impeccable detector
       then reclassifies the page as DARK and raises two findings that are only
       anti-patterns on a dark page (dark-glow on the button halo, radial-halo
       on the bloom), both of which sit on light surfaces here.
     · a fixed `body::after` layer painting --dark-a behind the lower viewport
       at z-index -1 — also works, verified by pixel sample (page top
       rgb(255,253,252), below footer rgb(13,15,20)), and costs the SAME two
       findings for the same reason: the detector reads any dark full-width
       declaration as the page being dark.
     · extending the footer downward with `.foot::after` — inert. `.dark`
       carries overflow:hidden and clips it before it reaches the overscroll
       region at all.
   Config `ignoreValues` entries do not bind these two findings: they are
   computed from rendered state rather than a matchable source value — the same
   reason the earlier `cramped-padding` exemption could not be registered.

   ⚠ Reverted at Kacperek's call: two permanent false findings in the detector
   baseline is a worse trade than a stripe visible only when overscrolling a
   windowed browser. The detector baseline for this file therefore stays 4.
   ⚠ If this is ever revisited, the body::after version is the one to restore —
   it leaves the root alone and is the least invasive of the three. */
.foot{padding-block:var(--s9) var(--s8)}

/* Explicit tracks, not auto-fit. The sign-up column needs a real measure (an
   email field plus a button) while the link columns want to stay narrow, and
   auto-fit gives every track the same width by definition — it cannot express
   "one wide, the rest narrow". The old grid was auto-fit because every column
   held the same kind of thing; that stopped being true when the form moved in. */
.foot__grid{display:grid;gap:var(--s8) var(--s6);grid-template-columns:1fr}
@media (min-width:52rem){
  .foot__grid{grid-template-columns:repeat(3,minmax(0,1fr))}
  .foot__lead{grid-column:1/-1}
}
@media (min-width:64rem){
  /* The lead takes a fixed measure and the three link columns share what is
     left, so they reach the container's right edge. An fr on the lead made it
     grow with the viewport and pushed the columns into the left two-thirds,
     leaving a dead band down the right of the footer. */
  .foot__grid{grid-template-columns:34rem repeat(3,minmax(0,1fr))}
  .foot__lead{grid-column:auto;max-width:none}
  /* The narrow-layout caps would leave the form and the fine print stranded
     at the left of a 34rem track. Let both use the measure they are given. */
  .foot__lead .ea__form{max-width:30rem}
  .foot__lead .foot__fine{max-width:44ch}
}

/* scroll-margin because this is now the #early target for both CTAs, and it is
   a flex child at the foot of the page rather than a section with its own top
   padding — without it the heading lands flush under the sticky nav. */
.foot__lead{display:flex;flex-direction:column;align-items:flex-start;
  max-width:26rem;scroll-margin-top:var(--s8)}
/* logo-full-paper.svg ships at #FFF7F4, drawn for exactly this ground. */
.foot__mark{height:22px;width:auto}
.foot__cta{font-size:var(--t-h3);font-weight:500;letter-spacing:-.03em;
  line-height:1.2;color:#fff;margin-top:var(--s6)}
/* The privacy line is a footnote to the form, so it takes the footer's legal
   size and muted white — NOT .small, which is sized for body copy on ground. */
.foot__fine{margin-top:var(--s5);font-size:.78125rem;line-height:1.6;
  color:rgba(255,255,255,.45);max-width:34ch}
.foot__fine a{color:rgba(255,255,255,.7);border-bottom:1px solid rgba(255,255,255,.3);
  transition:color .18s}
.foot__fine a:hover{color:#fff}
/* h3, not h4: the nearest preceding heading is the footer's own .foot__cta h2
   (it was the early-access section's h2 before that section folded in here),
   and jumping h2 → h4 breaks the outline screen readers navigate by.
   The visual weight is unchanged — these are labels, and the type scale is set
   here rather than by the tag. */
.foot h3{font-size:var(--t-mono);font-weight:400;color:rgba(255,255,255,.45);
  margin-bottom:var(--s5);letter-spacing:-.01em}
.foot__col li + li{margin-top:.875rem}
.foot__col a{font-size:.9375rem;color:#fff;letter-spacing:-.02em;transition:color .18s}
.foot__col a:hover{color:rgba(255,255,255,.7)}

.foot__notes{margin-top:var(--s9);display:flex;flex-direction:column;gap:var(--s4)}
.foot__notes li{display:flex;gap:.5rem;max-width:100ch;
  font-size:.78125rem;line-height:1.6;color:rgba(255,255,255,.45)}
.foot__notes li > span:first-child{flex:none;font-variant-numeric:tabular-nums}

.foot__wp{margin-top:var(--s6);display:flex;flex-wrap:wrap;
  align-items:baseline;gap:.5rem 1.5rem}
.foot__wp .label{margin:0;color:rgba(255,255,255,.45)}
.foot__wp a{font-size:.78125rem;color:rgba(255,255,255,.45);transition:color .18s}
.foot__wp a:hover{color:#fff}

.foot__bar{margin-top:var(--s7);padding-top:var(--s6);
  display:flex;flex-direction:column;gap:var(--s4);
  font-size:.78125rem;color:rgba(255,255,255,.45)}
/* ⚠ The row gap absorbs the links' own vertical padding below, so it drops
   from .625rem to 0 — otherwise the touch areas would be separated by 10px of
   dead space AND the visible rhythm would open up. The horizontal gap is
   reduced for the same reason: the padding now supplies part of it. */
.foot__bar ul{display:flex;flex-wrap:wrap;gap:0 1.25rem}
.foot__bar a{transition:color .18s}
.foot__bar a:hover{color:#fff}

/* ── touch targets in the footer ──────────────────────────────────────────
   ⭐ Added 2026-09-04. Measured at 390px, the legal row rendered at 15px tall
   and the column links at 19px — under WCAG 2.5.8's 24x24 floor, let alone
   2.5.5's 44x44. ⚠⚠ These are the IMPRINT, RISK DISCLOSURE, HOLDER RIGHTS,
   COMPLAINTS, PRIVACY and TERMS links: the six a regulator actually taps, and
   the smallest things on the page.
   ⭐ THE FIX IS PADDING, NOT TYPE SIZE. Growing the type would change the
   footer's whole visual register — this is deliberately fine print and should
   look like it. Padding grows the HIT AREA while the ink stays put, which is
   what 2.5.8 actually asks for.
   ⚠ inline-flex + align-items:center, not display:block: these sit inside
   flex-wrapped rows, and a block would break the wrapping. */
.foot__bar a,.foot__wp a,.foot__col a,.foot__fine a{
  display:inline-flex;align-items:center;min-height:44px}
/* ⚠ The negative margins keep the LAYOUT where it was while the hit area
   grows past it. Without them every footer row would gain ~25px of height and
   the column rhythm (.875rem between items) would visibly loosen. */
.foot__bar a,.foot__wp a{margin-block:-.75rem}
/* ⚠ MEASURED, not guessed. Zeroing this collapsed the column pitch from ~34px
   to 22.6px — the links grew their hit area and the list lost its rhythm at
   the same time. .71875rem restores the original 34px pitch while the 44px
   targets stay. ⚠ It is NOT the original .875rem: the link's own -.6875rem
   negative margin now eats part of the gap, so the two numbers are coupled.
   Change one and re-measure the other. */
.foot__col li + li{margin-top:.71875rem}
.foot__col a{margin-block:-.6875rem}

/* ⚠ The whitepaper row's label is not a link and must not gain the height, or
   it stops sitting on the same baseline as the three PDFs beside it. */
.foot__wp .label{min-height:0;margin-block:0}
@media (min-width:64rem){
  .foot__bar{flex-direction:row;align-items:center;justify-content:space-between}
}

/* ── margin flags — REMOVED 2026-09-04 ────────────────────────────────────
   The prototype-only canon flags and their F-key toggle are GONE, together
   with the nine violations they marked. data-flag count is 0.
   ⛔ Do not reintroduce either. A flag is a to-do; a keystroke that hides
   to-dos is how eleven of them stayed live in the page for a week. If a
   canon question reopens, resolve it or block the promotion — do not paint
   it amber and move on. History: COPY-SOURCE.md § Canon, DECISIONS § 4.16. */


/* ══ hero — the load entrance ══════════════════════════════════════════════
   ⚠ A LOAD ANIMATION, NOT A SCROLL ONE. The hero is above the fold by
   definition, so there is nothing to observe and NO JS IS INVOLVED — the whole
   sequence is animation-delay. That is also why the hero is the one part of
   the reveal system that still arrives correctly if the observer never runs.

   The sequence, in order (Kacperek 2026-09-03):
     0ms          the title's four words rise + unblur, 90ms apart
     +HOLD 520ms  then the lead, then the actions, 140ms apart

   ⭐ THE HOLD IS THE POINT, and it is the one number to argue with. Without it
   the lead and buttons animate underneath a headline that is still resolving,
   and four staggered words plus two more elements reads as six things moving
   at once rather than as a headline that lands and is then answered. Nexura
   does the same thing with a flat 600-800ms delay on its hero copy; this is
   that idea, measured from the end of the title run instead of guessed:
     3 gaps x 90ms + 900ms word duration = 1170ms of title
   The hold starts the lead at 520ms — deliberately BEFORE the last word has
   finished, so the two overlap rather than queue. A true sequential wait
   reads as a slideshow.

   ⚠ Only the TITLE blurs. Everything else floats clean, on opacity and travel
   only. The blur earns its keep by being unique to it — applied more widely it
   stopped reading as an effect and started reading as the page failing to
   focus, and it put a filter on body copy a reader is trying to read.
   ⛔ Do not add a filter to .hero-rv or to the page-wide .rv. */

/* ── the title, word by word ──────────────────────────────────────────────
   ⚠⚠ THE SPLIT IS IN THE MARKUP, NOT IN JS. Splitting on load means the
   headline does not exist until a script runs — the .ol-word trap, which
   shipped on the live site. See the markup comment on the h1.

   ⚠ inline-block is REQUIRED: transform does not apply to a non-replaced
   inline box, so without it the words would blur and fade but never move.
   ⚠ It also means each word becomes its own line box, which is why the
   spaces are left outside the spans in the markup — see there. */
.hw{display:inline-block;
  animation:hero-word 900ms var(--rv-ease) both;
  animation-delay:calc(var(--w,0) * 90ms)}
@keyframes hero-word{
  from{opacity:0;transform:translateY(28px);filter:blur(12px)}
  to{opacity:1;transform:none;filter:blur(0)}}

/* ⚠ The h1 itself must NOT animate — the words do. Giving it .hero-rv as
   well would fade the block in over its own children, which double-dips the
   opacity and makes the first word arrive at 0.25 rather than 0. */
.hero-rv--title{opacity:1}

/* ── everything else in the hero ──────────────────────────────────────────
   ⚠⚠ MODIFIERS AFTER THE BASE RULE. `.hero-rv` and its modifiers are all
   (0,1,0) — equal specificity — so source order decides. Written the other way
   round (which is how it was first built) `hero-rise` silently replaced the
   title's animation: the headline travelled instead of blurring and NOTHING
   reported it, found only by dumping the computed `filter`.
   ⛔ Do not reorder. Same failure as the .afeat --rest tie recorded earlier in
   this file: a broad selector beats a specific one whenever they tie, and the
   symptom always looks like a taste problem.

   --hd is the hero's own stagger index, kept separate from --d so a hero
   tweak cannot shift the section reveals. --hero-hold is the wait described
   at the top of this block. */
.hero-rv{
  --hero-hold:520ms;
  animation:hero-rise var(--rv-dur) var(--rv-ease) both;
  animation-delay:calc(var(--hero-hold) + var(--hd,0) * 140ms)}
@keyframes hero-rise{
  from{opacity:0;transform:translateY(var(--rv-rise))}
  to{opacity:1;transform:none}}

/* ⚠ The actions FLOAT further than the page default — the one place the
   reference's 80px is nearly kept. They sit under the headline with clear
   space below, so a long rise has room to read without crossing any rule.
   44px is half nexura's 80, matched to our shorter hero.
   ⚠ ONE declaration, not two: the delay was once split into a second
   `.hero-rv--float{}` block below the keyframes, working only because the two
   were adjacent — one edit from the tie described above. */
.hero-rv--float{
  animation:hero-float 1000ms var(--rv-ease) both;
  animation-delay:calc(var(--hero-hold) + var(--hd,0) * 140ms)}
@keyframes hero-float{
  from{opacity:0;transform:translateY(44px)}
  to{opacity:1;transform:none}}

/* ⚠⚠ NO REDUCED-MOTION BRANCH IS NEEDED HERE AND THAT IS NOT AN OVERSIGHT.
   Every rule above is a CSS animation with fill-mode `both`, so the global
   reduced-motion rule below collapses it to .01ms and the hero lands on its
   END state — visible, in place, unblurred, words included. The trap only
   bites when JS gates the reveal, which is why the scroll observer needs an
   explicit branch and this does not. */

/* ══ scroll reveal ═════════════════════════════════════════════════════════
   One class, one observer, ~40 elements. Tokens and the § 5 overrule are in
   :root; DECISIONS.md § 4.12 carries the reasoning.

   ⚠⚠ THE REST STATE CARRIES OPACITY ONLY — THE TRANSFORM LIVES IN THE
   KEYFRAMES. This is not a style preference, it is the recorded fix for a
   real bug (live site, site.css:674): a standing `transform` on the rest
   state strands the element off-position for anyone whose .is-in never
   arrives, and it arrives via JS. Opacity fails safe; a translate does not.
   ⛔ Do not "simplify" by hoisting the transform up here.

   ⚠ ANIMATION, NOT TRANSITION, and this one is Safari-specific. Safari skips
   load-time transitions on elements that have not painted yet, so an
   above-the-fold reveal fires intermittently with no animation at all. The
   live site already learned this (main-ol.js:107). Safari is the browser this
   prototype is reviewed in, which makes it the browser that would show it. */
.rv{opacity:0}
.rv.is-in{
  animation:rv-in var(--rv-dur) var(--rv-ease) both;
  animation-delay:calc(var(--d,0) * var(--rv-step))}
@keyframes rv-in{
  from{opacity:0;transform:translateY(var(--rv-rise))}
  to{opacity:1;transform:none}}

/* ⚠ The stagger index is capped in the MARKUP at 5, not here. Beyond ~5 the
   last item waits longer than the reveal itself lasts, so a late tile in a
   six-up grid sits blank while everything around it has arrived. The live
   site clamps the same way (Blocks.astro:150, `Math.min(i, 5)`). */

/* ⚠⚠ REDUCED MOTION IS HANDLED IN JS, NOT HERE, AND THAT IS DELIBERATE.
   The global nuke below sets animation-duration:.01ms, which lands a running
   reveal on its END state — correct. But it CANNOT help an element whose
   .is-in never arrives, because the rest state is opacity:0 and the class is
   added by script. So the observer's reduced-motion path adds .is-in to
   everything immediately rather than returning early.
   ⛔ Never make that path `return` — that is the .ol-word trap (brand.css
   below, index.html § reveal), the single most repeated warning in the source
   docs, and it renders as a permanently blank page section. */

/* reduced motion renders the FINAL state, never a frozen first frame */
@media (prefers-reduced-motion:reduce){
  *,*::before,*::after{animation-duration:.01ms!important;
    animation-iteration-count:1!important;transition-duration:.01ms!important}
}

/* ══ credit — the pinned four-card sequence ═════════════════════════════════
   ⚠⚠ THE FIRST position:sticky IN THIS FILE, and a deliberate break of
   DESIGN.md § 5, whose ⛔ list names `sticky-scrub`. Since 2026-09-03 it
   breaks a SECOND item on that same list — "transform: scaleX() progress bars
   (the canonical scroll-driven demo)" — which is now the bar running across
   the four cards. Both are Kacperek's instruction; the rulebook is to be
   revised once the redesign lands. See the markup comment on #credit and the
   overrule table in DECISIONS.md § 4.12. ⛔ Do not "tidy" either back.

   ⭐ REBUILT 2026-09-03 (ref aeye.framer.ai, "Understand the flow"), closing
   open item 18. Was: heading + morphing particle canvas left, intro and four
   text steps right. Now: heading left, intro opposite it, and the four steps
   as a full-width strip of cards with one continuous progress bar across them.
   The canvas, its 1,039-particle JSON and the five dither states are RETAINED
   ON DISK, unreferenced — the 09-02 decision box is explicit that the credit
   modes must not be deleted in order to retire the section.

   ⚠ Sticky is silently destroyed by an ancestor with overflow:hidden — that
   creates a scroll container and the box pins to it instead of the viewport.
   Checked at build: nothing on #credit's chain (.section, .wrap, body, html)
   sets overflow. If a wrapper ever needs clipping here it must be
   overflow-x:CLIP, not hidden — the same reason recorded four times over in
   src/olbra.css and src/site.css. ⛔ And the STRIP must never be given
   overflow-x:auto/scroll to "fix" a narrow viewport: that is a scroll
   container, and it would kill the pin above it. */

/* The section is the positioning context; the ROW is what pins, and the tail
   after it is what the pin spends.
   ⭐ TWO ROWS NOW: heading and intro share row 1, the card strip spans the
   full width beneath them. The strip is the sequence, so it gets the width. */
/* ⚠ ONE COLUMN since 2026-09-03, not two. The right column held the intro
   prose; all three paragraphs were cut, so a two-track grid with an empty
   second track would have left the heading pinned to a .85fr measure for no
   reason — a column that exists only because it used to have something in it.
   The heading spans, the strip spans, and the row is now simply two stacked
   blocks. ⛔ Do not reinstate the second column to "balance" the heading. */
.credit__row{display:grid;grid-template-columns:minmax(0,1fr);
  grid-template-rows:auto auto;
  gap:clamp(2rem,1rem + 3vw,3.5rem);
  align-items:start;
  position:sticky;top:clamp(1rem,calc(50vh - 15.4rem),13rem)}
/* ⭐ THE ROW PINS CENTRED, NOT NEAR THE TOP (Kacperek, 2026-09-03).
   `calc(50vh - <half the row>)` is what centres it; the CAP is what decides
   whether that survives. The previous 4.5rem cap clamped the centring away at
   every ordinary viewport — the term evaluated to ~7.5rem at 900px and the cap
   pulled it back to 4.5, so the row sat high with the whole tail of empty
   section below it.
   ⚠ 15.4rem is HALF THE MEASURED ROW (493px at 1440x900), not a guess — that
   is the only value that actually centres, and it has now MOVED FOUR TIMES:
   16.3rem when the intro prose was cut, 17.5 when the image grew taller, 15.4
   when the card dividers and their padding went. A
   stale half leaves the section subtly off with nothing reporting it — the
   drift is ~19px per change, which is invisible unless measured.
   ⛔ Re-derive it whenever ANYTHING changes the row's height: the copy, the
   image's min-height, the card padding. Nothing enforces the relationship, and
   no check will fail — the section will simply sit slightly wrong.
   ⚠⚠ THE CAP IS THE NUMBER THAT ACTUALLY BINDS, and it was wrong twice. At
   1440x900 true centring wants top:190px; a 4.5rem cap gave 72 and a 9rem cap
   gave 144, so in both cases the clamp was silently overriding the centring it
   was written to allow. 12rem (192px) clears it. ⛔ If you change the row's
   height, check the CAP as well as the half — a cap below the centring term is
   indistinguishable from centring that does not work.
   ⚠ The cap is still a real guard: the row must FIT while pinned
   (off + rowH <= innerHeight). At 1440x700 the centring term drops to ~4.9rem
   on its own and the cap never engages, which is correct — a short viewport
   should pin high rather than push the last card off screen. Verified at four
   viewports; check 5d asserts the fit. */
/* The heading keeps its own measure via the h2's max-width:17ch — the column
   no longer constrains it, so that inline cap is what stops it running the
   full 1200px. ⚠ It is doing real work now; do not remove it as redundant. */
.credit__head {grid-column:1;grid-row:1}
.credit__strip{grid-column:1;grid-row:2}

/* ⭐ align-self:start IS WHAT MAKES THE PIN WORK — not a height.
   A grid item stretches to its row by default, so a column would be as tall
   as the tallest beside it; a sticky box travels within its OWN box, and one
   that tall has nowhere left to go, so it unpins near the end and the content
   slides away mid-sequence.
   ⚠ This exact bug is recorded on the live site's PhoneScroller (/app), which
   is where this pattern comes from. Do not remove it as redundant.
   ⚠ A second reason existed while the intro sat opposite this column (the two
   would stretch to each other's height); the intro was cut 2026-09-03 and that
   reason went with it. The pin reason above stands alone and is sufficient. */
.credit__head{align-self:start}

/* ⭐ THE SECTION HAS TO BE TALLER THAN THE VIEWPORT, or there is no pin.
   ⚠ This was the first bug of the 09-02 build: at its natural height the
   whole section was 441px against a 900px viewport, so it never filled the
   screen, the sticky box had no travel, and all four steps sat on state 0 —
   the sequence silently did not exist. Nothing errored.
   The travel is bought AFTER the pinned row, never inside it, so no card is
   ever pushed off screen to make room for it. */
.credit__tail{height:120vh;pointer-events:none}
/* ⚠⚠ 120vh, MEASURED IN BOTH DIRECTIONS — this is the number the section is
   most sensitive to, and a bare one invites a nudge.

   ⭐ THE METRIC IS PIXELS OF SCROLL PER CARD (the pinned range / 4), not
   "does card 04 finish in time". The first attempt measured the latter and
   scored every candidate 98%, because the frame at the top of the pinned range
   is the HANDOVER — all four bars read full there until the runtime's first
   rAF zeroes them — so card 04 looked complete from the start at every tail.
   A metric that returns the same answer for 80vh and 160vh is measuring the
   harness. Per-card scroll is what the reader actually experiences.

   Measured at 1440x900 / 1280x800 / 1440x700:
   ·  80vh → 180 / 160 / 140px per card. ⛔ 140px at 1440x700 is below the
      floor: a step is over before it can be read.
   · 100vh → 225 / 200 / 175px. Works everywhere, tight at the short viewport.
   · 120vh → 270 / 240 / 210px. Comfortable at all three. ← chosen
   · 140vh → 315 / 280 / 245px. ⛔ Each step starts to drag.

   ⚠ The pin's usable range is `tail - off`, so the tail must clear the sticky
   offset before it buys any sequence at all. Re-measure BOTH ends if the row
   height or the sticky offset changes; the three are related and nothing
   enforces it. ⚠ Below 72rem this is moot — the section unpins entirely. */

/* ── the strip: four cards, one line ──────────────────────────────────────
   ⚠⚠ .steps li is `display:grid; grid-template-columns:2.4rem 1fr;
   align-items:baseline` (see .steps above). .steps--strip li is (0,2,0) and
   .steps li is (0,1,1), so this wins on specificity — but ONLY for the
   properties it actually restates. grid-template-columns is harmless once the
   box is flex, but `align-items:baseline` is NOT ignored and collapses the
   card's cross-axis sizing, which presents as the cards mysteriously
   shrink-wrapping. Reset all three explicitly. This is the same failure mode
   recorded three times in this file (.afeat's --rest, .coin__inner's stroke,
   .hero-rv--title's animation): a broad selector tying with a specific one. */
/* ⛔ NO DIVIDERS BETWEEN THE CARDS (Kacperek, 2026-09-03). They were 1px
   hairlines with the cards padded off them; the strip is now four columns
   separated by a real gutter and nothing else.
   ⚠ THE GUTTER REPLACED THE PADDING, it was not added to it. With no rule to
   sit off, per-card side padding would only shrink the content for no reason
   and push the four bars apart — so --cstep-pad is now VERTICAL only and the
   gap does the separating. */
/* ⭐⭐ THE LINE IS ONE ELEMENT SPANNING ALL FOUR CARDS, not four bars.
   It was per-card bars whose negative margins made them meet at the card
   dividers; with the dividers gone (2026-09-03) and a real gutter between the
   cards, four bars read as four separate tracks — there was nothing left for
   them to meet AT. Only a single element crosses a gutter unbroken.

   ⚠⚠ THE PER-CARD .cstep__bar STILL EXISTS, as an invisible SPACER, and it is
   load-bearing. It is what reserves the slot the line sits in: it takes the
   `margin-top:auto` that collects each card's slack, so the copy above it and
   the image below it land at the same y in all four cards. Delete it and the
   images jump to different heights and the line has nothing to align to.
   ⛔ Do not "simplify" the spacers away because they look empty. */
.credit__strip-wrap{position:relative}
.steps--strip{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));
  gap:var(--s6);--cstep-pad:0rem}
.steps--strip li{display:flex;flex-direction:column;align-items:stretch;
  grid-template-columns:none;gap:0;padding:0}

/* The numeral. Inherits content/font/size from .steps li::before — nothing is
   restated here but its position and the state it can take. */
.steps--strip li::before{display:block;margin-bottom:var(--s4);
  transition:color .28s ease}

/* ⭐⭐ THIS IS WHAT MAKES THE FOUR BARS ONE LINE, and it measures nothing.
   The four cards are already equal height (grid row + align-items:stretch).
   What was NOT enough — and this was measured, not assumed — is
   `.cstep__desc{flex:1}` on its own: `flex-grow` grows an item from its own
   CONTENT height, so a 3-line description stayed 12px taller than a 2-line one
   and the bar under it sat 11.62px low. Two of the four descriptions wrap to
   three lines, so the line was visibly broken in the middle.

   The fix is to make the description's BOX uniform instead of its content:
   `min-height` set from the longest wrap (3 lines at --t-sm/1.5 = 4.5em)
   pushes every bar to the same y, and `margin-top:auto` on the bar then holds
   it there even if a future description runs longer still.
   ⛔ Do not swap the min-height for a fixed `height` — a 4-line description
   would overflow it silently rather than push the bar down honestly.
   ⚠ Guarded by check 5e in verify-reveal.mjs, which is the ONLY thing that can
   see this: an 11px stagger across four cards is invisible in a screenshot and
   reads as a design choice rather than a bug. */
/* ⚠⚠ THE MECHANISM IS `margin-top:auto` ON THE BAR, and the two things that
   had to be true before it worked were both found by measuring, not looking:

   1. The DESCRIPTION had to stop setting the bar's y. `flex:1` does not
      equalise heights — flex-grow grows an item from its own CONTENT box — so
      the two descriptions that wrap to three lines sat 12px taller and their
      bars sat 12px low. A min-height sized to the longest wrap (3 lines at
      --t-sm/1.5 = 4.5em) makes every description occupy the same box.
   2. The PLACEHOLDER had to stop absorbing the slack. aspect-ratio gives it a
      height, but a flex item is still shrinkable by default, so it was being
      squashed by up to 12px in the cards with shorter text — which then moved
      everything above it. flex-shrink:0 holds its ratio.

   With both, the leftover space is genuinely free, `margin-top:auto` on the
   bar collects ALL of it in one place, and the four bars land on one y no
   matter how any description wraps. ⛔ Do not remove any of the three: each
   was necessary and none was sufficient. */
.cstep__desc{margin:0;color:var(--ink-2);font-size:var(--t-sm);
  line-height:1.5;padding-bottom:var(--s4);flex:1 0 auto}

/* ⚠⚠ `align-items:end` ON THE BAR'S SHARE OF THE CARD IS WHAT ALIGNS THE FOUR
   BARS, and the two obvious mechanisms both failed here — recorded because
   each looked correct and neither was:

   · `min-height:4.5em` — meant as "three lines", is not. `em` resolves against
     font-size (14px), giving 63px, while three RENDERED lines are 87px because
     each line box rounds up. The floor sat below the tallest description, so it
     never engaged at all.
   · `min-height:calc(3lh + 1px)` — `lh` is the COMPUTED line-height (21px), so
     3lh = 63-64px. Same miss, for a different reason, and `CSS.supports()`
     returns true for it, so it looks like it worked.

   ⭐ The description simply IS a different height in card 03, which wraps to
   three lines where the others wrap to two. Rather than predict that height,
   the copy block above the bar is given `flex:1` and the bar is pushed to its
   bottom with `margin-top:auto`: the slack collects between the description and
   the bar, so the bar lands at the same y regardless of how anything wraps.
   ⛔ This is the mechanism. Do not reintroduce a min-height on the description
   to "reserve space" — it cannot know the answer, and check 5e will fail.
   ⚠ The declaration itself lives in the main .cstep__bar rule below, NOT here.
   Written as a separate rule above it, both are (0,1,0) and the later one wins
   on source order — the fifth instance of that trap in this file. */

/* ⚠ THE BAR NO LONGER BLEEDS, and the line is no longer literally continuous.
   The negative margin existed so adjacent bars met at the hairline divider,
   turning it into a tick mark rather than a break. With the dividers gone
   (2026-09-03) there is nothing to meet at: the bars are four separate tracks
   with the grid's gutter between them, which is what the reference does too.
   ⛔ Do not reinstate the bleed to "close" the gaps — margin-inline against a
   gutter makes adjacent bars overlap into each other's column.

   ⚠ HISTORICAL: while the cards had 1px dividers, the detector reported four
   `cramped-padding` false positives here — it saw the bar flush against a
   border. Removing the dividers (2026-09-03) removed the boundary and the
   finding with it; the detector is back to its baseline 4. Kept as a note
   because the diagnosis was measured (text children sat at a 25px inset; only
   the bar was flush, by design) and the same shape would return if a border
   ever comes back. */
/* The per-card SPACER. Transparent and the same height as the line above it,
   so the slot is reserved identically in every card. ⚠ Its height and the
   line's must match — they are two halves of one measurement. */
.cstep__bar{height:2px;margin-top:auto}

/* ⚠⚠ THE REST STATE IS THE FINISHED STATE — scaleX(1), not 0. Read the note
   at the foot of this block before changing it; it is the whole reduced-motion
   and no-JS guarantee, and it is the opposite of every other effect here.
   ⛔ No will-change: four promoted layers for a 2px bar is memory for nothing. */
/* ⭐ THE FILL RUNS PALE PINK → ORANGE (Kacperek, 2026-09-03), the hero plate's
   own two tones rather than a flat accent.
   ⚠ ORANGE AT THE LEADING EDGE, pale behind it — the reverse was built first
   and is wrong: --plate-pale is 1.24:1 on the cream ground, so with pale
   leading, the tip of the bar (the part the eye tracks) was the least visible
   part of it. The stronger tone belongs where the motion is.
   ⚠ The gradient is painted across the WHOLE track and revealed by the scale,
   NOT sized to the fill — a background-size tied to --f would repaint on every
   scroll frame, where scaleX is compositor-only. So the ramp is fixed to the
   bar's full width and the transform uncovers it left to right.
   ⚠ Tokens, never hexes: --plate-warm IS --coin-orange (aliased at § the
   palette), and both travel with the plate if it is ever retuned. */
/* ⭐ THE LINE ITSELF. Absolutely positioned in .credit__strip-wrap and pinned
   to the bottom of the spacer row, so it inherits the y the cards computed
   rather than needing its own measurement.
   ⚠ `bottom` is measured from the wrapper's foot: the image is the only thing
   below the bar, so this is the image's height plus its top margin. Both are
   tokens, so the calc tracks them — ⛔ do not replace it with a literal.

   ⚠⚠ THE TRACK IS A HAIRLINE, NOT AN INK RAIL (Kacperek, 2026-09-03). It was
   5px of --ink with the fill inset inside it, which made the unreached part of
   the sequence the heaviest thing in the section — a black bar under three
   cards that had not been reached yet, competing with the copy. The track's
   job is only to show where the line will go; the OXBLOOD is what carries the
   meaning, so the fill sits ON the track rather than inside it.
   ⛔ Do not restore an ink track to "make the empty part visible". That was
   tried and it inverts the read: the untouched remainder should be the quiet
   part. --rule (0.22 alpha) is enough to trace the path. */
.credit__line{position:absolute;left:0;right:0;height:2px;background:var(--rule-2);
  bottom:calc(11rem + var(--s3));pointer-events:none}
/* ⭐ OXBLOOD, flat, and the FULL height of the track — no inset. --ox is
   11.09:1 on the cream ground, so at 2px it still reads cleanly.
   ⚠ Flat, not a gradient: a ramp across one line spanning all four cards would
   put its light end under card 01 and its dark end under card 04 permanently,
   which reads as the early steps being less complete than the late ones.
   ⚠ The pale-pink → orange ramp this replaced measured 1.24:1 and 1.63:1
   against the ground — fainter than the track holding it. A gauge has to be
   legible before it is decorative. */
.credit__lineFill{position:absolute;inset:0;display:block;background:var(--ox);
  transform:scaleX(var(--f,1));transform-origin:left center}

/* ⭐⭐ THE TITLES ARE ALWAYS PRESENT, AND THEY CARRY THE STATE (Kacperek,
   2026-09-03). The four titles are the SEQUENCE — "hold → pledge → lend →
   repay" — so all four are legible from the moment the strip appears, which is
   what justifies the numbering at all (DESIGN.md § 3c). What changes as the
   scroll reaches each card is their WEIGHT of colour: grey until reached, ink
   once reached.
   ⛔ Not --ox. That was the first build's choice and it is wrong here: the
   title is the loudest text in the card, and setting it in the brand accent
   made four oxblood headlines compete with the bar, which is the element
   actually reporting progress. Ink is the resolved state; the accent belongs
   on the bar and the image frame.
   ⚠ Rest is --ink-3 (5.39:1) — the documented floor for small text, so an
   unreached title is genuinely readable rather than hinted. The effect
   emphasises; it never hides. ⛔ NEVER --fill-rest: its own token comment says
   "SCROLL-FILL RESTING STATE ONLY. It is not a text colour: never reuse it."

   ⚠ The title sits directly under the numeral (order: numeral → title →
   description → bar → image), so it has no top margin and the gap below it is
   what separates it from its description. */
/* ⚠ The `.cstep__title` rule that stood here was DEAD (removed 2026-09-15):
   a second rule with the same selector and the same specificity sits further
   down this file and overrode it declaration for declaration, so editing this
   one changed nothing on the page. The live rule is the one in oxblood, below
   the bar — search `.cstep__title`. */

/* ⭐⭐ THE IMAGE IS THE WHOLE LOWER HALF OF THE CARD (Kacperek, 2026-09-03).
   It was a 62%-wide box tucked bottom-right; it now fills everything below the
   bar, edge to edge, and `flex:1` is what makes it do so — it is the last
   child and the only element allowed to grow, so all the card's slack lands
   in it rather than above it.

   ⚠⚠ THIS IS ALSO WHAT KEEPS THE FOUR BARS ON ONE LINE, and it replaces the
   `margin-top:auto` that used to do it. With the image absorbing every spare
   pixel BELOW the bar, the bar's y is fixed by what sits ABOVE it — numeral +
   title + description — and the description's min-height (sized to the longest
   wrap, 3 lines) makes that stack identical in all four cards.
   ⛔ Do not give the image a fixed height or an aspect-ratio: either makes its
   height depend on the card's WIDTH, and the outer two cards are ~24px wider
   because they drop their outside padding. That is exactly the bug that put
   the bars 11.62px out of line the first time — an aspect-ratio box turned
   extra width into extra height. `flex:1` has no such dependency.
   ⚠ A div, not an <img>: there is no file to point at yet. min-height is a
   floor for very short viewports; normally the image is taller than it. */
/* ⭐ SAME TREATMENT AS #problem's .pcard__art (Kacperek, 2026-09-03): a filled
   --sunken box with a mono label, so it reads as a placeholder rather than as
   a design decision. It was an oxblood-outlined empty box, which looked like
   a deliberate framed slot.
   ⛔ NO BORDER (Kacperek, explicitly) and ⛔ NO border-radius — the latter is
   a standing rule from 2026-09-01: images on this page have square corners.
   The 12px radius belongs to CARDS, which are a different object — a card is a
   container the page draws, an image is a picture it places, and rounding the
   picture makes it look like chrome.
   ⚠ The card's own left hairline is what separates the four; the placeholder
   does not need an edge of its own and gains a competing one if given it. */
.cstep__ph{flex:1;min-height:11rem;margin-top:var(--s3);
  background:var(--sunken);display:grid;place-items:center;
  padding:var(--s4);opacity:1;overflow:hidden}
/* ⭐ INTERIM ART, 2026-09-04 — the same form at four densities (7.3% → 23.8%
   coverage), so the sequence the copy describes is visible in the pictures.
   ⚠⚠ NO aspect-ratio HERE, AND THAT IS LOAD-BEARING. DECISIONS § 4.13: the
   outer two cards drop their outside padding, making their content box ~24px
   wider — with a ratio that extra width becomes extra HEIGHT (120.89 vs
   109.27px) and cards 01 and 04 ride 11.62px high, breaking the one line the
   four bars form. The box keeps its FIXED height and the image fits inside it.
   ⛔ Do not "tidy" this into aspect-ratio.
   ⚠ Placeholder-quality, like #problem's — custom art still expected.
   ⚠ RGBA with a transparent ground — see .pcard__art img. */
.cstep__ph img{width:100%;height:100%;object-fit:contain}

/* ── the dimmed state — AFTER the base rules, and gated on .is-live ────────
   ⛔ MODIFIERS GO AFTER BASE RULES. Three separate bugs in this file came from
   a broad selector tying with a specific one and winning on source order.

   ⭐⭐ AND NOTE THE DIRECTION: the base above is the FINISHED state and this
   TAKES IT AWAY. .is-live is added by the runtime only on the path where it
   will genuinely drive the sequence, so no-JS, a JS error, reduced motion and
   the flat layout all land on four full bars and four lit titles for free,
   with no special path to forget.
   ⚠ It has to be this way round. The global reduced-motion nuke collapses
   ANIMATIONS AND TRANSITIONS — and `transform:scaleX(var(--f))` is a static
   declaration, so the nuke does not touch it. Written the usual way round
   (rest at scaleX(0), JS fills it) a reduced-motion or JS-off reader would get
   four permanently empty tracks. That is the .ol-word trap in a new shape, and
   it is the single most repeated warning in these documents. */
.credit.is-live .cstep__title{transition:color .28s ease}
.credit.is-live .cstep__desc,
.credit.is-live .cstep__ph{transition:opacity .34s ease}
/* ⚠⚠ opacity ONLY — the description keeps its box whether or not it is shown.
   Its min-height is what puts all four bars on one y (see .cstep__desc), so
   hiding it with display:none, height:0 or a visibility toggle that collapses
   the box would break the continuous line the section is built on. It fades,
   it never leaves. Verified by check 5e at both ends of the sequence. */
.credit.is-live .cstep:not(.is-lit) .cstep__title{color:var(--ink-3)}
.credit.is-live .cstep:not(.is-lit) .cstep__desc{opacity:0}
/* ⚠ opacity only — the border-colour half of this went with the outline. */
.credit.is-live .cstep:not(.is-lit) .cstep__ph{opacity:.45}
.credit.is-live .cstep:not(.is-lit)::before{color:var(--ink-3)}
.credit.is-live .cstep::before{transition:color .28s ease}
.credit.is-live .cstep.is-lit::before{color:var(--ink)}

/* ── 2x2 before the stack ─────────────────────────────────────────────────
   Four cards across a 1200px container leaves ~250px each, and step 03's
   description is the longest in the set. Below 72rem they go two-up, and each
   grid ROW gets its own continuous line automatically — the flex mechanism
   above needs no help.

   ⚠⚠ AND THE PIN IS RELEASED HERE, WHICH IS THE POINT OF THE SECOND QUERY
   BELOW. Two rows of cards doubles the strip's height: measured at 1024x768
   the row came to 857px against a 768px viewport, so the fourth card was
   BELOW THE FOLD for the whole pin — the reader scrubs a sequence whose last
   step they cannot see, which is the one failure this layout exists to
   prevent. A 2x2 grid and a pin are incompatible at ordinary laptop heights.
   ⛔ Do not re-pin this range without re-measuring off + rowH <= innerHeight
   at 1024x768 AND 1280x800; the strip has to go back to one row to fit. */
@media (max-width:72rem){
  .steps--strip{grid-template-columns:repeat(2,minmax(0,1fr))}
}

/* ⚠ The unpin that the 2x2 above requires. Kept as its own block so the
   grid change and the pin change cannot drift apart — they are one decision.
   Everything below 72rem is a plain scrolling section: no pin, no scrub, and
   the runtime's `flat` matchMedia does not cover this range, so .is-live is
   still added and the cards would still light. That is fine and deliberate —
   what must not happen is a PINNED row taller than the viewport. */
@media (max-width:72rem){
  .credit__row{position:static;top:auto}
  .credit__tail{height:0}
}

/* ── short viewports ──────────────────────────────────────────────────────
   ⚠ The pinned row must FIT: off + rowHeight <= innerHeight, or the last card
   is cut off while it is pinned — the one failure this layout cannot survive.
   1440x700 is the binding case (measured 2026-09-03: 151px of headroom before
   the rebuild). Trim the card's own padding and the placeholder first; the
   sticky offset's clamp is already doing this work at the top. */
@media (max-height:760px){
  .steps--strip{--cstep-pad:var(--s4)}
  .cstep__ph{min-height:9rem;margin-top:var(--s3)}
  .cstep__desc{padding-bottom:var(--s4)}
}

/* ── reduced motion: no pin, no sequence, everything lit ──────────────────
   ⚠ THE FINAL STATE, NEVER A FROZEN FIRST FRAME. Almost all of this is free:
   .is-live is never added, so the base rules above already render four full
   bars and four lit titles. All this block has to do is remove the travel. */
@media (prefers-reduced-motion:reduce){
  .credit__row{position:static;top:auto}
  .credit__tail{height:0}
  .cstep__title,.cstep__desc,.cstep__ph,.steps--strip li::before{transition:none}
}

/* ── below the two-column breakpoint: a plain stack ───────────────────────
   Nothing pins and nothing is scroll-linked, the same collapse as every other
   two-column section here. The runtime's own `flat` matchMedia matches this
   number and never adds .is-live below it, so all four cards render lit and
   full with no extra rules. ⚠ ONE NUMBER IN TWO PLACES — 56rem here and in
   the script; they must change together. */
@media (max-width:56rem){
  .credit__row{grid-template-columns:1fr;gap:var(--s7);position:static;top:auto}
  .credit__head,.credit__strip{grid-column:1;grid-row:auto}
  .credit__tail{height:0}
  .steps--strip{grid-template-columns:1fr;gap:var(--s7)}
  .cstep__desc{padding-bottom:var(--s4)}
  .cstep__ph{min-height:12rem}
}

/* ── the external-link arrow ───────────────────────────────────────────────
   ⭐ Marks a link that opens in a NEW TAB (Kacperek, 2026-09-07). Docs and
   Blog are reference material a reader returns FROM, so they open beside the
   page rather than replacing it — and an unannounced new tab is the thing that
   makes a back button appear broken.
   ⚠ THE ARROW IS NOT THE ONLY SIGNAL. It is aria-hidden and paired with a
   visually-hidden "(opens in a new tab)", because a glyph conveys nothing to a
   screen reader and meaning carried by a symbol alone fails the same test as
   meaning carried by colour alone.
   ⚠ Sized in em and given its own leading space, so it tracks the link's type
   at any scale and never collides with the word. */
/* ⚠ THE ARROW TRAVELS ON HOVER, up and to the right — the direction it
   points, which is what makes the motion read as "this leaves the page"
   rather than as a wobble. 2px on each axis: enough to notice, small enough
   that it never collides with the next nav item.
   ⚠ `transform`, not margin or position — the only property here that
   composites, and the arrow sits inside a link that may be mid-hover on a
   scrolling page.
   ⚠ .22s and the house button curve, so it matches the button's own hover
   rather than introducing a second easing for one glyph. */
.ext{font-size:.78em;line-height:1;margin-left:.28em;
  display:inline-block;vertical-align:baseline;
  color:var(--ink-3);
  transition:color .18s ease,
             transform .22s cubic-bezier(.44,0,.56,1)}
a:hover .ext,a:focus-visible .ext{color:inherit;transform:translate(2px,-2px)}
/* ⚠ The travel goes under reduced motion; the COLOUR change stays, so the
   hover is still legible without anything moving. */
@media (prefers-reduced-motion:reduce){
  .ext{transition:color .18s ease}
  a:hover .ext,a:focus-visible .ext{transform:none}
}
.nav__links a .ext,.navm__panel a .ext{color:inherit;opacity:.55}
.nav__links a:hover .ext,.navm__panel a:hover .ext{opacity:1}
.foot__col a .ext{opacity:.5}
.foot__col a:hover .ext{opacity:1}

/* ══ #credit — THE CARDS ARE THE ILLUSTRATION, 2026-09-07 ═══════════════════
   ⭐⭐ THE FIGURES ARE GONE, AND THAT IS THE DESIGN. Kacperek: "lets rework
   the section and cards so they become their own thing without the
   illustration here."

   ⛔ DO NOT RESTORE A FIGURE SLOT. Three approaches were tried and rejected:
   one coin carried through four states (every step had to ADD a thing, which
   left no room for motion and put a ring around the disc twice — DESIGN.md
   § 4's rosette); four independent icons (a coin banded at 63.6% ground, but
   a padlock — mostly negative space — banded at 88.1% and read thin); and a
   background field behind each icon (works numerically, but changes what the
   --sunken tile is FOR and drifts toward the hero's licensed register).

   ⚠ The root cause is structural, not a tuning failure: OUR DITHER MAPS TONE,
   so a picture with little ink has little to band. The hero plate escapes it
   by being a FIELD. An icon on blank paper is the opposite shape of problem.

   ⭐ Competitive research, 2026-09-07 (~20 sites, assets and pixels sampled,
   not impressions): almost nobody puts a discrete object in a small tile.
   Bridge — Stripe's stablecoin arm and our nearest competitor — ships this
   exact section as hairline rules, an outline icon and type, no illustration.
   So do Linear and Vercel. Restraint currently signals seriousness here.
   Full record: HANDOFF-2026-09-07-v2.md § 1.

   ── What carries the section now ──────────────────────────────────────────
   The scrub already existed and already lit each card in turn; it was only
   ever changing text colour and fading a picture. With the picture gone it
   carries the whole appearance, so the cards ARRIVE as the reader scrolls.
   ⛔ This is NOT the .rv reveal system, and it must not become it:
   verify-reveal.mjs:332 asserts ZERO .rv inside #credit, because .rv sets
   opacity:0 on elements inside a PINNED row — a reveal that never fires would
   leave the sequence blank — and .rv also overwrites transform, which fights
   the pin. The scrub is the right mechanism and it is already here. */

/* The card is now the object. A ground step and a hairline, which DESIGN.md
   § 3 § 378 explicitly permits — "Cards, panels and tiles are allowed, with a
   ground step and, where it helps, a hairline."
   ⚠ NOT a border on all four sides: the page carries the frame (§ 387), and a
   fully-boxed card would fight the section's own dashed registration rules. */
.steps--strip li.cstep{
  padding:var(--s5) var(--s5) var(--s6);
  border-top:1px solid var(--rule);
  transition:background .38s ease, border-color .38s ease, transform .38s var(--rv-ease)}

/* ⭐⭐ THE CARD GOES WHITE WHEN IT ACTIVATES (Kacperek, 2026-09-07).
   The lit card lifts off the page onto --ground, the site's own white; the
   unlit ones sit on nothing at all. That is the whole card treatment — no
   fill at rest, a ground and a hairline when the reader arrives.

   ⚠⚠ THE REST STATE IS STILL THE LIT STATE. DECISIONS § 4.13's inversion is
   untouched: `.cstep` alone is transparent, but with no JS every card carries
   .is-lit, so a JS-off reader sees all four white and complete. The unlit
   look only exists under .is-live. ⛔ Do not move the white onto bare .cstep
   "so it shows by default" — that is the same rule read backwards, and it
   would leave the ACTIVE state with nowhere to go. */
/* ⚠⚠ SPECIFICITY: this MUST out-weigh `.steps--strip li.cstep` above, which
   is (0,2,1) — two classes and an element. `.cstep.is-lit` alone is (0,2,0)
   and LOSES, silently: the card stayed transparent while the rule looked
   correct. Matching the base selector's shape and adding .is-lit makes it
   (0,3,1). ⛔ This file records five earlier instances of exactly this trap;
   do not "tidy" the selector back down. */
/* ⚠⚠ THE REST STATE IS THE FINISHED STATE — and I got this wrong once here.
   Writing the white on `.is-lit` alone left a JS-OFF reader with four
   transparent cards, because nothing ever adds .is-lit without the runtime.
   ⭐ So the WHITE IS THE BASE, and .is-live is what takes it away. Every card
   ships lit; the runtime introduces the unlit look. That is DECISIONS § 4.13's
   inversion, and it is the single most repeated warning in these documents. */
.steps--strip li.cstep{
  background:var(--ground);
  border-top-color:var(--rule)}
/* Under the runtime, the lit one keeps it explicitly (the :not() below removes
   it from the others). Same shape as the base selector so it cannot lose. */
.credit.is-live .steps--strip li.cstep.is-lit{
  background:var(--ground);
  border-top-color:var(--rule)}

/* The unlit ones, once the runtime is driving. */
.credit.is-live .cstep:not(.is-lit){
  background:transparent;
  border-top-color:transparent;
  transform:translateY(var(--s2))}

/* The number and the title lift with the card rather than fading separately —
   one movement, not three. ⚠ .cstep__desc's opacity is already handled above
   by the existing .is-live rules; do not duplicate it here. */
.credit.is-live .cstep{transition-delay:0s}
.credit.is-live .cstep.is-lit{transform:none}

/* ⚠ The bar sat at the card's foot when a figure was below it. With the
   figure gone the card ends at the bar, so the description no longer needs to
   reserve the slack — but margin-top:auto on .cstep__bar STAYS. It is what
   holds the four bars on one y whatever the copy wraps to, and check 5e is
   the only thing that can see an 11px stagger. */
.cstep__desc{flex:1 0 auto;padding-bottom:var(--s5)}

/* ⚠ The line's `bottom` was calc(11rem + var(--s3)) — the figure's height and
   margin, a literal copy of geometry that no longer exists. With nothing
   below the bar it sits at the card's foot. */
/* ⭐ THE LINE SITS BELOW THE CARDS (Kacperek, 2026-09-07). It used to sit at
   the card's foot with the figure beneath it; with the figure gone there is
   nothing to separate it from, so it belongs UNDER the strip — the four cards
   are the sequence and the line measures across them.
   ⚠ `bottom:0` is the wrapper's foot, and .credit__strip-wrap wraps the <ol>
   exactly, so this puts the line on the strip's bottom edge. The per-card
   .cstep__bar spacers keep their slot at the card foot for the alignment
   mechanism (see below) but nothing is drawn there any more. */
.credit__line{bottom:0}

/* ── ⭐⭐ THE BAR TELLS THE STORY (Kacperek, 2026-09-07) ────────────────────
   With the figures gone the line is the section's ONLY visual device, so it
   carries the whole argument: where you are, and how far through.

   ⚠⚠ I BROKE THIS ONCE ALREADY, IN ONE SESSION, AND THE FAILURE WAS SILENT.
   `.credit__lineFill` is `position:absolute; inset:0` — and that inset is what
   gives it its HEIGHT. Setting `position:relative` on it (to get a z-index)
   made the inset inert and the fill collapsed to 0px tall: the oxblood
   vanished completely while every other measurement still read correctly.
   ⛔ DO NOT change its `position`. A bare `z-index` layers it fine. */
.credit__line{height:2px}

/* ⭐ THE DITHER, ON THE TRACK. The unreached part of the line is now a dotted
   rule rather than a solid hairline — the same register the page's dashed
   rules and the figures' dot screen use, so the one remaining device speaks
   the brand's own language. ⚠ 2px pitch at 2px height: coarser reads as a
   dashed border, finer dissolves into a solid line at 1x.
   ⛔ Do NOT thicken the track. DECISIONS records the 5px --ink version: "the
   unreached part of the sequence became the heaviest thing in the section — a
   black bar under three cards that had not been reached yet, competing with
   the copy." The track shows where the line WILL go; the oxblood is what
   means something. */
.credit__line{
  background:repeating-linear-gradient(90deg,
    var(--rule-2) 0 1px, transparent 1px 3px)}

/* ⛔ NO NODES ON THE TRACK, AND NO CIRCLES ANYWHERE HERE (Kacperek,
   2026-09-07). Four dots marking each card's start were tried and cut on
   sight: the line is a continuous measure, not a set of stations, and a
   circle is a shape this section does not otherwise use. ⚠ Do not re-add them
   to "show the steps" — the four cards already do that, and the numbers
   already say so. */

/* ⚠ z-index ONLY. See the warning above — never `position`. */
.credit__lineFill{z-index:1}

/* ⭐⭐ A TRAVELLING BLOOM — orange → pale pink, moving along the filled part.
   ⚠⚠ DECISIONS explicitly rejected a gradient here, and the reason has to be
   answered rather than ignored: "a ramp across one line spanning all four
   cards would put its light end under card 01 and its dark end under card 04
   PERMANENTLY, which reads as the early steps being less complete than the
   late ones", and the pale-pink→orange ramp it replaced "measured 1.24:1 and
   1.63:1 against the ground — fainter than the track holding it."
   ⭐ Both objections are answered by construction, not overruled:
   · it MOVES, so no card has a permanently lighter end;
   · it is a HIGHLIGHT OVER the oxblood, not a replacement for it — --ox stays
     the fill's background, so the gauge's contrast is untouched. */
.credit__lineFill::after{
  content:"";position:absolute;inset:0;pointer-events:none;
  background:linear-gradient(90deg,
    transparent 0%,
    rgba(255,187,135,.85) 38%,
    var(--bloom) 50%,
    rgba(255,187,135,.85) 62%,
    transparent 100%);
  background-size:42% 100%;background-repeat:no-repeat;
  animation:credit-bloom 5.5s linear infinite}

@keyframes credit-bloom{
  from{background-position:-45% 0}
  to  {background-position:145% 0}}

/* ⛔ MOTION IS OPT-IN. A continuously travelling highlight is exactly what
   prefers-reduced-motion is for, and the global nuke only collapses
   animations — which here would freeze the bloom mid-track as a static ramp,
   i.e. the very thing DECISIONS rejected. So it is removed, not paused. */
@media (prefers-reduced-motion:reduce){
  .credit__lineFill::after{display:none}
}

/* ── the cards ─────────────────────────────────────────────────────────────
   ⭐ THE PALE PINK BLOOM (Kacperek, 2026-09-07), the same --bloom the button
   halo and the tile use — so the section borrows a device the page already
   has rather than inventing one.
   ⚠ It is on the LIT card only. The bloom marks where the reader is; a wash
   under all four would be decoration, and DESIGN.md § 1.4 keeps decoration
   away from anything that could be mistaken for state. */
/* ⛔ THE PINK BLOOM IS GONE (Kacperek, 2026-09-07, asked twice).
   ⚠⚠ IT DID NOT COME OUT THE FIRST TIME, AND THE REASON IS WORTH KEEPING:
   the bloom was written on `.cstep::before` — which is ALSO the step number's
   pseudo-element (`content: counter(step, decimal-leading-zero)`). Setting
   `content:none` to kill the bloom was overridden by the counter rule, so the
   pink radial kept painting at opacity .55 while the CSS read as removed.
   ⭐ Never share a pseudo-element between a decoration and a content counter:
   there is only one ::before per element, and whichever rule wins `content`
   takes the whole thing with it.
   The card going white on activation IS the state change; a wash under it was
   a second signal saying the same thing. */
.cstep{position:relative}

/* ⚠⚠ THE PER-CARD .cstep__bar STAYS, EVEN THOUGH NOTHING IS DRAWN IN IT.
   It is a transparent SPACER, and `margin-top:auto` on it is what collects the
   card's slack so all four cards end at the same y whatever their copy wraps
   to. Removing it collapses that mechanism and check 5e — the only thing that
   can see an 11px stagger — starts failing.
   ⭐ With the line moved below the strip it no longer needs to reserve a
   VISIBLE slot, so its height goes to 0 and the cards close up by 2px. The
   margin-top:auto is the part that matters and it is untouched. */
.cstep__bar{height:0}

/* The strip needs room under it for the line to sit in. */
.credit__strip-wrap{padding-bottom:var(--s5)}

/* ── ⭐ THE ICONS, IN TWO STATES (Kacperek, 2026-09-07) ─────────────────────
   Each card carries the same icon twice — an unlit cut and a lit cut — and
   the scrub cross-fades between them as the card lights.

   ⚠⚠ TWO FILES, NOT A CSS RECOLOUR, AND THAT IS THE POINT. The figures are
   flat five-band PNGs in the plate palette; a filter() or opacity change would
   shift every band at once and wreck the ramp. Supplying both cuts means the
   artwork decides what "unlit" looks like, not a blend mode.
   ⛔ Do not "simplify" this to one image plus a filter.

   ⚠ Both cuts are in the markup and both load. That is deliberate: swapping a
   src on scroll would flash an unloaded image the first time each card lights.

   ⭐ THE REST STATE IS THE LIT STATE, like everything else in this section.
   With no JS every card is lit, so `-on` is what shows and `-off` sits under
   it at opacity 0. The runtime's .is-live is what introduces the unlit cut.
   ⛔ Do not invert this — DECISIONS § 4.13, the .ol-word trap. */
.cstep__ic{position:relative;width:100%;max-width:var(--cstep-ic,7rem);
  aspect-ratio:1;margin:0 0 var(--s4)}
.cstep__ic img{position:absolute;inset:0;width:100%;height:100%;
  object-fit:contain;display:block}
.cstep__ic-off{opacity:0}
.cstep__ic-on{opacity:1}

/* The swap, gated on .is-live so no-JS never sees it. */
.credit.is-live .cstep__ic img{transition:opacity .38s ease}
.credit.is-live .cstep:not(.is-lit) .cstep__ic-off{opacity:1}
.credit.is-live .cstep:not(.is-lit) .cstep__ic-on{opacity:0}

/* ⚠ Reduced motion: the swap is a state change, not decoration, so it still
   happens — it just happens instantly. The global nuke already kills the
   transition; this line only documents that the swap itself is intended. */
@media (prefers-reduced-motion:reduce){
  .cstep__ic img{transition:none}
}

/* ⚠ The icon is the card's first child, so it sits ABOVE the title. It is
   inside the card, not in a tile of its own: the research (HANDOFF § 1) found
   that a figure in its own --sunken box is what made a sparse object read as
   stranded. Here it sits directly on the card ground with nothing around it. */

@media (max-width:72rem){ .credit{--cstep-ic:8rem} }
@media (max-width:46rem){ .credit{--cstep-ic:9rem} }

/* ══ #credit — THE REFERENCE LAYOUT, 2026-09-07 ═════════════════════════════
   Rebuilt to match aeye.framer.ai's four-card row exactly, measured off the
   live page at 1440 rather than eyeballed:

     card 294x384, no fill, no border, no radius, 8px gutter
     y16   // 001            16px / 500   #474747
     y110  description       16px / 400   #474747
     y233  TITLE             24px / 500   #0055FF   <- the accent, and LARGE
     y288  icon 80x80

   ⭐ The order is INVERTED from ours: number → description → title → icon.
   The title is the resting point at the bottom, not the entry point at the
   top, and it is the only coloured thing in the card.
   ⚠ Our bar sits between description and title — the reference has no bar,
   but ours is the section's argument and it lands where their rule does. */

/* ⭐ THE NUMBER — larger and mono, as the reference has it. */
.steps--strip li.cstep::before{
  font-size:var(--t-sm);font-weight:500;letter-spacing:-.02em;
  color:var(--ink-3);margin-bottom:var(--s6)}

/* ⭐ THE TITLE — below the bar, in oxblood. This is the reference's blue
   accent in our palette. ⚠ --ox is 11.09:1 on the ground, so it carries at
   this size without qualification.
   ⚠⚠ WAS `var(--t-h4,1.5rem)` AND --t-h4 WAS NEVER DEFINED (2026-09-15):
   grep returned exactly one hit — this consumer — so the fallback always won
   and the heading silently opted out of the fluid scale every other heading
   uses. It is --t-h3 now, which is the shared card-title size the type pass
   established, and whose 24px cap is the number this comment described.
   ⛔ Do not reintroduce --t-h4 for one consumer.
   ⚠ A SECOND `.cstep__title` RULE SAT ~460 LINES ABOVE at equal specificity
   and was entirely dead — same selector, overridden declaration for
   declaration. Removed with this edit. ⛔ This is the only one. */
.cstep__title{
  margin:var(--s5) 0 var(--s4);
  font-size:var(--t-h3);font-weight:500;letter-spacing:-.02em;
  line-height:1.3;color:var(--ox)}
/* ⚠ Unlit, the title drops to --ink-3 rather than fading out: it is the
   card's name and has to stay readable before the reader arrives. */
.credit.is-live .cstep:not(.is-lit) .cstep__title{color:var(--ink-3)}

/* ⭐ THE DESCRIPTION comes FIRST now, so it no longer needs the bottom padding
   that separated it from a title beneath it. */
.cstep__desc{padding-bottom:0;margin-bottom:0}

/* ⭐ THE ICON, LAST AND SMALL — 80x80 in the reference against a 294 card,
   i.e. 27% of the width. ⛔ Not the 7rem block it was: that made it the
   card's subject, and in this layout the TITLE is the subject. */
.cstep__ic{width:5rem;max-width:5rem;margin:auto 0 0;aspect-ratio:1}

/* ⚠ The bloom's removal is handled above, at its own rule. An attempt to kill
   it here with `content:none` was a NO-OP — see the note there. */

/* ⚠⚠ THE BAR MOVED INTO THE MIDDLE OF THE CARD, so `margin-top:auto` on it no
   longer aligns anything — the slack now collects BELOW it, under the title
   and the icon, and the four bars drifted 31.19px apart the moment the order
   changed. Measured immediately, not noticed by eye: an 11px stagger already
   reads as a design choice, and 31px reads as a broken line.

   ⭐ The fix is to move the auto-margin to whatever is LAST. The icon now
   takes the slack, which pins everything above it — the bar included — to a
   shared y, exactly as the bar used to do for the description.
   ⛔ Do not put a min-height back on .cstep__desc to "reserve" space: it
   cannot know how the copy wraps, and DECISIONS records two failed attempts
   at precisely that (em and lh both miss the real wrapped height, for
   different reasons). */
.cstep__desc{flex:0 0 auto}
.cstep__bar{margin-top:0}
.cstep__ic{margin-top:auto}

/* ⭐⭐ THE DESCRIPTION NEEDS A FLOOR, and it has to be a MEASURED px value.
   With the bar above the title rather than below it, the bar's y is set by the
   description's height — and card 03 wraps to three lines where the others wrap
   to two. Measured at 1440: 63px against 42px, which is the whole 21px spread.

   ⚠⚠ TWO PLAUSIBLE UNITS BOTH MISS, and DECISIONS records both failures:
   · `min-height:4.5em` resolves against font-size, giving 63px on paper but
     not matching three RENDERED lines, because each line box rounds up;
   · `min-height:3lh` uses the computed line-height and misses for the same
     reason. ⚠ CSS.supports() returns true for it, so it looks like it worked.
   ⭐ So this is the measured render height of the longest description, in px,
   at the design width. ⛔ If the copy changes, RE-MEASURE — do not derive it.
   Verified after: bar spread 0.00px at 1280/1440/1920. */
.cstep__desc{min-height:63px}

/* ⚠ Below the 4-across breakpoint the cards are wider, every description
   wraps to fewer lines, and a 63px floor would leave a gap under the short
   ones. The strip is not one row there anyway, so the shared-y guarantee does
   not apply — see the note on the 2x2 and stacked layouts above. */
@media (max-width:72rem){ .cstep__desc{min-height:0} }

/* ══ THE BAR RETURNS TO THE MIDDLE OF THE CARD, 2026-09-07 ══════════════════
   ⚠⚠ It was moved BELOW the strip earlier this session, when the cards had no
   title or icon under it and there was nothing to separate. With the reference
   layout the card reads description → BAR → title → icon, so the line belongs
   BETWEEN them, exactly where the reference puts its rule.

   ⭐ `.credit__line` is absolutely positioned in .credit__strip-wrap and takes
   its y from `bottom`. The per-card `.cstep__bar` is the invisible spacer that
   reserves the slot — it is 0-height and nothing is drawn in it; the line is
   what the reader sees, and it spans all four cards as ONE element so it is
   continuous across the gutters.
   ⚠⚠ THE TWO ARE ONE MEASUREMENT IN TWO PLACES. If the card's internals move,
   both change together — this is the bug that drew the line straight through
   the pictures earlier today (line at y5166, figure box 5096-5356).
   ⭐ So the offset is derived from the tokens that actually set it: everything
   BELOW the bar is the title (its margins + line box) and the icon. */
.cstep__bar{height:2px;margin-top:0}
/* ⚠ The offset is what lands the line ON the .cstep__bar spacer, and it is
   MEASURED, not derived: a token sum was tried first and put the line 62px
   low, between the title and the icon, because the title's line box and its
   two margins do not add up to the tokens that set them.
   ⭐ Everything below the bar, measured at 1440: title margin-top 24 + title
   31 + title margin-bottom 16 + icon 80 + card padding-bottom 32 = 183, plus
   the icon's auto-margin slack. The value below is read off the rendered
   card. ⛔ Re-measure if the card's internals change — and check it, because
   this exact coupling drew a line through the artwork earlier today. */
/* ⚠ 216 = card 353 − bar 137. The line's own 2px height is why it measures
   two above the spacer's top; they are flush. */
/* ⚠ SUPERSEDED — see above. The live value is `bottom:168px`. */

/* The strip no longer needs room under it for the line. */
.credit__strip-wrap{padding-bottom:0}

/* ⭐⭐ THE DESCRIPTION SITS DIRECTLY ON THE BAR (Kacperek, 2026-09-07).
   ⚠ A previous pass opened a 59px gap here to push the bar down to the
   reference's 41.7% proportion. That was the wrong lever: the reference's
   copy sits TIGHT to its rule, and the proportion comes from the card's own
   depth, not from air under the paragraph.
   ⭐ So the gap closes to one spacing step and the bar lands where the copy
   ends. The proportion follows from the content, which is what the reference
   is actually doing. */
.cstep__bar{margin-top:var(--s3)}

/* ══ #credit cards — STROKE, SPACING, ICON PLACEMENT · 2026-09-07 ═══════════

   ⭐ THE STROKE MATCHES .afeat, THE APP SECTION'S CARDS (Kacperek). That card
   sits on --rule-2 (.08) and goes to --rule (.22) on hover; here the ACTIVE
   card takes the darker one, so the same two values mean the same thing in
   both places — a card the reader is on is outlined a little harder.
   ⛔ Do not introduce a third border value for this. Two rules already carry
   the whole vocabulary, and DESIGN.md § 1.3 keeps the accent colours for
   token state, never for chrome. */
.steps--strip li.cstep{
  border:1px solid var(--rule-2);
  border-radius:var(--r-card);
  padding:var(--s5)}
.credit.is-live .steps--strip li.cstep.is-lit,
.steps--strip li.cstep.is-lit{
  border-color:var(--rule)}
/* ⚠ The unlit card keeps its stroke — it is a card either way. Only the
   WEIGHT changes, which is the whole point of borrowing .afeat's two values.
   ⛔ Do not fade the border to transparent here: that was the earlier
   treatment and it made the unlit cards stop being objects. */
.credit.is-live .steps--strip li.cstep:not(.is-lit){
  border-color:var(--rule-2)}

/* ⭐ THE ICON MOVES TO THE BOTTOM-RIGHT CORNER (Kacperek). Absolutely
   positioned so it cannot affect the card's height — which matters, because
   the icon was the element carrying `margin-top:auto` and therefore setting
   where the bar landed. That job moves back to the title.
   ⚠ Inset from the card's own padding, so it sits in the corner without
   touching the stroke. */
.cstep{position:relative}
.cstep__ic{
  position:absolute;right:var(--s5);bottom:var(--s5);
  width:4.5rem;max-width:4.5rem;margin:0;aspect-ratio:1}

/* ⚠⚠ THE AUTO-MARGIN HAS TO GO SOMEWHERE. With the icon absolute it is out of
   flow and can no longer collect the card's slack, so the four bars would
   drift again the moment a description wrapped differently. The TITLE is now
   the last in-flow element and takes it.
   ⭐ This is the third time this session the auto-margin has had to follow the
   last in-flow child. If the card's order changes again, move it again. */
.cstep__title{margin-top:auto;margin-bottom:0}

/* ⚠⚠ THE TITLE MUST NOT RUN UNDER THE ICON, and padding-right alone did not
   do it: the longest title wraps to two lines, and its second line sits at
   exactly the icon's y. Measured: overlap true on all four cards.
   ⭐ The icon is absolute, so it takes no space — the reservation has to be
   made in the title's own box. `width` caps the text column short of the
   corner and `min-height` holds the card's lower half open so a one-line
   title does not let the bar drift up. */
.cstep__title{width:calc(100% - 5.5rem);padding-right:0}

/* ⭐ SPACING. ⚠⚠ The card must keep a FIXED HEIGHT: with the icon absolute it
   no longer occupies flow, so the card collapsed 368 -> 280 and the bar's
   position moved with it. The reference card is 384 tall against a 294 width;
   ours is 260 wide, so 336 holds the same proportion.
   ⛔ Not aspect-ratio — DECISIONS § 4.13 forbids it on this element (the outer
   two cards drop their outside padding, so a ratio turns extra width into
   extra HEIGHT and they ride ~12px high, breaking the four-bar line). */
.steps--strip li.cstep{min-height:21rem}
.steps--strip li.cstep::before{margin-bottom:var(--s5)}
.cstep__desc{margin-bottom:0}
.cstep__bar{margin-top:var(--s3)}
.cstep__title{padding-top:var(--s4)}

/* ── GAP AND BAR POSITION · 2026-09-07 ─────────────────────────────────────
   ⭐ THE GUTTER TIGHTENS to the reference's 8px. It was --s6 (32px), which
   read as four separate objects; at 8px the four cards read as one strip with
   the line running through it, which is what the section is arguing.
   ⚠ The line spans all four cards as ONE element, so a narrower gutter also
   means less of it is drawn over empty page. */
.steps--strip{gap:var(--s2)}

/* ⭐ THE BAR GOES THROUGH THE MIDDLE OF THE CARDS (Kacperek). It sat at 43%,
   just above centre. 168 is half of the card's 336.
   ⚠⚠ TWO PLACES, ONE MEASUREMENT — and this coupling has already drawn a line
   straight through the artwork once today. `.credit__line` takes its y from
   `bottom` on the wrapper; `.cstep__bar` is the in-flow spacer that reserves
   the slot. If the card's height changes, BOTH change. */
.credit__line{bottom:168px}

/* ⚠ The in-flow spacer has to follow the line to the middle, or the copy above
   it stops sitting on it: measured 22px apart after the move. The description
   ends at 132 and the card's centre is 168, so the spacer takes the difference
   as its top margin. ⛔ Re-measure if the card height or the copy changes —
   this is the same two-places-one-measurement coupling as the line itself. */
.cstep__bar{margin-top:36px}

/* ⚠⚠ THE TITLE SITS DIRECTLY BELOW THE BAR — it must NOT take the auto-margin.
   `margin-top:auto` was moved onto the title when the icon went absolute and
   stopped being the last in-flow child; that pushed the title to the card's
   foot (measured y233 against a bar at y168, a 65px gap) when it belongs
   right under the rule.
   ⭐ Nothing needs the auto-margin any more: the card has a fixed min-height
   (21rem) and the bar's position is set by its own measured margin above, so
   the four bars share a y by construction rather than by slack collection.
   ⛔ Do not reintroduce it here — that is what put the title at the bottom. */
.cstep__title{margin-top:0;padding-top:var(--s4)}

/* ══ THE LINE IS A ROW MEASURE — IT ONLY EXISTS WHILE THERE IS A ROW ════════
   ⚠⚠ MOBILE BUG, found 2026-09-07 (Kacperek): "on mobile view the bar is
   broken and overlaps the last card".

   Measured. `.credit__line` is absolutely positioned in .credit__strip-wrap at
   a `bottom` tuned to a 336px card in a 4-across row. Below 72rem the strip
   goes 2-up and then 1-up, the wrapper grows to 680 and then 1368px, and that
   same offset lands the line INSIDE a card instead of across the row:

     1440  4 cols   wrap  336   line at 166  — through all four. CORRECT.
     1152  2 cols   wrap  680   line at 510  — through cards 3 and 4
      768  1 col    wrap 1368   line at 1198 — through card 4 only
      390  1 col    wrap 1368   line at 1198 — through card 4 only

   ⭐ The line's whole job is to run ACROSS the four cards and show progress
   through them. Stacked, there is no row to run across — a horizontal rule
   through one card measures nothing and reads as a mistake, which is exactly
   what it looked like.
   ⛔ Do NOT try to reposition it per breakpoint. There is no correct y for a
   row measure when the cards are in a column; the honest answer is that the
   device does not apply. The four cards still carry the sequence — they are
   numbered, and below 56rem the scrub does not run at all (see `flat` in
   index.html), so every card shows its finished state anyway.
   ⚠ The per-card .cstep__bar spacers stay: they are 2px of reserved slot and
   removing them would shift the copy at exactly the widths this rule covers. */
@media (max-width:72rem){
  .credit__line{display:none}
}

/* ══ THE DASHBOARD'S REVEAL — REMOVED 2026-09-14 ══════════════════════════
   Went with the SVG it animated (.pm-rv-dig / -dig2 / -bar / -up / -row /
   -card, their keyframes, --pm-hold, and their reduced-motion branch).

   ⭐⭐ ONE LESSON FROM IT SURVIVES AND NOW GOVERNS THE HERO CAROUSEL, so it is
   kept here rather than lost with the code:

     THE GLOBAL REDUCED-MOTION RULE DOES NOT COVER A DELAYED ANIMATION.
     The nuke at the foot of this file sets animation-duration:.01ms, which
     lands an UNDELAYED animation on its end state instantly — but it does
     NOT touch animation-delay. A delayed animation therefore sits on its
     `from` frame (opacity 0, translated) for the whole delay, on the one
     setting whose entire purpose is to avoid that. The dashboard needed an
     explicit `animation:none!important` branch for exactly this.

   ⚠ THE CAROUSEL AVOIDS THE PROBLEM BY CONSTRUCTION, not by a branch: slide 0
   carries .is-current in the MARKUP, the cross-fade is a `transition` rather
   than an animation, and the JS timer is simply never started under reduced
   motion. ⛔ Nothing in the hero may rest at opacity:0 waiting for a script —
   that is the .ol-word trap, and it shipped on the live site once already. */

/* ══ #card — the card face, and its live plate ═════════════════════════════
   2026-09-14. The face was inline-styled until now; it moves here because it
   became a plate.js host and the .is-live cross-fade needs a selector.
   ⚠ The declarations below are the inline ones VERBATIM — this was a lift, not
   a redesign. The only additions are the canvas rules.
   ⭐ Flat (Kacperek, 2026-09-14, restating the original note): no tilt, no
   perspective, no float, no specular sweep. The card lies in the page. ⛔ The
   moving dither is the life in this object; tilting it as well would be two
   effects competing for the same job. */
.cardface{position:relative;overflow:hidden;
  aspect-ratio:1.586;border-radius:16px;padding:var(--s5);
  display:flex;flex-direction:column;justify-content:space-between;
  background:var(--ground);
  border:1px solid var(--rule-2);
  /* ⚠ max-width and margin-inline moved to .cardstack — the STACK is the object
     that sizes and centres now, and the front card fills it. Leaving them here
     too would have the card centre inside an already-centred stack. */
  width:100%}

/* The still. ⛔ IT STAYS IN THE MARKUP AND IT IS THE BASE STATE — plate.js only
   ADDS a canvas once a GL context, a compiled shader and the field have all
   been proved. no-JS, no-WebGL, a failed compile, reduced motion, a lost
   context and an offscreen card all land here with no special path for anyone
   to forget. (plate.js's header: "Do not simplify this by putting a <canvas>
   in index.html.")
   ⚠ object-position 50% 50%: § 4.28 set this when the art was re-cropped
   1.792 -> 1.586, and there is nothing left to anchor. It does NOT need to
   agree with data-anchor here — see the markup's note; the card is wider than
   the source, so the shader's y-offset is 0 whatever the anchor says. */
.cardface > img{position:absolute;inset:0;width:100%;height:100%;
  object-fit:cover;object-position:50% 50%;z-index:0}

/* The cross-fade, identical to .sc__art's (and to .hero__art's, until the hero
   became a photograph on 2026-09-14) — same ordering, same duration, so
   the four plates behave the same way.
   ⚠ z-index:0, matching the still: the card's content sits at z-index:1 above
   both, and without this the canvas would paint over the chip and the mark. */
.cardface canvas{position:absolute;inset:0;width:100%;height:100%;
  display:block;opacity:0;transition:opacity .5s ease;z-index:0}
.cardface.is-live canvas{opacity:1}
.cardface.is-live > img{opacity:0;transition:opacity .5s ease}

/* Belt and braces, as on the other three. plate.js returns before creating the
   canvas under reduced motion, so this should never engage — but what it
   guards is motion reaching someone who asked for none, and DESIGN.md § 0a
   says that class of bug does not bend. */
@media (prefers-reduced-motion:reduce){ .cardface canvas{display:none} }

/* ══ #card — the stack ═════════════════════════════════════════════════════
   2026-09-14, from a UI-card reference (Kacperek). Three cards, the front one
   live, the two behind it flat.

   ⭐ THE STACK IS THE SIZED OBJECT, not the card. max-width and the auto
   margins moved up here from .cardface, which now fills its parent — otherwise
   the card centres inside an already-centred stack and the offsets below are
   measured against the wrong box.
   ⚠ The height comes from the front card's aspect-ratio; the backs are
   absolute, so they add none. That is what keeps the section's rhythm the same
   as it was with one card. */
/* ⚠ padding-bottom, not a height: the backs are absolute and add nothing to the
   box, so without reserved room the section's flow clips them and the stack
   reads as one card.
   ⚠⚠ IT IS DERIVED, NOT PICKED — and the first value was picked and was 62px
   too big at 1440. The reserve must equal exactly how far the lowest back HANGS
   BELOW the front card, which is NOT its top offset: each back is inset on both
   sides, so it is also SHORTER, and the overhang is (offset − height deficit).
     back--2: inset 7% a side -> 86% width -> 86% height
              overhang = 16% − 14% = 2% of the front card's height
   ⭐ --ov names that 2.4% once — 2% plus a hair, because at 390 the exact
   figure left the back card 0.6px SHORT of the box and the browser clipped it.
   ⚠ Measured both ways: at 2% the dead space is 1.8px at 1440 and −0.6 at 390;
   at 2.4% it is a hairline at both and nothing is cut. ⛔ A percentage of the STACK box cannot express
   this: the stack box includes this very padding, so the value would define
   itself. Percentages here resolve against the front card's height via
   aspect-ratio instead. */
.cardstack{--ov:2.4%;
  position:relative;max-width:26rem;margin-inline:auto;
  padding-bottom:calc(var(--ov) / 1.586)}

/* The two behind. ⛔ FLAT TONE, NO PLATE — a GL context each for two cards
   that are ~90% hidden would put three on this section alone, and plate.js's
   header already flags "revisit if a third plate appears" at page level. The
   stack reads as depth; the moving field is the front card's job. */
.cardstack__back{position:absolute;left:0;right:0;top:0;
  aspect-ratio:1.586;border-radius:16px;
  border:1px solid var(--rule-2);z-index:0}
/* ⚠⚠ THEY MUST EMERGE **BELOW**, AND THE FIRST ATTEMPT PUT THEM NOWHERE.
   Measured: with `translate(x%,y%) scale(.95)` the backs landed INSIDE the
   front card on all four edges — left +19.3, right −0.6, top +20.3, bottom
   +8.5 at 1440, i.e. entirely hidden, and the stack rendered as a single card.
   ⭐ The cause is that scale() shrinks about the CENTRE, so it pulls the bottom
   edge UP by half the lost height — which the downward translate then has to
   spend before anything shows. Net: nothing.
   ⭐ So the backs are NOT scaled. They are the same box, narrowed by an inset
   on each side and pushed straight down, which is what a real stack of cards
   does and what the reference shows.
   ⚠ Percentages of the stack box, not px: the card is 416px at 1440 and 326 at
   390, so a fixed inset reads as a different depth at each. */
.cardstack__back--1{left:3.5%;right:3.5%;top:8%;background:var(--raised)}
.cardstack__back--2{left:7%;right:7%;top:16%;background:var(--sunken)}
/* The front card sits above both and is the only thing in flow. */
.cardface{position:relative;z-index:1}

/* ── the reveal ───────────────────────────────────────────────────────────
   ⭐ The page's own device, not a new one: --rv-rise / --rv-ease / --rv-dur are
   the tokens every other section reveals with, and --rv-step is the stagger.
   The BACK cards arrive first and the front lands last, so the stack assembles
   from behind — the opposite order reads as the front card being covered up.
   ⚠ .cardstack carries .rv, so the host is already opacity:0 until .is-in. The
   three transforms below are additional and run on the same trigger. */
@media (prefers-reduced-motion:no-preference){
  .cardstack.is-in .cardstack__back--2{animation:cs-rise var(--rv-dur) var(--rv-ease) both}
  .cardstack.is-in .cardstack__back--1{animation:cs-rise var(--rv-dur) var(--rv-ease) both;
    animation-delay:calc(var(--rv-step) * .5)}
  .cardstack.is-in .cardface{animation:cs-rise var(--rv-dur) var(--rv-ease) both;
    animation-delay:var(--rv-step)}
}
/* ⚠⚠ THE KEYFRAME MUST CARRY THE BACKS' OWN TRANSFORM, or animating them wipes
   the translate+scale that makes them a stack — `transform` is one property and
   the animation wins. So the rise is expressed as a translate the element
   composes with, via a custom property, and each element keeps its offset in
   its own rule.
   ⛔ Do not "simplify" this into a shared transform on .cardstack: moving the
   host moves all three together and there is no stagger left to see. */
@keyframes cs-rise{
  from{opacity:0;translate:0 var(--rv-rise)}
  to  {opacity:1;translate:0 0}}

/* ⛔ MOTION IS OPT-IN. The global reduced-motion rule collapses
   animation-duration and says NOTHING about animation-delay — HANDOFF-09-12 § 2
   records the reveal shipping with 95 elements stranded hidden for exactly that
   reason. The @media above is a no-preference branch rather than a reduce
   override, so under reduce these rules never exist and the stack renders at
   its rest state. */

/* ── the card's own content ───────────────────────────────────────────────
   ⚠ The lockup is INLINE svg, so it inherits --ink. As an <img> the file's
   fill="currentColor" resolves to BLACK — the bug § 4.41a found on the O mark.
   ⚠ Height matches what the wordmark shipped at before the O experiment. */
.cardface__top{position:relative;z-index:1;line-height:0}
.cardface__logo{height:17px;width:auto;display:block;color:var(--ink)}

/* The chip, on the card's vertical centre line. Absolute so it sits on its own
   axis without dragging the head or foot rows — see the markup's note. */
.cardface__chip{position:absolute;z-index:1;
  left:var(--s5);top:50%;transform:translateY(-50%);
  width:34px;height:26px;border-radius:5px;
  background:linear-gradient(135deg,rgba(22,20,26,.14),rgba(22,20,26,.06));
  border:1px solid rgba(22,20,26,.16)}

/* The foot. ⚠ --ink, not --ink-2: this sits over the plate's densest band and
   the muted step has no headroom there. Contrast is sampled from pixels after
   any plate change — an <img>/<canvas> has no backgroundColor and no gate can
   see it (§ 4.41d). */
.cardface__foot{position:relative;z-index:1;display:flex;
  flex-direction:column;gap:.15rem;color:var(--ink)}
.cardface__name{font-size:.8125rem;font-weight:500;letter-spacing:.06em}
/* ⚠ tabular-nums and a mono stack: a masked number is a GRID of glyphs, and
   proportional digits make the four groups visibly uneven. */
.cardface__num{font-size:.8125rem;letter-spacing:.14em;
  font-variant-numeric:tabular-nums;
  font-family:ui-monospace,SFMono-Regular,Menlo,monospace}

/* ── the field, weighted to the right ─────────────────────────────────────
   ⭐⭐ A MASK, NOT A WAVE KNOB, and that is not a shortcut — it is the only
   thing that can work. The wave is a stack of HORIZONTAL bands spanning the
   full width (`waveBand`: y0 + tilt*(x-ar/2) + amp*sin(...)), so uSpread sets
   vertical extent, uTilt slopes the bands, and NOTHING in it positions density
   horizontally. Measured rather than assumed: dir:'corner' was tried on this
   host and rendered the card 5/1/1/1/2 % ink by column — effectively empty, the
   same failure .sc__art's note records for a corner radius in a wide box.
   ⭐ So the framing is done the way #assets does it (§ 4.39), which is the
   sanctioned precedent for exactly this.

   ⛔⛔ THE MASK GOES ON THE <img> AND THE <canvas>, NOT THE HOST. The two
   cross-fade against each other as .is-live lands; masking the host fades the
   mask with them. The same rule .assets__art and .sc__art both carry.
   ⚠ If one gradient is retuned, retune BOTH or the art shifts when the shader
   arrives.
   ⚠ -webkit- first, then standard — Safari still needs the prefix. */
.cardface > img,.cardface canvas{
  -webkit-mask-image:radial-gradient(92% 130% at 108% 50%,
    #000 0%,#000 38%,rgba(0,0,0,.34) 68%,transparent 94%);
  mask-image:radial-gradient(92% 130% at 108% 50%,
    #000 0%,#000 38%,rgba(0,0,0,.34) 68%,transparent 94%)}

/* ══ THE NAV OVER THE HERO PHOTOGRAPH ═════════════════════════════════════
   ⭐ ADDED 2026-09-14 (Kacperek: "float over the photo"), with the hero photo
   carousel. Plasma and Nike both run the nav transparently over the image and
   the hero starts at the very top of the window.

   ⚠⚠ SCOPED TO THE HOME PAGE BY `body:has(.hero)`. The nav is shared with
   eleven other routes that have no hero and are cream throughout; a bare
   `.nav{background:transparent}` would make every one of them lose its bar.
   ⚠ :has() is the right tool and is safe here — it is supported everywhere
   this prototype targets, and where it is NOT supported the rule is simply
   dropped and the nav keeps its cream bar, which is the current, correct
   behaviour. A failure mode that lands on the old design is the one to pick.

   ⚠ THE STATES RIDE index.html's ONE rAF-coalesced scroll listener.
   `is-scrolled` has existed since the nav gained its hairline; `is-past-hero`
   was added beside it on 2026-09-14 and is measured against the HERO'S OWN
   bottom, not a constant — the hero is 100svh on desktop but content-height
   on mobile, so any fixed number would be wrong at one of them.
   ⛔ Do not add a second listener — § 5's "one scroll listener" rule. */
/* ⭐⭐ THREE STATES, NOT TWO (2026-09-14, Kacperek, from the Plasma
   reference): at the top the nav is fully transparent; scrolling THROUGH the
   hero it becomes a FROSTED bar — translucent, with the photograph blurred
   behind it; and only once the hero has passed does it go opaque cream.

   ⚠⚠ THE FROST IS `backdrop-filter`, WHICH NEEDS A TRANSLUCENT BACKGROUND TO
   BE VISIBLE AT ALL. An opaque fill hides the very thing being blurred, so
   the alpha and the blur are one decision — ⛔ do not raise the alpha toward
   1 "to make it more readable"; that deletes the effect and the frost becomes
   a plain bar.
   ⚠ -webkit- prefixed too: Safari still requires it, and § 23 already lists
   Safari as unconfirmed for this build.
   ⚠ A 1px hairline under the frosted bar, in paper rather than ink — over a
   photograph the page's own --rule (near-black at .22) is invisible. */
/* ⚠⚠ NOT `transparent` — A GRADIENT SCRIM, AND IT IS A CONTRAST FIX.
   Measured at the top of the page, with nothing behind the bar: the links read
   3.06-3.87:1 against a 4.5 floor, because the nav sits at the top of the
   frame where the photograph is brightest and the hero scrim's vertical pass
   has faded to nothing by then.
   ⭐ A top-down gradient that dies to zero by the bar's foot reads as "no
   bar" — which is the design — while giving the type something to sit on.
   ⛔ Do not replace it with `transparent` on a tidy-up: the effect it creates
   is the absence of a bar, not the absence of a background. */
body:has(.hero) .nav{
  background:linear-gradient(180deg,rgba(10,9,12,.46) 0%,
    rgba(10,9,12,.22) 55%,rgba(10,9,12,0) 100%);
  transition:background 240ms var(--rv-ease),
             -webkit-backdrop-filter 240ms var(--rv-ease),
             backdrop-filter 240ms var(--rv-ease),
             box-shadow 240ms var(--rv-ease)}
body:has(.hero) .nav.is-scrolled{
  /* ⚠ .42, not .34 — the links measured 4.35:1 at 1280 mid-hero against a 4.5
     floor. ⛔ This is the ceiling, not a knob: the frost only reads as frost
     while the photograph is visible through it, so buying contrast here has a
     hard limit. If a real photograph ever fails at .42, fix the PHOTOGRAPH
     (the prompts ask for a dark, quiet top edge) rather than raising this. */
  background:rgba(10,9,12,.42);
  -webkit-backdrop-filter:blur(16px) saturate(1.4);
          backdrop-filter:blur(16px) saturate(1.4);
  box-shadow:inset 0 -1px 0 rgba(255,255,255,.12)}
/* ⭐ Opaque only once the hero is behind us. is-past-hero is set by the SAME
   rAF-coalesced scroll listener that sets is-scrolled — ⛔ no second handler.
   ⚠ The blur is explicitly cleared: leaving it on an opaque bar costs a
   compositor layer and a repaint for an effect nobody can see. */
body:has(.hero) .nav.is-past-hero{
  background:var(--ground);
  -webkit-backdrop-filter:none;backdrop-filter:none;
  box-shadow:none}

/* The nav's own grid furniture is drawn for cream: --rule is near-black at
   .22 alpha, and the corner rings are hard-coded fill:#FFFDFC. Both are
   invisible or wrong over a photograph, so the nav carries none of it until
   it is back on paper. */
body:has(.hero) .nav:not(.is-past-hero) .wrap::before,
body:has(.hero) .nav:not(.is-past-hero) .wrap::after,
body:has(.hero) .nav:not(.is-past-hero) .nav__rule{display:none}

/* Links and the mobile trigger go paper while floating. ⚠ #fff for the links
   rather than .dark's rgba(255,255,255,.65) body value: these are 14px
   interactive targets, not prose, and the photograph under them is the least
   predictable ground on the page. */
/* ⚠⚠ FULL WHITE, NOT .86 — MEASURED. At .86 the links read 3.06-3.76:1 over
   the photograph (floor 4.5), because the nav sits at the TOP of the frame
   where the light is and the scrim's vertical pass is nearly clear there.
   ⭐ These are 14px interactive targets, not prose: .dark's rgba(255,255,255,
   .65) body value has no business here, and neither does .86.
   ⚠ A text-shadow rather than a heavier bar: darkening the frost enough to
   carry .86 would have made the blur a smoked panel. The shadow is invisible
   on the frosted state and does the work at the transparent top state.
   ⛔ Re-measure with /tmp/navc.mjs after any photograph or frost change — no
   gate in this repo can see type over an image. */
body:has(.hero) .nav:not(.is-past-hero) .nav__links a{color:#fff;
  text-shadow:0 1px 3px rgba(10,9,12,.55)}
body:has(.hero) .nav:not(.is-past-hero) .nav__links a:hover{color:#fff}
/* ⚠ The hamburger's three bars are <i> elements with their own
   `background:var(--ink)` — `color` does not reach them, so the trigger stayed
   near-black over the photograph and was effectively invisible at 390px.
   ⛔ Do not "fix" this by giving the bars currentColor globally: --ink is
   correct everywhere else the nav appears. */
body:has(.hero) .nav:not(.is-past-hero) .navm__btn{color:#fff}
body:has(.hero) .nav:not(.is-past-hero) .navm__bars i{background:#fff}
/* ⚠ Once the panel is open it draws its own cream sheet over the hero, so the
   bars must go back to ink or they vanish against it. */
body:has(.hero) .nav:not(.is-past-hero) .navm[open] .navm__bars i{background:var(--ink)}

/* ⚠ The CTA inverts: oxblood on cream is accent #3, but oxblood on a dark
   photograph is a low-contrast lump. White pill, ink label — .dark's own
   button treatment, reused rather than reinvented. */
/* ⚠ The pill measured 4.48:1 mid-hero at 1440 — that is the WHITE PILL
   against a bright photograph, not the ink label on the pill, which is
   18:1 by construction. A hairline ring separates the two. */
body:has(.hero) .nav:not(.is-past-hero) .btn{background:#fff;color:var(--ink);
  box-shadow:0 0 0 1px rgba(10,9,12,.18)}
body:has(.hero) .nav:not(.is-past-hero) .btn:hover{background:rgba(255,255,255,.86)}

/* ── the two logo cuts ──────────────────────────────────────────────────── */
.nav__mark{position:relative}
.nav__mark-paper{position:absolute;left:0;top:50%;transform:translateY(-50%);
  opacity:0;transition:opacity 240ms var(--rv-ease);pointer-events:none}
.nav__mark-ink{transition:opacity 240ms var(--rv-ease)}
body:has(.hero) .nav:not(.is-past-hero) .nav__mark-ink{opacity:0}
body:has(.hero) .nav:not(.is-past-hero) .nav__mark-paper{opacity:1}
.nav__mark-paper{height:21px;width:auto}

/* ⚠⚠ THE HERO NOW STARTS AT THE TOP OF THE WINDOW, so it must not also
   subtract the nav's height — that reserved a 68px strip the transparent nav
   no longer occupies and left the photograph 68px short of the fold.
   ⭐ The nav is `position:sticky` and therefore still IN FLOW, so the hero has
   to be pulled up under it by exactly its height rather than simply growing.
   ⚠ 100svh, not 100vh: the small viewport unit, matching the rule it replaces
   (and its @supports fallback below). */
body:has(.hero) .hero{min-height:100svh;margin-top:calc(var(--nav-h) * -1);
  padding-top:var(--nav-h)}
@supports not (min-height:100svh){
  body:has(.hero) .hero{min-height:100vh}
}

/* ══ THE MOBILE HERO IS A DIFFERENT COMPOSITION, NOT A NARROW ONE ═════════
   ⭐⭐ Kacperek, 2026-09-14, from the Plasma mobile reference: below 62rem the
   hero INVERTS. Cream ground, ink type, and the photograph becomes a
   CONTAINED ROUNDED CARD below the buttons instead of a full-bleed backdrop.

   ⚠⚠ THIS IS WHY IT IS RIGHT, AND IT IS NOT A STYLE PREFERENCE. Stacked, the
   copy centres — so it stops sitting over the dark left third and lands over
   the middle of the frame, where a left-weighted scrim has faded out.
   Measured before this change, at 390x844: the lead came out at 2.53:1
   against a 4.5 floor and the headline at 3.05:1 against a 3.0 floor, on all
   three photographs, while desktop measured 7.5-8.5:1 throughout. A scrim
   heavy enough to fix that is a scrim that has thrown the photograph away.
   ⭐ Putting the type on PAPER removes the problem rather than fighting it,
   and the photograph gets to be a photograph.

   ⛔ Do not "unify" the two compositions on a tidy-up pass. They are one
   decision made twice, and the wide one depends on a dark left third that a
   centred single column does not have. */
@media (max-width:62rem){
  /* ⛔ .dark's overrides must all come off: the ground is paper again. The
     class stays in the markup because the DESKTOP rules key off .hero.dark,
     and removing it there would take the wide composition with it. */
  .hero.dark{background:var(--ground);color:var(--ink)}
  .hero.dark::before{display:none}
  .hero.dark h1{color:var(--ink)}
  .hero.dark p{color:var(--ink-2)}
  .hero.dark strong{color:var(--ink)}
  .hero.dark .btn{background:var(--ox);color:var(--ground)}
  .hero.dark .btn:hover{background:var(--ox-deep)}
  .hero.dark .btn-ghost{background:transparent;color:var(--ink);
    box-shadow:inset 0 0 0 1px var(--rule)}
  .hero.dark .btn-ghost:hover{box-shadow:inset 0 0 0 1px var(--ink)}

  /* ⚠⚠ THE SHOTS LEAVE position:absolute AND RE-ENTER THE FLOW. That is the
     whole mechanism — as an absolute box it could not push the licence line
     down, and as a flow item it must.

     ⚠⚠ AND IT MUST BE RE-ORDERED, because source order puts it FIRST. The
     photograph is the hero's backdrop on desktop, so it is written before the
     copy; in flow that lands it ABOVE the headline, while the reference puts
     it after the CTA. `order` fixes it without touching the markup — and the
     markup order is the one that must not change, because with JS off and on
     desktop the backdrop has to be behind the copy in the stacking order.
     ⛔ Do not "fix" this by moving the div in index.html. */
  .hero.dark{display:flex;flex-direction:column}
  .hero.dark .hero__inner{order:1}
  .hero.dark .hero__shots{order:2}
  .hero.dark .hero__dots{order:3}
  .hero.dark .hero__licence{order:4}
  .hero.dark .hero__shots{position:relative;inset:auto;
    width:calc(100% - var(--s5) * 2);margin:var(--s5) auto 0;
    aspect-ratio:4 / 3;border-radius:18px;
    border:1px solid var(--rule-2)}
  .hero.dark .hero__shot img{border-radius:17px}

  /* ⛔ NO SCRIM ON MOBILE. Nothing sits on the photograph any more, so a wash
     over it would be dimming an image for no reader. */
  .hero.dark .hero__scrim{display:none}

  /* The dots sit under the card, in flow, in ink. ⚠ --hero-print is a DESKTOP
     measurement (the distance from the hero's foot up to the licence); in flow
     the dots simply follow the card. */
  .hero.dark .hero__dots{position:static;transform:none;
    margin:var(--s5) auto 0;justify-content:center}
  .hero.dark .hero__dot::after{background:var(--rule)}
  .hero.dark .hero__dot.is-on::after{background:var(--ink)}
  .hero.dark .hero__dot:hover::after{background:var(--ink-3)}
  .hero.dark .hero__dot:focus-visible{outline-color:var(--ox)}

  /* The licence goes back to page ink. */
  .hero.dark .hero__licence{color:var(--ink-3)}
  .hero.dark .hero__licence strong{color:var(--ink-2)}

  /* ⚠⚠ AND THE HERO STOPS BEING A FOLD-HEIGHT BOX. Full-bleed, it was exactly
     one screen; with a photograph in the flow it is as tall as its content,
     and forcing 100svh would strand the licence line off the bottom — which is
     open item 43's failure, reintroduced by a rule meant to prevent it.
     ⭐ The nav is opaque again here, so the negative top margin goes too. */
  /* ⚠ padding-top returns as real space rather than the nav offset it was on
     desktop: the headline sat hard against the nav's rule without it. And
     .hero__inner's own margin-top is a NEGATIVE desktop value (it cancels the
     section padding to align the copy with the plate's top edge), which here
     pulled the first line up under the nav — it has no plate to align to any
     more. */
  body:has(.hero) .hero{min-height:0;margin-top:0;padding-top:var(--s6)}
  .hero.dark .hero__inner{margin-top:0;padding-top:0;padding-bottom:0}
  /* ⛔ NO FROST ON MOBILE. The hero inverts to cream here, so the nav sits on
     paper rather than over a photograph — a blurred translucent bar over
     ground blurs nothing and costs a compositor layer. */
  body:has(.hero) .nav,
  body:has(.hero) .nav.is-scrolled{background:var(--ground);
    -webkit-backdrop-filter:none;backdrop-filter:none;box-shadow:none}
  body:has(.hero) .nav:not(.is-past-hero) .wrap::before,
  body:has(.hero) .nav:not(.is-past-hero) .wrap::after,
  body:has(.hero) .nav:not(.is-past-hero) .nav__rule{display:revert}
  body:has(.hero) .nav:not(.is-past-hero) .nav__links a{color:var(--ink-2)}
  body:has(.hero) .nav:not(.is-past-hero) .nav__links a:hover{color:var(--ink)}
  body:has(.hero) .nav:not(.is-past-hero) .navm__bars i{background:var(--ink)}
  body:has(.hero) .nav:not(.is-past-hero) .btn{background:var(--ox);color:var(--ground)}
  body:has(.hero) .nav:not(.is-past-hero) .btn:hover{background:var(--ox-deep)}
  body:has(.hero) .nav:not(.is-past-hero) .nav__mark-ink{opacity:1}
  body:has(.hero) .nav:not(.is-past-hero) .nav__mark-paper{opacity:0}
}

/* ══ THE HERO COPY SITS IN THE BOTTOM-LEFT ════════════════════════════════
   ⭐ Kacperek, 2026-09-14, from the Plasma reference: the headline anchors to
   the BOTTOM-LEFT rather than centring in the fold, with a thin rule above the
   fine print and the fine print left-aligned under it.

   ⚠⚠ MEASURED OFF THE REFERENCE, not estimated. As a share of the hero's
   height: copy block 47%→82%, hairline 86%, fine print 92%, bottom margin 8%.
   Ours sat at 33%→61% with a centred licence, which is why the two read as
   different pages even with the same photograph behind them.

   ⭐ WHY BOTTOM-LEFT IS BETTER HERE AND NOT JUST DIFFERENT: the photographs
   put their subject in the upper middle and their light in the upper right.
   Centred copy competes with that; bottom-left sits in the part of the frame
   the composition has already quieted, which is also why the scrim's
   horizontal pass is weighted the way it is.

   ⛔ DESKTOP ONLY. Below 62rem the hero inverts to cream with the photograph
   as a card after the CTA — there is no bottom-left to anchor to, and the copy
   is centred by the rule two blocks up. */
@media (min-width:62.0625rem){
  /* ⚠ align-items:end, NOT a margin-top:auto on .hero__copy. The copy is a
     GRID ITEM; pushing it with auto margins fights the grid's own alignment
     and loses when the track resizes. */
  .hero.dark .hero__inner{align-items:end;
    /* ⚠ The 47%→82% band is expressed as bottom padding rather than a height:
       the copy is as tall as its own lines, and pinning it to a percentage
       would clip a headline that wraps to three lines at a narrow desktop.
       ⚠ RAISED after measuring: at 1rem the copy ended at 87% against the
       reference's 82%, which read as cramped at the foot. 3.5vh puts it on
       82-83% at 1440 and holds the proportion as the viewport grows. */
    padding-bottom:clamp(1.5rem,.6rem + 3.5vh,4rem)}

  /* ── the hairline above the fine print ────────────────────────────────
     ⚠ A BORDER ON THE LICENCE, not a separate element: one less box, and it
     cannot drift away from the text it separates.
     ⚠ rgba white, not --rule: the page's own rule is near-black at .22 alpha
     and is invisible over a photograph.
     ⚠ It spans the CONTAINER, matching .wrap — the reference's rule stops at
     its own measure rather than bleeding to the window edge. */
  /* ⚠ NO left-edge correction is needed and one was nearly added: a probe
     reported the print 2.2% left of the headline, but it was comparing the
     licence's ELEMENT box against the headline's TEXT box. Measured properly,
     both start at x=102 at 1440 — `.wrap`'s 32px padding already puts the
     print on the copy's left edge. ⛔ Do not "align" them; they are aligned. */
  .hero.dark .hero__licence{
    text-align:left;
    margin-top:clamp(1.25rem,.5rem + 2.2vh,2.25rem);
    padding-top:clamp(1rem,.4rem + 1.6vh,1.75rem);
    border-top:1px solid rgba(255,255,255,.18);
    max-width:none}

  /* ⚠ THE DOTS STAY CENTRED (Kacperek, 2026-09-14) even though the copy and
     the fine print are now left-aligned. They were briefly moved to the right
     edge on the assumption they would collide with the print — they do not:
     the print is a single line at this width and the dots clear it VERTICALLY,
     which is the axis that matters. Measured below.
     ⛔ Do not move them again without checking the clearance first. */
  .hero.dark .hero__dots{bottom:calc(var(--hero-print,7rem) + var(--s5))}
}


/* ══════════════════════════════════════════════════════════════════════════
   ⚠ THE LOCAL APPEND — the ONLY part of this file that is not a verbatim copy.
   Everything above comes from _internal/brand-2026-08/v3/brand.css and is
   overwritten on the next re-copy. Rules here cover components the standalone
   prototype never needed, because the home page has no long-form legal pages.
   ⛔ Keep additions below this banner, or they are lost.
   ══════════════════════════════════════════════════════════════════════════ */

/* ── spec — the identifier readout ─────────────────────────────────────────
   The register territory's core object: CVR, LEI, the authorisation. The
   prototype has no key/value device because the home page states these in
   prose, so this is built from the same tokens rather than imported.

   ⚠ A <dl>, not a <table>. These are name/value pairs and a screen reader
   should hear them as definitions; a table would announce rows and columns
   that carry no meaning here.
   ⚠ The VALUE takes the mono face, the KEY does not — the mono is for
   transcribable data (CLAUDE.md § the register), and a label is not data. */
.spec{margin-top:var(--s6);border-top:1px solid var(--rule-2);max-width:68ch}
.spec__row{display:grid;grid-template-columns:minmax(0,14rem) minmax(0,1fr);
  gap:var(--s4);padding-block:var(--s4);
  border-bottom:1px solid var(--rule-2)}
.spec__k{margin:0;font-size:var(--t-sm);color:var(--ink-3);letter-spacing:-.02em}
.spec__v{margin:0;font-family:'Fragment Mono',ui-monospace,monospace;
  font-size:var(--t-mono);line-height:1.6;color:var(--ink);
  letter-spacing:-.01em;overflow-wrap:anywhere}
.spec__v a{border-bottom:1px solid var(--rule);color:inherit}
.spec__v a:hover{border-bottom-color:var(--ink)}
/* ⚠ Below 40rem the two columns stack. A 14rem label column on a 390px
   viewport leaves ~7rem for a 42-character contract address, which wraps to
   four lines and stops being checkable — the one thing this component is for. */
@media (max-width:40rem){
  .spec__row{grid-template-columns:1fr;gap:var(--s1)}
}

/* ── note — the caveat callout ─────────────────────────────────────────────
   ⚠⚠ DELIBERATELY NOT AN ALERT BOX. The live site's own rule records the
   reasoning and it is preserved here: this states a factual difference between
   two kinds of protection (safeguarded vs insured), not an error or a risk
   state. Red or amber would overclaim exactly as badly as 13px grey
   underclaims — and amber is --deployed, a TOKEN STATUS colour. ⛔ Never use a
   state token here: a state colour is a factual claim about a token, and
   spending it on a callout drains the meaning from the place it matters. */
.note{border-left:2px solid var(--ox);padding:var(--s4) var(--s5);
  background:var(--sunken);max-width:68ch;
  display:flex;flex-direction:column;gap:var(--s2)}
.note strong{font-size:var(--t-body);font-weight:500;color:var(--ink);
  letter-spacing:-.02em}
.note p{margin:0;font-size:var(--t-sm);line-height:1.6;color:var(--ink-2)}

/* ── prose inside .stack ───────────────────────────────────────────────────
   The legal pages are mostly running text, which the prototype has little of:
   its longest block is two paragraphs. ⚠ The measure is 68ch, matching .spec
   and the FAQ answer, so a reader moving between them keeps one line length.

   ⚠⚠ EVERY RULE HERE IS `:not(.fill)` AND THAT IS NOT DEFENSIVE STYLING — it
   is a real collision that shipped. The home page's scroll-fill passage is
   `class="stack fill"`, so it matches `.stack p` too; and because this append
   sits at the END of the file it WINS on specificity ties. The result was the
   #solution passage rendering at --t-body 17.68px instead of --t-fill 32px —
   the big type that fills in word by word, shrunk to body copy, live on
   olbra.org. Measured against the prototype: .fill p was 32px there and
   17.68px here, with everything else identical.

   ⛔ Do not drop the :not(.fill), and do not "fix" a future version of this by
   raising .fill's specificity instead — the bug is that a LEGAL-PAGE prose
   rule reaches the home page at all. These four selectors are the only place
   .stack is styled outside the prototype's own block. */
.stack:not(.fill) p{margin:0;font-size:var(--t-body);line-height:1.65;color:var(--ink-2)}
.stack:not(.fill) p strong{color:var(--ink);font-weight:500}
.stack a{border-bottom:1px solid var(--rule);color:var(--ink)}
.stack a:hover{border-bottom-color:var(--ink)}
.stack:not(.fill) ul,.stack:not(.fill) ol{margin:0;padding-left:1.15rem;
  display:flex;flex-direction:column;gap:var(--s2)}
.stack:not(.fill) li{font-size:var(--t-body);line-height:1.6;color:var(--ink-2)}

/* ── pcard, in a text context ──────────────────────────────────────────────
   .pcard is the prototype's problem-card and carries an art slot these pages
   do not fill. ⚠ Only the type is set here; the layout, the grid and the
   40rem stacked-row fix all come from the prototype's own rules. */
.pcard h3{margin:0;font-size:var(--t-h3);font-weight:500;letter-spacing:-.03em;
  color:var(--ink)}
.pcard p{margin:0;font-size:var(--t-sm);line-height:1.6;color:var(--ink-2)}
.pcard a{border-bottom:1px solid var(--rule);color:var(--ink)}

/* ── dtable — genuinely tabular data ───────────────────────────────────────
   ⚠ A real <table>, unlike .spec: /docs compares six columns across three
   tokens, so rows and columns carry meaning and a screen reader should
   announce them.
   ⚠ Hairlines only — no zebra striping, no cell borders. DESIGN § 7.1: no
   strokes, surfaces are told apart by ground. A ruled grid here would be the
   spreadsheet look the register territory is not. */
/* ⚠ max-width:100% is load-bearing: without it the wrapper grows to its
   table's intrinsic width and `overflow-x:auto` has nothing to overflow, so
   the scroll silently moves up to the document. */
.tablewrap{margin-top:var(--s6);overflow-x:auto;max-width:100%;min-width:0;
  /* ⚠ The table must scroll INSIDE its own box; the page body must never
     scroll horizontally. Six columns cannot fit 390px and should not try. */
  -webkit-overflow-scrolling:touch}
.dtable{width:100%;border-collapse:collapse;font-size:var(--t-sm);
  white-space:nowrap}
.dtable th{text-align:left;font-weight:500;font-size:var(--t-mono);
  color:var(--ink-3);letter-spacing:-.01em;padding:0 var(--s5) var(--s3) 0;
  border-bottom:1px solid var(--rule)}
.dtable td{padding:var(--s4) var(--s5) var(--s4) 0;color:var(--ink-2);
  border-bottom:1px solid var(--rule-2);vertical-align:middle}
.dtable tr>*:last-child{padding-right:0}
.dtable .mono{font-family:'Fragment Mono',ui-monospace,monospace;
  font-size:var(--t-mono);color:var(--ink);letter-spacing:-.01em}
.dtable a{color:var(--ink);border-bottom:1px solid var(--rule)}
.dtable a:hover{border-bottom-color:var(--ink)}
/* ⚠ .status carries its own dot and its own state colour. It is reused here
   unchanged so the docs table and the home page's token rows cannot drift —
   and so the dot keeps meaning exactly one thing across the site. */
.dtable .status{margin-top:0}

/* Two-column card grid, for the five platform layers. ⚠ The base .pcards is
   three across, which leaves a widow on a five-item set. */
.pcards--2{grid-template-columns:repeat(2,minmax(0,1fr))}
@media (max-width:56rem){.pcards--2{grid-template-columns:1fr}}

/* ⚠ A spec value that is a SENTENCE, not an identifier. The mono face is for
   transcribable data; a definition paragraph in 13px mono is a wall. */
.spec__v--prose{font-family:inherit;font-size:var(--t-sm);color:var(--ink-2);
  line-height:1.6;letter-spacing:-.02em}

/* ══════════════════════════════════════════════════════════════════════════
   THE CONTINUOUS PAGE BOX — subpage layout (Kacperek, 2026-09-04)
   "use similar bg-plate as in stablecoin section on homepage, remove the gaps
    between the sections so its one continuous box, and add a little padding
    before the footer so the box doesn't touch that"

   ⭐ THE HOME PAGE AND A SUBPAGE WANT OPPOSITE THINGS HERE, and the reason is
   worth keeping. On the home page each section is a different KIND of thing —
   a plate, a card grid, a type-only band — so the 126px break between boxes is
   what stops them reading as one undifferentiated run. A subpage is ONE
   document: its sections are chapters of a single argument, so the break makes
   it look like separate pages stacked. One box, ruled inside, is the honest
   shape for it.
   ══════════════════════════════════════════════════════════════════════════ */

/* ⚠ .is-joined already does exactly this on the home page — cancel the flex
   row-gap, then drop the lower section's duplicate top rule and corner rings
   so the upper section's bottom edge becomes the shared separator. Reusing it
   rather than writing a second mechanism means the two cannot drift.
   ⛔ Do not reimplement the join; add the class. */
.page--doc main{row-gap:0}
.page--doc .section + .section::before{
  background-image:
    repeating-linear-gradient(to bottom,var(--rule) 0 var(--dash-on),
      transparent var(--dash-on) calc(var(--dash-on) + var(--dash-off))),
    repeating-linear-gradient(to bottom,var(--rule) 0 var(--dash-on),
      transparent var(--dash-on) calc(var(--dash-on) + var(--dash-off))),
    repeating-linear-gradient(to right,var(--rule) 0 var(--dash-on),
      transparent var(--dash-on) calc(var(--dash-on) + var(--dash-off)));
  background-size:1px 100%,1px 100%,100% 1px;
  background-position:left top,right top,left bottom}
/* ⚠ THREE layers, not four — the top horizontal is dropped because the
   section above supplies it. Counting layers matters: an override that
   restates a multi-layer property and gets the count wrong silently opens the
   box at one edge, which is the trap .is-joined's own comment records. */
.page--doc .section + .section::after{
  background-position:0 100%,100% 100%}

/* ⚠ CLEARANCE BEFORE THE FOOTER. Without it the box's bottom rule lands
   directly on the dark band's top edge and the two read as one thick line —
   the register frame appears to be part of the footer rather than to close
   above it. The gap is --main-gap, the same break the home page puts between
   its sections, so the page ends on the measure it was set in. */
.page--doc main{padding-bottom:var(--main-gap)}

/* ── the page plate ────────────────────────────────────────────────────────
   The same dithered field the home page's #stablecoins sits on, behind the top
   of the document box.

   ⚠ IT IS A FIELD THE PAGE SITS ON, NOT A PICTURE. The home page's own note
   records the failure mode: letting the plate run a section's full height made
   `cover` crop to ~60% of the width and blow the art up, so it stopped being a
   ground and started competing with the copy. Here it is pinned to a fixed
   height near the top and faded out, which keeps the source's own aspect and
   its own crop.
   ⚠ No `data-plate`: the live WebGL shader is a home-page device. A subpage
   gets the still, which is what every failure path on the home page falls back
   to anyway. */
/* ⚠⚠ THE PLATE IS POSITIONED AGAINST THE FIRST SECTION, NOT AGAINST `main`.
   Measured against main it ran 112px PAST the first box's bottom rule and
   across the right vertical — the field crossed the frame it is supposed to
   sit inside, so it read as a picture laid over the page rather than as a
   ground the page rests on. Its own `height` cannot fix that: the box's height
   depends on how the head's type wrapped, which has no CSS expression.
   ⭐ `inset` on the section's own padding box gives it the box's real
   geometry for free, and it then follows the head at any viewport. */
/* ⚠⚠ THE PLATE IS A SIBLING OF THE SECTIONS, NOT A CHILD — it sits directly
   inside <main>. So `.section:first-of-type{position:relative}` can never
   contain it, and an earlier attempt to anchor it there left the plate
   positioned against the VIEWPORT: measured 12→1428px wide and 508px past the
   first box. Two wrong anchors in a row, both of which looked plausible.
   ⭐ It is anchored on `main` and given the FIRST SECTION'S geometry instead:
   --doc-plate-h is written by the inline script from that section's measured
   height, the same mechanism the home page uses for --sc-plate-stop, and for
   the same reason — the height depends on how the head's type wrapped and
   there is no CSS expression for it.
   ⚠ The fallback keeps the plate SHORT rather than long: too short is a
   smaller field, too long crosses the frame. */
.page--doc main{position:relative}
.page--doc .plate{position:absolute;z-index:0;pointer-events:none;line-height:0;
  overflow:hidden;
  left:50%;transform:translateX(-50%);
  width:calc(min(100% - 2.5rem,var(--container)) - var(--plate-inset) * 2);
  top:var(--plate-inset);
  height:calc(var(--doc-plate-h,18rem) - var(--plate-inset) * 2);
  /* ⚠ The fade is a mask, not an opacity: the art has to dissolve INTO the
     ground, and a uniform 30% plate reads as a grey wash over the whole box. */
  -webkit-mask-image:linear-gradient(to bottom,#000 0%,#000 38%,transparent 100%);
          mask-image:linear-gradient(to bottom,#000 0%,#000 38%,transparent 100%)}
.page--doc .plate img{width:100%;height:100%;object-fit:cover;object-position:50% 0}
/* ⚠⚠ THE PLATE MUST BE CLIPPED BY THE BOX, and it is not by construction: it
   is a sibling of the sections, not a child, so the section's own
   overflow:hidden never applies to it. Unclipped it crosses the dotted rule on
   the right and runs past the corner rings at the foot — the field stops
   reading as something the page sits ON and starts reading as a picture laid
   over the frame.
   The plate's own overflow:hidden clips its <img> to the plate box; what was
   missing is that the plate box itself is inset to the RULE's line, not to the
   section's full width. --plate-inset does that on all four sides, exactly as
   the home page's .sc__art is inset from its own section. */

/* ⚠ The first section must sit ABOVE the plate, or the head's type is painted
   over by it. z-index on .wrap, not on .section: the section's own ::before is
   the dotted box at z-index 1 and must stay below the content. */
.page--doc .section > .wrap{position:relative;z-index:2}

@media (max-width:40rem){
  /* ⚠ Restates the FULL calc, gutter included — an override that restates a
     property inherits nothing from the rule it overrides. Same note as the
     home page's own .sc__art twin. */
  /* ⚠ Nothing to restate: the plate is now inset against the section, so it
     inherits the narrower box automatically. The old rule restated a width
     that no longer exists. */
}

@media (prefers-reduced-motion:reduce){
  /* Nothing to disable — the subpage plate is a still by construction. */
}

/* ⚠ --norules IS INCOMPATIBLE WITH THE CONTINUOUS BOX and the page heads no
   longer carry it. It hides BOTH pseudo-elements, so a --norules section in
   the middle of a joined run punches a hole in the box: the rules stop, the
   corner rings vanish, and the next section draws a fresh top edge as if a new
   document had started. ⛔ Do not add it to a .page--doc section. */
.page--doc .section--norules::before,
.page--doc .section--norules::after{display:revert}

/* ── /docs — the two-column reference shell ────────────────────────────────
   ⭐ /docs is a SINGLE-PAGE REFERENCE: five anchored sections on one long
   page with a persistent contents rail. The rail is what makes it navigable
   rather than merely long — a reader deep in §04 can see what else exists and
   where they are. ⛔ Do not remove it as tidy-up; it was dropped once and had
   to be restored (Kacperek: "the documentation page was slightly different
   for a reason").

   ⚠ The rail sits OUTSIDE .wrap, because .wrap is the measure the dotted grid
   is drawn to and the sections each carry their own. The grid columns are set
   on a container that spans them instead. */
.page--doc .doc2col{display:grid;
  grid-template-columns:minmax(0,14rem) minmax(0,1fr);
  align-items:start;
  width:min(100% - 2.5rem,var(--container));margin-inline:auto;
  padding-inline:var(--gutter)}
/* ⚠ The body column must not re-apply .wrap's width — its sections already
   carry it. Cancelling it here is what keeps the section rules aligned with
   the head's above. */
.page--doc .doc2col__body{min-width:0}
.page--doc .doc2col__body .wrap{width:auto;padding-inline:0;margin-inline:0}
/* ⚠ And the sections inside lose their own dotted box: the rules would draw
   around the BODY column only, at a different width from the head's box above
   — two measures on one page, which is the thing the grid exists to prevent.
   The continuous box is drawn by the head and the outer frame instead. */
.page--doc .doc2col__body .section::before,
.page--doc .doc2col__body .section::after{display:none}

.page--doc .doctoc{position:sticky;top:calc(var(--nav-h) + var(--s6));
  padding-top:var(--section)}
.page--doc .doctoc__inner{display:flex;flex-direction:column;gap:var(--s4)}
.page--doc .doctoc ul{list-style:none;margin:0;padding:0;
  display:flex;flex-direction:column;
  border-left:1px solid var(--rule-2)}
.page--doc .doctoc li{margin:0}
/* ⚠ The active/hover edge is drawn by moving the item's OWN left border over
   the list's, not by a background: a filled pill here would read as a button
   and this is a position indicator. */
.page--doc .doctoc a{display:block;padding:var(--s2) 0 var(--s2) var(--s4);
  margin-left:-1px;border-left:1px solid transparent;
  font-size:var(--t-sm);color:var(--ink-3);letter-spacing:-.02em;
  transition:color .18s ease,border-color .18s ease}
.page--doc .doctoc a:hover{color:var(--ink);border-left-color:var(--ox)}
.page--doc .doctoc a:focus-visible{outline:2px solid var(--ox);outline-offset:2px}

/* ⚠ Below 72rem the rail is hidden, not stacked. A sticky index needs a column
   beside the text; stacked at the top it is a list the reader scrolls past
   once and never sees again, which is worse than the document order they
   already have. */
@media (max-width:72rem){
  /* ⚠⚠ `width:auto` DOES NOT CLAMP A BLOCK WHOSE CHILD IS WIDER THAN IT. The
     docs table is 628px intrinsic; at 390px this box measured 660px wide and
     the PAGE scrolled 270px sideways — the one thing the layout must never do.
     `max-width:100%` plus `min-width:0` is what actually holds it: the first
     caps the box, the second lets a flex/grid descendant shrink below its
     content, which is what allows .tablewrap to become the scroller instead of
     the document. */
  .page--doc .doc2col{display:block;width:auto;max-width:100%;min-width:0;
    padding-inline:0}
  .page--doc .doctoc{display:none}
  .page--doc .doc2col__body .wrap{
    width:min(100% - 2.5rem,var(--container));margin-inline:auto;
    padding-inline:var(--gutter)}
  .page--doc .doc2col__body .section::before,
  .page--doc .doc2col__body .section::after{display:revert}
}

/* ⚠ Anchors must clear the sticky nav, or a jump lands with the heading
   underneath the bar. Applied to the sections themselves, since they are the
   targets. */
.page--doc .section[id]{scroll-margin-top:calc(var(--nav-h) + var(--s5))}

/* ── the document plate's canvas ───────────────────────────────────────────
   ⚠⚠ WITHOUT THIS THE CANVAS RENDERS BELOW THE STILL AND IS INVISIBLE, which
   is precisely how "the plate isn't animating" presents. plate.js appends a
   bare <canvas>; with no rule it computes `position:static; display:inline`
   and lands in normal flow AFTER the <img>. Measured on /docs before the fix:
   the still at top 80, the canvas at top 368 — outside the masked box
   entirely. The shader was compiling and drawing the whole time; the result
   was simply not where anyone could see it.
   ⭐ The home page never hit this because `.sc__art canvas` and
   `.hero__art canvas` have carried these rules from the start. Copying the
   markup without the CSS is what created the gap.

   ⚠ opacity 0 → 1 on .is-live, not `display`. The canvas must be laid out and
   painted before it is revealed, or the fade crosses a blank frame. */
.page--doc .plate canvas{position:absolute;inset:0;width:100%;height:100%;
  display:block;opacity:0;transition:opacity .5s ease}
.page--doc .plate.is-live canvas{opacity:1}

/* ⚠⚠ HIDE THE STILL ONCE THE CANVAS IS LIVE, AND ONLY THEN. The wrapper
   carries a bottom-fade mask, so the canvas is transparent toward its foot and
   the PNG underneath shows THROUGH it — two different fields at once, which
   reads as a static image baked into the animation.
   ⚠ It touches no failure path: `is-live` is only ever set after a GL context,
   a compiled shader AND the tone field have all been proved, so no-JS,
   no-WebGL, a compile failure, a lost context, Save-Data and a missing field
   all still show the still at full opacity.
   ⛔ NOT display:none — that collapses the <img> and with it the box the
   canvas is absolutely positioned against. */
.page--doc .plate.is-live img{opacity:0;transition:opacity .5s ease}

/* ⚠ Reduced motion: plate.js returns before creating any canvas, so this is
   belt-and-braces for a context that somehow survives a preference change. */
@media (prefers-reduced-motion:reduce){
  .page--doc .plate canvas{display:none}
  .page--doc .plate.is-live img{opacity:1}
}

/* ── the blog ──────────────────────────────────────────────────────────────
   ⭐ Rebuilt on the 2026-08 base 2026-09-07. The indigo `.ol-prose` carried a
   display serif and a 3.4rem drop cap; DESIGN § 8 names "display serif, italic
   drop caps, ruled broadsheet columns" as the EDITORIAL-MAGAZINE
   anti-reference — one of the two registers this rebrand exists to escape.
   ⛔ Do not reinstate a drop cap. If first-paragraph emphasis is ever wanted
   it is a lead-in (a larger first line), never a cap. */

/* ── the post row ──
   ⚠ ONE component at two sizes, not two components. The featured post and a
   list row are the same object; styling them separately is how they drift. */
.blist{list-style:none;margin:var(--s6) 0 0;padding:0;
  border-top:1px solid var(--rule-2)}
.blist li{border-bottom:1px solid var(--rule-2)}

.bpost{display:grid;grid-template-columns:minmax(0,11rem) minmax(0,1fr);
  gap:var(--s5);align-items:start;
  padding-block:var(--s5);color:inherit}
.bpost__meta{margin:0;font-family:'Fragment Mono',ui-monospace,monospace;
  font-size:var(--t-mono);color:var(--ink-3);letter-spacing:-.01em;
  display:flex;flex-wrap:wrap;gap:.5rem}
/* ⚠ The category is not a pill. A filled chip here would read as a status,
   and status colour on this page means token issuance — see § the status dot. */
.bpost__cat{color:var(--ink-3)}
.bpost__title{margin:0;font-size:var(--t-h3);font-weight:500;
  letter-spacing:-.03em;line-height:1.25;color:var(--ink);
  transition:color .18s ease}
.bpost__desc{margin:var(--s2) 0 0;font-size:var(--t-sm);line-height:1.6;
  color:var(--ink-2);max-width:68ch}
/* ⚠ The whole row is the target, so the hover has to be legible without a
   underline appearing on one word. Oxblood on the title only — the row does
   not move, per § 4.2's "no card lift". */
.bpost:hover .bpost__title{color:var(--ox)}
.bpost:focus-visible{outline:2px solid var(--ox);outline-offset:4px}

/* The featured post: the same row, one step up the scale, single column. */
.bpost--lead{grid-template-columns:1fr;gap:var(--s3);
  margin-top:var(--s6);padding-block:0;border:0}
.bpost--lead .bpost__title{font-size:var(--t-h2);letter-spacing:-.04em;
  line-height:1.12;text-wrap:balance;max-width:24ch}
.bpost--lead .bpost__desc{font-size:var(--t-body);max-width:62ch}

@media (max-width:52rem){
  /* ⚠ Stacks rather than shrinking the date column: at 11rem the meta line
     wraps to three lines and the row stops scanning. */
  .bpost{grid-template-columns:1fr;gap:var(--s2)}
}

/* ── the post body ──
   ⚠ 68ch, matching .spec and the FAQ answer, so a reader moving between the
   blog and the legal pages keeps one measure. */
.bprose{max-width:68ch;margin-top:var(--s6);
  font-size:var(--t-body);line-height:1.7;color:var(--ink-2)}
.bprose > * + *{margin-top:var(--s5)}
.bprose h2{margin-top:var(--s8);font-size:var(--t-h2);font-weight:500;
  letter-spacing:-.04em;line-height:1.12;color:var(--ink);text-wrap:balance}
.bprose h3{margin-top:var(--s7);font-size:var(--t-h3);font-weight:500;
  letter-spacing:-.03em;color:var(--ink)}
.bprose strong{color:var(--ink);font-weight:500}
.bprose a{color:var(--ink);border-bottom:1px solid var(--rule)}
.bprose a:hover{border-bottom-color:var(--ink)}
.bprose ul,.bprose ol{padding-left:1.15rem;
  display:flex;flex-direction:column;gap:var(--s2)}
.bprose li{line-height:1.6}
/* ⚠ A sunken well with an oxblood edge, not a tinted card. § 7.1: surfaces are
   told apart by ground, and a bordered rounded box is the indigo card. */
.bprose blockquote{margin-inline:0;padding:var(--s4) var(--s5);
  background:var(--sunken);border-left:2px solid var(--ox);color:var(--ink)}
.bprose blockquote p{margin:0}
.bprose code{font-family:'Fragment Mono',ui-monospace,monospace;
  font-size:.92em;background:var(--sunken);padding:.1em .35em;border-radius:4px}
.bprose hr{border:0;height:1px;background:var(--rule-2);margin-block:var(--s7)}

/* ── the contact form ──────────────────────────────────────────────────────
   ⭐ Added 2026-09-16 when /contact was ported off the retired .ol-* system.
   ⚠⚠ NOT .ea__form. That control is the footer's: one pill, one field, on a
   DARK ground, with every rule written `.dark .ea__form …` so it can outrank
   `.dark .btn`. This is a stacked multi-field form on the LIGHT page ground,
   so it shares the tokens and nothing else. ⛔ Do not try to make one serve
   both — the first attempt at that is what put white-on-white text in the
   footer once already.
   ⚠ Fields are outlined, not filled, which is the same treatment .bt, .afeat
   and the phone's tiles carry: on this paper ground a filled input reads as a
   panel rather than as somewhere to type. */
.cform{display:flex;flex-direction:column;gap:var(--s5);max-width:38rem}
.cform__row{display:grid;grid-template-columns:1fr 1fr;gap:var(--s4)}
.cform__field{display:flex;flex-direction:column;gap:var(--s2);min-width:0}
.cform__field label{font-size:var(--t-mono);letter-spacing:.02em;
  text-transform:uppercase;color:var(--ink-3)}
.cform input,.cform select,.cform textarea{
  width:100%;min-width:0;padding:.7rem var(--s3);
  font-family:inherit;font-size:var(--t-body);line-height:1.5;color:var(--ink);
  background:var(--ground);border:1px solid var(--rule);border-radius:var(--r-card);
  transition:border-color .2s ease}
.cform textarea{min-height:9rem;resize:vertical}
/* ⚠⚠ align-self, AND IT IS LOAD-BEARING. .cform is `flex column`, so a bare
   .btn stretches to the full column — a 608px "Send" bar. The page this was
   ported from carried the same fix and the same warning (it had regressed there
   once, when the reply-time note that used to contain the button was removed).
   ⛔ Do not "tidy" this away. */
.cform > .btn{align-self:flex-start}
.cform input:hover,.cform select:hover,.cform textarea:hover{border-color:var(--rule-2)}
.cform input:focus-visible,.cform select:focus-visible,.cform textarea:focus-visible{
  outline:none;border-color:var(--ox)}
/* ⚠ The native arrow is removed, so one has to be drawn back — a select with
   no affordance reads as a text field that will not accept typing. */
.cform select{appearance:none;
  background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath fill='none' stroke='%236B6870' stroke-width='1.5' d='M1 1.5 6 6.5l5-5'/%3E%3C/svg%3E");
  background-repeat:no-repeat;background-position:right var(--s3) center;
  padding-right:calc(var(--s3) * 2 + 12px)}
/* ⚠ Same reasoning as .ea__msg: empty by default, always in the DOM so
   aria-live has something to watch. The colours are the LIGHT-ground pair —
   the footer's #FF9DAF / #8FE3AE are measured against #0b0c0f and would fail
   here. Measured on --ground #FFFDFC: #A3123A 7.66:1 · #1F6B42 6.39:1. */
.cform__msg{margin:0;font-size:var(--t-sm);line-height:1.55}
.cform__msg:empty{display:none}
.cform__msg.is-bad{color:#A3123A}
.cform__msg.is-good{color:#1F6B42}
/* The direct-contact rail: hairline entries, no card chrome. */
.crail{display:flex;flex-direction:column;gap:var(--s6)}
.crail__item{border-left:1px solid var(--rule);padding-left:var(--s4);
  display:flex;flex-direction:column;gap:var(--s2)}
.crail__item .label{margin:0}
.crail__item a{color:var(--ink);border-bottom:1px solid var(--rule);align-self:flex-start}
.crail__item a:hover{border-bottom-color:var(--ink)}
.crail__item p{margin:0;font-size:var(--t-sm);line-height:1.6;color:var(--ink-2)}
/* The two-column shell. ⚠ Stacks at 56rem, the same breakpoint .nav__links
   uses — below it the rail would be a 12rem column beside a squeezed form. */
.clayout{display:grid;grid-template-columns:minmax(0,7fr) minmax(0,5fr);
  gap:var(--s8) var(--s9);align-items:start}
@media (max-width:56rem){
  .clayout{grid-template-columns:1fr;gap:var(--s8)}
}
@media (max-width:40rem){
  .cform__row{grid-template-columns:1fr}
}

/* ── the app's legal documents (/app-privacy, /app-terms) ──────────────────
   ⭐ Added 2026-09-16 with BLegalDoc, when the two frozen app documents came
   off the retired .ol-* layer. ⚠ They reuse .stack and .spec rather than
   introducing a parallel prose vocabulary — .stack already styles p / ul / li
   / a for the /legal set, and .spec is the label-value readout. ⛔ Do not add
   a second prose system here; these two documents and the six /legal ones are
   the same kind of page and should not drift apart. */
.ldoc{gap:var(--s7)}
.ldoc__sec{display:flex;flex-direction:column;gap:var(--s4);max-width:68ch}
.ldoc__sec h2{margin:0;font-size:var(--t-h3);font-weight:500;
  letter-spacing:-.025em;color:var(--ink)}
/* The link list carries an optional note under each entry, so the two cannot
   share a line. */
.ldoc__links{list-style:none;padding:0;margin:0;
  display:flex;flex-direction:column;gap:var(--s3)}
.ldoc__links li{display:flex;flex-direction:column;gap:2px;font-size:var(--t-body)}
.ldoc__note{font-size:var(--t-sm);color:var(--ink-3);line-height:1.5}

/* ── 404 ───────────────────────────────────────────────────────────────────
   ⚠ Deliberately plain: this page is often reached by a miss on an ASSET
   path, so it carries no plate, no canvas and no shader. It should render
   before anything else has a chance to fail. */
.nf__acts{display:flex;flex-wrap:wrap;gap:var(--s3);margin-top:var(--s7)}
.nf__links{margin-top:var(--s8);padding-top:var(--s5);
  border-top:1px solid var(--rule-2)}
.nf__links .label{margin:0 0 var(--s4)}
.nf__links ul{list-style:none;padding:0;margin:0;
  display:flex;flex-wrap:wrap;gap:var(--s3) var(--s6)}
.nf__links a{font-size:var(--t-body);color:var(--ink);
  border-bottom:1px solid var(--rule)}
.nf__links a:hover{border-bottom-color:var(--ink)}

/* ── /r/{code} — the referral landing ─────────────────────────────────────
   ⚠⚠ .ref__out IS THE POINT OF THE PAGE. The code has to be readable across a
   room and typed into a phone by hand, because a universal link only opens an
   app that is already installed and nothing carries the code across the
   install. ⛔ Do not shrink it, and keep `user-select:all` — one tap selects
   the whole code on a device with no clipboard API. */
.ref__code{margin-top:var(--s7)}
.ref__code .label{margin:0 0 var(--s3)}
.ref__coderow{display:flex;flex-wrap:wrap;align-items:center;gap:var(--s4)}
.ref__out{font-family:'Fragment Mono',ui-monospace,monospace;
  font-size:clamp(2rem,7vw,3rem);line-height:1;letter-spacing:.28em;
  font-weight:500;color:var(--ink);user-select:all;
  /* ⚠ The letter-spacing adds a trailing gap after the last glyph; the
     negative margin pulls the copy button back to an even distance. */
  margin-right:calc(-.28em)}
.ref__acts{display:flex;flex-wrap:wrap;gap:var(--s3);margin-top:var(--s6)}
.ref__noapp{margin-top:var(--s8);padding-top:var(--s5);
  border-top:1px solid var(--rule-2);max-width:52ch}
.ref__noapp .label{margin:0 0 var(--s3)}
.ref__noapp p{margin:0;font-size:var(--t-body);line-height:1.65;color:var(--ink-2)}

/* ── /ramp-return ──────────────────────────────────────────────────────────
   Reuses the referral page's .ref__* vocabulary — the two are the same shape
   (a monospace identifier, a copy button, an "open the app" action) and should
   not grow two. ⚠ The provider id is long and arbitrary where a referral code
   is four characters, so it overrides the size and the wrapping. */
.ramp__out{font-size:var(--t-body);letter-spacing:0;margin-right:0;
  overflow-wrap:anywhere;font-weight:500}
/* The second note block follows the first, so it does not repeat the rule. */
.ramp__manual{margin-top:var(--s6);padding-top:0;border-top:0}

/* ── the investor-access form's extras ─────────────────────────────────────
   Shares .cform / .crail with /contact; only the checkbox and the
   submit-plus-note row are particular to it. */
.cform__check{display:flex;align-items:flex-start;gap:var(--s3);cursor:pointer}
.cform__check input{width:auto;min-width:0;flex:none;margin-top:.15rem;
  accent-color:var(--ox)}
.cform__check span{font-size:var(--t-sm);line-height:1.6;color:var(--ink-2)}
/* ⚠ The button must not stretch — .cform is a flex column, the same trap
   .cform > .btn guards against. Wrapping it in a row does that too. */
.cform__submit{display:flex;flex-wrap:wrap;align-items:center;gap:var(--s4)}
.cform__submit .label{margin:0}
.crail__list{list-style:none;padding:0;margin:0;
  display:flex;flex-direction:column;gap:var(--s2)}
.crail__list li{font-size:var(--t-sm);line-height:1.6;color:var(--ink-2)}
.crail__item--plain{border-left:0;padding-left:0}

/* ── /claim — the pay-anyone landing ───────────────────────────────────────
   ⚠⚠ THE RECEIPT IS THE PAGE'S ARGUMENT, not a flourish. A stranger who was
   sent money arrives here before they are a customer, and the three facts in
   this card — amount, instrument, public reference — are what they check
   against the message they already have. It is dark against the paper ground
   on purpose: it has to read as a rendered document rather than as more page.
   ⛔ Do not lighten it into the page, and do not add a sender name to it. */
.clm__head{margin-top:var(--s5)}
.clm__head h1{margin:0 0 var(--s4);max-width:20ch}
.clm__pill{display:inline-block;margin:0 0 var(--s4);
  padding:.28rem .7rem;border-radius:var(--r-pill);
  font-size:var(--t-mono);letter-spacing:.02em;
  background:var(--sunken);color:var(--ink-2);border:1px solid var(--rule-2)}
.clm__receipt{margin-top:var(--s7);max-width:30rem;
  background:var(--dark-a,#0b0c0f);border-radius:var(--r-card);
  padding:var(--s6)}
/* ⚠ Every label in the receipt is on the DARK ground, so it cannot use
   --ink-3. Measured on #0b0c0f: rgba(255,255,255,.62) is 9.4:1. */
.clm__rlab{margin:0;font-size:var(--t-mono);letter-spacing:.02em;
  text-transform:uppercase;color:rgba(255,255,255,.62)}
.clm__amount{margin:var(--s2) 0 0;font-size:clamp(2rem,6vw,2.75rem);
  line-height:1.05;letter-spacing:-.03em;font-weight:500;color:#fff}
.clm__rrow{display:flex;align-items:baseline;gap:var(--s3);margin-top:var(--s5)}
.clm__tok{font-family:'Fragment Mono',ui-monospace,monospace;
  font-size:var(--t-sm);color:#fff}
.clm__rref{margin-top:var(--s4);padding-top:var(--s4);
  border-top:1px solid rgba(255,255,255,.12)}
.clm__ref{margin:var(--s2) 0 0;font-family:'Fragment Mono',ui-monospace,monospace;
  font-size:.6875rem;line-height:1.6;overflow-wrap:anywhere;
  color:rgba(255,255,255,.82)}
.clm__rnote{margin:var(--s3) 0 0;font-size:var(--t-mono);line-height:1.5;
  color:rgba(255,255,255,.55)}
.clm__acts{display:flex;flex-wrap:wrap;align-items:center;gap:var(--s3);
  margin-top:var(--s6)}
.clm__h2{margin:0 0 var(--s6);font-size:var(--t-h3);font-weight:500;
  letter-spacing:-.025em;color:var(--ink)}
.clm__body{margin:0;font-size:var(--t-body);line-height:1.65;
  color:var(--ink-2);max-width:58ch}
.clm__body + .label{margin-top:var(--s6)}
.label + .clm__body{margin-top:var(--s3)}
.clm__steps{list-style:none;padding:0;margin:var(--s4) 0 0;
  display:flex;flex-direction:column;gap:var(--s4)}
.clm__steps li{display:flex;gap:var(--s4);max-width:58ch;
  font-size:var(--t-body);line-height:1.65;color:var(--ink-2)}
.clm__n{flex:none;font-family:'Fragment Mono',ui-monospace,monospace;
  font-size:var(--t-sm);color:var(--ink-3)}
.clm__trust{display:flex;flex-direction:column;gap:var(--s5)}
.clm__tt{margin:0 0 var(--s2);font-size:var(--t-body);font-weight:500;
  color:var(--ink)}
.clm__help{margin-top:var(--s8);padding-top:var(--s5);
  border-top:1px solid var(--rule-2)}
.clm__help .label{margin:0 0 var(--s3)}
.clm__helplinks{display:flex;flex-wrap:wrap;gap:var(--s3) var(--s6);
  margin-top:var(--s4)}
.clm__helplinks a{font-size:var(--t-body);color:var(--ink);
  border-bottom:1px solid var(--rule)}
.clm__helplinks a:hover{border-bottom-color:var(--ink)}

/* ── the early-access form after it was WIRED (2026-09-16) ────────────────
   ⚠⚠ .ea__form IS A HORIZONTAL PILL — `display:flex; align-items:center` on a
   26rem row that was designed to hold exactly two children, an input and a
   button. Wiring the form added two MORE: the Turnstile widget and the status
   region. Both landed INSIDE the pill, so the widget sat on top of the button
   and the announced message was squeezed into the row.
   ⭐ The fix is a wrapper, not a rewrite: the pill keeps its two children and
   the two new ones stack UNDER it. ⛔ Do not "simplify" this by putting the
   widget back in the pill — it is 300px wide and the pill is 416px. */
.ea{display:flex;flex-direction:column;align-items:flex-start;gap:var(--s3)}
.ea .ea__form{margin-top:var(--s5)}
/* The widget is invisible in managed mode and only paints when Cloudflare
   wants a challenge, so it usually costs nothing; when it does paint it must
   not push the fine print sideways. */
.ea__turnstile{min-height:0}
.ea__turnstile:empty{display:none}
