/* motion-a.css — MINTHEP, Surface A motion layer (index.html)
   ---------------------------------------------------------------------------
   Load order: tokens · base · components · surface-a · MOTION-A · lqip.
   After surface-a so a motion rule supersedes a surface rule at equal
   specificity without editing it; before lqip so [data-loaded] stays last.

   The declarative half of MOTION-SPEC.md — js/motion-a.js writes numbers, every
   unit, gradient, transform and curve lives here. So the resting page is
   described entirely in CSS and is correct before JS runs, if JS 404s, and if
   JS never runs.

   TWO GATES, not interchangeable:
     .js          inline <head> script, index.html:6. ALWAYS runs. Used by M5
                  alone, which needs no motion module — only core.js's .is-in.
     .has-motion  motion-a.js, added only after window.gsap AND
                  window.ScrollTrigger both exist AND REDUCED is false. Every
                  rule whose value comes from JS hangs off this.
   Nothing here may be the only thing between the reader and content: with JS
   off the page must look as it does after all animation ends.

   RULE A — every custom property JS writes is a UNITLESS NUMBER; CSS applies
   the unit via calc(… * 1px / * 1deg). That deletes every unit negotiation
   between implementers who cannot see each other's files.

   No raw hex, no raw px, no bare z-index (as surface-a.css). Missing numbers
   derive by calc() over --s-* / --bw-*. Two argued exceptions: the sheen alpha
   in §1, and RULE A's unit multipliers.

   Three of ten effects cost this file nothing and are deliberately absent:
   M4 band velocity (JS sets playbackRate; a rule here would tempt the
   `animation` shorthand, which resets play-state and defeats core.js's
   [data-anim] gate) · M6 stagger (components.css:515) · M10 (components.css:295).
*/


/* 0. REGISTERED PROPERTIES ==================================================
   inherits: false is not cosmetic — a non-inheriting registered property does
   not invalidate descendants' style, so a per-frame write touches one element.
   Registration is not what lets GSAP tween these; it buys that scope and a
   typed initial-value. Firefox < 128 / Safari < 16.4 ignore @property, so each
   is also declared in its own rule below and read through a var() fallback. */

@property --a-sheen  { syntax: '<number>'; inherits: false; initial-value: 0; }
@property --a-tilt-x { syntax: '<number>'; inherits: false; initial-value: 0; }
@property --a-tilt-y { syntax: '<number>'; inherits: false; initial-value: 0; }
@property --a-por-y  { syntax: '<number>'; inherits: false; initial-value: 0; }

/* The one inherits: true property, and it must be: a ::before inherits from its
   originating element, so at inherits: false the pseudo would read
   initial-value instead of the tweened value and the spine would never draw.
   The system's only per-frame subtree invalidation (3 <ol>, ~46 descendants)
   and so the first thing to cut if the perf gate fails. */
@property --a-spine  { syntax: '<number>'; inherits: true;  initial-value: 0; }

/* SECTION SIGNATURES (§10). Every one of these is inherits: true on purpose and
   for the same reason as --a-spine: each drives pseudo-elements, and a pseudo
   inherits from its originating element, so at inherits: false it would read
   initial-value forever and nothing would ever move. The cost is a subtree style
   invalidation per frame, which is why each is declared on the tightest possible
   container (.services, not :root) and why the counts are written down beside
   each rule.

   READ THE INITIAL-VALUES. Every one is the FINISHED state, not the starting
   state — 1, 4, 1, 6, 1, not 0. That is the whole no-JS and reduced-motion
   contract in one line: with nothing running, `clamp(0, 4 - 3, 1)` is 1 for
   every row, so the page renders complete. JS animates FROM 0, which is only
   ever reachable while JS is live. Invert any of these and the page ships blank
   to every reader without motion. */
/* INTEGRATION FIX — this was inherits: false, which is the one value it cannot
   have. JS writes --a-read on .ledger-skills; the only rule that READS it is
   .ledger-skills::after (10.1). A pseudo-element inherits from its originating
   element, so a NON-inheriting registered property never reaches it: the pseudo
   computes the initial-value instead. Measured on a full-page crawl, the element
   swept 1 -> 0.0337 -> 0.9999 exactly as animated while the pseudo's translate
   held one single value, 0px 100%, for every frame — the read-head sat parked at
   the foot of a 3044px ledger and never travelled. Same shape as the bug
   --a-spine already documents two blocks down, which is why that one is
   inherits: true. initial-value stays 1 so the finished-state contract above is
   untouched, and nothing else in the file or the page reads this name, so
   inheriting it into .ledger-skills' descendants collides with nothing. */
