/* ─────────────────────────────────────────────────────────────────────────
   tilasmi.com — public site. Hand-built. No framework, no build step.
   Brand tokens copied from brand/system/tokens/tokens.css (that file wins).
   Serve from this directory: paths are root-relative.
   ───────────────────────────────────────────────────────────────────────── */

@font-face{font-family:'Unbounded';src:url('/assets/fonts/unbounded/Unbounded-300.woff2') format('woff2');font-weight:300;font-display:swap}
@font-face{font-family:'Unbounded';src:url('/assets/fonts/unbounded/Unbounded-400.woff2') format('woff2');font-weight:400;font-display:swap}
@font-face{font-family:'Unbounded';src:url('/assets/fonts/unbounded/Unbounded-700.woff2') format('woff2');font-weight:700;font-display:swap}
@font-face{font-family:'Albert Sans';src:url('/assets/fonts/albert-sans/AlbertSans-400.woff2') format('woff2');font-weight:400;font-display:swap}
@font-face{font-family:'Albert Sans';src:url('/assets/fonts/albert-sans/AlbertSans-500.woff2') format('woff2');font-weight:500;font-display:swap}
@font-face{font-family:'Albert Sans';src:url('/assets/fonts/albert-sans/AlbertSans-700.woff2') format('woff2');font-weight:700;font-display:swap}
@font-face{font-family:'IBM Plex Mono';src:url('/assets/fonts/ibm-plex-mono/IBMPlexMono-400.woff2') format('woff2');font-weight:400;font-display:swap}
@font-face{font-family:'IBM Plex Mono';src:url('/assets/fonts/ibm-plex-mono/IBMPlexMono-500.woff2') format('woff2');font-weight:500;font-display:swap}
@font-face{font-family:'IBM Plex Mono';src:url('/assets/fonts/ibm-plex-mono/IBMPlexMono-600.woff2') format('woff2');font-weight:600;font-display:swap}
@font-face{font-family:'IBM Plex Sans Arabic';src:url('/assets/fonts/ibm-plex-sans-arabic/IBMPlexSansArabic-400.woff2') format('woff2');font-weight:400;font-display:swap}
@font-face{font-family:'IBM Plex Sans Arabic';src:url('/assets/fonts/ibm-plex-sans-arabic/IBMPlexSansArabic-500.woff2') format('woff2');font-weight:500;font-display:swap}
@font-face{font-family:'IBM Plex Sans Arabic';src:url('/assets/fonts/ibm-plex-sans-arabic/IBMPlexSansArabic-600.woff2') format('woff2');font-weight:600;font-display:swap}
@font-face{font-family:'IBM Plex Sans Arabic';src:url('/assets/fonts/ibm-plex-sans-arabic/IBMPlexSansArabic-700.woff2') format('woff2');font-weight:700;font-display:swap}

:root{
  --paper:#F2F5F8; --ink:#16171C; --accent:#006AA3; --accent-lt:#3886C1;
  --tint:#D6E0EC; --muted:#6B6C6C; --white:#FFF;
  --hair:rgba(22,23,28,.10); --hair-2:rgba(22,23,28,.06);
  --f-display:'Unbounded',system-ui,sans-serif;
  --f-text:'Albert Sans',system-ui,sans-serif;
  --f-data:'IBM Plex Mono',ui-monospace,monospace;
  --r:20px; --r-sm:10px; --r-pill:999px;
  --max:1200px; --gut:clamp(16px,4vw,40px);
  --ease:cubic-bezier(.16,1,.3,1);          /* luxe */
  --ease-out:cubic-bezier(.22,.61,.36,1);
  --dur:800ms;
  --flip:1; --org-x:0%;                     /* mirroring tokens — CONTRACTS §1, the only mechanism for RTL */
  /* MOTION.md §2.2 — the wave-7 motion floors. --dur-ambient is DESIGN.md's `ambient` token, the
     period of this page's one sustained living-page device (the hero wash, §1 below). --dist-line/
     --blur-line are the T10 line-split numbers, verbatim, kept here as the CSS-side record even
     though the live tween runs through GSAP with the same two numbers as literals (GSAP doesn't
     read custom properties without an explicit getComputedStyle call) — if either number ever
     changes, both representations change in the same edit. */
  --dur-ambient:16000ms;
  --dist-line:40px;
  --blur-line:10px;
  /* --rm-count RETIRED wave 8. It existed for exactly one consumer: .about-art's min-height
     formula, which manufactured sticky travel for the roadmap. The roadmap is pinned now, so
     that formula is gone and this variable had no reader left. It was never the real step-count
     coupling either — that lives in the four .25 band divisors in the dot-pop rules near
     .roadmap, and in main.js §6's (RM_N - 1) segment maths, which reads the DOM. See the note
     at those divisors. */
}
[dir="rtl"]{ --flip:-1; --org-x:100% }

*,*::before,*::after{box-sizing:border-box}
html{-webkit-text-size-adjust:100%}
body{
  margin:0;background:var(--paper);color:var(--ink);
  font-family:var(--f-text);font-size:16px;line-height:1.62;
  -webkit-font-smoothing:antialiased;overflow-x:hidden;
}
img,svg{max-width:100%;display:block}
a{color:inherit;text-decoration:none}
h1,h2,h3{margin:0;font-family:var(--f-display);font-weight:400;letter-spacing:-.02em}
p{margin:0}
button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}

/* ── smooth scrolling (Lenis) ────────────────────────────────────────────
   Native smooth-scroll is switched OFF while Lenis drives, and back ON as
   the fallback when it is absent or reduced motion is requested. */
html:not(.lenis){scroll-behavior:smooth}
html.lenis,html.lenis body{height:auto}
.lenis.lenis-smooth{scroll-behavior:auto!important}
.lenis.lenis-smooth [data-lenis-prevent]{overscroll-behavior:contain}
.lenis.lenis-stopped{overflow:hidden}

/* Anchor clearance under the sticky header. ⚠ THIS IS A RULE, NOT A LIST — the previous version
   enumerated #hero,#services,#about,#work,#book, which is the same trap RQ-141 caught in CONTRACTS
   §5: an illustrative enumeration read as exhaustive, so #contact and every future section anchor
   silently had none. Any element that can be a fragment target gets the clearance; scroll-margin is
   inert on everything else, so the broad selector costs nothing.
   The value must clear the WHOLE header, and the header is not one height: 81px above 1080px,
   135px between 1080 and 720 (where .header-in wraps and .nav takes its own row), 127px below 720
   (smaller logo, tighter padding). Measured, not assumed — see the three media queries below. */
[id]{scroll-margin-top:100px}

/* The direction-neutral hidden pattern. It replaces `left:-9999px`, which was correct in LTR
   (leftward overflow is never scrollable) and wrong in RTL, where the scroll origin flips and the
   same offset became 9,999px of REAL scrollable canvas: every /ar/ page reported
   documentElement.scrollWidth 11439 against a 1440 viewport, which is what made all eighteen
   Arabic captures come back blank or squeezed. body{overflow-x:hidden} meant a live visitor could
   almost certainly never scroll there, so it never broke the site — it broke the ability to VERIFY
   the site, which is worse, because it broke it silently. The tell was in the contract from the
   start: verify.py's overflow probe had to be blinded to this element BY NAME to stay green, and a
   rule that needs the verifier blinded to it is a rule worth retiring (RTL-CONVERSION.md §2, now
   superseded). This honours §2's own stated intent — "a hiding mechanism rather than a position" —
   more faithfully than the physical offset did, and creates zero overflow in either tree. */
.skip{position:absolute;top:0;inset-inline-start:0;width:1px;height:1px;
  overflow:hidden;clip-path:inset(50%);white-space:nowrap}
  /* No margin:-1px. The classic visually-hidden recipe carries one, but that recipe predates
     clip-path and the margin's only job there was to pull a 1px box out of flow; here the element
     is already absolutely positioned against the viewport, and in RTL the negative margin pushed
     its right edge to 1441px on a 1440px viewport — one pixel of overflow, which is exactly the
     kind of thing that gets a by-name exemption written for it. Anchored at inset-inline-start:0
     it sits inside the viewport in both trees and needs no exemption from anything. */
.skip:focus{width:auto;height:auto;margin:0;overflow:visible;clip-path:none;
  inset-inline-start:12px;top:12px;z-index:99;background:var(--ink);color:var(--paper);padding:10px 16px;border-radius:var(--r-pill)}
  /* opposite ruling from the line above, deliberately: once tabbed to, this pill is a real,
     visible, keyboard-reachable navigational element, so it belongs at the reading-start corner
     in both trees, not a fixed physical corner (RTL-CONVERSION.md §7). */
:focus-visible{outline:2px solid var(--accent);outline-offset:3px;border-radius:4px}

/* ── shared bits ─────────────────────────────────────────────────────── */
.card{background:var(--white);border:1px solid var(--hair-2);border-radius:var(--r);
  box-shadow:0 1px 2px rgba(22,23,28,.03),0 18px 50px -30px rgba(22,23,28,.28)}

.eyebrow{font-family:var(--f-data);font-size:11.5px;letter-spacing:.14em;text-transform:uppercase;color:var(--muted);margin-bottom:14px}
.sec-title{font-size:clamp(26px,3.4vw,40px);line-height:1.14;max-width:18ch}
.sec-title.big{font-size:clamp(32px,5vw,62px);line-height:1.06;max-width:15ch;letter-spacing:-.03em}
.rule{border:0;border-top:1px solid var(--hair);margin:26px 0 40px}

.btn{display:inline-flex;align-items:center;gap:.55em;font-family:var(--f-data);
  font-size:12px;letter-spacing:.1em;text-transform:uppercase;font-weight:500;
  padding:16px 26px;border-radius:var(--r-pill);border:1px solid transparent;
  transition:transform 180ms var(--ease),background 180ms ease,color 180ms ease,border-color 180ms ease;
  will-change:transform}
.btn i{font-style:normal;transition:transform 220ms var(--ease)}
.btn:hover i{transform:translateX(calc(var(--flip) * 3px))}
.btn-ink{background:var(--ink);color:var(--paper)}
.btn-ink:hover{background:var(--accent)}
.btn-sm{padding:12px 20px;font-size:11px}
.btn-line{border-color:var(--ink);color:var(--ink);background:transparent;padding:13px 24px}
.btn-line:hover{background:var(--ink);color:var(--paper)}
.btn-paper{background:var(--white);color:var(--ink);border-color:var(--hair)}
.btn-paper:hover{background:var(--ink);color:var(--paper)}
.btn-round{border-radius:var(--r-pill)}

.link-u{display:inline-block;font-family:var(--f-data);font-size:12px;letter-spacing:.06em;
  color:var(--accent);border-bottom:1px solid currentColor;padding-bottom:2px}
.link-u:hover{color:var(--ink)}

/* ── header — the lockup is deliberately large ───────────────────────── */
.header{position:sticky;top:0;z-index:50;background:rgba(242,245,248,.82);
  backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border-bottom:1px solid transparent;
  transition:border-color 240ms ease,background 240ms ease}
.header.is-stuck{border-bottom-color:var(--hair-2);background:rgba(242,245,248,.94)}
.header-in{max-width:var(--max);margin:0 auto;padding:16px var(--gut);
  display:flex;align-items:center;gap:20px;justify-content:space-between}
.brand img{height:38px;width:auto;transition:transform 300ms var(--ease)}
.brand:hover img{transform:scale(1.03)}
.nav{display:flex;gap:6px;background:var(--white);border:1px solid var(--hair-2);
  border-radius:var(--r-pill);padding:4px}
.pill{display:inline-flex;align-items:center;gap:7px;padding:8px 15px;border-radius:var(--r-pill);
  font-size:13.5px;color:var(--muted);transition:background 200ms ease,color 200ms ease}
.pill .dot{width:5px;height:5px;border-radius:50%;background:currentColor;opacity:.45;
  transition:opacity 200ms ease,background 200ms ease}
.pill:hover{color:var(--ink);background:var(--paper)}
.pill.is-current{color:var(--ink)}
.pill.is-current .dot{opacity:1;background:var(--accent)}
/* NOT var(--muted), and the reason is measured. #6b6c6c at 12px composites against the tint the
   sub-page header sits on (#d6e0ec) at 3.95:1, under the 4.5:1 WCAG AA floor for text this size,
   and verify.py's contrast sweep flags it on six pages. #5f6060 on the same ground measures
   4.72:1. The token itself is left alone: --muted is correct everywhere it sits on paper, and
   darkening it globally to fix one chip would repaint the whole site. Fixed 2026-08-04. */
.lang{font-family:var(--f-data);font-size:12px;letter-spacing:.04em;color:#5f6060;
  border-bottom:1px solid transparent;transition:color 180ms ease,border-color 180ms ease}
.lang:hover{color:var(--ink);border-color:var(--ink)}

/* ── 1 · hero ────────────────────────────────────────────────────────── */
.hero{max-width:var(--max);margin:0 auto;padding:clamp(14px,2vw,26px) var(--gut) clamp(60px,7vw,110px)}
.hero-card{overflow:hidden}
.hero-grid{display:grid;grid-template-columns:1.02fr .98fr;gap:30px;
  padding:clamp(34px,5vw,66px) clamp(26px,4vw,56px) clamp(26px,3vw,44px);align-items:center}
.hero-title{font-size:clamp(34px,5.4vw,64px);line-height:1.14;letter-spacing:-.035em;font-weight:400}
.hero-title .line{display:block}
.hero-title .hl{font-style:normal;position:relative;display:inline-block;padding:0 .16em;z-index:0}
.hero-title .hl::before{content:"";position:absolute;inset:.08em -.02em .1em;background:var(--tint);
  border-radius:6px;z-index:-1;transform-origin:var(--org-x) center}
.hero-title .hl-2::before{background:var(--accent)}
.hero-title .hl-2{color:var(--paper)}
/* .hero-copy: JOB 2, wave 8. <p class="hero-sub"> is retired (its two lines of description moved
   into the board device itself); .hero-title is now this column's only child. Checked against disk
   before touching anything: .hero-copy never carried a gap or justify-content rule to begin with,
   so the brief's premise that it was "tuned around two children" doesn't hold here; it was always a
   bare wrapper leaning entirely on .hero-grid's own align-items:center to centre its content against
   .hero-art. That still works with one child (a shorter box centres the same way a taller one did),
   so this rule is new, not a retune of an old one: written explicitly rather than left implicit, so
   a future second child doesn't silently inherit behaviour nobody decided on. */
.hero-copy{display:flex;flex-direction:column;justify-content:center}

/* the day's work — the hero device ---------------------------------------
   AMENDED 2026-08-03 (sixth pass). It was a CSS phase-locked sweep band that ticked eight rows
   off on a 12s loop; it is now AN AUTOPLAYING RUN. The board opens empty, the system takes the
   jobs one at a time, and the row it is on says what it is waiting for — "waiting for a reply to
   the email" — on the live line under the list. RETIRED with the sweep: .board-sweepwrap /
   .board-sweep, the boardSweep keyframes, the two shared constants (SWEEP_SPAN 8.04 /
   SWEEP_OFFSET -4.5) and all five rowCycle/rowBox/rowTick/rowJob/rowStrike keyframes.
   ⚠ THE WHOLE CLASS OF BUG THEY CARRIED GOES WITH THEM: the phase arithmetic that had to be kept
   in step across two files, and the animation-shorthand-resets-animation-delay trap that
   desynchronised a row's own children. Every state here is a class toggled by main.js and a plain
   transition, so there is nothing left to keep in phase. KEPT deliberately: the eight real jobs
   with what one run costs a person, the flex sizing chain (⚠ never a percentage height — see the
   blocker note below), and the reduced-motion still of four done / four waiting.
   Spec: HERO-DEVICE.md, amended. */
/* the ambient wash — this page's one living-page device (MOTION-DEVICES.md device 2, floor item 4;
   visual-language.md §16, one sustained device per page, subordinate to everything sharing its
   screen). .board is width:100%/height:100% of .hero-art with no gap of its own, so this can't live
   INSIDE .hero-art's own box — it would render entirely behind the board's opaque white card and
   never be seen. Instead it's oversized (inset:-30%) and bleeds into the hero-grid gutter and the
   card's own padding around the board, then gets clipped at the outer edge by .hero-card's existing
   overflow:hidden. Pure CSS, no JS, so it is unconditionally alive (through .board's own HOLD_MS
   pause too — this is the answer to that pause reading as dead, not a fifth device) and needs no
   observer. Radial and centred rather than a directional gradient specifically so it never raises
   an RTL question (§ below, and MOTION-DEVICES.md device 2's own RTL note). */
.hero-art{position:relative;min-height:clamp(360px,37vw,462px)}
.hero-art::before{content:"";position:absolute;inset:-30%;z-index:0;pointer-events:none;
  border-radius:50%;background:radial-gradient(closest-side,rgba(0,106,163,.07),rgba(0,106,163,0) 72%);
  opacity:.7;transform:translate3d(-4%,-3%,0) scale(1);will-change:transform;
  animation:ambHeroDrift var(--dur-ambient) cubic-bezier(.445,.05,.55,.95) infinite}
