/* dp-stage.css — builder B2, GitHub issues #10 (statement/before-after) and #11 (five-step cover
 * stack). Everything here uses a brand-new `dps-` class prefix so nothing B1 deletes from
 * site.css (.dp-stage*, .dp-ba-*, .dp-chapter*, .dp-step-art, .dp-object, .dp-step-note,
 * html.dp-motion) can ever collide with this markup, and nothing here can collide back.
 *
 * Gating contract (report Shared Implementation rule 6 + owner instruction): every scroll-pinned /
 * initially-invisible rule below is double-gated — a `min-width:1024px` media query in CSS AND a
 * live JS check (dp-stage.js only ever sets `data-dps-armed` on <html> when
 * `!prefers-reduced-motion && innerWidth>=1024`). Outside that, every rule in this file keeps its
 * BASE (unscoped) declaration: plain flow, full opacity, no transform, no sticky. Nothing may
 * default to opacity:0 without JS having armed the root attribute first.
 */

:root {
  /* ROUND 6 BLOCK 1 / FIX 6: site.css's own --ease-mask (cubic-bezier(.73,.54,.05,.99), the report's
   * measured mask/width-reveal easing) was removed along with the html.dp-motion block that defined
   * it — this file keeps its own copy under a dps- name rather than depend on a deleted token. */
  --dps-ease-mask: cubic-bezier(.73, .54, .05, .99);
  /* R7 issue #35: ink text at a boosted fixed alpha for the two Block-5 card tones (sage, amber)
   * whose luminance sits close enough to ink's normal 64%-alpha --mute that body copy fell under
   * 4.5:1 (measured 3.46:1 / 3.79:1) — 82% clears both (5.11:1 / 5.79:1). See Block 5 below. */
  --dps-mute-strong: rgba(16, 32, 27, .82);
}

/* ----------------------------------------------------------------------------------------------
 * Block 3+4 — StageStatementCompare.astro (issue #10)
 * ------------------------------------------------------------------------------------------- */
.dps-compare { padding: calc(var(--sec) * 1.1) 0; background: var(--paper); color: var(--ink); overflow: clip; }
.dps-stage { position: relative; }

/* base (mobile / reduced-motion / no-js): plain flow, heading then two stacked cards, all visible */
.dps-presentation { display: flex; flex-direction: column; align-items: center; gap: 40px; }
.dps-heading {
  max-width: 900px; margin: 0 auto; text-align: center; font-family: var(--serif);
  /* FINISHER FIX (reviewer HIGH, round 8): matching the page's standard --h2 size here made this
   * heading "look like a paragraph floating in an empty full-screen stage" — it is the sole
   * content of a pinned, otherwise-empty scene and needs DISPLAY scale, not body-copy scale.
   * #dpsHeading is already exempt from lab/probe-heading-parity.mjs's h2-size-parity check
   * (policy/heading-lines.json still requires it to render as exactly 2 lines at every width).
   * 26px floor unchanged from the prior working value (issue #25's own measured constraint: 13
   * glyphs at 30px already measured 378px against the 350px box at 390 — 26px clears it with
   * margin, verified again by lab/probe-clipped-text.mjs). 6.2vw/72px ceiling verified against the
   * 900px box at 1440/1024/768 by the same clip probe — both `.dps-line`s stay single-line
   * (`white-space:nowrap`, unconditional above) at every width. */
  font-size: clamp(26px, 6.2vw, 72px); font-weight: 500; letter-spacing: -.03em;
  line-height: 1.2; line-break: strict;
}
/* R7 issue #25 (owner, round 7): "not even displaying evenly" / 4-line wrap at 1730px. Root cause
 * (measured, lab/probe-line-wrap.mjs): CJK text has an implicit break opportunity between almost
 * any two characters, so plain `white-space:normal` wraps whenever the shrink-to-fit box is even a
 * few px tighter than the unwrapped line — true at every width once real Noto Serif HK metrics are
 * loaded (headless Chromium's font-substitution fallback hid this in an earlier pass). Each line is
 * a fixed, pre-authored two-line break already (data-line 1/2); forcing nowrap here removes the
 * browser's wrap decision entirely, so "no wrap" only has to be true because the box is sized wide
 * enough (see the armed `width:max-content` fix below + the font-size formula above), never as a
 * side effect of shrink-to-fit picking a narrower box. */
.dps-line { display: block; white-space: nowrap; }
.dps-line + .dps-line { margin-top: .18em; }
.dps-clause { display: inline-block; }
.dps-trigger, .dps-stopper { display: none; }

/* ROUND 8 (#46/#47) — the phone-branch elements' UNCONDITIONAL default, deliberately NOT inside a
   media query: at >=1024 (and unarmed below it) the pin wrapper must be transparent to layout so
   the desktop DOM is exactly what it was before this round, the backdrop must not paint, and the
   phone title must not render at all ("not necessary to have it on desktop", issue #47). The
   max-width block further down turns all three on, only once the phone branch is armed. */