@property --a-read   { syntax: '<number>'; inherits: true;  initial-value: 1; }
@property --a-svc    { syntax: '<number>'; inherits: true;  initial-value: 4; }
@property --a-quote  { syntax: '<number>'; inherits: true;  initial-value: 1; }
@property --a-faq    { syntax: '<number>'; inherits: true;  initial-value: 6; }
@property --a-close  { syntax: '<number>'; inherits: true;  initial-value: 1; }

/* SPEC GAP — --a-mark is deliberately NOT registered, though MOTION-SPEC §1.1
   says it should be. Registering it breaks 34 skill rows.
   The name is already taken: surface-a.css:139 declares --a-mark on :root as
   calc(var(--s-4) + var(--s-1)) — 20px — and consumes it as a LENGTH at :819
   (.skill-row__mark, 34 rows) and :1711 (.form-a__success svg). Register it
   '<number>'/inherits:false and that :root declaration is invalid at
   computed-value time, falls back to initial-value, and consumers resolve
   `inline-size: var(--a-mark)` to `inline-size: 1` — itself invalid, computing
   to auto. 35 icons lose their sizing. syntax '*' does not rescue it:
   inherits:false stops .skill-row__mark seeing :root, inherits:true lets the
   20px reach [data-footer-mark] and poison the §7 clip-path calc.
   Fix that keeps the frozen cross-file name AND the shipped page: leave it
   unregistered, shadow it on [data-footer-mark] (§7). IMPLEMENTER-JS is
   unaffected — GSAP needs no registration to tween a custom property. All that
   is lost is invalidation scope on one aria-hidden <p> with no element
   children. surface-a.css is additive-only, so renaming there was never
   available. Flagged for reconciliation. */


/* 1. M1 — HERO PORTRAIT SPECULAR SWEEP ======================================
   One <i class="hero__sheen"> inside .hero__portrait, already position:
   relative + overflow: clip (surface-a.css:578), so containment is free. */

.hero__sheen {
  position: absolute;

  /* -10% because the plane is SKEWED: one exactly as tall as its frame shows
     triangular notches at the top and bottom edges as it crosses. Clipped by
     the parent, so the overhang costs no layout. */
  inset-block: -10%;
  inset-inline-start: 0;
  inline-size: 46%;
  transform: skewX(-14deg);

  /* Non-negotiable — .hero is M2's pointer source, and a sheen that swallowed
     pointermove would blank the tilt wherever it sat. */
  pointer-events: none;

  /* The one alpha literal: a specular highlight is light, not ink, so it has no
     --ink-* token. 8.5% white is ~one tonal step at the plane's centre and
     nothing at its edges — which is what makes the sweep invisible at both ends
     of its travel. */
  background-image: linear-gradient(
    90deg,
    transparent,
    rgb(255 255 255 / .085),
    transparent
  );

  /* Hidden by DEFAULT, revealed by .has-motion — never the reverse. */
  opacity: 0;

  /* -110% parks it off the left edge, +230% off the right: 46% x 340%. */
  --a-sheen: 0;
  translate: calc(var(--a-sheen, 0) * 340% - 110%) 0;
}

.has-motion .hero__sheen { opacity: 1; }


/* 2. M2 — HERO PORTRAIT POINTER TILT ========================================
   .hero__plate hosts the perspective (no transform/perspective/will-change on
   it today, so nothing is overridden). The tilt goes on .hero__portrait, one
   level ABOVE the <img>, which already animates its own `scale`
   (surface-a.css:600) and must not be fought over. JS clamps to +/-3.2deg —
   ~10px of corner travel at 380px wide, inside .hero's overflow: clip. */

.has-motion .hero__plate {
  perspective: calc(var(--s-14) * 5 + var(--s-9) - var(--s-1));   /* 1100px */
}