@keyframes ambHeroDrift{
  0%,100%{transform:translate3d(-4%,-3%,0) scale(1)}
  50%    {transform:translate3d(4%,3%,0) scale(1.08)}
}
.board{position:relative;z-index:1;width:100%;height:100%;min-height:inherit;
  display:flex;flex-direction:column;gap:12px;
  background:var(--white);border:1px solid var(--hair-2);border-radius:var(--r);
  box-shadow:0 1px 2px rgba(22,23,28,.03),0 18px 50px -30px rgba(22,23,28,.28);
  padding-block:clamp(16px,2.2vw,22px);padding-inline:clamp(16px,2.4vw,24px);
  overflow:hidden;
}
.board-head{flex:0 0 auto;display:flex;align-items:flex-start;justify-content:space-between;gap:12px}
.board-head-t{display:flex;flex-direction:column;gap:3px;min-width:0}
.board-title{font-family:var(--f-text);font-size:15px;line-height:1.3;font-weight:500;color:var(--ink)}
.board-sub{font-family:var(--f-data);font-size:10.5px;letter-spacing:.05em;text-transform:uppercase;color:var(--muted)}
/* the "running" pill — the one piece of chrome that says this is live rather than a picture */
.board-live{flex:0 0 auto;display:inline-flex;align-items:center;gap:7px;padding:5px 11px;
  border-radius:var(--r-pill);background:rgba(0,106,163,.09);color:var(--accent);
  font-family:var(--f-data);font-size:9.5px;font-weight:600;letter-spacing:.12em;text-transform:uppercase}
.board-live i{width:6px;height:6px;border-radius:50%;background:currentColor;
  animation:livePulse 1700ms ease-in-out infinite}
@keyframes livePulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.3;transform:scale(.7)}}
/* ⚠ THE BLOCKER THIS FILE SHIPPED ONCE, AND THE RULE THAT PREVENTS IT COMING BACK.
   .board-rows used to fill its parent with height:100%. A percentage height only resolves against
   a DEFINITE parent height, and this chain has none: .hero-art carries min-height with no height,
   so .board's own height:100% resolves to auto, so .board-body's used height (293px, from flexing)
   is not a height a percentage can see. height:100% therefore fell back to auto — and with every
   .row at flex:1 1 0 the auto height was 7px (eight zero-height rows plus seven 1px rules). All
   eight job labels stacked on top of each other in a 7px strip, illegible, ABOVE THE FOLD ON THE
   HOME PAGE, and 26 green DOM checks had no opinion about it because nothing overflowed, nothing
   errored and every row was present in the DOM with both its job name and its duration.
   The fix is to stop asking a percentage a question flexbox can answer: .board-body becomes the
   column flex container and .board-rows fills it as a flex item. Flex distribution uses the USED
   height, so it does not care whether that height is definite. NEVER reintroduce a percentage
   height anywhere in this chain — give an element flex:1 1 auto + min-height:0 instead. */
.board-body{position:relative;flex:1 1 auto;min-height:0;display:flex;flex-direction:column}
.board-rows{position:relative;z-index:2;flex:1 1 auto;min-height:0;display:flex;flex-direction:column;
  margin:0;padding:0;list-style:none}
.row{position:relative;flex:1 1 0;min-height:0;display:flex;align-items:center;gap:10px;
  border-block-end:1px solid var(--hair-2);
  transition:transform 180ms var(--ease-out),box-shadow 180ms var(--ease-out)}
.row:last-child{border-block-end:0}
/* the active row is substantial, not just tinted — MOTION-DEVICES.md device 2, floor item 1. A
   small 1px lift is deliberate: this is one row among eight, not a hero-scale card, and a bigger lift
   here would compete with device 4's own tile-hover vocabulary. Released the instant is-run comes
   off (no rule needed — the base .row above already has no transform, so is-done/idle both resolve
   to none automatically). */
.row.is-run{transform:translateY(-1px);box-shadow:0 4px 10px -6px rgba(0,106,163,.35)}
/* the running highlight rides a pseudo-element so it can bleed past the row's own inline padding
   without a radius fighting the hairline rule. .board-rows carries z-index:2 and therefore its own
   stacking context, so z-index:-1 here stays inside the board — it cannot paint behind the card. */
.row::before{content:"";position:absolute;inset:1px -9px;border-radius:8px;z-index:-1;
  background:rgba(0,106,163,0);transition:background 420ms var(--ease-out)}
.row.is-run::before{background:rgba(0,106,163,.09)}
.row.is-done::before{background:rgba(0,106,163,.03)}

.row-box{flex:0 0 auto;position:relative;width:14px;height:14px;border-radius:5px;
  border:1.5px solid rgba(22,23,28,.28);background:transparent;
  transition:background 320ms var(--ease-out),border-color 320ms var(--ease-out),border-radius 320ms var(--ease-out)}
.row-box::after{content:"";position:absolute;left:2.7px;top:5.6px;width:6px;height:3px;
  border-left:1.6px solid var(--white);border-bottom:1.6px solid var(--white);
  transform:rotate(-45deg) scale(0);transform-origin:center;
  /* left/border-left/border-bottom stay physical — this is the checkmark's own geometry, the same
     exemption CONTRACTS gave the retired .chip .box::after: a tick is a tick in Arabic, and
     mirroring it draws a backwards one. */
  transition:transform 300ms var(--ease) 70ms}
.row.is-done .row-box{background:var(--accent);border-color:var(--accent)}
.row.is-done .row-box::after{transform:rotate(-45deg) scale(1)}
/* the working state: the box becomes a spinner. A ring rather than a bar because the row has no
   progress to report — it is waiting on somebody else, which is the whole point of the device. */
.row.is-run .row-box{border-radius:50%;border-color:rgba(0,106,163,.22);border-top-color:var(--accent);
  animation:rowSpin 720ms linear infinite}
@keyframes rowSpin{to{transform:rotate(360deg)}}

/* ⚠ flex:0 1 auto, NOT 1 1 auto. The strikethrough is a pseudo-element spanning this box, so a
   row-job that grows to fill the row draws a rule from the job name all the way to the duration
   column — a job "struck through" across 200px of empty space. Shrink-to-fit keeps the line on the
   words; .row-min then needs margin-inline-start:auto to stay pinned to the far edge. min-width:0
   stays: without it, ellipsis silently does nothing in a flex row. */
.row-job{flex:0 1 auto;min-width:0;
  position:relative;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;
  font-family:var(--f-text);font-size:13.5px;line-height:1.3;color:var(--ink);
  transition:color 360ms ease}
.row-job::after{content:"";position:absolute;inset-inline:-1px;top:52%;height:1px;
  background:currentColor;transform:scaleX(0);transform-origin:var(--org-x) center;
  transition:transform 420ms var(--ease-out) 60ms}
.row.is-done .row-job{color:var(--muted)}
.row.is-done .row-job::after{transform:scaleX(1)}
.row.is-run .row-job{color:var(--ink)}
.row-min{flex:0 0 46px;margin-inline-start:auto;text-align:end;
  font-family:var(--f-data);font-size:11.5px;letter-spacing:.03em;color:var(--muted);
  font-variant-numeric:tabular-nums;unicode-bidi:isolate;transition:color 360ms ease}
.row.is-run .row-min{color:var(--accent)}

/* the live line — this is where the run narrates itself, and it is deliberately its own full-width
   row rather than a column inside .row: "waiting for a reply to the email" does not fit beside a
   job name at phone width, and truncating the sentence would delete the only bit of the device
   that says what the system is actually doing. */
.board-foot{flex:0 0 auto;display:flex;align-items:center;justify-content:space-between;
  gap:10px 16px;flex-wrap:wrap;padding-top:11px;border-top:1px solid var(--hair-2)}
.board-now{display:inline-flex;align-items:center;gap:9px;min-width:0;
  font-family:var(--f-data);font-size:11px;letter-spacing:.02em;color:var(--accent)}
.board-now .spin{flex:0 0 auto;width:12px;height:12px;border-radius:50%;
  border:1.5px solid rgba(0,106,163,.22);border-top-color:var(--accent);
  animation:rowSpin 720ms linear infinite}
/* the status sentence as a directional wipe, not an opacity cross-fade — MOTION-DEVICES.md device 2,
   floor item 2 (mask-wipe floor; the typewriter extension is legal headroom, not built here, since
   it needs a per-character JS count this file has no hand in). clip-path's inset() only takes
   physical box edges, so the mirroring has to happen through the calc() below rather than through a
   logical keyword: at --flip:1 (LTR) the right-hand inset animates 0→100% and the left stays 0, so
   the visible text recedes toward the end edge; at --flip:-1 (AR) the two swap, so it recedes toward
   the LEFT, which is the end edge in that tree. Going from the fully-clipped state back to
   inset(0 0 0 0) on the reverse (main.js removing .is-swap) reads as the new sentence growing back
   in FROM the start edge — a wipe-out-then-type-in pair from one pair of rules, no second mechanism
   needed. */
.board-now b{position:relative;font-weight:400;min-width:0;overflow:hidden;text-overflow:ellipsis;
  white-space:nowrap;clip-path:inset(0 0 0 0);transition:clip-path 300ms var(--ease-out)}
.board-now.is-swap b{
  clip-path:inset(0 calc((1 + var(--flip)) / 2 * 100%) 0 calc((1 - var(--flip)) / 2 * 100%))}
.board-count{flex:0 0 auto;font-family:var(--f-data);font-size:10.5px;letter-spacing:.05em;
  text-transform:uppercase;color:var(--muted)}
/* the digit-roll — MOTION-DEVICES.md device 2, floor item 3. data-board-count is always a single
   digit (0-8), so no tabular-nums column-width concern the way a multi-digit figure would have.
   Needs a small DOM change this file can't make (main.js owns data-board-count's contents): each
   increment should stack an outgoing <span class="digit digit-out"> over an incoming
   <span class="digit digit-in">, both absolutely positioned inside this now-relative <b>, so the old
   digit translates up and out while the new one translates in from below — see the handoff note for
   the exact sequencing. Until that DOM exists this rule is inert and textContent swaps exactly as it
   does today; nothing regresses either way. */
.board-count b{position:relative;display:inline-block;min-width:.62em;height:1em;
  vertical-align:-0.14em;overflow:hidden;color:var(--ink);font-weight:600;unicode-bidi:isolate}
.board-count b .digit{position:absolute;inset-inline-start:0;top:0;display:inline-block;
  transition:transform 220ms var(--ease-out),opacity 220ms var(--ease-out)}
.board-count b .digit-out{transform:translateY(-100%);opacity:0}
.board-count b .digit-in{transform:translateY(0);opacity:1}

/* bottom strip ---------------------------------------------------------- */
.hero-strip{display:grid;grid-template-columns:auto repeat(3,1fr);border-top:1px solid var(--hair-2)}
.strip-cta{display:grid;place-items:center;padding:26px 30px;border-inline-end:1px solid var(--hair-2)}
/* JOB 2, wave 8: <span class="strip-p"> is retired, .strip-h is now .strip-body's only child.
   align-items was unset before (default stretch), which is invisible with two children, since the
   taller one (.strip-h + .strip-p, two lines) always exceeded .strip-ico's fixed 34px, so the icon
   and the heading both sat flush at the row's own cross-start edge and nobody could tell stretch was
   even in effect. One line of .strip-h alone is shorter than the 34px icon, so the same default now
   reads as a real defect: .strip-body stretches to the row height (set by whichever cell in
   .hero-strip's shared grid row is tallest, most likely .strip-cta's own button) while its one-line
   content stays pinned to the top of that box, leaving the icon and the label visibly NOT centred
   against each other. align-items:center fixes both the old-and-invisible and the new-and-visible
   case in one rule: it is what the two-child layout should always have had. Row height across the
   three boxes stays uniform for free: .hero-strip is a single-row grid, and grid's own default
   align-items:stretch already equalises .strip-cta/.strip-box against whichever cell is tallest;
   nothing here undoes that. */
.strip-box{display:flex;align-items:center;gap:14px;padding:24px 26px;border-inline-end:1px solid var(--hair-2);
  transition:background 220ms ease}
.strip-box:last-child{border-inline-end:0}
.strip-box:hover{background:var(--paper)}
.strip-ico{flex:0 0 auto;width:34px;height:34px;border:1px solid var(--hair);border-radius:8px;
  display:grid;place-items:center;transition:border-color 220ms ease,background 220ms ease}
.strip-box:hover .strip-ico{border-color:var(--accent);background:var(--white)}
.strip-ico svg{width:17px;height:17px;fill:none;stroke:var(--ink);stroke-width:1.5;
  stroke-linecap:round;stroke-linejoin:round}
.strip-box:hover .strip-ico svg{stroke:var(--accent)}
.strip-h{display:block;font-family:var(--f-data);font-size:11px;font-weight:600;letter-spacing:.04em;
  text-transform:uppercase;line-height:1.45;text-wrap:balance}
.strip-h i{font-style:normal;display:inline-block;transition:transform 220ms var(--ease)}
.strip-box:hover .strip-h i{transform:translateX(calc(var(--flip) * 4px))}

/* ── 2 · services — the accordion slides ─────────────────────────────── */
.services{max-width:var(--max);margin:0 auto;padding:0 var(--gut) clamp(60px,7vw,110px)}
.sec-head{margin-bottom:30px}
.sec-head .sec-title{max-width:22ch}
.acc{overflow:hidden}
/* MOTION-DEVICES.md device 1, floor item 2: the row's own background now matches the panel's own
   COLLAPSE half (380ms) rather than the expand half — background is what "un-highlights" on
   collapse, so it should move at the rate of the thing it's marking, not the thing revealing it. */
.acc-row{border-bottom:1px solid var(--hair-2);
  transition:background-color 380ms var(--ease)}
.acc-row:last-child{border-bottom:0}
.acc-row.is-open{background:var(--tint)}
.acc-hit{display:grid;grid-template-columns:120px 1fr;gap:20px;width:100%;text-align:start;
  padding:clamp(22px,2.6vw,32px) clamp(24px,3.4vw,48px)}
/* the index number settles into its "current" state rather than just changing colour under a static
   digit — MOTION-DEVICES.md device 1, floor item 2. 4px, one direction, no overshoot (the site's
   spring/bounce ban still applies to a "settle"). */
.acc-idx{font-family:var(--f-data);font-size:12.5px;color:var(--muted);padding-top:.6em;
  transition:color 400ms ease,transform 240ms var(--ease-out);unicode-bidi:isolate}
.acc-row.is-open .acc-idx{color:var(--ink);transform:translateY(-4px)}
.acc-title{display:block;font-family:var(--f-display);font-size:clamp(22px,2.9vw,36px);
  line-height:1.16;letter-spacing:-.025em;transition:color 300ms ease,transform 620ms var(--ease)}
.acc-row:hover .acc-title{color:var(--accent)}
.acc-row.is-open .acc-title{color:var(--ink);transform:translateX(calc(var(--flip) * 6px))}
.acc-tags{display:flex;flex-wrap:wrap;gap:8px 0;margin-top:12px;font-family:var(--f-data);
  font-size:11.5px;letter-spacing:.05em;color:var(--muted);text-transform:uppercase;
  transition:color 400ms ease,transform 620ms var(--ease)}