.dps-m-pin { display: contents; }
.dps-m-cover { display: contents; }
.dps-m-bg { display: none; }
/* ROUND 9, issue #57 — the actual cause of the mismatched glyphs, measured with
   CSS.getPlatformFontsForNode (lab/diag-fonts-390.mjs): at 390 this title rendered as 9 glyphs of
   Noto Serif HK + 3 glyphs of PMingLiU. `lab/generate-font-subset.mjs` decides each character's
   family by reading computed `font-family` in a page it loads at a HARD-CODED 1440x900 — and the
   only rule that made this element serif lived inside `@media (max-width: 1023.98px)`. At 1440 the
   element therefore computed to the inherited --sans, so 找 / 甚 / 麼 were subset into the SANS
   face and were simply absent from the serif face the phone actually uses. (The same single-
   viewport blindness is why PROBE-FONT-COVERAGE reported 0 uncovered; both scanners are made
   viewport-aware in this same change, and the probe's negative test covers it.)
   The family/weight are therefore declared HERE, unconditionally, so they no longer depend on
   which viewport a scanner happens to use. The element stays `display:none` at >=1024, so this is
   invisible on desktop. Weight 500 also matches the desktop heading, which the owner compared it
   against. */
.dps-m-title { display: none; font-family: var(--serif); font-weight: 500; }