.has-motion .hero__portrait {
  --a-tilt-x: 0;
  --a-tilt-y: 0;

  /* transform, clip-path and the img's `scale` are independent properties the
     browser composes, so this ADDS to the shipped reveal, never replaces it. */
  transform:
    rotateX(calc(var(--a-tilt-x, 0) * 1deg))
    rotateY(calc(var(--a-tilt-y, 0) * 1deg));

  /* Inert today — overflow: clip forces transform-style flat. Kept so a future
     edit that drops the clip does not silently lose the 3D context. */
  transform-style: preserve-3d;
}

/* Fine-pointer-gated, not global: the browser cannot infer that a custom
   property feeding a transform IS a transform animation, so without the hint
   the portrait repaints instead of recompositing — but M2 only initialises when
   FINE_POINTER && !LOW_END, and a global hint would buy a permanently resident
   GPU layer on touch devices for an effect that can never run there. */
@media (hover: hover) and (pointer: fine) {
  .has-motion .hero__portrait { will-change: transform; }
}


/* 3. M3 — HERO PORTRAIT COUNTER-PARALLAX ====================================
   --a-por-y runs 0 -> -26 inside a frame whose aspect-ratio and overflow: clip
   are already fixed: no layout moves, no CLS. `translate`, not `transform` —
   they are independent properties, and the img already animates `scale`, so
   translate composes where transform would merely be safe. */

.has-motion .hero__portrait :where(img) {
  --a-por-y: 0;
  translate: 0 calc(var(--a-por-y, 0) * 1px);
}


/* 4. M5 — PROJECT PLATE MEDIA CURTAIN · pure CSS, no JS, no ScrollTrigger ====
   Scoped .js, NOT .has-motion: it consumes only the .is-in class core.js
   already adds, so it survives a GSAP load failure, a LOW_END device and an old
   browser untouched. Plates 02/03/04 have no .plate__media and go unmatched.

   `round var(--r-xs)` is MANDATORY at both ends — a bare inset(0) overrides the
   element's border-radius and squares the corners for the whole transition,
   reading as a rendering fault rather than a reveal.

   No will-change: components.css:522 documents a deliberate refusal across the
   [data-reveal] population for GPU-memory reasons, and M6 takes that population
   from 17 to 67. clip-path composites without the hint anyway. */

.js .plate[data-reveal] .plate__media {
  clip-path: inset(0 0 0 100% round var(--r-xs));
  /* Plate fades up over --dur-5 (components.css:515); the curtain opens over
     --dur-6 starting --dur-2 later — overlapped, not butt-joined. */
  transition: clip-path var(--dur-6) var(--ease-mask);
  transition-delay: var(--dur-2);
}

.js .plate[data-reveal].is-in .plate__media {
  clip-path: inset(0 round var(--r-xs));
}


/* 5. M7 — LEDGER SPINE ======================================================
   ol.ledger-exp already has border-inline-start: var(--bw-hair) solid
   var(--accent-500) (surface-a.css:1060). This ::before sits exactly on top of
   it, so at --a-spine 0 the ledger looks precisely as it does today and the
   effect reads as that hairline being drawn, not a second rule appearing beside
   it. One composited `scale` — no SVG, no plugin, no path length to measure.
   position: relative on the <ol> is safe: .exp-entry is already relative, so
   its own ::before node square keeps resolving against the entry. */

.has-motion ol[data-spine] {
  position: relative;
  --a-spine: 0;
}

.has-motion ol[data-spine]::before {
  content: '';
  position: absolute;
  inset-block: 0;
  inset-inline-start: calc(var(--bw-hair) * -1);
  inline-size: var(--bw-hair);

  /* Fading to 25% by the foot reads as light falling down the rule rather than
     a solid border. */
  background-image: linear-gradient(
    to bottom,
    var(--accent-400),
    color-mix(in oklab, var(--accent-400) 25%, transparent)
  );

  transform-origin: top;
  scale: 1 var(--a-spine, 0);
  pointer-events: none;
}