.acc-tags span+span::before{content:"\2022";margin:0 12px;opacity:.5}
.acc-row.is-open .acc-tags{color:#4A5568;transform:translateX(calc(var(--flip) * 6px))}
/* The slide itself: height is measured in JS, the inner rides up behind it.

   TIMING IS MEASURED, NOT CHOSEN BY EYE OR BY BEZIER ALGEBRA — CONTRACTS §2.
   Two earlier pairs both read as a snap, and both were "animated" on paper:
     620ms cubic-bezier(.16,1,.3,1)  ->  ~88% travelled at 150ms   (the original complaint)
     440ms cubic-bezier(.32,.72,0,1) ->   87% travelled at 150ms, over by 300ms
   An ease-out with a collapsed x2 front-loads everything, so the declared duration
   describes almost none of the perceived motion. This pair was picked by sampling
   eight candidates in a real browser on this real panel:
     600ms cubic-bezier(.35,0,.2,1)  ->   24% travelled at 150ms, still moving at 400ms
   A quarter open when the eye lands on it reads as travel; 87% reads as already done.

   ⚠ AND THE MEASUREMENT WAS RIGHT WHILE THE MOTION STILL READ AS A SWAP — 2026-08-03, sixth pass.
   The panel genuinely slid; nobody could see it, because the outgoing panel collapsed on the SAME
   600ms as the incoming one expanded, so the two cancelled and the accordion's total height barely
   moved. There is no vertical travel to perceive when the container never changes height. The fix
   is asymmetry, not a new curve: COLLAPSE IS 380ms, EXPAND IS now 900ms (up from 600ms, seventh
   pass, wave 8), slower and roomier, so the group visibly contracts and then slides back open with
   more room to read the travel. Do NOT equalise the two: a real row-1-to-row-3 swap travels 119px
   where it travelled about zero at matched durations (see the note above); the asymmetry itself is
   the fix, not a side effect of it. Keep max-height the only transitioned property on .acc-panel: a
   second one turns transitionDuration into a list and fails the check below.

   ⚠ CURVE UNCHANGED, AND THAT IS A DELIBERATE DEVIATION FROM THE WAVE-8 BRIEF, NOT AN OVERSIGHT.
   The brief asked for "a gentler curve" in the family of cubic-bezier(.22,.61,.24,1) alongside the
   900ms move. verify.py's ACCORDION_TRANSITION_CURVE check (amended the same day, "pass 7") pins
   getComputedStyle(...).transitionTimingFunction to the exact string 'cubic-bezier(0.35, 0, 0.2, 1)';
   its own adjoining comment says explicitly "only the pinned value below moves," meaning duration
   alone. Since this file cannot edit verify.py and the check is the actual gate, the curve stays
   cubic-bezier(.35,0,.2,1) here; only the duration moved. Filed as a brief-vs-disk conflict for
   whoever reconciles the two, not resolved by guessing which one is stale.

   ┌─ AMENDED 2026-08-04 (eleventh pass) — 900ms IS REVERSED TO 200ms, AND THIS IS A POLICY
   │  REVERSAL, NOT A TUNING NUDGE. Everything above is left standing on purpose: it is a true
   │  record of a real measurement, and it is now SUPERSEDED rather than wrong. Read the trail as
   │  620 -> 440 -> 600 -> 900 -> 200.
   │  ⚠ What the measurement above actually established is that an ease-out with a collapsed x2
   │  front-loads its travel, so the DECLARED duration describes almost none of the perceived
   │  motion. That finding survives untouched, and it is why the CURVE does not move here.
   │  What it did NOT establish is that 900ms is the right amount of a visitor's time to spend
   │  opening a services panel. "A quarter open when the eye lands on it reads as travel" is a
   │  claim about the first 150ms; it was then used to justify a total that is nearly a second,
   │  which does not follow from it. Shirsho asked for ~0.2s. That is the decision.
   │  <s>900ms open / 380ms collapse / 400-560ms staggered contents to ~840ms</s>
   │  -> 200ms open / 140ms collapse / 180-220ms staggered contents to ~300ms.
   │  ⚠⚠ THE ASYMMETRY IS PRESERVED AND IT IS THE PART THAT MUST NOT BE LOST. The note at
   │  :425-433 above is the sixth pass's hard-won finding: with expand and collapse EQUAL, a
   │  row-1-to-row-3 swap travels about zero, because the outgoing panel collapses on exactly the
   │  clock the incoming one expands on and the group's height never changes. There is no vertical
   │  travel to perceive when the container never changes height. The RATIO is what carries that
   │  (900:380 = 2.37, 200:140 = 1.43 — shallower, because at 200ms an equally aggressive ratio
   │  would put collapse at 84ms, which is a cut, not a slide); the SCALE is what moved.
   │  Three verify.py checks encoded 900ms AS POLICY and are repointed inside their own dated
   │  boxes rather than deleted — they are stale checks against a reversed policy, the same shape
   │  as the eighth pass's reduced-motion reversal, and a deleted check cannot fail later:
   │    ACCORDION_TRANSITION_DURATION       "0.9s"          -> "0.2s"
   │    ACCORDION_150MS_UNDER_45PCT                         -> ACCORDION_SETTLED_BY_300MS
   │    ACCORDION_STILL_CHANGING_AT_400MS                   -> ACCORDION_STILL_CHANGING_AT_100MS
   │  That last one is the one that matters: it is what stops 0.2s quietly becoming 0.
   └─ ⚠ 900ms IS NOT UNIQUE TO THIS DEVICE. styles.css :843 / :849 / :854 are .plate-wait's bar
      draw and its barTip keyframe, on a completely different clock. They were checked and are
      DELIBERATELY UNTOUCHED. Grep before assuming a duration belongs to you.

   ┌─ AMENDED AGAIN 2026-08-04 (TWELFTH PASS) — 200ms GOES BACK TO 900ms, AND THE FINDING IS NOT
   │  ABOUT THE NUMBER. The box above is a correct implementation of a MISREAD REQUIREMENT, and it
   │  is left standing for exactly that reason. Read the trail as 620 -> 440 -> 600 -> 900 -> 200
   │  -> 900.
   │  ⚠⚠ WHAT WAS ACTUALLY ASKED FOR WAS THE HOVER RESPONSE, NOT THE PANEL. "~0.2s" was read as
   │  the panel's expand duration; what it meant was the 320ms dwell in main.js before a hovered
   │  row opens (main.js §9). The pass reversed a measured policy to hit a target that belonged to
   │  a different number in a different file, and nothing on this tree measured the dwell, which is
   │  exactly how it survived eleven passes untouched. THAT is the entry worth carrying: when a
   │  timing complaint arrives, establish WHICH clock it is about before moving one.
   │  <s>200ms open / 140ms collapse / 180-220ms staggered contents to ~300ms</s>
   │  -> 900ms open / 380ms collapse / 400-560ms staggered contents to ~840ms. Ratio back to
   │  900:380 = 2.37, and the asymmetry argument at :425-433 and :458-464 is unchanged in both
   │  directions: it was the reason the ratio had to be preserved at 200ms, and it is the reason
   │  the numbers are worth restoring together rather than one at a time.
   │  ⚠ .acc-row's `background-color 380ms` at :393 was never rescaled through either move. It was
   │  at 2x the expand while the panel sat at 200ms; at 900ms it is back in its INTENDED
   │  relationship (background matches the collapse half, which is the thing it un-highlights).
   │  Leave it alone: it is correct again by restoration, not by luck.
   │  The three verify.py checks repointed in the box above are repointed BACK, again inside their
   │  own dated boxes rather than deleted:
   │    ACCORDION_TRANSITION_DURATION       "0.2s"                     -> "0.9s"
   │    ACCORDION_SETTLED_BY_300MS          -> ACCORDION_150MS_UNDER_45PCT
   │    ACCORDION_STILL_CHANGING_AT_100MS   -> ACCORDION_STILL_CHANGING_AT_400MS
   │  That last one still matters for the same reason in the other direction: without it, 0.9s and
   │  0s pass identically.
   └─ ⚠ AND THE ACTUAL ASK NOW HAS A CHECK, WHICH IT NEVER HAD: ACCORDION_HOVER_OPENS_BY_200MS
      hovers a closed row and asserts it is open 200ms later. A requirement with no check is how
      this one went eleven passes without being touched. */
.acc-panel{position:relative;max-height:0;overflow:hidden;
  transition:max-height 380ms cubic-bezier(.35,0,.2,1)}
.acc-row.is-open .acc-panel{transition:max-height 900ms cubic-bezier(.35,0,.2,1)}
/* the moving edge — MOTION-DEVICES.md device 1, floor item 1. A leading line the eye can follow into
   the reveal, riding WITH the max-height change rather than instead of it. Needs a companion element
   this file can't add on its own: <span class="acc-edge" aria-hidden="true"></span> as the first
   child of .acc-panel, before .acc-panel-in: that markup has since landed (index.html), so this is
   live, not inert. Asymmetric to match the panel it rides on: 380ms collapse, 900ms expand (up from
   600ms with the panel itself, wave 8), synced to the same window and the same curve; a leading
   edge riding a different easing than the max-height it leads would visibly separate from it.
   ⚠ AMENDED 2026-08-04 (eleventh pass): <s>380ms collapse / 900ms expand</s> -> 140ms / 200ms.
   The rule this line records is unchanged and is the reason both numbers had to move together:
   the edge must ride the SAME window as the panel it leads. An edge left at 900ms over a 200ms
   panel would arrive four fifths of a second after the thing it is supposed to be leading, which
   is not a leading edge, it is a second animation. See :413-443's box for the reversal itself.
   ⚠ AMENDED AGAIN 2026-08-04 (twelfth pass): <s>140ms / 200ms</s> -> back to 380ms / 900ms, with
   the panel. The rule is still the rule and it is why this line moves whenever that one does. The
   requirement the eleventh pass was answering turned out to be about the HOVER DWELL, not the
   panel; see the second dated box at :472-499. */
.acc-edge{position:absolute;top:0;inset-inline:0;height:2px;background:var(--accent);
  transform:scaleX(0);transform-origin:var(--org-x) center;transition:transform 380ms var(--ease-out)}
.acc-row.is-open .acc-edge{transform:scaleX(1);transition:transform 900ms var(--ease-out)}
/* padding-block now opens a gap above the content too (was 0): with the lede as .acc-panel-in's
   first child riding straight up against the row heading with no breathing room while the panel was
   still expanding, the two visually collided during the slide. clamp mirrors the block-end value's
   own shape at roughly 70% of its scale, so the top clearance reads as intentional spacing rather
   than a leftover 0. */
.acc-panel-in{padding-block:clamp(18px,2vw,26px) clamp(26px,3vw,36px);
  padding-inline:calc(120px + clamp(24px,3.4vw,48px) + 20px) clamp(24px,3.4vw,48px)}
/* the contents ride in behind the slide, staggered, rather than the whole block fading as one —
   a staggered arrival is what makes an open read as "it opened" rather than "it is now open".
   ⚠ RESCALED 2026-08-04 (eleventh pass) with the panel above: <s>400/560/400ms durations,
   120/200/280ms delays, tail landing at ~840ms</s> -> 180/220/180ms and 40/80/120ms, tail at
   ~300ms. The stagger is kept — it is the "it opened" reading, and dropping it would leave a
   200ms panel with a block of text simply present inside it — but a tail that outlasts the panel
   by 640ms would mean the contents were still arriving long after the slide had finished, which
   is what makes a fast panel feel unresolved rather than fast. Third child lands just after the
   panel settles, not four times later.
   ⚠ RESCALED BACK 2026-08-04 (twelfth pass): <s>180/220/180ms, 40/80/120ms</s> -> 400/560/400ms
   and 120/200/280ms, tail at ~840ms against a 900ms panel. The rule the eleventh pass wrote here
   is the reason: the tail must land with the panel, not long after it, and it is the panel that
   moved back. This block has no independent opinion about scale — it follows :475-477.
   ⚠⚠ AND THE SELECTOR SET IS STILL :nth-child(1/2/3) ONLY. .acc-panel-in must keep exactly three
   children (lede, .acc-vis, .acc-foot); a fourth gets no delay and arrives ahead of the third.
   The seven-row rewrite of this section (twelfth pass) held to three per row deliberately. */
.acc-panel-in > *{opacity:0;transform:translateY(16px);filter:blur(5px);
  transition:opacity 400ms var(--ease-out),transform 560ms cubic-bezier(.35,0,.2,1),filter 400ms ease}
.acc-row.is-open .acc-panel-in > *{opacity:1;transform:none;filter:none}
.acc-row.is-open .acc-panel-in > :nth-child(1){transition-delay:120ms}
.acc-row.is-open .acc-panel-in > :nth-child(2){transition-delay:200ms}
.acc-row.is-open .acc-panel-in > :nth-child(3){transition-delay:280ms}

/* ── the panel contents, redesigned 2026-08-03 (sixth pass) ─────────────────
   They were two paragraphs of body copy and a button — "big blocks of text on an agency website",
   which is the complaint. Now: one sentence, three cards that show the thing rather than describe
   it, and a figure where a real one exists. The copy that carried a claim moved onto the figure
   verbatim; the copy that carried nothing was cut, not reworded. */
.acc-lede{font-size:clamp(15.5px,1.5vw,17.5px);line-height:1.52;color:var(--ink);max-width:46ch}
.acc-vis{display:grid;grid-template-columns:repeat(auto-fit,minmax(178px,1fr));gap:12px;margin-top:22px}
.vcard{background:var(--white);border:1px solid var(--hair-2);border-radius:var(--r-sm);
  padding:16px 15px 17px;display:flex;flex-direction:column;gap:10px;
  transition:transform 420ms var(--ease),border-color 300ms ease,box-shadow 300ms ease}
.vcard:hover{transform:translateY(-3px);border-color:var(--hair);
  box-shadow:0 16px 34px -26px rgba(22,23,28,.55)}
.vico{width:32px;height:32px;border-radius:9px;border:1px solid var(--hair);background:var(--paper);
  display:grid;place-items:center;transition:background 300ms ease,border-color 300ms ease}
.vcard:hover .vico{border-color:var(--accent);background:rgba(0,106,163,.06)}
.vico svg{width:16px;height:16px;fill:none;stroke:var(--accent);stroke-width:1.6;
  stroke-linecap:round;stroke-linejoin:round}
.vcard b{font-family:var(--f-data);font-size:11px;font-weight:600;letter-spacing:.06em;
  text-transform:uppercase;color:var(--ink);line-height:1.35}
.vcard span{font-size:13px;line-height:1.5;color:var(--muted)}
/* .acc-foot: the four CTAs it used to position with margin-inline-start:auto are retired from the
   HTML this pass; .acc-fig is now its only child. Checked: flex-wrap/gap/align-items all still make
   sense with one child (they're no-ops with a single flex item, not wrong), so nothing here needed
   to change beyond removing the .btn rule that had nothing left to push to the far edge. */
.acc-foot{display:flex;flex-wrap:wrap;align-items:center;gap:14px 26px;margin-top:22px}
.acc-fig{display:inline-flex;align-items:baseline;gap:10px;font-size:13px;line-height:1.45;
  color:var(--muted);max-width:52ch}
.acc-fig b{flex:0 0 auto;white-space:nowrap;   /* "30 min" broke across two lines without it */
  font-family:var(--f-data);font-size:20px;font-weight:600;letter-spacing:-.02em;
  color:var(--ink);unicode-bidi:isolate}

/* ── 3 · about ───────────────────────────────────────────────────────── */
.about{max-width:var(--max);margin:0 auto;padding:0 var(--gut) clamp(60px,7vw,110px)}
.about-head{display:flex;align-items:flex-start;justify-content:space-between;gap:30px}
.about-grid{display:grid;grid-template-columns:1.02fr .98fr;gap:clamp(24px,4vw,64px);align-items:start}
.about-lede{font-size:clamp(17px,1.7vw,21px);line-height:1.55;color:var(--muted);max-width:36ch}
.about-lede strong{color:var(--ink);font-weight:500}
/* .roi, replacing .stats/.stat. ⚠ ITS CHILDREN ARE NOW FOUR .vcard COMMITMENTS, not .roi-item
   figures (wave 8) — the reasoning below about how this grid behaves at 1, 2, 3 and 4 children is
   what made that swap free, and is left intact because it is still what governs the layout.
   Read "item" below as "child". The historical note: .stats' fixed
   1fr 1fr grid was built for exactly two children and would leave a visible hole with one. auto-fit
   fixes that at every count instead of just today's:
     1 item:  the single track sizes to the container width, minmax(1fr) would stretch the card to
              fill it, so .roi-item itself carries a max-width cap; it reads as one deliberate
              headline stat, not a stretched-out grid cell with three ghosts beside it.
     2-3:     auto-fit produces exactly as many tracks as items (never phantom empty ones the way
              auto-fill can), so they sit side by side up to the container's width before wrapping.
     4:       same mechanism, no rule changes needed; at .about-left's own column width four
              minmax(200px) tracks comfortably fit at 1440/1024, and wrap to two rows at 768/390
              the same way any auto-fit grid does, which is the correct fallback, not a defect.
   No responsive override is written for this: auto-fit's own column math already collapses to one
   column once the container is narrower than minmax's own floor (roughly 214px with the gap), which
   happens well before the 768/390 breakpoints proper. The old .stats{grid-template-columns:1fr} at
   max-width:720px is retired along with .stats itself, not replaced, because there is nothing left
   for it to do. */
.roi-label{margin:clamp(34px,4vw,56px) 0 16px;font-family:var(--f-data);font-size:11.5px;
  letter-spacing:.12em;text-transform:uppercase;color:var(--muted)}
.roi{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:14px}
/* ⚠ .roi-item / .roi-n / .roi-u / .roi-p / .roi-note ARE DELETED (wave 8), not commented out.
   The panel they styled was one measured figure plus a paragraph promising three more that do
   not exist on disk (RQ-150), and that paragraph carried "the licence fee that never recurs",
   which is the same false commercial model hero strip box 3 carried. Its four children are now
   .vcard, the unscoped global card the services accordion already uses, so this grid needed no
   rule change at all — the note above it already reasoned through the 4-child case. Grepped
   before deleting, per the .bento/.beat-live precedent below: zero occurrences of any of the
   five in any .html or .js in either tree. .roi and .roi-label survive and are in use. */

/* ── the roadmap — replaces the retired .orb / .orb-in / .node stage ─────────
   The orb was decoration with three words floating around it: it said nothing about how a project
   with us actually runs, and the light-source-does-not-mirror ruling it existed to demonstrate has
   no work left to do once the art is gone. This is the same five steps every engagement runs, in
   order, going DOWN — Discovery, Mapping, Build, Improve, Publish. It is real content, so it is
   not aria-hidden and it carries a heading; the spine draws itself downward on reveal and each
   dot lights in sequence behind it, which is the motion the section used to buy with a float. */
/* ⚠ THE STICKY IS GONE, AND SO IS THE BOX THAT EXISTED TO FEED IT (wave 8).
   Both were deleted together because neither had a reason to exist without the other:

   `position:sticky;top:112px` on .roadmap held the roadmap card still while the h2, the lede
   and the whole left column scrolled away behind it. That is what Shirsho reported as wrong,
   and it is not a tuning question — A STICKY ELEMENT HOLDS ITSELF AND NOTHING ELSE, so no
   value of `top`, and no ScrollTrigger sitting beside it, could ever have held the section
   heading too. main.js §6 now pins .about-scene (the wrapper around the eyebrow, the head, the
   rule and the grid) and .roadmap is a plain static card inside it.

   `.about-art{min-height:calc(260px + var(--rm-count) * 160px)}` was 1060px of manufactured
   height whose ONLY job was to give that sticky panel somewhere to travel, because .roadmap is
   .about-art's only child and an auto-height parent gives a sticky child exactly zero travel.
   A pin defines its own range, so the box is dead weight under it — and it was also the reason
   this section rendered taller than its content, with a stretch of empty column under a shorter
   card. `align-self:stretch` goes with it for the same reason: it was there to make the sticky
   containing block full-height.

   What this removes is not a safety net. main.js's RM_MIN_TRAVEL gate guarded a range STARVED
   by being borrowed from the sticky's own travel; a pinned range cannot be starved, so the
   failure mode is unreachable rather than unguarded. */
.about-art{position:relative}
.roadmap{padding:clamp(22px,2.4vw,30px) clamp(20px,2.2vw,28px);
  background:var(--white);border:1px solid var(--hair-2);border-radius:var(--r);
  box-shadow:0 1px 2px rgba(22,23,28,.03),0 18px 50px -30px rgba(22,23,28,.28);
  --rm-progress:0}
.rm-steps{margin:0;padding:0;list-style:none}
.rm-head{display:flex;flex-direction:column;gap:3px;margin-bottom:22px}
.rm-head b{font-family:var(--f-text);font-size:15px;font-weight:500;color:var(--ink)}
.rm-head span{font-family:var(--f-data);font-size:10.5px;letter-spacing:.05em;
  text-transform:uppercase;color:var(--muted)}
.rm-step{position:relative;display:grid;grid-template-columns:28px 1fr;gap:14px;
  padding-bottom:clamp(18px,2vw,26px)}
.rm-step:last-child{padding-bottom:0}
/* the spine: one segment per step, drawn from its own dot down to the next one */
.rm-step::before{content:"";position:absolute;top:30px;bottom:2px;inset-inline-start:13px;width:2px;
  border-radius:2px;background:linear-gradient(180deg,var(--tint),rgba(214,224,236,.35));
  transform:scaleY(0);transform-origin:top;transition:transform 620ms var(--ease-out)}
.rm-step:last-child::before{display:none}
.about-art.is-in .rm-step::before{transform:scaleY(1)}
.rm-dot{position:relative;z-index:2;width:28px;height:28px;border-radius:50%;display:grid;
  place-items:center;background:var(--white);border:1.5px solid var(--tint);
  font-family:var(--f-data);font-size:10px;font-weight:600;letter-spacing:0;color:var(--muted);
  unicode-bidi:isolate;
  transition:background 460ms var(--ease),border-color 460ms var(--ease),color 460ms var(--ease),
             box-shadow 460ms var(--ease)}
.about-art.is-in .rm-dot{background:var(--accent);border-color:var(--accent);
  color:var(--white);box-shadow:0 0 0 4px rgba(0,106,163,.10)}
.rm-b h3{font-family:var(--f-display);font-size:16px;line-height:1.25;letter-spacing:-.015em;
  padding-top:4px}
.rm-b p{margin-top:6px;font-size:13.5px;line-height:1.5;color:var(--muted);max-width:34ch}
/* the sequence, no-JS/no-scrub fallback: each step's dot and the spine below it wait their turn on a
   plain one-shot reveal, so the eye travels the roadmap downward instead of watching five things
   arrive at once. This stays exactly as it was — it is what renders if GSAP/ScrollTrigger never
   load, since .about-art still gets .is-in from the ordinary IO reveal (index.html:339) independent
   of any scroll-scrub. */
.about-art.is-in .rm-step:nth-child(2) .rm-dot,.about-art.is-in .rm-step:nth-child(2)::before{transition-delay:160ms}
.about-art.is-in .rm-step:nth-child(3) .rm-dot,.about-art.is-in .rm-step:nth-child(3)::before{transition-delay:320ms}
.about-art.is-in .rm-step:nth-child(4) .rm-dot,.about-art.is-in .rm-step:nth-child(4)::before{transition-delay:480ms}
.about-art.is-in .rm-step:nth-child(5) .rm-dot,.about-art.is-in .rm-step:nth-child(5)::before{transition-delay:640ms}

/* the scroll-scrub — MOTION-DEVICES.md device 3, wired to what main.js ACTUALLY ships (checked
   directly against the live file, not against this file's own first-draft guess at the JS's class
   names, which used html.scrub-ready/--rm-progress-only math that main.js never produces).
   main.js §6: once its ScrollTrigger registers it adds is-scrubbed to .about-art (unconditionally,
   at script-init time — not gated on visibility, so it can be present before .is-in is), writes a
   plain 0–1 --rm-progress on .roadmap every frame (inherited down to every descendant, including
   every .rm-dot, which is what the dot-pop math below reads), a per-step 0–1 --rm-seg directly onto
   each .rm-step (NOT inherited by the following sibling's own dot — custom properties don't cross
   sibling boundaries — which is exactly why the spine below reads --rm-seg but the dot-pop reads
   --rm-progress instead), and toggles is-lit directly on .rm-dot (not on its parent .rm-step).
   .about-art.is-in.is-scrubbed is the gate: is-in still comes from the plain IO reveal (so nothing
   here fires before the roadmap has actually scrolled into view), is-scrubbed from the ScrollTrigger
   above — needing BOTH is what gives this block (0,4,1) against the fallback rules' (0,3,1)/(0,2,0),
   so it wins once both are true without disturbing the fallback for the reduced/no-GSAP path, which
   never gets is-scrubbed at all.
   ⚠ transition:none is not optional on the spine — the base .rm-step::before rule carries a 620ms
   transition for the fallback's own one-shot draw, and without killing it here every scroll-driven
   --rm-seg write would get smoothed 620ms behind the actual scroll position, which is exactly the
   "eased scrub" this house's own rule forbids (DESIGN.md §16: "the easing of a scrub is the reader's
   own hand" — scrubbed values are read linearly, once per frame, never CSS-eased). Same for the dot:
   its OWN base transition (background/border-color/color/box-shadow, 460ms) stays for the LIT colour
   flip, which is a discrete one-time state change and fine to ease; only the continuously-scrubbed
   transform gets transition:none here. */
.about-art.is-in.is-scrubbed .rm-step::before{transform:scaleY(var(--rm-seg,0));transition:none}
/* the currently-lit dot gets more presence than a colour change (visual-weight half of the same
   floor item): it pops from .7 to full scale across the SAME window its own feeding segment draws
   in, read off the inherited --rm-progress since a sibling step's --rm-seg isn't reachable here.
   Dot 1 (nth-child(1)) has no feeding segment — it's the start node, always full scale.

   ⚠ THIS IS THE STEP-COUNT COUPLING — one number, four places, and they are the four `.25`s
   below. Wave 8 moved this note here from the retired --rm-count variable, which never governed
   this at all: it only fed .about-art's min-height, which is now deleted. `.25` is 1/(5-1), the
   width of one segment's progress window for a FIVE-step roadmap. Adding a sixth step means
   changing all four to `.2` and adding a fifth rule, and the offsets (0, .25, .5, .75) follow.
   The JS half needs no edit: main.js §6 derives (RM_N - 1) from the DOM. If the dots ever pop
   out of step with the spine, this is the first thing to check. */
.about-art.is-in.is-scrubbed .rm-step:nth-child(2) .rm-dot{
  transform:scale(calc(.7 + .3 * clamp(0,calc(var(--rm-progress) / .25),1)));transition:transform 120ms linear}
.about-art.is-in.is-scrubbed .rm-step:nth-child(3) .rm-dot{
  transform:scale(calc(.7 + .3 * clamp(0,calc((var(--rm-progress) - .25) / .25),1)));transition:transform 120ms linear}
.about-art.is-in.is-scrubbed .rm-step:nth-child(4) .rm-dot{
  transform:scale(calc(.7 + .3 * clamp(0,calc((var(--rm-progress) - .5) / .25),1)));transition:transform 120ms linear}
.about-art.is-in.is-scrubbed .rm-step:nth-child(5) .rm-dot{
  transform:scale(calc(.7 + .3 * clamp(0,calc((var(--rm-progress) - .75) / .25),1)));transition:transform 120ms linear}
/* is-lit is what main.js actually toggles, straight on .rm-dot — a short 120ms linear settle rather
   than true transition:none since this one only flips at discrete checkpoints (not continuously
   scrubbed every frame the way scaleY/scale above are), so a brief settle reads as a confirmation
   click, not scroll-lag. Higher specificity than the plain .about-art.is-in .rm-dot rule above so it
   wins once scrubbing owns this dot. */
.about-art.is-in.is-scrubbed .rm-dot{background:var(--white);border-color:var(--tint);color:var(--muted);
  box-shadow:none;transition:transform 120ms linear,background 200ms linear,border-color 200ms linear,
  color 200ms linear,box-shadow 200ms linear}
.about-art.is-in.is-scrubbed .rm-dot.is-lit{background:var(--accent);border-color:var(--accent);
  color:var(--white);box-shadow:0 0 0 4px rgba(0,106,163,.10)}
/* the currently-active segment (progress inside its own (0,1) range, neither undrawn nor finished)
   reads heavier while it's mid-draw, receding the instant it completes — no JS class needed, this is
   pure CSS off the same --rm-seg the spine itself reads, layered as a second glow element blended in
   by opacity rather than toggled by a boolean class. min()/clamp() only (not abs(), which is too new
   for this site's browser floor): opacity ramps 0→1 across the first 15% of the segment's own draw,
   holds full through the middle, ramps back 1→0 across the last 15% — min(seg, 1-seg) is a triangle
   peaking at .5 and zero at both 0 and 1, divided by .15 and clamped turns that into the same
   fade-in/hold/fade-out shape without a single conditional. */
.about-art.is-in.is-scrubbed .rm-step::after{content:"";position:absolute;top:30px;bottom:2px;
  inset-inline-start:13px;width:2px;border-radius:2px;pointer-events:none;
  background:linear-gradient(180deg,var(--accent),rgba(0,106,163,.35));
  box-shadow:0 0 8px 0 rgba(0,106,163,.35);
  opacity:clamp(0,calc(min(var(--rm-seg,0),1 - var(--rm-seg,0)) / .15),1);
  transform:scaleY(var(--rm-seg,0));transform-origin:top}
.rm-step:last-child::after{display:none}

/* ── 4 · work ────────────────────────────────────────────────────────── */
.work{max-width:var(--max);margin:0 auto;padding:0 var(--gut) clamp(50px,6vw,90px)}
.work-head{display:grid;grid-template-columns:.9fr 1.1fr;gap:clamp(24px,5vw,80px);
  align-items:end;margin-bottom:clamp(26px,3vw,44px)}
.work-note{font-size:14px;line-height:1.6;color:var(--muted);max-width:38ch}
.work-head-r{display:flex;flex-direction:column;align-items:flex-start;gap:18px}

/* ── the home stacking scene, JOB 5, wave 8, replaces .bento's 4-tile CSS-grid pin (and with it
   .tile-wide and .beat-live, both orphaned: .bento, tile-wide and the evidence-scene beat classes no
   longer appear anywhere in the HTML or in main.js, confirmed by grep before deleting either). Markup
   contract (main.js §5, coded against exactly):
     <div class="stack" data-scene="stack">
       <div class="stack-track">
         <article class="tile stack-card" data-stack-beat="1">…</article>
         <article class="tile stack-card" data-stack-beat="2">…</article>
         <article class="tile stack-card stack-card--all" data-stack-beat="3">…</article>
       </div>
     </div>
   main.js sets transform (translateZ + scale) and opacity on each .stack-card per scroll progress,
   and sets will-change itself; this file owns perspective, position and everything else. It adds
   .is-scene to .stack ONLY after ScrollTrigger.create() has actually returned (never before the
   feature test, never at parse time), so the CSS default below is what a visitor gets if GSAP/
   ScrollTrigger are missing or the section throws before that point.

   ⚠ THE BLOCKER THIS SITE HAS ALREADY SHIPPED FROM THIS EXACT SHAPE OF BUG, TWICE: once as
   html.reveal-ready .reveal's specificity inversion (~30 permanently-invisible blocks, see § reveal
   below), once as .board-rows' percentage-height collapse (§1 above). The rule that would reproduce
   it here is a CSS-authored opacity:0 on .stack-card at any specificity; there is deliberately none
   below. The absolute-positioning rule is gated behind .work-scene.is-scene and NOTHING else, and
   the fallback (.stack-card alone) is opacity:1 in flow. This is not a specificity contest: when
   .is-scene is absent, the .work-scene.is-scene .stack-card selector simply never matches the DOM,
   so the 1-class .stack-card rule is the ONLY one that applies: (0,1,0) stands alone, nothing to
   outrank it. (Selector repointed wave 8 when .is-scene moved from .stack up to the .work-scene
   wrapper; the gate and the fallback are unchanged, only the element carrying the class moved.) Cards do NOT carry .reveal (main.js §5 keeps them out of the IO reveal loop on
   purpose), so the reveal-density merge-fix pattern below doesn't apply to them either. */
.stack{position:relative;
  /* 1800px: big enough that a 1200px-wide card (this site's own --max) doesn't fisheye at its own
     edges, small enough that main.js's -420px translateZ still reads as real depth rather than a
     barely-there parallax. Picked, not derived: CSS can't measure the felt depth of a curve any
     more than the accordion's timing could be picked by algebra (§2 above), this just has no browser
     available this pass to sample candidates in. */
  perspective:1800px}
.stack-track{position:relative;display:flex;flex-direction:column;gap:20px}
/* the pinned scene's own screen budget. ⚠ NEVER a percentage height against an auto-height parent:
   .board-rows above is the standing example of exactly that collapsing eight rows into 7px while
   every DOM probe passed. Once .is-scene lands, every .stack-card goes position:absolute and stops
   contributing to .stack-track's own height, so without this the track (and .stack above it, which
   has no other content) collapses to zero the instant the scene registers, a second, taller-stakes
   repeat of the same bug class, since a collapsed pinned scene reads as a blank pause in the scroll,
   not a missing screenshot. clamp() rather than a fixed px so it settles shorter on its own at the
   laptop/tablet widths without needing the responsive block to do all the work; the two breakpoints
   below restate tighter caps explicitly rather than leaning on this alone, matching how the header
   clearance values above are measured per breakpoint rather than assumed to scale. */
/* ⚠ .is-scene MOVED FROM .stack TO .work-scene (wave 8) — main.js §5 adds it to whatever
   [data-scene="stack"] resolves to, and that attribute now sits on the wrapper holding the head
   AND the cards, because pinning .stack alone threw the heading off-screen the instant the pin
   engaged. Every rule below is repointed rather than duplicated; the fallback contract is
   unchanged and still the important half: when .is-scene is absent (no GSAP, or a throw before
   ScrollTrigger.create returns) the 2-class .work-scene.is-scene .stack-card selector simply
   never matches, so the 1-class .stack-card rule stands alone at opacity 1, in flow.

   ⚠⚠ AND NOT ONE PERCENTAGE HEIGHT IN THIS CHAIN. The fifth pass's blocker was exactly this
   shape: .board-rows{height:100%} against an auto-height parent resolved to `auto`, collapsing
   eight rows into a 7px strip above the fold while three DOM probes passed on it. The chain
   below is pure flex — a definite min-height on the scene, then flex:1 1 auto with min-height:0
   down two links — so the worst case if a link is wrong is a card that is too SHORT, caught by
   the clamp floor on the track, never one that is invisible.
   min-height:0 on .stack is not decoration: a flex item's default min-height is `auto`, which
   refuses to shrink below its content and would let the track push the scene past the viewport. */
.work-scene.is-scene{min-height:calc(100vh - 96px);display:flex;flex-direction:column}
.work-scene.is-scene .stack{flex:1 1 auto;min-height:0;display:flex;flex-direction:column}
/* ⚠ THE FLOOR IS AN EMERGENCY FLOOR NOW, NOT A TARGET (wave 8). It used to be
   clamp(420px,54vw,620px), sized to be the track's actual height because the track had no
   other source of one. Under the flex chain above it does: the scene has a definite
   min-height and flex:1 1 auto hands the track whatever the head leaves. At 1440x900 that
   is 523px, and the old 620px floor OVERRODE it and pushed 97px of every tile below the
   fold — measured, and the tile-bar was the part that went. The floor is now well under
   what flex gives at every width this scene runs at, so it only ever catches a genuine
   collapse. Natural card content measures 498px at 1440 and 460px at 1024. */
.work-scene.is-scene .stack-track{transform-style:preserve-3d;flex:1 1 auto;
  min-height:clamp(360px,30vw,460px)}
.work-scene.is-scene .stack-card{position:absolute;inset:0;
  /* ⚠ ELEVATION IS THE ONLY THING THAT MAKES A DECK READ AS A DECK. Without it the waiting cards
     are white rectangles on a near-white page and the whole device reads as three flat panels
     sliding, which is what the first screenshot showed. The shadow deepens with --card-p, so the
     card on stage sits visibly above the ones waiting, and the card currently zooming reads as
     coming TOWARD the reader rather than simply appearing over its neighbour.
     ⚠⚠ AND THE TRANSITION IS KILLED HERE, DELIBERATELY. .tile declares
     `transition:transform 460ms, box-shadow 460ms, border-color 460ms` for its in-flow hover
     lift. Inside a scrubbed scene every one of those properties is being written on every scroll
     frame, so a 460ms transition would smear the whole scene ~half a second behind the reader's
     own hand — the exact dishonesty §6's `scrub:true` note forbids. (The hover lift itself is
     already inert in here: GSAP writes transform as an inline style, which outranks .tile:hover's
     stylesheet rule, so nothing is being taken away that was working.) */
  transition:none;
  box-shadow:0 calc(16px + 30px * var(--card-p,0)) calc(38px + 56px * var(--card-p,0)) -28px
             rgba(22,23,28,.45)}
.stack-card{position:relative;opacity:1}
.tile{background:var(--white);border:1px solid var(--hair-2);
  border-radius:var(--r);overflow:hidden;display:flex;flex-direction:column;
  transition:transform 460ms var(--ease),box-shadow 460ms ease,border-color 460ms ease}
.tile:hover{transform:translateY(-5px);box-shadow:0 30px 64px -42px rgba(22,23,28,.62);border-color:var(--hair)}
.tile > a{display:flex;flex-direction:column;flex:1 1 auto}

/* the tile-art sizing box and the four plates it hosts -------------------
   .pagegrid/.stack/.people are retired; .plate/.plate-read/.plate-wait/
   .plate-hold/.plate-run replace them. Spec: TILE-PLATES.md.

   CSS-DEFECTS.md: TILE-PLATES §0 says .tile-cap is "the last flex child of
   .plate," but every DOM example in that file — and B3's frozen index.html —
   nests <p class="tile-cap"> as .plate's SIBLING, not its child, while also
   specifying .plate{position:absolute;inset:0}. Built literally, that
   pairing overlaps the caption on top of the graphic instead of sitting
   below it. Fix shipped here: .tile-art is the flex column (plate, then
   caption), .plate is a flex item (flex:1 1 auto, not absolute) that still
   gets exactly TILE-PLATES' own fixed height budget and untouched internal
   layout. This reproduces A6's own §6 arithmetic (the caption competes with
   the graphic for the same fixed envelope, exactly as their own math already
   assumed) rather than growing the tile taller. See the filed entry for the
   .plate-hold shortfall this produces at the narrowest width. */
.tile-art{position:relative;height:clamp(158px,14vw,196px);overflow:hidden;
  display:flex;flex-direction:column;gap:12px}
.plate{position:relative;flex:1 1 auto;padding:24px;
  background:linear-gradient(180deg,#E7EDF4 0%,#F4F7FA 100%);
  display:flex;flex-direction:column;justify-content:center;gap:16px;overflow:hidden;
  transition:transform 500ms var(--ease)}
.tile-cap{font-family:var(--f-data);font-size:11px;letter-spacing:.05em;color:var(--muted);
  line-height:1.4;padding-inline:24px}
/* MOTION-DEVICES.md device 4, "card lift and content zoom on hover": no plate carries photography
   (TILE-PLATES.md's own no-invented-imagery rule), so T23's image-zoom-on-hover device applies to
   the plate's own drawn content instead — same 1.04-1.06 range, same "past ~1.1 the crop shift gets
   distracting" ceiling a photo would have, scoped to .plate as a whole (not its sub-elements) so it
   scales as one composed picture the way a photo would. .tile-art's own overflow:hidden crops it,
   same as it would crop a photo. Distinct 500ms from the card's own 460ms lift so the two don't
   read as one flat step. */
.tile:hover .plate{transform:scale(1.05)}
/* ⚠ AND IT IS SCOPED OUT OF THE PINNED SCENE (2026-08-04, twelfth pass). Hovering a case-study
   card in #work visibly zoomed its graphic OUT OF FRAME. Cause, exactly: the 1.05 above was sized
   against .tile-art's clamp(158px,14vw,196px) strip. Inside the scene, :882 makes that strip
   height:auto / flex:1 1 0 / min-height:clamp(180px,20vh,280px), so .plate is most of a
   full-screen card and the SAME 5% displaces several times the pixels, cropped on every side by
   two nested overflow:hidden. Device 4's own ceiling ("past ~1.1 the crop shift gets distracting")
   was written about a photo in a small strip and does not transfer to a plate that is most of a
   card.
   ⚠ WHY IT SURVIVED INTO THE SCENE AT ALL: `transition:none` and GSAP's inline transform both
   land on .stack-card, and .plate is a DESCENDANT of it. So the card's own translateY(-5px) lift
   really is inert in there (an inline style outranks the stylesheet) while the plate's zoom is
   fully live with its own 500ms transition. "The hover is disabled in the scene" was true of the
   card and false of the plate.
   ⚠ `none`, NOT A SMALLER NUMBER, AND THE REASON IS COMPOUNDING. .plate-grow > * and
   .plate-run .run-feed already scale THEMSELVES off --card-p with a `bottom center` / `center`
   origin while the parent .plate scales from `center` — two disagreeing origins multiplying,
   which is the asymmetric growth being seen. Damping 1.05 to 1.02 would leave that intact.
   Out of the scene nothing changes: the in-flow tiles on /work/ and the JS-disabled fallback
   keep 1.05 exactly as before, which is where it was designed and where it works. */
.work-scene.is-scene .tile:hover .plate{transform:none}

/* ── THE FLASHCARD DECK'S OWN SIZING, AND IT IS SCENE-SCOPED ─────────────────────────────
   2026-08-04, eleventh pass. The complaint: "tiny text in a huge box." In the pinned scene a
   card is position:absolute;inset:0 inside a track that is most of the viewport tall, while
   .tile-art was pinned to height:clamp(158px,14vw,196px) — a fixed strip sized for an in-flow
   grid card, holding the one thing that carries the claim, with the rest of a 500px+ box
   spent on 19px h3 and 11px caption. The graphic gets the room the card actually has.
   ⚠ ONLY INSIDE THE SCENE. Below 1081px (and with no GSAP) .is-scene never lands, the cards
   go back in flow at their natural height, and :784's fixed strip is correct there — it is
   what keeps three in-flow cards from each being a screen tall. Do not "simplify" these two
   into one rule. */
/* ⚠ flex:1 1 0, NOT 1 1 auto, AND THE BASIS IS THE WHOLE BUG. Built first as `flex:1 1 auto`,
   which makes the base size the item's own CONTENT height — and this item's content is an SVG in
   a flex column, whose max-content height is large and unbounded. Measured at 1440x900: .tile-art
   came out 554px inside a 523px card and pushed .tile-body (203px) and .tile-bar (67px) to
   y=932..1202, entirely below the card's own bottom edge at 900. .tile{overflow:hidden} then
   CLIPPED them, so the card rendered as a plate and nothing else — no headline, no figures, no
   bar — and every DOM probe passed because all three elements were present, opacity 1, non-zero
   box. That is this tree's "present, correct and unreadable" a fourth time, and it was found by
   opening the screenshot. flex-basis:0 makes the art take only what is LEFT after the body and
   the bar have their content heights, which is what "the visual earns the room" has to mean.
   ⚠ AND THE FLOOR IS AN EMERGENCY FLOOR, per the wave-8 note above: at 1440x900 flex hands the
   art 253px and this floor is 180px, comfortably under it, so it only ever catches a genuine
   collapse. A floor sized as a TARGET is what pushed 97px of every tile below the fold last pass. */
.work-scene.is-scene .tile-art{height:auto;flex:1 1 0;min-height:clamp(180px,20vh,280px)}
/* the detail arrives as the card grows: a deck-scale card reads as a clean face — headline,
   graphic — and the body copy and figures fade up as --card-p climbs, so nothing on a 0.5-scale
   card is text you are being asked to read and cannot. --card-p is written by main.js §5 on the
   card itself; the fallback value 1 is what makes this rule inert everywhere the scene does not
   run (in flow, below the gate, with JS off), which is the whole safety property. */
.work-scene.is-scene .stack-card .tile-body p,
.work-scene.is-scene .stack-card .tile-figs{
  opacity:var(--card-p,1);
  transition:opacity 180ms linear}
/* card 3 has never had CSS of its own — .stack-card--all was a hook with zero rules. It carries
   no case-study evidence, so its plate is the only thing giving it a face; letting the body sit
   centred against a taller art box stops it reading as the short, half-empty card in the deck. */
.stack-card--all .tile-body{justify-content:center}

/* .plate-grow — clief-notes: twenty to forty thousand, drawn as the curve that climbs.
   Replaces .plate-read (deleted 2026-08-04, eleventh pass — three drawn sheets and a marker,
   saying "everything gets read, one marker decides", which was a claim about PROCESS on a card
   whose headline is a number. Confirmed by grep before deleting: .plate-read appeared only on
   index.html and ar/index.html, nowhere in _retired/ or the sub-pages).
   Every moving part reads --beat, which main.js writes as this card's own progress through its
   hold — so the line, the figure and the axis all arrive together and all STOP together. */
/* ⚠ THE FIGURE AND THE AXIS ARE OVERLAID, NOT STACKED, AND THE REASON IS THE ASPECT RATIO.
   Built first as a plain flex column — figure, then curve, then axis. That left the curve 98px
   tall in a 1070px-wide plate, and because preserveAspectRatio="none" stretches the 400x150
   viewBox to fit, an 11:1 box FLATTENS the climb: measured on the screenshot, a curve that rises
   through 91% of its own viewBox rendered as a near-horizontal line. A growth curve that looks
   flat argues against the number beside it. Overlaying the two labels hands the curve the plate's
   whole inner height (98px -> ~175px) and roughly halves the distortion, and the big figure
   reading over the shallow left end of the line is better composition than sitting above it. */
.plate-grow{position:relative;padding:20px 24px 40px}
/* the whole plate scales up with the card rather than snapping to full size at the seam.
   transform-origin at the bottom keeps the curve's baseline pinned while it grows. */
.plate-grow > *{transform:scale(calc(.9 + .1 * var(--card-p,1)));transform-origin:bottom center}
.plate-grow .grow-fig{position:absolute;top:20px;inset-inline-start:24px;z-index:2;
  display:flex;align-items:baseline;gap:10px;font-family:var(--f-data);
  unicode-bidi:isolate;transform-origin:bottom left}
.plate-grow .grow-a{font-size:15px;color:var(--muted);font-feature-settings:"tnum" 1}
.plate-grow .grow-arrow{font-style:normal;font-size:14px;color:var(--muted);opacity:.75}
.plate-grow .grow-b{font-size:clamp(26px,3.4vw,38px);font-weight:600;color:var(--accent);
  letter-spacing:-.02em;font-feature-settings:"tnum" 1;line-height:1}
/* ⚠⚠ THE REVEAL IS A CLIP, NOT A DASH, AND THE DASH IS WHY. Built first as the obvious
   stroke-dasharray:1 / stroke-dashoffset:calc(1 - var(--beat)) off pathLength="1" — the standard
   draw-an-SVG-line trick. IT DOES NOT WORK ON THIS PATH, because `vector-effect:non-scaling-stroke`
   moves the stroke into SCREEN space, and Chromium then computes the dash in screen pixels while
   pathLength="1" normalises user space. Measured on the built page: getComputedStyle reported
   stroke-dasharray "1px" and stroke-dashoffset "calc(0.366667px)" against a 422px path — a 1px-on
   / 1px-off pattern, not a 63%-drawn line. On screen it read as a broken line in two pieces, which
   is the artifact that sent me to measure it. pathLength and non-scaling-stroke cannot both be
   honoured here, and non-scaling-stroke is the one that is load-bearing (see the markup note:
   preserveAspectRatio="none" would otherwise render a stroke thicker vertically than horizontally).
   clip-path has no length semantics at all, so there is nothing left to disagree about: the curve
   is simply revealed left to right as --beat runs. It stays PHYSICALLY left-to-right in both trees
   for the same reason the path itself is not mirrored — a climbing curve is not directional
   content, and reversing it would make growth read as decline in the Arabic tree.
   No transition on the clip: this is scrubbed, and smoothing a value the reader is dragging would
   put the line behind their own hand. */
/* ⚠ flex:1 1 0, AND IT IS THE SAME BASIS BUG AS .tile-art, ONE LEVEL DOWN. As `flex:1 1 auto`
   the SVG's base size came from its own intrinsic aspect ratio and it measured 98px inside a
   183px content box — so the curve stayed flat however much room the plate gave it. A zero basis
   takes the intrinsic ratio out of the negotiation entirely and the flex algorithm hands it the
   whole leftover band.
   ⚠⚠ AND NOT position:absolute WITH FOUR INSETS, WHICH WAS TRIED AND MADE THE CURVE VANISH:
   an absolutely-positioned REPLACED element with width/height auto resolves from its intrinsic
   size, not from its insets, so the over-constrained box is simply ignored. Insets work for a
   div and not for an <svg>, which is exactly the sort of thing that is cheaper to measure than
   to reason about.
   The plate's padding-bottom (not this element) reserves the band the axis labels sit in — the
   other defect the screenshot showed, with the line running through the words "at the start". */
/* ⚠⚠ THE CURVE DOES MIRROR, AND THE FIRST BUILD OF IT WAS WRONG. It shipped physical, on the
   reasoning that "a climbing curve is not directional content" — which is true of a curve ALONE
   and false of this one, because it has a labelled axis under it. `.grow-axis` is a flex row
   using logical insets, so under RTL "في البداية" (at the start) correctly moves to the RIGHT and
   "اليوم" (today) to the left. With the path left physical, the line was therefore LOW at "today"
   and HIGH at "at the start" — **an Arabic reader saw the growth as a decline.** Caught by
   opening the AR screenshot, not by any check; AR_ARROW_MIRROR only guards the glyph.
   scaleX(var(--flip)) fixes both halves at once: the path flips to climb toward the reading end,
   AND the reveal follows it for free, because clip-path is resolved in the element's own box
   before the transform — so the same `inset(0 X 0 0)` that draws left-to-right in English draws
   right-to-left in Arabic, from the start of the axis in both. */
.plate-grow .grow-curve{flex:1 1 0;width:100%;min-height:0;display:block;overflow:visible;
  transform:scale(calc(.9 + .1 * var(--card-p,1))) scaleX(var(--flip,1));
  clip-path:inset(0 calc((1 - var(--beat,1)) * 100%) 0 0)}
.plate-grow .grow-path{stroke:var(--accent);stroke-width:2.5;stroke-linecap:round}
.plate-grow .grow-axis{position:absolute;inset-inline:24px;bottom:20px;z-index:2;
  display:flex;justify-content:space-between;
  font-family:var(--f-data);font-size:10.5px;letter-spacing:.04em;color:var(--muted)}

/* .plate-wait — recall: the wait collapsed, 15s -> 0.15s */
.plate-wait{gap:16px}
.plate-wait .row{display:flex;align-items:center;gap:10px;font-family:var(--f-data);font-size:11px;
  color:var(--muted)}
.plate-wait .lbl{flex:0 0 auto;width:40px}
.plate-wait .bar{flex:1 1 auto;height:14px;border-radius:7px;background:rgba(22,23,28,.08);
  overflow:hidden}
.plate-wait .bar b{position:relative;display:block;height:100%;min-width:14px;border-radius:7px;
  background:var(--accent);transform:scaleX(0);transform-origin:var(--org-x) center;
  transition:transform 900ms var(--ease)}
.plate-wait .row:nth-child(2) .bar b{transition-delay:120ms}
/* a leading-tip highlight travels with the draw — MOTION-DEVICES.md device 4, .plate-wait floor
   item. inset-inline-end:0 rides the growing edge automatically because it inherits the parent's own
   scaleX (no separate positioning maths needed, and it mirrors for free via the parent's own
   transform-origin:var(--org-x)). A pure CSS animation tied to .is-in rather than a JS timer, so it
   needs no coordination with main.js: fades in during the last ~150ms of the 900ms draw, out
   immediately after — the reader gets a specific point to track, not just a shape changing width. */
.plate-wait .bar b::after{content:"";position:absolute;inset-inline-end:-1.5px;top:50%;
  width:4px;height:4px;border-radius:50%;background:var(--white);
  box-shadow:0 0 6px 1px var(--accent-lt);transform:translateY(-50%);opacity:0}
.is-in .plate-wait .bar b::after{animation:barTip 900ms linear}
.is-in .plate-wait .row:nth-child(2) .bar b::after{animation-delay:120ms}
@keyframes barTip{
  0%,82%{opacity:0}
  85%,97%{opacity:1}
  100%{opacity:0}
}
.plate-wait .val{flex:0 0 auto;width:48px;text-align:end;color:var(--ink);unicode-bidi:isolate}
.is-in .plate-wait .bar b{transform:scaleX(var(--w,1))}

/* .plate-hold — injury-guard: 151 finished, 4 marked bad, the plate visibly held */
.plate-hold{gap:8px}
.plate-hold .hold-clamp{position:relative;height:4px;border-radius:2px;flex:0 0 auto;
  background:rgba(22,23,28,.22)}
.plate-hold .hold-clamp::before,.plate-hold .hold-clamp::after{content:"";position:absolute;
  top:50%;width:8px;height:8px;border-radius:50%;background:var(--ink);transform:translateY(-50%)}
.plate-hold .hold-clamp::before{inset-inline-start:-2px}
.plate-hold .hold-clamp::after{inset-inline-end:-2px}
.plate-hold .hold-grid{flex:1 1 auto;display:grid;
  grid-template-columns:repeat(auto-fill,minmax(12px,1fr));gap:4px;align-content:center;min-height:0}
/* cells scale in, not just fade in — MOTION-DEVICES.md device 4, .plate-hold floor item, so each
   cell visibly arrives rather than materialising in place. The four "bad" cells settle a beat
   heavier (an extra 50ms on their own transition-duration, not delay) so they read as landing
   slightly harder among the 151 uniform ones without reading as a separate wave. */
.plate-hold .hold-grid i{aspect-ratio:.72;border-radius:2px;background:var(--accent);opacity:.26;
  transform:scale(.4);
  transition:opacity 480ms var(--ease),background 480ms var(--ease),box-shadow 480ms var(--ease),
             transform 480ms var(--ease)}
.plate-hold .hold-grid i.bad{background:var(--ink);transition-duration:530ms}
.is-in .plate-hold .hold-grid i.on{opacity:.95;transform:scale(1)}
.is-in .plate-hold .hold-grid i.bad{opacity:.85;box-shadow:inset 0 0 0 1.5px var(--white);transform:scale(1)}

/* .plate-run — lead-pipeline: 155 of 155, growing into the forum they are collected from and
   landing in the platform they are collected FOR.
   ⚠ TWO COLUMNS, AND WHICH ONE YOU SEE DEPENDS ON HOW BIG THE CARD IS (2026-08-04, eleventh
   pass). .run-l — the figure, the ruler, the progress line — is what a DECK-scale card shows,
   and it is legible at 0.52 scale because it is three short rows of large type. .run-feed is
   the skeleton forum those leads come out of; it fades and scales in on --card-p, so the plate
   grows from "155 with the line" into a Facebook forum as the card takes the screen.
   Layout never animates: the feed's box is reserved at every progress and only opacity and
   transform move. That is §5's pure-transform contract, and it is also why the two columns
   never reflow mid-scrub.
   ⚠ .run-note IS GONE, not restyled: it carried "daily since 18 Jul 2026", and every date
   string came off this card this pass (the case-study page keeps its dates). The ruler's tick
   styling and runPulse below are untouched — that pulse is the "and it has not stopped" and it
   is a CSS infinite on its own clock, independent of the scrub.

   ┌─ AMENDED 2026-08-04 (TWELFTH PASS): THREE COLUMNS, AND THE SENTENCE ABOVE THAT SAYS "TWO"
   │  IS THE THING BEING CORRECTED. Shirsho: the middle column "reads as abstract skeleton". It
   │  did. A grey circle, a grey name bar and two grey body bars is the universally understood
   │  drawing of CONTENT THAT HAS NOT LOADED YET, not of a forum full of people, so the device
   │  was saying "loading" on a card whose whole argument is "155 collected, none missed".
   │  Three changes and one addition, and the split moves 38/62 -> 24/36/40:
   │    1. .feed-pfp keeps its 22px grey circle and gains a greyed default-profile SILHOUETTE.
   │       On .is-got the circle goes accent and the silhouette goes white, so the existing
   │       collected-state semantics are preserved rather than replaced.
   │    2. .feed-name stops being a 6px bar and becomes a real (placeholder) NAME, still going
   │       accent on .is-got exactly as the bar did.
   │    3. .feed-line is UNTOUCHED and still never turns accent. :1060-1063's reason still
   │       holds: what the pipeline collects is the lead, not the text of the post.
   │    4. NEW .run-crm — the platform the leads land in, as a small table. Without it the plate
   │       showed collection and never showed delivery, which is the half the case study is
   │       actually about.
   │  ⚠ THE CRM ROWS LIGHT ON THE SAME GOT_AT BEATS AS THEIR POSTS (main.js §5, one loop, by
   │  index), so a post lighting and a row appearing are one event rather than two devices on
   │  two clocks. No new observer and no new timer: it stays a pure function of scroll and it
   │  stops where the hold stops.
   │  ⚠ EVERY ROW'S BOX IS RESERVED AT EVERY PROGRESS. The three .crm-rows are in the DOM at all
   │  times at opacity 0; only opacity and a 4px translate move. Layout still never animates,
   │  which is §5's pure-transform contract and the reason the three columns cannot reflow
   │  mid-scrub.
   └─ ⚠ .run-crm JOINS .run-feed IN THE <=1080px RULE, not in a rule of its own. That breakpoint
      is the JS pin gate's other side and the two numbers must stay one number. */
.plate-run{flex-direction:row;align-items:stretch;gap:clamp(12px,1.6vw,24px)}
.plate-run .run-l{flex:1 1 24%;min-width:0;display:flex;flex-direction:column;
  justify-content:center;gap:12px}
.plate-run .run-feed{flex:1 1 36%;min-width:0;display:flex;flex-direction:column;
  justify-content:center;gap:7px;
  opacity:var(--card-p,1);transform:scale(calc(.86 + .14 * var(--card-p,1)));
  transform-origin:center;transition:opacity 180ms linear}
/* the destination. Same --card-p fade-and-scale as the feed, on purpose: the two are one
   picture of the same journey and arriving on different clocks would read as two widgets. */
.plate-run .run-crm{flex:1 1 40%;min-width:0;display:flex;flex-direction:column;
  justify-content:center;gap:7px;
  opacity:var(--card-p,1);transform:scale(calc(.86 + .14 * var(--card-p,1)));
  transform-origin:center;transition:opacity 180ms linear}
.plate-run .crm-cap{font-family:var(--f-data);font-size:10.5px;letter-spacing:.04em;
  color:var(--muted);text-transform:uppercase}
.plate-run .crm-tbl{display:flex;flex-direction:column;gap:4px;padding:8px 10px;
  border-radius:9px;background:var(--white);border:1px solid var(--hair-2)}
/* one grid template on both the head and the rows, so a column edge cannot drift between them */
.plate-run .crm-head,
.plate-run .crm-row{display:grid;grid-template-columns:1.35fr 1fr .95fr;gap:8px;
  align-items:center;font-family:var(--f-data);font-size:10.5px;line-height:1.3}
.plate-run .crm-head{letter-spacing:.04em;color:var(--muted);text-transform:uppercase;
  padding-bottom:5px;border-bottom:1px solid var(--hair-2)}
.plate-run .crm-head span,
.plate-run .crm-row span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* opacity + a 4px lift only. No height, no margin, no display change: the row's box exists at
   every progress, which is what stops the table reflowing under a scrubbing hand. */
.plate-run .crm-row{color:var(--ink);opacity:0;transform:translateY(4px);
  transition:opacity 260ms var(--ease),transform 260ms var(--ease)}
.plate-run .crm-row.is-got{opacity:1;transform:none}
.plate-run .crm-st{color:var(--accent)}
/* the tick rides the status word with a LOGICAL margin rather than an absolute inset, so it
   lands after the word in both trees with no RTL special case to keep in step. */
.plate-run .crm-st::after{content:"\2713";margin-inline-start:4px;font-weight:600}
.plate-run .run-fig{display:flex;align-items:baseline;gap:6px;font-family:var(--f-data);
  unicode-bidi:isolate;
  opacity:0;transform:translateY(6px);transition:opacity 380ms var(--ease),transform 380ms var(--ease)}
.plate-run .run-n{font-size:clamp(26px,3.4vw,34px);font-weight:600;color:var(--accent);
  letter-spacing:-.02em;font-feature-settings:"tnum" 1}
.plate-run .run-of{font-size:13px;color:var(--muted)}
/* the collect counter. --got is written by main.js onto .run-prog as a 0..1 fraction and
   inherits down to the bar; scaleX rather than width keeps it off layout, and the origin is
   var(--org-x) so it fills from the reading edge in both trees. */
.plate-run .run-prog{display:flex;flex-direction:column;gap:6px}
.plate-run .run-prog-l{font-family:var(--f-data);font-size:10.5px;letter-spacing:.04em;
  color:var(--muted)}
.plate-run .run-prog-l b{font-weight:600;color:var(--accent);font-feature-settings:"tnum" 1}
.plate-run .run-prog-bar{display:block;height:4px;border-radius:2px;
  background:rgba(22,23,28,.1);overflow:hidden}
.plate-run .run-prog-bar i{display:block;height:100%;border-radius:2px;background:var(--accent);
  transform:scaleX(var(--got,0));transform-origin:var(--org-x) center;
  transition:transform 200ms var(--ease-out)}
/* the skeleton posts. Grey until collected; on .is-got the avatar and the username line go
   accent and the card picks up a hairline ring, while the two body lines STAY skeleton-grey —
   what the pipeline collects is the lead, not the text of the post, and colouring the whole
   card would say otherwise. */
.plate-run .feed-post{display:grid;grid-template-columns:auto 1fr;gap:5px 9px;align-items:center;
  padding:8px 10px;border-radius:9px;background:var(--white);border:1px solid var(--hair-2);
  transition:border-color 220ms ease,box-shadow 220ms ease}
/* ⚠ THE AVATAR CARRIES A PERSON NOW (2026-08-04, twelfth pass). It was a flat grey disc, which
   with a grey name bar under it is the standard drawing of a skeleton loader. The silhouette is
   the greyed default-profile figure every social platform ships, and it is what makes the column
   read as people rather than as pending content. The circle's own grey and its 22px are
   unchanged, and so is the collected-state semantics: on .is-got the disc goes accent and the
   figure inverts to white, which is the same one-step state change the flat disc had. */
.plate-run .feed-pfp{grid-row:1/4;align-self:start;width:22px;height:22px;border-radius:50%;
  background:rgba(22,23,28,.14);transition:background 220ms var(--ease);
  display:grid;place-items:center;overflow:hidden}
.plate-run .feed-pfp svg{width:15px;height:15px;fill:rgba(22,23,28,.30);stroke:none;
  transition:fill 220ms var(--ease)}
/* ⚠ AND THE NAME IS TEXT, NOT A BAR. Same reason. It goes accent on .is-got exactly as the bar
   did, so nothing about the state machine moved — only what the state is drawn on.
   No letter-spacing declared here on purpose: this is var(--f-data) inside the Arabic tree too,
   and tracking a joined script pulls it apart at the ligature. Anything added here later belongs
   on the [lang="ar"] reset list further down. */
.plate-run .feed-name{font-family:var(--f-data);font-size:10.5px;line-height:1.25;
  color:var(--muted);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
  transition:color 220ms var(--ease)}
.plate-run .feed-line{height:5px;border-radius:3px;background:rgba(22,23,28,.10)}
.plate-run .feed-line:last-child{width:70%}
.plate-run .feed-post.is-got{border-color:rgba(0,106,163,.35);
  box-shadow:0 0 0 1px rgba(0,106,163,.16)}
.plate-run .feed-post.is-got .feed-pfp{background:var(--accent)}
.plate-run .feed-post.is-got .feed-pfp svg{fill:var(--white)}
.plate-run .feed-post.is-got .feed-name{color:var(--accent)}
.plate-run .run-ruler{display:flex;align-items:center;gap:5px;
  opacity:0;transform:translateY(6px);transition:opacity 380ms var(--ease),transform 380ms var(--ease);
  transition-delay:120ms}
.plate-run .run-ruler i{flex:1 1 auto;height:5px;border-radius:3px;background:var(--accent);
  opacity:.3;min-width:6px}
.plate-run .run-ruler i:last-child{position:relative}
.plate-run .run-ruler i:last-child::after{content:"";position:absolute;inset-inline-end:-2px;
  top:50%;width:8px;height:8px;border-radius:50%;background:var(--accent);transform:translateY(-50%);
  animation:runPulse 2.2s var(--ease-out) infinite}
@keyframes runPulse{
  0%,100%{box-shadow:0 0 0 0 rgba(0,106,163,.45)}
  50%    {box-shadow:0 0 0 6px rgba(0,106,163,0)}
}
.is-in .plate-run .run-fig{opacity:1;transform:none}
.is-in .plate-run .run-ruler{opacity:1;transform:none}
.is-in .plate-run .run-ruler i{opacity:.85}

/* .plate-fan — card 3, "see every case study": the deck fans out.
   Three mini faces stacked square on top of each other at rest, fanning into a row as --beat
   runs. Two are solid; the third is a DASHED GHOST, and that is a claim, not a texture — two
   studies are published and the third is the space the next one goes in. A third solid card
   would say there are three.
   Positioned absolutely from the plate's centre so the fan opens symmetrically at any width,
   and multiplied by --flip so it opens toward the reading edge in both trees. left:50% is
   physical on purpose: the mirroring is carried by --flip, and mixing a logical inset with a
   physical translate would flip it twice under RTL and land back where it started. */
.plate-fan{position:relative}
/* the 24% cap was measured against a narrow in-flow tile and left the fan at 88px inside a
   1070px plate — a stamp in the middle of an empty field. Sized against the plate it actually
   sits in now. */
.fan-card{position:absolute;top:50%;left:50%;width:clamp(64px,11%,132px);aspect-ratio:.76;
  border-radius:9px;background:var(--white);border:1px solid var(--hair);
  box-shadow:0 10px 24px -16px rgba(22,23,28,.55);
  display:flex;flex-direction:column;justify-content:flex-end;gap:5px;padding:9px;
  transform:translate(-50%,-50%)
            translateX(calc(var(--fan-x,0px) * var(--beat,1) * var(--flip,1)))
            rotate(calc(var(--fan-r,0deg) * var(--beat,1)))}
.fan-card i{display:block;height:4px;border-radius:2px;background:rgba(22,23,28,.16)}
.fan-card i:first-child{width:82%}
.fan-card i:last-child{width:54%}
.fan-card--1{--fan-x:-96px;--fan-r:-6deg;z-index:3}
.fan-card--1 i:first-child{background:var(--accent);opacity:.5}
.fan-card--2{--fan-x:0px;--fan-r:0deg;z-index:2}
.fan-card--2 i:first-child{background:var(--accent);opacity:.5}
.fan-card--ghost{--fan-x:96px;--fan-r:6deg;z-index:1;background:transparent;
  border-style:dashed;box-shadow:none}
.fan-card--ghost i{background:rgba(22,23,28,.09)}

.tag{font-family:var(--f-data);font-size:10px;letter-spacing:.1em;text-transform:uppercase;
  color:var(--muted);border:1px solid var(--hair);border-radius:var(--r-pill);padding:4px 10px;
  flex:0 0 auto}
.tag-client{color:var(--accent);border-color:rgba(0,106,163,.35)}
.tile-t{font-size:14px;line-height:1.35;font-weight:500}
.tile-bar{display:flex;align-items:center;gap:12px;padding:16px 18px;background:var(--white);
  border-top:1px solid var(--hair-2);margin-top:auto}
.tile-go{margin-inline-start:auto;flex:0 0 auto;width:34px;height:34px;border-radius:50%;
  border:1px solid var(--hair);display:grid;place-items:center;font-style:normal;font-size:14px;
  transition:background 260ms ease,border-color 260ms ease,color 260ms ease,transform 420ms var(--ease)}
.tile:hover .tile-go{background:var(--ink);border-color:var(--ink);color:var(--paper);transform:translateX(calc(var(--flip) * 3px))}
.tile-body{padding:20px 20px 22px;display:flex;flex-direction:column;gap:12px}
.tile-body h3{font-size:19px;line-height:1.24;letter-spacing:-.02em;max-width:24ch}
.tile-body p{font-size:13.8px;line-height:1.6;color:var(--muted);max-width:60ch}
.tile-figs{display:flex;flex-wrap:wrap;gap:8px 20px;margin-top:2px;
  font-family:var(--f-data);font-size:11px;letter-spacing:.04em;color:var(--ink)}
.tile-figs b{font-weight:600;color:var(--accent);unicode-bidi:isolate}

/* ── the reassurance panel (replaces the old caveat box) ─────────────── */
.assure{margin-top:18px;background:var(--tint);border-radius:var(--r);
  padding:clamp(34px,5vw,68px) clamp(24px,5vw,64px);
  display:grid;grid-template-columns:1.1fr .9fr;gap:clamp(26px,4vw,56px);align-items:center}
.assure h2{font-size:clamp(24px,3.2vw,40px);line-height:1.18;letter-spacing:-.028em;max-width:16ch}
.assure h2 .q{color:var(--accent)}
.assure-list{display:grid;gap:14px}
.assure-item{display:flex;gap:13px;align-items:flex-start}
.assure-item .k{flex:0 0 auto;width:22px;height:22px;border-radius:50%;background:var(--white);
  display:grid;place-items:center;margin-top:2px}
.assure-item .k svg{width:12px;height:12px;fill:none;stroke:var(--accent);stroke-width:2.2;
  stroke-linecap:round;stroke-linejoin:round}
.assure-item p{font-size:14.5px;line-height:1.6;color:#3C424B}
.assure-item p b{font-weight:600;color:var(--ink)}

/* ── 5 · booking ─────────────────────────────────────────────────────── */
.book{max-width:var(--max);margin:0 auto;padding:clamp(40px,5vw,80px) var(--gut) clamp(50px,6vw,90px)}
.book-head{display:grid;grid-template-columns:1fr 1fr;gap:clamp(24px,5vw,70px);
  align-items:end;margin-bottom:clamp(24px,3vw,40px)}
.book-head p{font-size:15px;line-height:1.66;color:var(--muted);max-width:44ch}
.book-head p+p{margin-top:12px}
.cal-frame{background:var(--white);border:1px solid var(--hair-2);border-radius:var(--r);
  overflow:hidden;box-shadow:0 1px 2px rgba(22,23,28,.03),0 18px 50px -30px rgba(22,23,28,.28)}
/* Embed height — and the one lever we actually have over Calendly's cookie banner.
   ⚠ THAT BANNER IS NOT OURS AND CANNOT BE RESTYLED. It is Calendly's own consent notice, rendered
   INSIDE their cross-origin iframe, so z-index, offsets and display:none are all unavailable to us
   — the fourth-pass review's "cookie banner z-order" framing assumed it was our layer, and it is
   not. It is pinned to the BOTTOM of the iframe viewport, which leaves exactly one lever: make the
   iframe taller and the banner slides down with it while the calendar stays top-anchored.
   Measured in a real browser with the banner showing (never guessed), as the height needed to clear
   the TIME ZONE selector, which at 700px was fully covered at every width:
       1440 -> 819    1024 -> 999    768 -> 978    430 -> 1024    390 -> 1045
   Hence 840 / 1060, with the breakpoint on our own header wrap. The cost is whitespace inside the
   card for visitors who never see the banner; the alternative is a covered timezone selector on the
   booking conversion, which is the more expensive of the two. Re-measure if Calendly restyles it.
   Height lives here and NOT in the element's inline style any more — Calendly's own snippet ships
   it inline, where it outranks every media query in this file and silently pinned all widths to
   700px. widget.js sizes its iframe to 100% of this box, so the stylesheet is enough. */
.calendly-inline-widget{min-width:320px;height:840px}
.cal-fallback{display:flex;flex-wrap:wrap;gap:10px 24px;align-items:center;
  padding:18px 22px;border-top:1px solid var(--hair-2);font-size:13.5px;color:var(--muted)}
.cal-fallback a{font-family:var(--f-data);font-size:13.5px;color:var(--ink);
  border-bottom:1px solid var(--ink);padding-bottom:1px}
.cal-fallback a:hover{color:var(--accent);border-color:var(--accent)}

/* ── footer ──────────────────────────────────────────────────────────── */
.footer{background:var(--tint);margin-top:clamp(30px,4vw,60px)}
.footer-in{max-width:var(--max);margin:0 auto;padding:clamp(40px,5vw,72px) var(--gut) 26px}
.footer-brand{display:inline-block;margin-bottom:clamp(28px,4vw,52px)}
.footer-brand img{height:34px;width:auto}
.footer-grid{display:grid;grid-template-columns:1.15fr .85fr;gap:clamp(30px,5vw,70px)}
.footer-t{font-size:clamp(34px,5.4vw,66px);line-height:1.04;letter-spacing:-.035em}
.footer-p{margin-top:20px;max-width:42ch;font-size:14.5px;line-height:1.65;color:#454A52}
.footer .btn-paper{margin-top:26px}
.footer-alt{margin-top:30px;font-size:13.5px;color:#4A5058}
.footer-mail{position:relative;display:inline-block;margin-top:6px;font-family:var(--f-data);
  font-size:14px;border-bottom:1px solid var(--ink);padding-bottom:2px;
  transition:color 200ms ease,border-color 200ms ease}
.footer-mail:hover{color:var(--accent);border-color:var(--accent)}
/* MOTION.md §4 — the ambient device, this page's floor-1 "never stops, lives outside any one card"
   requirement: the hero board (device 2) is real and JS-driven but lives entirely inside the hero
   card, so a visitor scrolled past it at second 30 needs something else still alive, and the footer
   is about as far from the hero as this page gets. Pure CSS, infinite, no JS, no observer — it is
   unconditionally alive from load. Sits 2px below the mail link's own static border-bottom rather
   than replacing it, so this reads as a second, quieter device, not a redesign of the link. Needs
   amb-underline added to the <a class="footer-mail"> in index.html/ar/index.html — inert without it,
   nothing regresses either way. transform-origin:var(--org-x) draws from the reading-start edge in
   both trees with no [dir="rtl"] override, same mechanism CONTRACTS §1 already uses everywhere else. */
.footer-mail.amb-underline::after{content:"";position:absolute;inset-inline:0;bottom:-2px;height:1px;
  background:currentColor;transform-origin:var(--org-x) center;
  animation:ambDraw var(--dur-ambient) var(--ease) infinite}
@keyframes ambDraw{
  0%   {transform:scaleX(0)}
  35%  {transform:scaleX(1)}
  65%  {transform:scaleX(1)}
  100% {transform:scaleX(0)}
}
.footer-cols{display:grid;grid-template-columns:repeat(3,1fr);gap:26px}
.fcol h3{font-family:var(--f-text);font-size:14px;font-weight:700;letter-spacing:0;margin-bottom:16px}
.fcol a{display:block;font-size:13.5px;color:#4A5058;padding:4px 0;transition:color 180ms ease}
.fcol a:hover{color:var(--ink)}
.fmuted{font-size:13px;line-height:1.6;color:#5A606A;margin:10px 0}
.fu{display:inline-block;font-size:13.5px;border-bottom:1px solid currentColor}
.footer-bar{display:flex;flex-wrap:wrap;gap:14px;justify-content:space-between;align-items:center;
  margin-top:clamp(36px,5vw,64px);padding-top:20px;border-top:1px solid rgba(22,23,28,.14)}
.footer-bar p{font-family:var(--f-data);font-size:11px;line-height:1.6;color:#4A5058;max-width:76ch}
.totop{font-family:var(--f-data);font-size:11px;letter-spacing:.06em;text-transform:uppercase}
.totop i{font-style:normal;display:inline-block;transition:transform 240ms var(--ease)}
.totop:hover i{transform:translateY(-3px)}

/* ═══════════════════════  CASE STUDY PAGES  ═══════════════════════════ */
.cs{max-width:var(--max);margin:0 auto;padding:clamp(20px,3vw,40px) var(--gut) 0}
.crumb{display:flex;align-items:center;gap:10px;font-family:var(--f-data);font-size:11.5px;
  letter-spacing:.08em;text-transform:uppercase;color:var(--muted);margin-bottom:clamp(24px,4vw,44px)}
.crumb a{border-bottom:1px solid transparent;transition:border-color 200ms ease,color 200ms ease}
.crumb a:hover{color:var(--ink);border-color:var(--ink)}
.crumb span{opacity:.5}

.cs-hero{display:grid;grid-template-columns:1.1fr .9fr;gap:clamp(28px,5vw,70px);align-items:start;
  padding-bottom:clamp(34px,4vw,56px)}
.cs-tags{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:20px}
.cs-title{font-size:clamp(30px,4.6vw,58px);line-height:1.08;letter-spacing:-.035em;max-width:17ch}
.cs-deck{margin-top:24px;font-size:clamp(16px,1.6vw,19px);line-height:1.62;color:var(--muted);
  max-width:52ch}
.cs-deck strong{color:var(--ink);font-weight:500}
.cs-panel{background:var(--white);border:1px solid var(--hair-2);border-radius:var(--r);
  padding:26px 24px;box-shadow:0 18px 50px -34px rgba(22,23,28,.4)}
.cs-panel h2{font-family:var(--f-data);font-size:11px;letter-spacing:.14em;text-transform:uppercase;
  color:var(--muted);font-weight:500;margin-bottom:18px}
.cs-facts{display:grid;gap:16px}
.cs-fact{display:grid;gap:4px;padding-bottom:16px;border-bottom:1px solid var(--hair-2)}
.cs-fact:last-child{padding-bottom:0;border-bottom:0}
.cs-fact b{font-family:var(--f-data);font-weight:600;font-size:clamp(21px,2.4vw,27px);
  line-height:1.1;letter-spacing:-.03em;font-feature-settings:"tnum" 1;unicode-bidi:isolate}
.cs-fact span{font-size:13.2px;line-height:1.5;color:var(--muted)}

.cs-body{max-width:var(--max);margin:0 auto;padding:0 var(--gut) clamp(40px,5vw,72px)}
.cs-sec{display:grid;grid-template-columns:220px 1fr;gap:clamp(20px,4vw,56px);
  padding:clamp(30px,4vw,52px) 0;border-top:1px solid var(--hair)}
.cs-sec > h2{font-family:var(--f-data);font-size:11.5px;letter-spacing:.14em;text-transform:uppercase;
  color:var(--muted);font-weight:500;padding-top:6px}
.cs-prose{max-width:68ch}
.cs-prose p{font-size:16.5px;line-height:1.72}
.cs-prose p+p{margin-top:18px}
.cs-prose p strong{font-weight:600}
.cs-prose h3{font-size:22px;line-height:1.24;letter-spacing:-.025em;margin-top:32px}
.cs-prose h3+p{margin-top:12px}
.cs-prose .pull{margin-top:26px;padding-inline-start:22px;border-inline-start:3px solid var(--accent);
  font-family:var(--f-display);font-size:clamp(19px,2.2vw,25px);line-height:1.32;
  letter-spacing:-.025em}
.cs-list{margin:20px 0 0;padding:0;list-style:none;display:grid;gap:14px}
.cs-list li{display:flex;gap:13px;font-size:15.5px;line-height:1.6}
.cs-list li::before{content:"";flex:0 0 auto;width:7px;height:7px;border-radius:2px;
  background:var(--accent);margin-top:.6em}
.cs-list li b{font-weight:600}

.cs-note{margin-top:26px;background:var(--white);border:1px solid var(--hair-2);
  border-radius:var(--r-sm);padding:20px 22px}
.cs-note h4{margin:0 0 8px;font-family:var(--f-data);font-size:11px;letter-spacing:.12em;
  text-transform:uppercase;color:var(--accent);font-weight:600}
.cs-note p{font-size:14px;line-height:1.62;color:var(--muted)}

.cs-next{background:var(--white);border-top:1px solid var(--hair-2)}
.cs-next-in{max-width:var(--max);margin:0 auto;padding:clamp(40px,5vw,72px) var(--gut);
  display:flex;flex-wrap:wrap;gap:26px;align-items:center;justify-content:space-between}
.cs-next h2{font-size:clamp(24px,3.2vw,40px);line-height:1.14;letter-spacing:-.03em;max-width:18ch}
.cs-more{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-top:8px}
.cs-more a{display:block;background:var(--white);border:1px solid var(--hair-2);
  border-radius:var(--r-sm);padding:18px 20px;transition:transform 320ms var(--ease),box-shadow 320ms ease}
.cs-more a:hover{transform:translateY(-3px);box-shadow:0 20px 44px -30px rgba(22,23,28,.55)}
.cs-more .tag{display:inline-block;margin-bottom:10px}
.cs-more h3{font-size:16px;line-height:1.3;letter-spacing:-.02em}

/* ═══════════════════════  WORK BOARD (/work/)  ══════════════════════════
   JOB 6, wave 8: an unlisted page, built against this exact contract:
     <section class="kb-wrap"><div class="kb">
       <section class="kb-col">
         <div class="kb-col-head"><span class="kb-rail"></span><h2 class="kb-col-t">…</h2>
           <span class="kb-col-n">1</span></div>
         <div class="kb-stack"><a class="kb-card reveal" href="…">
           <span class="kb-tags"><span>…</span></span><h3>…</h3><p>…</p>
           <span class="kb-go">Read the case study <i>&rarr;</i></span></a></div>
       </section>
     </div></section>
   Spec: "kanban styling, not kanban semantics": column rails, stacked cards, tags; no status colour
   coding, no progress affordance, no drag handles. .kb-rail is intentionally the same neutral --hair
   tone on every column rather than a per-column colour, which is the one choice that would have
   smuggled todo/doing/done back in as an accent palette. */
.kb-wrap{max-width:var(--max);margin:0 auto;padding:clamp(40px,5vw,80px) var(--gut) clamp(60px,7vw,110px)}
/* fixed-width columns, not an auto-fit grid: a board scrolls sideways when it outgrows the row
   instead of reflowing, which is the one piece of kanban GEOMETRY (not semantics) this asks for.
   overflow-x lives here, on .kb, and nowhere else; body keeps its own overflow-x:hidden (line 51)
   untouched, so however many columns land (today 2, must hold at 4) or however tall one column gets
   (today 2 cards, must hold at 5), the scrollbar this produces is .kb's own, never the page's. */
.kb{display:flex;gap:20px;align-items:flex-start;overflow-x:auto;padding-bottom:6px;
  scrollbar-width:thin}
.kb-col{flex:0 0 clamp(240px,26vw,300px)}
.kb-col-head{display:flex;align-items:center;gap:10px;margin-bottom:16px}
.kb-rail{flex:0 0 auto;width:3px;height:18px;border-radius:2px;background:var(--hair)}
.kb-col-t{flex:1 1 auto;min-width:0;font-family:var(--f-display);font-size:15px;font-weight:400;
  letter-spacing:-.01em}
.kb-col-n{flex:0 0 auto;font-family:var(--f-data);font-size:11px;color:var(--muted);
  background:var(--paper);border:1px solid var(--hair-2);border-radius:var(--r-pill);padding:2px 8px}
.kb-stack{display:flex;flex-direction:column;gap:14px}
.kb-card{display:flex;flex-direction:column;gap:10px;background:var(--white);
  border:1px solid var(--hair-2);border-radius:var(--r-sm);padding:18px 20px 20px;
  transition:transform 320ms var(--ease-out),box-shadow 320ms ease,border-color 320ms ease}
.kb-card:hover{transform:translateY(-3px);border-color:var(--hair);
  box-shadow:0 20px 44px -30px rgba(22,23,28,.55)}
.kb-tags{display:flex;flex-wrap:wrap;gap:6px}
.kb-tags span{font-family:var(--f-data);font-size:9.5px;letter-spacing:.08em;text-transform:uppercase;
  color:var(--muted);border:1px solid var(--hair);border-radius:var(--r-pill);padding:3px 9px}
.kb-card h3{font-size:16px;line-height:1.32;letter-spacing:-.015em}
.kb-card p{font-size:13px;line-height:1.55;color:var(--muted)}
.kb-go{display:inline-flex;align-items:center;gap:.5em;margin-top:auto;padding-top:4px;
  font-family:var(--f-data);font-size:10.5px;letter-spacing:.05em;text-transform:uppercase;
  color:var(--accent)}
.kb-go i{font-style:normal;display:inline-block;transition:transform 220ms var(--ease)}
.kb-card:hover .kb-go i{transform:translateX(calc(var(--flip) * 3px))}

/* ── reveal ──────────────────────────────────────────────────────────────
   REDUCED-MOTION.md §4 — visible is the DEFAULT; JS opts an element INTO the hidden-then-reveal
   state, never the other way. ~30 blocks on this page carry .reveal. The old rule hid every one of
   them unconditionally with nothing but main.js's own completion to ever unhide them — an uncaught
   throw anywhere in main.js's five earlier sections (Lenis, the hero board, tile plates, the
   accordion) left every .reveal element invisible forever, with no CSS-only recovery. main.js now
   adds reveal-ready to <html> only from inside its own §4 (the reveal section itself), once the
   mechanism that guarantees .is-in eventually fires has actually run — so "JS never ran," "a CDN
   script 404'd," and "JS threw before this point" all resolve to the CSS default below: visible. */
.reveal{opacity:1;transform:none;filter:none}
html.reveal-ready .reveal{opacity:0;transform:translateY(30px);filter:blur(9px);
  transition:opacity var(--dur) var(--ease),transform var(--dur) var(--ease),filter var(--dur) var(--ease)}
.reveal.is-in{opacity:1;transform:none;filter:none}
/* ⚠ SPECIFICITY INVERSION, FOUND AND FIXED BEFORE SHIP — html.reveal-ready .reveal above is
   (0,2,1); .reveal.is-in above is (0,2,0). (0,2,1) beats (0,2,0), so without the line below the
   hidden state wins EVERY TIME reveal-ready is set, regardless of is-in — every .reveal block on the
   page renders permanently invisible the instant main.js's reveal section runs, reproducing the
   exact "does not move at all" symptom this whole session exists to fix, and doing it to a page a
   probe would call correct (is-in genuinely gets added; it just loses the cascade). Verified in a
   real browser both ways: without this line, .hero-art.reveal.is-in computed opacity:0; with it,
   opacity:1. Out-specifies both rules above on purpose. */
html.reveal-ready .reveal.is-in{opacity:1;transform:none;filter:none}

/* .reveal pairs its own opacity/transform/filter transition with several components that already own
   a transition for their OWN interactive state: .tile's hover lift, .stat's hover lift, .btn's
   hover/magnetic-release, .footer-mail's hover colour, and (once the reveal-density fix below lands
   in HTML) .acc-row's open/close background. Equal specificity plus later source order otherwise
   means .reveal's transition-property list wins OUTRIGHT and silently drops the component's own
   properties from the cascade, not merges with them — verified directly: with html.reveal-ready set,
   .tile.reveal:hover's computed transitionProperty was "opacity, transform, filter" with no
   box-shadow or border-color anywhere in it, so those two snapped instead of easing, and the lift's
   own transform ran at var(--dur) (800ms) instead of the documented 460ms. Same failure mode would
   hit the footer's magnetic "Book a call" CTA (.btn's 180ms hover/release) the instant it scrolled
   into view. Each pairing gets its FULL merged list restated here, at higher specificity than either
   rule alone, rather than silently losing whichever half loses the cascade. transform appears once
   per rule, last in the list, so the component's own (shorter, interaction-relevant) duration wins
   it outright — the one-time reveal-entrance transform running at that duration instead of the
   fade's 800ms is a smaller trade than losing the hover feedback for the rest of the page's life. */
html.reveal-ready .tile.reveal{transition:opacity var(--dur) var(--ease),filter var(--dur) var(--ease),
  box-shadow 460ms ease,border-color 460ms ease,transform 460ms var(--ease)}
/* .stat.reveal retired with .stat itself; .roi-item.reveal then took its place and is retired in
   turn (wave 8, with the whole ROI panel). ⚠ NOTHING REPLACES IT HERE, AND THAT IS CORRECT: the
   four commitments that took the panel's slot are .vcard, whose own hover transition is
   `transform 420ms` — and .vcard.reveal is ALREADY covered by the generic merged rule this block
   exists to provide, because .vcard's hover-lift is a transform, and `html.reveal-ready
   .reveal.is-in` sets transform:none at (0,3,1). The specific pairing below handles that. */
html.reveal-ready .vcard.reveal{transition:opacity var(--dur) var(--ease),filter var(--dur) var(--ease),
  box-shadow 300ms ease,border-color 300ms ease,transform 420ms var(--ease)}
html.reveal-ready .btn.reveal{transition:opacity var(--dur) var(--ease),filter var(--dur) var(--ease),
  transform 180ms var(--ease),background 180ms ease,color 180ms ease,border-color 180ms ease}
html.reveal-ready .footer-mail.reveal{transition:opacity var(--dur) var(--ease),filter var(--dur) var(--ease),
  transform var(--dur) var(--ease),color 200ms ease,border-color 200ms ease}
html.reveal-ready .acc-row.reveal{transition:opacity var(--dur) var(--ease),filter var(--dur) var(--ease),
  transform var(--dur) var(--ease),background-color 380ms var(--ease)}
/* .kb-card.reveal: JOB 6, the /work/ board page. Same pairing again: .kb-card carries the site's
   usual hover-lift (transform/box-shadow/border-color, 320ms) and the brief explicitly asks that
   nothing here fight the reveal mechanism, so it gets the merged list up front rather than being
   discovered broken later. */
html.reveal-ready .kb-card.reveal{transition:opacity var(--dur) var(--ease),filter var(--dur) var(--ease),
  box-shadow 320ms ease,border-color 320ms ease,transform 320ms var(--ease-out)}
/* the SAME inversion the .is-in fix above needed, one level down: html.reveal-ready .reveal.is-in
   (0,3,1) sets transform:none, which now outranks .tile:hover/.stat:hover's own transform (0,2,0) —
   the hover-lift would compute correctly on paper and then never actually move, the box-shadow/
   border-color transition fix above running against a transform stuck at none. Restated here at
   higher specificity than the is-in fix, so a hover on an already-revealed card still lifts it. */
html.reveal-ready .tile.reveal.is-in:hover{transform:translateY(-5px)}
html.reveal-ready .vcard.reveal.is-in:hover{transform:translateY(-3px)}
html.reveal-ready .kb-card.reveal.is-in:hover{transform:translateY(-3px)}

/* reveal-density fix — MOTION.md §7. #services previously arrived as two coarse blocks: .sec-head,
   then the whole .acc card in one step, so its four accordion rows had no reveal of their own at
   all. Needs .acc-row reveal added in index.html (one class per row, no data-delay needed) — inert,
   with no visual change from today, until that lands. The 60ms/row step is DESIGN.md's own stagger
   device and is deliberately finer than this file's usual data-delay*90ms step elsewhere on the
   page, so it's built here as a flat CSS transition-delay rather than reusing the generic per-element
   JS timeout — the row doesn't need to wait for a setTimeout to know its own position in the list. */
/* ⚠ THE THIRD SELECTOR IS n+3, NOT AN ENUMERATION, AND THAT IS A BUG FIX (2026-08-04, twelfth
   pass). It read `:nth-of-type(3), :nth-of-type(4)`, which was exhaustive when the accordion had
   four rows and silently stopped being exhaustive the moment it had seven: rows 5, 6 and 7 matched
   nothing, fell back to transition-delay:0ms, and therefore revealed BEFORE rows 3 and 4 — a
   stagger running 5,6,7,1,2,3,4 rather than 1..7. Nothing in the DOM is wrong in that state and no
   probe on this tree can see it; it is only visible in the recording, which is where it was
   caught. An enumeration of positions is a hardcoded row count wearing a selector's clothes. */
html.reveal-ready .acc-row.reveal:nth-of-type(1){transition-delay:0ms}
html.reveal-ready .acc-row.reveal:nth-of-type(2){transition-delay:60ms}
html.reveal-ready .acc-row.reveal:nth-of-type(n+3){transition-delay:120ms}

/* the static mask a line-split reveal rises out of — MOTION.md §3.1/§6. SplitType only splits text
   into line elements; it supplies no mask of its own, so without this a line-rise reads as the text
   sliding on top of the paragraph below it rather than resolving cleanly into place. .ln-split itself
   carries no rule — it is the bare element GSAP tweens y/filter/opacity on. */
.ln-mask{overflow:hidden;display:block}

/* ═══════════════════════  ARABIC TYPOGRAPHY  ═══════════════════════════
   Spec: CONTRACTS.md §5, amended 2026-08-03 wave 2. The rule is "every
   negative letter-spacing and every positively-tracked micro-type selector
   resets to normal" — implemented here from B1's own swept lists
   (RTL-CONVERSION.md §5), not from the six line numbers the section
   originally (and wrongly) named as if they were exhaustive. */
[lang="ar"]{
  --f-display:'IBM Plex Sans Arabic',system-ui,sans-serif;
  --f-text:'IBM Plex Sans Arabic',system-ui,sans-serif;
  --f-data:'IBM Plex Sans Arabic',ui-monospace,monospace;
}
/* every negative letter-spacing in the file — a joined script breaks under negative tracking */
[lang="ar"] h1,[lang="ar"] h2,[lang="ar"] h3,
[lang="ar"] .sec-title.big,
[lang="ar"] .hero-title,
[lang="ar"] .acc-title,
[lang="ar"] .tile-body h3,
[lang="ar"] .assure h2,
[lang="ar"] .footer-t,
[lang="ar"] .cs-title,
[lang="ar"] .cs-fact b,
[lang="ar"] .cs-prose h3,
[lang="ar"] .cs-prose .pull,
[lang="ar"] .cs-next h2,
[lang="ar"] .cs-more h3,
[lang="ar"] .rm-b h3,
[lang="ar"] .acc-fig b,
[lang="ar"] .plate-run .run-n{
  letter-spacing:normal;
}
/* every positively-tracked micro-type selector — tracking a joined script is the same defect
   in the other direction. .footer-bar p is deliberately absent: it declares no letter-spacing at
   all (its actual Arabic defect is the digits embedded in its prose, fixed with <bdi> at the
   content layer, not here). */
[lang="ar"] .eyebrow,
[lang="ar"] .btn,
[lang="ar"] .link-u,
[lang="ar"] .strip-h,
[lang="ar"] .acc-tags,
[lang="ar"] .roi-label,
[lang="ar"] .rm-head span,
[lang="ar"] .vcard b,
[lang="ar"] .tile-cap,
[lang="ar"] .tag,
[lang="ar"] .totop,
[lang="ar"] .crumb,
[lang="ar"] .cs-panel h2,
[lang="ar"] .cs-sec > h2,
[lang="ar"] .cs-note h4,
[lang="ar"] .board-live,
[lang="ar"] .board-count,
[lang="ar"] .board-now,
[lang="ar"] .board-sub,
[lang="ar"] .row-min,
/* ⚠ .plate-run .run-note is REPLACED here, not merely dropped (2026-08-04, eleventh pass): that
   element left the markup with the dates, and the two Arabic strings that took its place carry the
   same defect it was listed for — letter-spacing on a joined script pulls the letters apart at the
   ligature. Any new Arabic string set in var(--f-data) belongs on this list. */
/* ⚠ .crm-cap and .crm-head added 2026-08-04 (twelfth pass) under the rule the note above states:
   any new Arabic string set in var(--f-data) with positive tracking belongs on this list. Both
   declare letter-spacing:.04em. .feed-name and .crm-row are DELIBERATELY ABSENT — they declare
   no tracking at all, so there is nothing here to reset, and listing them would imply otherwise. */
[lang="ar"] .plate-run .run-prog-l,
[lang="ar"] .plate-run .crm-cap,
[lang="ar"] .plate-run .crm-head,
[lang="ar"] .plate-grow .grow-axis{
  letter-spacing:normal;
}
/* Arabic ascenders collide with this site's tight 1.06–1.14 line-heights — +0.12 on the six
   selectors verified against source (RTL-CONVERSION.md §5); this list is exhaustive. */
[lang="ar"] .sec-title{line-height:1.26}
[lang="ar"] .sec-title.big{line-height:1.18}
[lang="ar"] .hero-title{line-height:1.26}
[lang="ar"] .footer-t{line-height:1.16}
[lang="ar"] .cs-title{line-height:1.20}
[lang="ar"] .assure h2{line-height:1.30}

/* digits — western numerals stay in both trees (the proof asset, and Gulf business writing uses
   them); isolate every numeric run so it doesn't get reordered by surrounding RTL text. Unconditional
   (not lang-scoped) since it's harmless in the EN tree and load-bearing in the AR one. .row-min,
   .plate-wait .val and .plate-run .run-fig already carry this in their own component rules above. */
/* .roi-n dropped from this list wave 8 along with the rule itself: the ROI panel it belonged to
   is gone and no element on any page carries the class. Nothing in the four .vcard commitments
   that replaced it renders a numeral, so there is nothing here to isolate. */
.cs-fact b,.tile-figs b,.acc-idx{unicode-bidi:isolate}

/* ── responsive ──────────────────────────────────────────────────────── */
@media (max-width:1080px){
  /* ⚠ THIS BREAKPOINT IS THE SCENE GATE'S OTHER SIDE, EXACTLY. main.js §5/§6 pin only at
     min-width:1081px, so everything below here is the documented in-flow fallback, and the two
     numbers must stay the same number — one rule about when this page pins.
     .plate-run's two columns exist because a card that takes the whole screen has room to grow a
     forum into. In flow it does not: measured at 390 wide the three feed posts overflowed
     .tile-art's 158px strip and the third was clipped mid-post, while "leads collected 3/3"
     wrapped to two lines in a 118px column. The feed is dropped rather than shrunk — a
     three-post forum rendered 40px tall is the "present, correct and unreadable" failure this
     tree keeps paying for, and .run-l (the 155, the ruler, the collected line) is the half that
     carries the claim. .is-got still lights all three posts underneath; nothing about the state
     changes, only whether this width is asked to show it.
     ⚠ .run-crm JOINS THE FEED HERE (2026-08-04, twelfth pass), for the same measured reason and
     at the same breakpoint, not a new one. A three-row table with a header rendered inside a
     118px column at 390 wide is the same "present, correct and unreadable" failure the feed was
     dropped for, and the two belong to one picture — showing the destination with the source
     hidden would be worse than showing neither. .run-l is still the half that carries the claim,
     and .is-got still lights every post and every row underneath. */
  .plate-run{flex-direction:column}
  .plate-run .run-l{flex:1 1 auto}
  .plate-run .run-feed{display:none}
  .plate-run .run-crm{display:none}
  .header-in{flex-wrap:wrap;row-gap:10px}
  /* the header is 135px tall from here down — .header-in wraps and .nav takes a row of its own —
     so the anchor clearance has to grow with it or every nav click lands the section title behind
     the nav. Measured, not assumed. */
  [id]{scroll-margin-top:152px}
  .calendly-inline-widget{height:1060px}   /* see the measurement table on the base rule */
  .nav{order:3;width:100%;overflow-x:auto;justify-content:flex-start;
    -ms-overflow-style:none;scrollbar-width:none}
  .nav::-webkit-scrollbar{display:none}
  .pill{white-space:nowrap;flex:0 0 auto}
  .hero-grid{grid-template-columns:1fr;gap:18px}
  .hero-art{min-height:330px;order:2}
  .hero-strip{grid-template-columns:1fr 1fr}
  .strip-cta{border-inline-end:0;border-bottom:1px solid var(--hair-2);padding:22px}
  .strip-box:nth-child(2){border-bottom:1px solid var(--hair-2)}
  .strip-box:nth-child(3){border-inline-end:0}
  .about-grid{grid-template-columns:1fr}
  /* ⚠ THE TWO OVERRIDES THAT USED TO LIVE HERE ARE SWEPT (wave 8), because the base rules they
     were overriding are gone: `.about-art{min-height:0}` cancelled a 1060px manufactured sticky
     travel budget, and `.roadmap{position:static}` cancelled the sticky. Neither base rule
     exists any more, so both overrides were cancelling nothing — the kind of dead CSS that reads
     as intent and misleads the next reader into thinking a mechanism is still in play.
     The margin-top survives on its own merit: in one column the roadmap follows the four
     commitment cards and needs the separation the grid gap used to give it.
     ⚠ AND THE PIN DOES NOT COME DOWN HERE EITHER. main.js §6 registers its ScrollTrigger only
     above 1081px (gsap.matchMedia where available, so a resize across this boundary re-registers
     rather than stranding a pin-spacer). Below it, .about-art never gets .is-scrubbed, every
     scrub rule stays unmatched behind .about-art.is-in.is-scrubbed, and the section renders the
     honest one-shot draw. Trapping a phone's scroll for five viewports to play a device that is
     not laid out for it would be worse than not playing it. */
  .about-art{margin-top:26px}
  .work-head{grid-template-columns:1fr;align-items:start}
  /* ⚠ THE PINNED-SCENE OVERRIDE THAT LIVED HERE IS SWEPT (wave 8): the scene does not run at
     this breakpoint any more. main.js §5 registers its ScrollTrigger only above 1081px, because
     with the head now inside the pin, head + card measured 460px of content against 359px of
     space at 1024x800. .is-scene is therefore never added below 1080 and this selector could
     never have matched. Three cards render in flow at full opacity instead, which is the same
     fallback the no-GSAP path has always used. */
  /* /work/: columns stay side by side down to this breakpoint (JOB 6's "columns side by side at
     desktop" reading extends through tablet), just narrower; 720px below switches to a swipeable
     near-full-width column instead. */
  .kb{gap:16px}
  .kb-col{flex-basis:clamp(220px,34vw,280px)}
  .assure{grid-template-columns:1fr}
  .book-head{grid-template-columns:1fr}
  .footer-grid{grid-template-columns:1fr}
  .cs-hero{grid-template-columns:1fr}
  .cs-sec{grid-template-columns:1fr;gap:14px}
  .cs-sec > h2{padding-top:0}
}
@media (max-width:720px){
  .header-in{padding:12px 18px}
  [id]{scroll-margin-top:144px}   /* header is 127px here — smaller logo, tighter padding */
  /* Phone is where the banner is tallest (279px at 390 against 163 at 1440), so 1060 cleared the
     timezone selector by only 16px. 1100 makes it 56 — margin for a longer banner string in
     another locale rather than a number that is only just true today. */
  .calendly-inline-widget{height:1100px}
  .brand img{height:30px}

  /* ⚠ THE NAV MUST FIT, NOT JUST SCROLL. overflow-x:auto above is the safety net and it is staying
     — Arabic labels and any future fifth item need it — but a net is not a layout. At the real
     phone widths the four pills measured 383px against a 352px rail at 390 (and a 322px rail at
     360), so the last item rendered as a clipped "W" with the scrollbar hidden: scrollable in fact,
     unreachable in appearance, which is the same thing to a visitor who never thinks to swipe a
     nav. Tightening the pill's own padding, gap and dot spacing buys back ~60px and lands all four
     inside the rail down to 360. Verified at 360/390/414/430 in both trees. */
  .nav{gap:2px;padding:3px}
  .pill{padding:8px 10px;gap:5px;font-size:13px}
  .footer-brand img{height:28px}
  .hero-strip{grid-template-columns:1fr}
  .strip-box{border-inline-end:0;border-bottom:1px solid var(--hair-2)}
  .strip-box:last-child{border-bottom:0}
  .acc-hit{grid-template-columns:44px 1fr;gap:14px;padding:22px 20px}
  /* top padding restated at its own clamp floor rather than left at the base rule's clamp(18px,2vw,26px):
     2vw of a phone-width viewport undershoots the floor anyway, so this is just making that explicit. */
  .acc-panel-in{padding-block:18px 26px;padding-inline:78px 20px}
  .acc-tags span+span::before{margin:0 8px}
  .about-head{flex-direction:column;align-items:flex-start}
  .footer-cols{grid-template-columns:1fr 1fr}
  .assure{padding:34px 22px}
  .cs-more{grid-template-columns:1fr}
  /* the pinned-scene override that lived here is swept for the same reason as the one at the
     1080 breakpoint above: measured 535px of card content against 471px of space at 390x844,
     so the scene is gated off below 1081px and .is-scene never lands here. */

  /* /work/: a sensible scrollable arrangement at phone width, one column at (almost) full
     viewport width per swipe rather than several narrow slivers, with scroll-snap so a swipe lands
     on a column boundary instead of stopping mid-card. Still horizontal, still .kb's own scrollbar,
     never the page's: the same overflow-x:auto from the base rule, just a wider single-column step. */
  .kb{gap:14px;scroll-snap-type:x proximity}
  .kb-col{flex:0 0 82vw;scroll-snap-align:start}

  /* the work board at mobile — all 8 rows stay; .row-job shrinks and truncates rather than
     wrapping, which would change the row count the run walks through (HERO-DEVICE.md §8) */
  .board{padding-inline:16px;padding-block:16px}
  .board-title{font-size:14px}
  .board-sub{font-size:10px}
  .row-job{font-size:11.5px}
  .board-foot{font-size:10px}
  .board-now{font-size:10.5px}
  .board-count{font-size:9.5px}
  .hero-art{min-height:436px}

  /* the accordion cards go one per row rather than squeezing three into a 280px column */
  .acc-vis{grid-template-columns:1fr;gap:10px}

  /* .plate-hold at the narrowest widths: smaller cells (TILE-PLATES.md §6), plus the tightening
     this file's own tile-cap/.plate restructure requires to clear the budget — see
     CSS-DEFECTS.md for the arithmetic and the margin it clears by. */
  .tile-art{gap:4px}
  .plate-hold{gap:4px}
  .plate-hold .hold-clamp{height:3px}
  .plate-hold .hold-grid{grid-template-columns:repeat(auto-fill,minmax(8px,1fr));gap:3px}
}

/* ── the nav at real phone widths ─────────────────────────────────────────
   ⚠ THE NAV MUST FIT, NOT MERELY SCROLL. This is the fifth pass's rule, and wave 8 broke it
   again from a direction nothing was watching: RENAMING THE LABELS. `Work` became `Case studies`
   and `Why us` became `Why Tilasmi`, which is +48px of text across two pills, and the rail did
   not grow. Measured after the rename, before this rule: content 367px against a 322px rail at
   360 and a 352px rail at 390 — "Case studies" rendered with its last letters cut off, with the
   scrollbar hidden. Scrollable in fact, unreachable in appearance, which is the same thing to a
   visitor who never thinks to swipe a nav.

   ⚠⚠ AND NO PROBE ON THIS TREE WOULD HAVE CAUGHT IT. `.nav` is `overflow-x:auto`, so the
   clipping is contained inside the nav's own scroll box: the page's `scrollWidth` is unchanged,
   RTL_OVERFLOW passes, every DOM assertion passes. It was found by opening the phone screenshot
   and looking at it — the same way the fifth pass found the 7px work board. The ladder holds:
   probes < stills < recordings.

   The label is not the thing to change; it is a locked decision. The layout adapts. 12.5px text,
   tighter padding, and the 5px indicator dot hidden — the current pill is marked by a filled
   background instead, which is a STRONGER indicator at this size than a 5px dot, not a weaker
   one. Buys back ~57px: content lands at ~310px against the 322px rail at 360, so it fits at
   every width the fifth pass verified (360/390/414/430) with real margin rather than by 1px. */
@media (max-width:430px){
  .pill{padding:7px 9px;gap:0;font-size:12.5px}
  .pill .dot{display:none}
  .pill.is-current{background:var(--paper)}
}