.dps-cards { display: flex; flex-direction: column; gap: 24px; width: 100%; max-width: 560px; margin: 0 auto; }
.dps-card {
  position: relative; width: 100%; box-sizing: border-box; border-radius: var(--r-card, 24px);
  padding: 32px; overflow: hidden;
}
.dps-card[data-side="before"] { background: var(--bg-loop-process, #EEEFE8); color: var(--ink); }
.dps-card[data-side="after"] { background: var(--green); color: var(--paper); }
/* R7 issue #29 (owner: image card "completely cut off"). Measured (lab/probe-card-media.mjs, and
 * an isolated repro outside this component): with `display:grid; place-items:center`, the single
 * IMPLICIT row track is sized by Chromium's content-based "auto" track algorithm using the img's
 * own intrinsic ratio BEFORE the container's explicit 200px height is applied to it — so a
 * percentage `height:100%` on the img resolves against that content-sized row, not against
 * `.dps-media`'s real 200px box (measured 286x286 for a 720x720 square source inside a 286x200
 * box; verified `align-self:stretch`/`justify-self:stretch` on the img does NOT fix this, since
 * the row track itself, not the item's alignment, is what's wrong). The extra height then gets
 * clipped by `overflow:hidden` — object-fit:contain never runs on the true 200px box, so it cannot
 * be the fix (contain cannot crop; the box it was given was already wrong). Switching to flex
 * removes the grid-track circularity entirely: a flex item's percentage height resolves directly
 * against the flex container's own definite height, independent of align-items. Confirmed fixed
 * in the isolated repro (200x200, not 286x286) and in this file's own probe. */
.dps-media { height: 200px; display: flex; align-items: center; justify-content: center; margin-bottom: 18px; border-radius: var(--r-sm, 16px); overflow: hidden; }
.dps-card[data-side="before"] .dps-media { background: var(--bg-still-ba-alone, #F7F6F0); }
.dps-card[data-side="after"] .dps-media { background: var(--green); }
.dps-media img { width: 100%; height: 100%; object-fit: contain; }
.dps-media.asset-missing img { display: none; }
.dps-card h3 { margin-bottom: 14px; }
.dps-card ul { border-top: 1px solid var(--line); }
.dps-card[data-side="after"] ul { border-top-color: var(--line-d); }
.dps-card li { padding: 11px 0; border-bottom: 1px solid var(--line); }
.dps-card[data-side="after"] li { border-bottom-color: var(--line-d); }

/* armed desktop: single sticky presentation, scroll-scrubbed heading split + card emergence, then
 * a spring-driven split into two fixed cards. See dp-stage.js for --dps-a / --dps-b / .dps-split. */
@media (min-width: 1024px) {
  html[data-dps-armed] .dps-presentation {
    position: sticky; top: 0; height: 100vh; display: flex; flex-direction: column;
    align-items: center; justify-content: center; gap: 0; overflow: hidden; perspective: 1400px;
  }
  /* ROUND 6 NIT 9: kora's own two comparison triggers are themselves position:sticky;top:0 (inert
   * geometry helpers, never painted) — matched here even though it makes no visible difference,
   * so the measured structure and ours agree. */
  html[data-dps-armed] .dps-trigger { display: block; position: sticky; top: 0; height: 100vh; pointer-events: none; }
  /* R7 issue #30 (owner: too many scrolls to reveal / to leave). trigger1 MUST stay 100vh: its
   * static offset (= presentation's own 100vh height, independent of trigger1's own declared
   * height) is what keeps Stage B's arrival window [1vh,2vh] starting exactly when Stage A's
   * [0,1vh] ends — shrinking it would overlap the two stages (cards starting to split while the
   * heading is still exiting). trigger2's declared height does NOT affect the split threshold
   * (split fires purely off trigger2's rect.top crossing viewport*0.5, fixed by the motion
   * authority at "viewport y450" i.e. exactly half -- REPORT.md Part 3 Block4 -- independent of
   * its own CSS height), so it is free to shrink: it only changes how much of the container is
   * LEFT after split, i.e. the trailing dead scroll. Measured before: 100+100+100+50=350vh total,
   * release 2.5vh after arrival, split at 1.5vh -> 1.0vh of pure dead scroll after cards are done.
   * After: 100+100+50+30=280vh total, release 1.8vh after arrival, split still 1.5vh -> 0.3vh
   * tail, matching the issue's own "~0.3vh after final state" target. */
  html[data-dps-armed] .dps-trigger[data-dps-trigger="2"] { height: 50vh; }
  html[data-dps-armed] .dps-stopper { display: block; height: 30vh; pointer-events: none; }

  html[data-dps-armed] .dps-heading {
    /* R7 issue #25: measured (lab/probe-line-wrap.mjs) that an absolutely positioned box with only
     * `left:50%` set (no `right`, no `width`) shrink-to-fits against HALF the containing block
     * (the available space is only "containing-block-width minus the left offset", i.e. the box's
     * own right half) — not the full `max-width:900px` above it. That halved box (~471-680px
     * measured at 1024/1440/1730) was tight enough to wrap the 13-glyph line. `width:max-content`
     * sizes the box to its own (now nowrap, single-line) content instead, still capped by the
     * inherited `max-width:900px`, still centred by the existing translate(-50%, ...). */
    position: absolute; top: 50%; left: 50%; width: max-content; opacity: var(--dps-a, 0);
    transform: translate(-50%, calc(-50% - 300px * (1 - var(--dps-a, 0)))) scale(calc(1.1 - .1 * var(--dps-a, 0)));
    will-change: transform, opacity;
  }
  html[data-dps-armed] .dps-line {
    will-change: transform, opacity;
    /* fix-round-3: transform now rides the EASED --dps-e (= b*b, dp-stage.js), not raw --dps-b, so
     * the line stays on-screen longer at the start of Stage B — combined with the tighter opacity
     * cutoff below, this keeps every edge-crossing frame at opacity <= .40 (verified by
     * lab/probe-stage-edge.mjs, not just by eye). */
    transform: translateX(calc(var(--dps-dir, 1) * -2000px * var(--dps-e, 0)))
               rotateY(calc(var(--dps-dir, 1) * -60deg * var(--dps-e, 0)))
               scale(calc(1 + .5 * var(--dps-e, 0)));
    /* fix-round-2 (A) / fix-round-3: opacity is driven by its own eased, earlier-cutoff property
     * (--dps-line-o, dp-stage.js: 1-(b/0.45)^2, gone by b=0.45) — decoupled from the transform. */
    opacity: var(--dps-line-o, 1);
  }
  html[data-dps-armed] .dps-line + .dps-line { margin-top: 0; --dps-dir: -1; }

  html[data-dps-armed] .dps-cards {
    position: relative; max-width: none; width: 100%; height: 650px; margin: 0;
    /* HANDOFF FIX: rides --dps-cards-o (dp-stage.js), not raw --dps-b — held at 0 until the
     * statement line is fully gone (see dp-stage.js update() for the sequencing), so there is no
     * scroll position where both the statement and the cards are simultaneously visible. */
    opacity: var(--dps-cards-o, 0); transform: scale(calc(.7 + .3 * var(--dps-cards-o, 0)));
  }
  html[data-dps-armed] .dps-card {
    position: absolute; top: 50%; left: 50%; width: 500px; height: 650px; margin: -325px 0 0 -250px;
    border-radius: 40px; padding: 40px;
    /* ROUND 6 BLOCK 1: --dps-spring was never defined anywhere, so this always silently fell back
     * to the bundled cubic-bezier — use the report's own measured .76s spring curve (--ease76,
     * checked into site.css, unconditional/not deleted by B1) instead. */
    transition: transform .76s var(--ease76), opacity .3s linear;
  }
  html[data-dps-armed] .dps-card[data-side="before"] { z-index: 2; transform: translate(0, 0) scale(1); }
  html[data-dps-armed] .dps-card[data-side="after"] { z-index: 1; transform: translate(0, 0) scale(.8); }
  html[data-dps-armed] .dps-cards.dps-split .dps-card[data-side="before"] { transform: translate(-260px, 0) scale(1); }
  html[data-dps-armed] .dps-cards.dps-split .dps-card[data-side="after"] { transform: translate(260px, 0) scale(1); }
  /* ROUND 6 BLOCK 1: report's native CDP evidence records this exact opacity change at 800ms, not
   * the nominal .76s spring (Part 1 §2: "the native opacity animation reports 800ms rather than
   * the nominal .76s spring setting" — Shared rule 5 says to reproduce that recorded timing). */
  html[data-dps-armed] .dps-card-inner { transition: opacity 800ms linear; }
  html[data-dps-armed] .dps-cards.dps-split .dps-card[data-side="before"] .dps-card-inner { opacity: .5; }
  html[data-dps-armed] .dps-media { height: 180px; }
}

/* ----------------------------------------------------------------------------------------------
 * ROUND 8 — the PHONE branch (issues #46/#47, SPEC.md §A/§B). Everything here is gated behind
 * `html[data-dps-marmed]`, a SEPARATE arm attribute from the desktop `data-dps-armed`, and lives
 * inside a max-width query — the two branches can never both apply, and the unarmed default
 * (no JS, reduced motion) is still the plain fully-visible flow it has always been.
 * ------------------------------------------------------------------------------------------- */
@media (max-width: 1023.98px) {
  .dps-compare { padding: 72px 0; }
  html[data-dps-marmed] .dps-compare { padding: 0 0 72px; }
  /* 2.4 vh of pin (SPEC §A measured 2018px = 2.39vh), full-bleed out of `.wrap`'s side padding so
     the still reaches both edges of the phone. */
  html[data-dps-marmed] .dps-m-pin {
    display: block; position: relative; z-index: 0; height: 240vh;
    width: 100vw; margin-inline: calc(50% - 50vw);
  }
  /* R8 reviewer BLOCK (item 2). The hand-off kora actually performs: the next block is OPAQUE and
     simply scrolls up over the sticky statement. `z-index: 2` puts this panel above the pin,
     `background: var(--paper)` makes it cover rather than blend, and `overflow: clip` keeps the
     title's -300px spring start inside this box - so the title can never paint on top of the
     statement, and the statement needs no second opacity writer to get out of its way. Full-bleed
     so the paper reaches both phone edges, with the wrap's own side padding restored inside. */
  html[data-dps-marmed] .dps-m-cover {
    display: block; position: relative; z-index: 2;
    width: 100vw; margin-inline: calc(50% - 50vw);
    padding: 64px var(--pad) 0; box-sizing: border-box;
    background: var(--paper); overflow: clip;
  }
  /* ROUND 9, issue #56. The round-8 backdrop was assets/hero/poster-9x16.jpg — a video frame with
     a person in it. Owner: "something that matches our website background theme... a good abstract
     background... I just want people to focus on the message itself." So: no raster, no person, no
     new asset. A code-built abstract on the brand's own deep green — three large blurred radial
     glows (sage top-left, a soft amber warmth low-right, a deeper green pool bottom-left) over the
     `--green` ground, plus one very low-opacity grain layer. Static while pinned: nothing here
     animates, and because it is a plain gradient it costs no request and no decode.
     Contrast: the BRIGHTEST point of this backdrop is the centre of the amber glow, which composites
     to #3A4F35 (the amber stop is only 26% alpha over the green). Paper white #F2F3EE against that
     measures 7.9:1 — past 4.5:1 with room, and the statement sits above the amber glow, not on it.
     The hero's own poster-9x16.jpg preload in divorce-procedure.astro STAYS: HeroDP.astro still
     uses that file as the tall hero's poster (data-poster-tall), so it is still the phone LCP. */
  html[data-dps-marmed] .dps-m-bg {
    display: block; position: sticky; top: 0; height: 100vh; width: 100%;
    background-color: var(--green);
    background-image:
      radial-gradient(60% 42% at 18% 16%, rgba(122, 158, 130, .42), rgba(122, 158, 130, 0) 70%),
      radial-gradient(52% 38% at 86% 74%, rgba(233, 169, 58, .26), rgba(233, 169, 58, 0) 72%),
      radial-gradient(70% 50% at 8% 96%, rgba(11, 43, 33, .58), rgba(11, 43, 33, 0) 68%);
    background-repeat: no-repeat;
  }
  /* Grain: one inline SVG feTurbulence tile at 4% opacity. It is a static data URI on a pseudo-
     element (no extra request, nothing animated, no filter on a live layer — so it cannot show up
     in the perf gate's maxFilterTransitionsOrAnimations count). */
  html[data-dps-marmed] .dps-m-bg::after {
    content: ''; position: absolute; inset: 0; pointer-events: none; opacity: .04;
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='2'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)'/%3E%3C/svg%3E");
  }
  /* The statement pins over that still for the whole 2.4vh; the reveal itself occupies the FIRST
     1.0vh (SPEC §A: "reveal sits at the FRONT of the pin"), and the next block covers the pinned
     statement as the last character completes. */
  html[data-dps-marmed] .dps-heading {
    position: sticky; top: 0; height: 100vh; margin-top: -100vh;
    display: flex; flex-direction: column; align-items: center; justify-content: center;
    gap: 8px; padding: 0 20px; box-sizing: border-box; text-align: center;
    color: var(--paper); font-size: 30px;
    /* #57: was weight 400 / line-height 1.22 while the desktop heading renders at weight 500 and
       line-height 1.2 (measured: 72px/86.4px). Matched, so the phone and desktop statements are
       the same face at the same weight — no synthetic bold, no second family. Letter-spacing was
       already the same -0.03em ratio at both widths (-0.9px on 30px vs -2.16px on 72px). */
    line-height: 1.2; font-weight: 500;
  }
  html[data-dps-marmed] .dps-line { display: block; white-space: nowrap; }
  /* ONE writer, one property: dp-stage.js writes `--dps-m-s` (unitless pixels travelled into the
     reveal) on .dps-presentation and `--dps-m-stagger` = (1.0vh - 300px) / (units - 1). Every
     character then derives its OWN 0..1 progress here, from its own `--i`. There is no per-
     character JS, no class, and nothing else writes opacity/transform/filter on these spans. */
  html[data-dps-marmed] .dps-ch {
    display: inline-block; font-style: normal;
    --u: clamp(0, calc((var(--dps-m-s, 0) - var(--i, 0) * var(--dps-m-stagger, 30)) / 300), 1);
    opacity: var(--u);
    transform: scale(calc(.9 + .1 * var(--u)));
    filter: blur(calc((1 - var(--u)) * 5px));
  }
  /* #47 / SPEC §B: opacity 0->1, scale 1.1->1, translateY -300px->0, all off the SAME `--dps-a`
     spring value the desktop heading uses (dp-stage.js makeSpring, k=358 d=100 m=1). */
  html[data-dps-marmed] .dps-m-title {
    display: block; margin: 0 0 30px; text-align: center;
    font-family: var(--serif); font-size: clamp(24px, 6.4vw, 30px); line-height: 1.3;
    font-weight: 500; color: var(--ink);
    opacity: var(--dps-a, 0);
    transform: translateY(calc(-300px * (1 - var(--dps-a, 0)))) scale(calc(1.1 - .1 * var(--dps-a, 0)));
    will-change: transform, opacity;
  }
  /* SPEC §B: "cards on the phone stay a plain column with no entry motion, as kora does." */
}
@media (max-width: 1023.98px) and (prefers-reduced-motion: reduce) {
  /* SPEC §A: "Reduced motion = all units visible, no pin." The arm attribute is never set under
     reduced motion, so this is belt-and-braces against a mid-session preference flip. */
  .dps-ch { opacity: 1 !important; transform: none !important; filter: none !important; }
  .dps-m-title { opacity: 1 !important; transform: none !important; }
}

/* ----------------------------------------------------------------------------------------------
 * Block 5 — Steps.astro cover stack (issue #11, ROUND 7 rebuild for issues #31/#32/#34/#35)
 * ------------------------------------------------------------------------------------------- */
/* ROUND 6 FIX 8: measured gap between slots is 10px, not the homepage/report-4 chapter-gap 24px. */
.dps-steps-stack { --dps-card-h: auto; --dps-slot-h: auto; list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
.dps-slot { position: relative; }
/* R8 #49: the slot now also carries the homepage deck's `[data-stack-card]` contract (see
   Steps.astro). stack-cards.css's own RESTING box for that attribute is a 30px-padded card,
   because on the homepage the <li> IS the card; here the card is the inner `.dps-card-step`, so
   the outer padding is cancelled and the slot stays a bare box. Everything stack-cards owns at
   <1024px (position/top/min-height, the `scale` gsap writes, the `--stack-dim` overlay) is
   untouched — only this resting padding is overridden. */
.dps-slot[data-stack-card] { padding: 0; }
/* R8 issue #48 (owner: "the timeline card's colouring isn't quite appealing... change it to a
 * better contrast colour... make sure it looks good for the object, the title, and the
 * description"). The R7 sequence was cream -> sage -> green-mid -> ink -> amber; its two middle
 * tones were the defect. Measured with the blend modes this file actually applies (multiply on
 * light tones, invert+hue-rotate+lighten on dark ones), the LINE-ART OBJECT against its own baked
 * art background read 6.30:1 on sage and 5.57:1 on green-mid — the two worst panels on the page —
 * and adjacent cards were as close as 2.05:1, which is why they "all look alike".
 * New sequence, alternating light/dark so every seam is obvious: cream -> deep green -> sand ->
 * ink -> amber. Measured (script output pasted in the round-8 handback):
 *   object line vs its art background: 11.03 / 8.56 / 9.78 / 11.48 / 7.08  (was min 5.57)
 *   adjacent card-to-card:             10.77 / 9.17 / 12.42 / 8.20         (was min 2.05)
 *   title vs card:                     14.58 / 11.17 / 12.42 / 15.13 / 8.20
 *   description/tip vs card:            4.82 /  6.05 /  7.74 /  7.60 / 5.79 (all >= 4.5)
 * Body/task/tip copy uses --dps-card-mute, boosted to --dps-mute-strong (82% alpha) on the two
 * light warm tones (sand, amber) where the site's normal 64% --mute falls under 4.5:1. Card 1
 * stays the loop's own measured background (--bg-loop-process) so its video needs no colour
 * treatment at all - see the video-blend block below. */
.dps-card-step {
  --dps-card-bg: var(--bg-loop-process); --dps-card-fg: var(--ink); --dps-card-mute: var(--mute);
  width: 100%; box-sizing: border-box; background: var(--dps-card-bg); color: var(--dps-card-fg);
  border-radius: var(--r-card, 24px); padding: 30px;
  /* R7 issue #35 "add a soft top shadow on each card so the overlap edge is visible when folding":
   * a negative-offset shadow paints ABOVE this card's own box; because later cards sit at a higher
   * z-index (Steps.astro's inline `z-index:{index+1}`), each incoming card's shadow renders over
   * whatever it is currently covering, making the seam readable at every fold. */
  box-shadow: 0 -28px 46px -26px rgba(16, 32, 27, .32);
}
.dps-slot[data-dps-tone="2"] .dps-card-step { --dps-card-bg: var(--green); --dps-card-fg: var(--paper); --dps-card-mute: var(--mute-d); }
.dps-slot[data-dps-tone="3"] .dps-card-step { --dps-card-bg: var(--sand); --dps-card-fg: var(--ink); --dps-card-mute: var(--dps-mute-strong); }
.dps-slot[data-dps-tone="4"] .dps-card-step { --dps-card-bg: var(--ink); --dps-card-fg: var(--paper); --dps-card-mute: var(--mute-d); }
.dps-slot[data-dps-tone="5"] .dps-card-step { --dps-card-bg: var(--amber); --dps-card-fg: var(--ink); --dps-card-mute: var(--dps-mute-strong); }
.dps-card-step-in { display: grid; grid-template-columns: minmax(0, 1fr) minmax(280px, .82fr); gap: clamp(30px, 5vw, 72px); align-items: center; }
.dps-copy { min-width: 0; }
.dps-copy .eyebrow { color: var(--dps-card-mute); }
.dps-copy .eyebrow i { background: var(--amber); }
.dps-copy .num { display: block; font-family: var(--serif); font-size: 56px; font-weight: 500; line-height: 1; color: var(--amber); margin-bottom: 8px; }
/* R8 #48: the two LIGHT WARM tones are now 3 (sand) and 5 (amber) — those are the backgrounds
 * close enough to amber's own luminance that an amber numeral/dot/tick nearly disappears
 * (measured 1.76:1 on sand, 1.16:1 on amber) - swap to the card's own ink foreground there. Tone 2
 * is now the deep green, where the sitewide amber accent reads at 5.7:1, so it keeps it. */
.dps-slot[data-dps-tone="3"] .dps-copy .num, .dps-slot[data-dps-tone="5"] .dps-copy .num,
.dps-slot[data-dps-tone="3"] .dps-copy li::before, .dps-slot[data-dps-tone="5"] .dps-copy li::before { color: var(--ink); }
/* the eyebrow dot is an empty decorative box (no text content), so it needs `background`, not
 * `color` - a stray `background` on the two text rules above painted a solid ink-coloured box
 * behind the checkmark GLYPH itself (screenshot-caught, R7 fix). */
.dps-slot[data-dps-tone="3"] .dps-copy .eyebrow i, .dps-slot[data-dps-tone="5"] .dps-copy .eyebrow i { background: var(--ink); }
/* PERF pass (issue #28 builder, small defect fix): card 1's title wrapped unevenly (a long first
   line, a short ragged last line) even with the existing max-width — text-wrap: balance
   redistributes the break points evenly instead of the browser's default greedy wrap; max-width
   stays as the fallback for the one in ~4 browsers without balance support. */
.dps-copy h3 { font-size: clamp(24px, 2.1vw, 30px); margin-bottom: 10px; max-width: 21ch; text-wrap: balance; }
/* R7 issue #33 "make it more appealing for details": summary (bold, one line at typical widths)
 * ahead of the full detail paragraph - both reuse the same existing body copy, no new facts. */
.dps-copy .dps-summary { color: var(--dps-card-fg); font-weight: 600; font-size: 16px; line-height: 1.5; margin-bottom: 8px; }
.dps-copy .dps-detail { color: var(--dps-card-mute); font-size: 14px; margin-bottom: 16px; }
/* font-weight is explicit because an <h4> otherwise inherits the UA's bold, and the subsetted
   serif only loads 400 and 500 -- 700 was being rendered as a synthetic faux-bold
   (measured 2026-09-21, lab/probe-mt-typescale.mjs). */
.dps-copy h4 { margin: 20px 0 8px; font-family: var(--serif); font-weight: 500; font-size: 17px; }
.dps-copy ul { display: grid; gap: 7px; color: var(--dps-card-mute); margin-bottom: 20px; }
.dps-copy li { position: relative; padding-left: 18px; }
.dps-copy li::before { content: "✓"; position: absolute; left: 0; color: var(--amber); }
.dps-tip { margin-top: 14px; padding: 14px 16px; border-radius: var(--r-sm, 16px); background: rgba(16, 32, 27, .05); color: var(--dps-card-mute); font-size: 14px; }
.dps-slot[data-dps-tone="2"] .dps-tip, .dps-slot[data-dps-tone="4"] .dps-tip { background: rgba(242, 243, 238, .1); }
.dps-art { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
/* background now tracks the CARD's own tone (was hard-coded to --bg-loop-process, correct only
 * while every card shared one colour) so the frame area around the video still matches its card. */
/* PERF round 3, issue 3: `contain:paint` scopes this frame's own paint invalidation (the blended/
   filtered video inside plays continuously while the card transforms during the fold) to its own
   box, on top of the `isolation:isolate` already here for the blend GROUP -- keeps the compositor
   from having to consider anything outside this ~1:1 square when the video repaints each frame.
   A/B'd against the two round-7 additions here (`mix-blend-mode`/`filter` on the video, the card's
   `box-shadow`) at 1x and 4x CPU: run-to-run variance in this environment (observed swings of
   +-70% on identical code between back-to-back runs) exceeded the effect size enough that neither
   could be confidently isolated from noise -- reported honestly rather than a fabricated verdict.
   `contain:paint` is a zero-risk, same-look containment win regardless of that result; the video/
   filter combination itself is a well-documented compositor cost (blend-mode + filter together
   force a shader pass every frame the video updates, not just steady-state), so it stays flagged
   as the leading suspect for a future round with room for a longer, contention-free measurement
   window, or the poster-while-moving/video-at-rest split suggested in the brief -- not implemented
   here because it needs dp-stage.js's already-fixed play/pause scheduling, out of this pass's
   file scope. */
.dps-art-frame { display: grid; place-items: center; aspect-ratio: 1; overflow: hidden; border-radius: var(--r-card, 24px); background: var(--dps-card-bg); isolation: isolate; contain: paint; }
.dps-art-frame img, .dps-art-frame video { width: 100%; height: 100%; object-fit: contain; will-change: transform; }
.dps-art-frame.asset-missing img, .dps-art-frame.asset-missing video { display: none; }
/* R7 issue #35 "our 3D objects should be similar to the background": the step-N.mp4 loops are
 * baked onto the still's own measured flat background (`lab/line-art-alpha.py`'s corner sample,
 * ~cream) - never regenerated. Two treatments, chosen per card tone and confirmed against
 * screenshots at both viewports: `multiply` on tones whose card is at or lighter than that baked
 * cream (R8 #48: now 1 cream, 3 sand, 5 amber) - the baked bg multiplies down onto the card tone
 * while the dark line-art stays dark; `invert+hue-rotate` then `lighten` on the two darker tones
 * (R8 #48: now 2 deep green, 4 ink) - inverting first turns the baked light bg near-black so
 * `lighten` against the (already dark) card lets the card colour show through where the bg was,
 * while the now-inverted (light) line-art stays visible on top. The transparent alpha-cutout
 * `<img>` fallback (no baked bg) never gets either treatment. */
.dps-art-frame video { mix-blend-mode: multiply; }
.dps-slot[data-dps-tone="2"] .dps-art-frame video, .dps-slot[data-dps-tone="4"] .dps-art-frame video {
  filter: invert(1) hue-rotate(180deg); mix-blend-mode: lighten;
}

/* desktop layout only (no motion implied): two columns instead of stacked */
@media (min-width: 1024px) {
  .dps-card-step-in { grid-template-columns: minmax(0, 1fr) minmax(280px, .82fr); }
}
@media (max-width: 1023.98px) {
  .dps-card-step-in { grid-template-columns: 1fr; }
  .dps-art-frame { width: min(100%, 280px); margin-inline: auto; }
}
/* R7 issue #32: card padding was a flat 40px at every width - homepage's own stacking cards
 * (.card, public/site.css) use 30px desktop / 22px <=899px; matched here (measured before/after in
 * the handback report). */
@media (max-width: 1023.98px) {
  .dps-card-step { padding: 22px; }
}

/* armed desktop only: sticky cover-stack, no shrink, no dim from the COVER mechanism itself — later
 * opaque cards paint over earlier ones by DOM/z-index order alone (report Part 3 Block 5: "Never
 * scale or dim the outer card" refers to this cover layer; the separate owner-ruled fold recipe
 * below is additive, not the same thing — see its own comment). Equal fixed card height
 * (--dps-card-h/--dps-slot-h, now a hard viewport-relative clamp set by dp-stage.js, PERF pass
 * 2026-09-17 — was content-measured and could run to 3-4x a viewport height for a tall chapter,
 * measured as the single largest contributor to this page's 34vh of pinned scroll / 218 long
 * tasks) is what prevents a taller earlier card peeking out from behind a shorter later one. */
@media (min-width: 1024px) {
  html[data-dps-armed] .dps-slot { position: sticky; top: 0; height: var(--dps-slot-h, 92vh); display: flex; align-items: center; }
  html[data-dps-armed] .dps-card-step { height: var(--dps-card-h, 640px); overflow: hidden; }

  /* R7 issue #31 "fade in/out buggy... no class toggling that re-fires on each observer crossing":
   * `.dps-tip` used to flip a class from an IntersectionObserver crossing threshold .5, driving a
   * CSS transition that re-fires every crossing (measured as the one real class-toggle source in
   * this block; the cover-stack and chart fades were already continuous). dp-stage.js now writes
   * `opacity`/`transform` directly every frame as a single monotonic function of scroll position -
   * no transition, no class, nothing to re-fire. This base (unarmed) rule is the only CSS needed;
   * JS inline styles take over once armed and are cleared on teardown. */
}

/* ----------------------------------------------------------------------------------------------
 * Block 5 — wait-chart: stays visible until the LAST card leaves (issue #34)
 * ------------------------------------------------------------------------------------------- */
@media (min-width: 1024px) {
  /* R7 issue #34: was opacity 1->0 driven by an independent 60vh `.dps-chart-trigger` positioned
   * BEFORE the deck even starts — measured 4,748px (5.3vh) of its own pinned range, fading to
   * nothing while no card had arrived yet ("the loading bar... will fade out first", owner, round
   * 7). `--dps-chart-p` is now 0 for this section's entire sticky range and only ramps toward 1 in
   * the last viewport-height before the LAST card's own pin range ends (dp-stage.js setupChart) —
   * it leaves together with the last card, never before. The trigger element is gone: this sticky
   * region already spans the whole `.wrap` (through all five cards) for free. */
  html[data-dps-armed] .dps-chart-stage {
    position: sticky; top: 130px; opacity: calc(1 - var(--dps-chart-p, 0));
    transform: scale(calc(1 - .1 * var(--dps-chart-p, 0))); will-change: opacity, transform;
  }
}

/* fix-round-2 (B), owner ruling: "the folding effect we created earlier" = the homepage
 * stack-cards.js shrink+dim recipe. data-dps-fold="1" is the SHIPPED default (Steps.astro); still
 * a one-attribute flip (JS reads it live every tick, no rebuild) if a future ruling wants plain
 * cover-only back — set data-dps-fold="0". Kept per R7 issue #31/#35: this scale+dim is a SINGLE
 * monotonic function of the same cached scroll progress dp-stage.js already computes for the art
 * motion (issue #31's "single source" requirement), and issue #35 explicitly asks for it ("so the
 * overlap edge is visible when folding") alongside the new top shadow above. */
/* Keep the sticky slot itself geometrically stable. Scaling that sticky containing block forced
 * Chromium to repaint/raster the whole stage during every fold step. The card has the same centre,
 * so moving the identical scale here is pixel-equivalent; promote it only while the section is
 * near the viewport (the observer toggles .dps-active) and release the five layers afterwards. */
html[data-dps-armed] .dps-steps-stack[data-dps-fold="1"] .dps-card-step {
  position: relative; transform: scale(var(--dps-fold-scale, 1));
}
html[data-dps-armed] .dp-steps.dps-active .dps-steps-stack[data-dps-fold="1"] .dps-card-step { will-change: transform; }
html[data-dps-armed] .dps-steps-stack[data-dps-fold="1"] .dps-card-step::after {
  content: ""; position: absolute; inset: 0; background: var(--ink); opacity: var(--dps-fold-dim, 0);
  pointer-events: none; border-radius: inherit;
}