/* 6. M8 — DIRECTIONALLY AWARE HEADER ========================================
   JS toggles one boolean class per direction change; CSS owns the movement.
   Verified safe: [data-drawer] is a SIBLING of <header data-nav>
   (index.html:108 vs :87), not a descendant, so a translate on the header can
   never become the containing block for the drawer's fixed positioning.

   Deliberately NO resting `translate: 0 0`. `translate: none` creates no
   containing block and no stacking context; `0 0` creates both despite an
   identity matrix. Transitions interpolate from `none` as if 0 0, so a resting
   declaration buys nothing and would cost a stacking context on a fixed,
   backdrop-filtered element for all the time the header is not tucked.

   Keyboard safety lives in JS — never tuck while the header contains
   document.activeElement. A focused link driven off-screen is a keyboard trap. */

.has-motion [data-nav] {
  /* components.css:180-183 already transitions background-color and box-shadow
     here. A bare `transition: translate …` would REPLACE that list and silently
     kill the .is-scrolled capsule fade — the shorthand resets every longhand it
     does not name. All three restated. */
  transition:
    translate var(--dur-3) var(--ease-luxe),
    background-color var(--dur-4) var(--ease-out-quiet),
    box-shadow var(--dur-4) var(--ease-out-quiet);
}

.has-motion [data-nav].is-tucked { translate: 0 -100%; }


/* 7. M9 — FOOTER WORDMARK RISE ==============================================
   [data-footer-mark] is the aria-hidden "MINTHEP" at --ink-850 on --ink-1000
   (1.15:1) — texture, not text, which is what makes clipping it acceptable.
   [data-footer] is overflow: clip, so the 0.10em rise cannot bleed.

   THE INSETS ARE COPIED VERBATIM from surface-a.css:458-459 — not re-derived,
   not re-measured, not rounded. "MINTHEP" carries no diacritics, so -26% is not
   strictly needed today; it is reused so no future edit derives a 0 top inset
   by reading this rule. On Anton an authored 0 top inset is a bug by
   definition: Ấ Ầ Ẫ Ế Ộ Ữ stack a mark on a mark whose ink rises up to 34.6px
   above the line box at display size. That already shipped as a bug once.

   At --a-mark 0 the foot inset is 1*116% - 16% = 100%, over-constraining the
   rect against the -26% top: an empty box, fully hidden. At 1 it is -16% —
   exactly the open state of .hero__name .line, the same curtain grammar
   closing the page it opened. */

/* SPEC GAP shadow, see §0: --a-mark is inherited from surface-a.css:139 as a
   20px LENGTH, and declaring the resting number here stops that length reaching
   the calc below, where it would make the whole clip-path invalid at
   computed-value time. Unconditional and outside .has-motion on purpose — GSAP
   reads the computed value before it sets one, and 1 is the finished,
   fully-visible state, correct for a page where JS never runs. */
[data-footer-mark] { --a-mark: 1; }

.has-motion [data-footer-mark] {
  --a-fmark-top:  -26%;   /* stacked-mark headroom — verbatim, never re-derived */
  --a-fmark-foot: -16%;   /* descender headroom    — verbatim, never re-derived */

  clip-path: inset(
    var(--a-fmark-top) 0
    calc((1 - var(--a-mark, 1)) * 116% + var(--a-fmark-foot)) 0
  );
  translate: 0 calc((1 - var(--a-mark, 1)) * 0.10em);
}


/* 8. NO-JS SAFETY ===========================================================
   Everything above is already scoped .js or .has-motion, so this adds no reveal
   that JS must trigger — it only removes cost. If either rule is ever what makes
   the page readable, the scoping is broken and that is the real bug. */

.no-js .hero__sheen  { display: none; }   /* release the compositor layer */
.no-js .plate__media { clip-path: none; }


/* 10. SECTION SIGNATURES =====================================================
   WHY THIS EXISTS. Measured before writing any of it: the page had 7
   ScrollTriggers and 5 of them served the hero, the experience ledger and the
   footer. #skills, #service and #testimonials had exactly ONE effect each and it
   was the SAME effect — the shared 18px [data-reveal] fade. #faq and #contact
   had none at all: 0 reveals, 0 triggers, 0 CSS animations, so a reader who
   scrolled past without clicking saw literally nothing move. Five of eight
   sections read as one beat repeated and the last two read as silence.

   Each block below gives one section a grammar that appears nowhere else:
     skills        a rule TRAVELS the list         (translate, vertical)
     service       hairlines LIGHT one by one      (scaleX, horizontal)
     testimonials  quote marks BLOOM               (scale + rotate)
     faq           markers SNAP into place         (rotate, repeated glyph)
     contact       a rule EXTENDS, buttons SETTLE  (scaleX + rotate on controls)
   Different axis, different property, different object — so no two adjacent
   sections can read as the same thing.

   THE SEQUENCING TRICK, used by service / testimonials / faq. One tween ramps a
   single number across the whole group (0→4, 0→3, 0→6); each child subtracts its
   own index and clamps to 0..1. Child n therefore has its full ramp while the
   number crosses n→n+1, so they fire strictly one after another off ONE
   ScrollTrigger. That is what "từng cái một" costs here: one trigger, not four.

   SCRUB ONLY, NEVER toggleActions, for anything inside a content-visibility:auto
   section. Measured: probe one-shots on #faq and #contact both fired onEnter at
   the SAME scrollY — the document maximum at that instant — because the document
   collapsed under them as sections realised. A scrub recomputes progress from
   corrected start/end on the next frame, so the worst case is one wrong frame
   instead of a fire that is missed or duplicated. The correction path is §5.4's
   contentvisibilityautostatechange refresh, which already exists.

   NO PINS, so C6 holds and no pin-spacer can be injected into a section whose
   height is reserved by --cv-size. Everything here is transform, opacity or
   colour: nothing below can change any section's height. */

/* 10.1 SKILLS — the read-head.
   3044px of ledger used to get one 350ms fade and then ~2400px of nothing. A
   single accent rule now travels the full length of the list as you scroll.
   A percentage in `translate` resolves against the element's OWN border box, so
   a ::after at block-size: 100% travels its own full height with no pixel
   constant from JS — JS still writes only a unitless 0..1, per RULE A. */
.has-motion .ledger-skills {
  position: relative;
  --a-read: 1;
}

.has-motion .ledger-skills::after {
  content: '';
  position: absolute;
  inset-inline: 0;
  inset-block-start: 0;
  block-size: 100%;
  border-block-start: var(--bw-rule) solid var(--accent-500);
  pointer-events: none;
  /* The only visible part is that 2px top border; the rest of the box is
     transparent and inert. One node restyled, one composited transform. */
  translate: 0 calc(var(--a-read, 1) * 100%);
}

/* 10.2 SERVICE — four hairlines, one after another.
   Each row already has an --ink-700 bottom hairline (surface-a.css:1299). This
   LIGHTS it rather than replacing it — the same "draw over what is already
   there" move the ledger spine makes, but horizontal and sequential, so the two
   never read as the same effect. --sr comes from :nth-child(), so no markup
   changes and no new attribute anywhere: C3 additive-only, index.html untouched. */
.has-motion .services { --a-svc: 4; }

.has-motion .service-row {
  position: relative;
  --sr: 0;
  --a-lit: clamp(0, calc(var(--a-svc, 4) - var(--sr, 0)), 1);
}
.has-motion .service-row:nth-child(2) { --sr: 1; }
.has-motion .service-row:nth-child(3) { --sr: 2; }
.has-motion .service-row:nth-child(4) { --sr: 3; }

.has-motion .service-row::after {
  content: '';
  position: absolute;
  /* NOT inset-inline: 0. An absolutely positioned box resolves its insets
     against the ancestor's PADDING box, and every .service-row carries
     `border-inline-start: var(--bw-slab) solid transparent`
     (surface-a.css:1401) — 4px that only becomes visible --accent-500 on the
     featured row. So a 0 start inset put this lit hairline 4px inside the
     --ink-700 border-box hairline it is supposed to be drawing over: measured
     1236px against 1240px, a 4px stub of unlit rule left at the head of every
     row, right where the featured row's slab makes it most obvious. Pulling
     the start edge out by exactly the border width lands the two on the same
     left edge. The end inset stays 0: there is no inline-end border. */
  inset-inline-start: calc(var(--bw-slab) * -1);
  inset-inline-end: 0;
  inset-block-end: calc(var(--bw-hair) * -1);
  block-size: var(--bw-hair);
  background-color: var(--accent-500);
  transform-origin: left;
  scale: var(--a-lit, 1) 1;
  pointer-events: none;
}

/* Rides the same --a-lit, so the arrow arrives exactly as its row's rule
   finishes drawing.

   SCOPED TO <=768px, AND THE SCOPE IS THE WHOLE POINT. Unscoped, this was work
   nobody could see: above 768px .service-row__arrow is opacity: 0 until hover
   (surface-a.css:1479), and the hover rule at :1486 (0,3,0) outranks this one
   (0,2,0) the instant it does become visible — so the scrub wrote a fresh
   translate onto four nodes every frame to move zero visible pixels. Probed at
   1440: all four arrows report opacity 0 at every scroll position across the
   whole window, while translate dutifully ramps -24px -> 0.

   THE WORK ORDER SAID DELETE IT. That would have been wrong, and the probe is
   why: at 390 the same four arrows report opacity 1 at every scroll position,
   because surface-a.css:1503-1508 makes the arrow permanently visible below
   768px — there is no hover on touch, so the affordance has to be. This is not
   dead code; it is desktop-dead code with a live phone half. The half that
   reads ships, the half that cannot be seen stops running.

   transition-property, for the same reason as the FAQ marker in §10.4 below:
   surface-a.css:1481 puts a 260ms transition on `translate`, and a transition
   cannot share a property with a per-frame scrub. The probe caught that too —
   at --a-svc 3.94, rows whose --a-lit had already reached 1 still computed
   -24px, so the arrow snapped at the end instead of riding in with its rule.
   opacity keeps its transition; below 768px opacity never changes, so nothing
   is lost and the declared intent stays legible to the next reader. */
@media (max-width: 768px) {
  .has-motion .service-row__arrow {
    transition-property: opacity;
    translate: calc((1 - var(--a-lit, 1)) * -24px) 0;
  }
}

/* 10.3 TESTIMONIALS — the quote marks bloom.
   The three .quote::before opening marks are already an 80px serif glyph at
   --ink-800 (surface-a.css:1447) — the largest unused graphic on the page.
   Scale + rotate is a grammar used in no other section. The mark is decorative
   pseudo-content sitting BEHIND the text at --z-base, never body copy, so the
   animated opacity floor of 0.25 is not a contrast question (C8); the quote text
   itself is never touched. */
.has-motion .quotes { --a-quote: 1; }

.has-motion .quote {
  --qi: 0;
  /* ×3 gives each of the three marks its own full 0→1 ramp inside one tween. */
  --a-lit: clamp(0, calc(var(--a-quote, 1) * 3 - var(--qi, 0)), 1);
}
.has-motion .quote:nth-child(2) { --qi: 1; }
.has-motion .quote:nth-child(3) { --qi: 2; }

.has-motion .quote::before {
  scale: calc(0.62 + var(--a-lit, 1) * 0.38);
  rotate: calc((1 - var(--a-lit, 1)) * -14deg);
  opacity: calc(0.25 + var(--a-lit, 1) * 0.75);
  /* The one paint-class declaration in this whole section. If a profile ever
     shows it hot, delete this line and the scale/rotate/opacity still carry the
     effect intact.

     THE MIX TARGET IS --accent-ink, NOT --accent-700. The bloom used to end on
     --accent-700 #8E2B37, which measures 2.38:1 against this section's ground —
     a mark that scales up, rotates in, reaches full opacity, and lands on a
     colour the eye cannot separate from the field behind it. An effect whose
     final frame is invisible is not an effect, and it is a literal instance of
     the complaint that started this pass. --accent-ink resolves to --accent-400
     here (#testimonials is a cold section) at 6.57:1, so the mark now actually
     arrives. Alias rather than literal, so the mark follows its section's
     temperature if #testimonials ever moves onto the warm ground.
     The opacity floor argument above is unchanged and still holds: this is
     decorative pseudo-content behind the text at --z-base, never body copy. */
  color: color-mix(in oklab, var(--accent-ink) calc(var(--a-lit, 1) * 100%), var(--ink-800));
}

/* 10.4 FAQ — six markers snap into place.
   The six .faq__q::after '+' markers already rotate 45deg on open
   (surface-a.css:1600). Reusing that exact object for a scroll cascade teaches
   the marker's language before the reader ever clicks one.

   :not([open]) IS MANDATORY, NOT DEFENSIVE. Without it this rule fights the
   open-state rotate and an expanded panel shows a '+' where its '×' should be. */
.has-motion .faq { --a-faq: 6; }

.has-motion .faq__item {
  --fi: 0;
  --a-lit: clamp(0, calc(var(--a-faq, 6) - var(--fi, 0)), 1);
}
.has-motion .faq__item:nth-child(2) { --fi: 1; }
.has-motion .faq__item:nth-child(3) { --fi: 2; }
.has-motion .faq__item:nth-child(4) { --fi: 3; }
.has-motion .faq__item:nth-child(5) { --fi: 4; }
.has-motion .faq__item:nth-child(6) { --fi: 5; }

.has-motion .faq__item:not([open]) > .faq__q::after {
  /* THE SCRUB HAS TO OWN `rotate`, OR NOTHING MOVES. surface-a.css:1690 puts a
     260ms --dur-3 transition on this pseudo's `rotate` so the '+' eases into
     its '×' on open. A transition and a scrub cannot share one property: every
     frame the scrub writes a new target, the 260ms transition restarts from
     wherever it had crawled to, and the value Zenoes instead of arriving.
     Measured before this line existed, the marker sat at 0.87deg across the
     whole window, creeping -89.3655 -> -88.4971 and never reaching 0 — the
     cascade was fighting the tween, so the section's whole signature was
     invisible on a page whose owner had just said the effects don't read.

     transition-property here REPLACES the shorthand's property list only; the
     durations and easings in that shorthand still apply to what survives, so
     background-color keeps its --dur-2 fade. And the moment the item opens this
     selector stops matching, the full shorthand is back, and the 0->45deg open
     rotation eases exactly as it always has: transitions read the AFTER-change
     style's transition-property, so the open animation is not lost. */
  transition-property: background-color;
  rotate: calc((1 - var(--a-lit, 1)) * -90deg);
  scale: calc(0.2 + var(--a-lit, 1) * 0.8);
  opacity: var(--a-lit, 1);
}

/* .faq__q is a real <summary> — a focusable control. translate never removes it
   from the hit test or the a11y tree, and its resting value is 0 because
   --a-faq's initial-value is its finished 6. */
.has-motion .faq__q {
  translate: calc((1 - var(--a-lit, 1)) * 20px) 0;
}

/* 10.5 CONTACT — the rule extends, the buttons settle.
   The close of the page, and previously its deadest section. The head rule is
   64px × 2px (--a-sec-rule, surface-a.css:291); ×9 is 576px against a 760px
   .shell--narrow, i.e. 76% of the column — an underline that sweeps most of the
   way across and stops, which reads as deliberate rather than as a full-bleed
   line. scale never affects layout, so #contact's --cv-size reservation is
   untouched.
   The buttons are interactive and must be square and legible at rest: --a-close
   rests at 1, so rotate is 0deg and translate is 0 in every non-JS state. */
.has-motion #contact .shell--narrow { --a-close: 1; }

.has-motion #contact .section-a__head::before {
  transform-origin: left center;
  scale: calc(1 + var(--a-close, 1) * 8) 1;
}

.has-motion #contact .form-a__actions .btn {
  --bi: 1;
  translate: 0 calc((1 - var(--a-close, 1)) * 26px);
  rotate: calc((1 - var(--a-close, 1)) * (var(--bi, 1) - 1) * 3deg);
}
.has-motion #contact .form-a__actions a.btn:nth-of-type(1) { --bi: 0; }
.has-motion #contact .form-a__actions a.btn:nth-of-type(3) { --bi: 2; }

/* 10.6 PORTFOLIO — zero-trigger upgrade, CSS only.
   #portfolio is NOT inert (it has the media curtain plus the fade), so spending
   a ScrollTrigger on the best-served section would be wrong. But the nine
   .plate__index numbers are 56px display type doing nothing. Curtaining them
   reuses the clip-path grammar of the hero name and the footer wordmark, at zero
   JS cost, riding the .is-in class core.js already sets.

   THE INSETS ARE LOAD-BEARING AND COPIED VERBATIM from surface-a.css:458 —
   never re-derived. An authored 0 top inset clips the stacked marks on
   Ấ Ầ Ẫ Ế Ộ Ữ; -26% / -16% is the measured headroom that does not. */
.js .plate[data-reveal] .plate__index {
  clip-path: inset(-26% 0 100% 0);
  transition: clip-path var(--dur-6) var(--ease-mask);
  transition-delay: var(--dur-3);
}

.js .plate[data-reveal].is-in .plate__index {
  clip-path: inset(-26% 0 -16% 0);
}


/* 9. REDUCED MOTION — KILL SWITCH. LAST BLOCK IN THE FILE, BY CONTRACT. ======
   Neutralises every effect this file introduces, landing each in its readable
   final state.

   NOT dead code, though it looks it: motion-a.js returns before adding
   .has-motion when REDUCED is true, so on a cold load no .has-motion rule can
   match. The live case is a reader flipping the setting MID-SESSION — core.js's
   REDUCED is a live binding, but .has-motion is added once and never removed,
   so the class outlives the preference. These rules make the page obey
   immediately instead of at the next reload.

   Every selector mirrors the one it neutralises EXACTLY, winning on source order
   at equal specificity rather than by escalation. Media queries add no
   specificity — ordering is the whole mechanism, hence this block is last.

   Handled upstream, not restated here because restating invites drift: the
   kinetic band (surface-a.css:752 drops animation-name and will-change) and all
   timing (base.css:629 forces every transition-duration to 1ms and every delay
   to 0ms, !important, across *, *::before, *::after). */

@media (prefers-reduced-motion: reduce) {
  /* Stronger than opacity: 0, and it releases the layer. */
  .hero__sheen { display: none; }

  /* perspective alone still establishes a containing block, so drop it too. */
  .has-motion .hero__plate { perspective: none; }

  .has-motion .hero__portrait {
    transform: none;
    will-change: auto;
  }

  .has-motion .hero__portrait :where(img) { translate: none; }

  /* Both ends, so no plate is caught mid-wipe if the preference changes while a
     transition is in flight. */
  .js .plate[data-reveal] .plate__media,
  .js .plate[data-reveal].is-in .plate__media { clip-path: none; }

  /* content: none removes the pseudo entirely, leaving the static --accent-500
     border that shipped before any of this existed. */
  .has-motion ol[data-spine]::before { content: none; }

  /* Both states — .is-tucked may already be on the element when it flips. */
  .has-motion [data-nav],
  .has-motion [data-nav].is-tucked { translate: none; }

  /* Which is also --a-mark's resting 1, so the two paths agree. */
  .has-motion [data-footer-mark] {
    clip-path: none;
    translate: none;
  }

  /* --- §10 section signatures ---------------------------------------------
     None of these rest states is blank, and that is the point: every §10
     property's @property initial-value IS its finished value (--a-read 1,
     --a-svc 4, --a-quote 1, --a-faq 6, --a-close 1), so the no-JS page and the
     cold reduced-motion page already render complete without a single rule
     here. This block exists only for the live case §9 describes — a reader
     flipping the OS setting mid-session, when .has-motion is already on the
     element and JS may have left a number mid-ramp. */

  /* content: none rather than opacity: 0 — removing the pseudo releases its
     layer and leaves the section exactly as it shipped before §10 existed. */
  .has-motion .ledger-skills::after { content: none; }
  .has-motion .service-row::after   { content: none; }

  .has-motion .service-row__arrow { translate: none; }

  .has-motion .quote::before {
    scale: none;
    rotate: none;
    opacity: 1;
    color: var(--ink-800);      /* surface-a.css:1457, the shipped value */
  }

  .has-motion .faq__item:not([open]) > .faq__q::after {
    rotate: 0deg;               /* not `none`: the open-state rule sets 45deg */
    scale: none;
    opacity: 1;
  }
  .has-motion .faq__q { translate: none; }

  .has-motion #contact .section-a__head::before { scale: none; }
  .has-motion #contact .form-a__actions .btn {
    translate: none;
    rotate: none;
  }

  /* Both ends, so no plate index is caught mid-curtain if the preference flips
     while a transition is in flight — same reasoning as .plate__media above. */
  .js .plate[data-reveal] .plate__index,
  .js .plate[data-reveal].is-in .plate__index { clip-path: none; }
}
