/* ============ Fonts (milkman.com.au Adobe Fonts kit, extracted) ============ */
@font-face{font-family:ballinger-mono;src:url("assets/fonts/ballinger-mono.woff2") format("woff2");font-display:swap;font-style:normal;font-weight:400;font-stretch:100%}
@font-face{font-family:ballinger-x-condensed;src:url("assets/fonts/ballinger-x-condensed.woff2") format("woff2");font-display:swap;font-style:normal;font-weight:800;font-stretch:100%}

/* ============ Tokens ============ */
:root {
  --paper: #E9E7E3;
  /* The hero's own paper, not the page's. It is only ever seen where the
     film ends: the strip the beats 2/3 lift exposes below the film, and
     the film's last-8% feather into it. Matched to the footage's own floor
     at its bottom rows either side of the table's shadow - 217-228 neutral
     grey, sampled directly from the frames (scratch px.mjs), NOT the page
     paper (#E9E7E3, warm, 233/231/227). The old warm paper under a neutral
     floor is what made the seam a visible band even where the alpha
     matched. Keep this neutral. */
  --hero-paper: #DCDDDE;
  --ink-on-paper: #1A1A1A;
  --black: #0F0F0F;
  --line: #2A2A2A;
  --white: #F5F4F2;
  --dim: #9A9A9A;
  --red: #F1393A;
  --charcoal: #3D3B36;
  /* Module colours. These drive the cards' glass tint AND, via that tint,
     the effective background white back-face text sits on - so contrast
     is not actually exempt just because nothing here is body text. Two of
     the four (below) needed a dedicated darker variant for exactly that
     reason; these four stay at full saturation for the other things they
     drive (front-face hover/focus colour on white, the ring accents). */
  --m-mirror: #FF3D2E;
  --m-room: #FF9500;
  --m-field: #1FD93C;
  --m-future: #4A66FF;
  /* --m-room and --m-field composited at the back glass's 68% alpha over
     the black stage land at 4.24:1 and 3.77:1 for the white .ocard-copy
     text sitting on them (12px at most, so the full 4.5:1 applies, not
     the 3:1 large-text minimum) - computed via the WCAG relative-luminance
     formula, not eyeballed. --m-mirror and --m-future clear 4.5:1 the same
     way already (6.35:1, 7.54:1) and are untouched.
     Same fix as --m-mirror-hover/--m-future-hover elsewhere on the page:
     a same-hue variant just dark enough to clear the threshold (4% and
     10% darker respectively - the minimum that works, found by search, not
     picked round), used ONLY for the back glass background. --m-room and
     --m-field keep driving everything else (front-face hover/focus text,
     ring accents) unchanged - this is scoped to .ocard-back specifically
     below, not a global swap. */
  --m-room-copy-safe: #F58F00;
  --m-field-copy-safe: #1CC336;

  --display: 'ballinger-x-condensed', 'Helvetica Neue Condensed', 'Arial Narrow', Arial, sans-serif;
  /* Body copy is plain Helvetica, not Ballinger: only the display
     headings carry the brand face. Note Helvetica has a real 500 and 600,
     unlike Ballinger's 400/700-only web kit, so intermediate weights
     actually render here instead of snapping to bold. */
  --body: 'Helvetica Neue', Helvetica, Arial, sans-serif;
  --mono: 'ballinger-mono', 'SF Mono', Menlo, monospace;

  --gutter: clamp(20px, 4vw, 56px);
  --maxw: 1280px;

  --radius: 8px;
  --radius-lg: 14px;

  /* ---------- Motion tokens ---------- */
  /* For new, general-purpose transitions and the simple/arbitrary ones
     already on the page (a handful of near-duplicate "color .2s ease" /
     ".22s ease" micro-swaps that varied for no documented reason).
     NOT applied to anything with its own derived timing: the beat
     crossfade's .28s (tied to a visibility delay - see .beat), the orbit
     flip's .3s cubic-bezier(.4,.1,.4,1) (timed to one half of the turn -
     see .ocard-inner), the letter-reveal's cubic-bezier(.22,1,.36,1) (see
     typeOutHeadline), or the hero's scroll-driven mask/lift (a function of
     scroll progress, not a CSS transition at all). Overwriting any of
     those with a token value would be replacing a measured number with an
     arbitrary one - each stays a literal, commented at its own rule. */
  --ease-out: cubic-bezier(.16, 1, .3, 1);
  --dur-fast: 150ms;
  --dur-base: 200ms;
  --dur-slow: 500ms;

  /* ---------- Section rhythm ---------- */
  /* #clients, #outcomes and #work already used this exact clamp() three
     times over; #who and #diagnostic already shared this larger one.
     Named here so that agreement is enforced rather than coincidental -
     a section using one of these families reads as a numeric edit to the
     token, not a fresh guess. #why, #modules and #inside keep their own
     bespoke values (asymmetric on purpose, tuned against their
     neighbours) and are left as literals. */
  --section-pad: clamp(56px, 9vw, 120px);
  --section-pad-lg: clamp(64px, 10vw, 140px);
}

/* The page has two worlds. It starts in the video's paper world and switches
   once, when the folder closes, to the dark session world. */
body[data-theme="paper"] { --bg: var(--paper); --fg: var(--ink-on-paper); --fg-dim: #55524D; --rule: rgba(26,26,26,.16); color-scheme: light; }
body[data-theme="dark"]  { --bg: var(--black); --fg: var(--white); --fg-dim: var(--dim); --rule: var(--line); color-scheme: dark; }

/* ============ Base ============ */
* { margin: 0; padding: 0; box-sizing: border-box; }
h1, h2, h3, .display { text-wrap: balance; }
p, li { text-wrap: pretty; }
/* No `scroll-behavior: smooth` on html, deliberately. ScrollTrigger's refresh
   (which runs on the window load event, on resize, and once from our own
   fonts.ready hook) measures every trigger at scroll position 0: it writes
   scroll-behavior:auto inline and calls scrollTo(0) in the same breath.
   Chrome does not apply a just-changed scroll-behavior to that immediate
   scrollTo - measured live, the "jump" ran as a smooth animation (998,
   990, 974... px over the next frames) - so every trigger got measured at
   the un-scrolled position and came out offset by exactly -scrollY. The
   hero's pin then started 1000px+ above the page, unpinned early, and the
   visitor scrolled through a black spacer in both directions ("black and
   empty when I scroll back up"). It only ever bit when a refresh landed
   while the page was already scrolled - here, the load-event refresh,
   which the 168 frame downloads hold back for several seconds on a real
   connection, i.e. exactly when a visitor has started scrolling. In-page
   links get their smooth scroll from the click handler near
   scrollRestoration instead: an explicit behavior:'smooth' is unaffected. */
body {
  font-family: var(--body);
  background: var(--bg);
  color: var(--fg);
  transition: background-color .6s ease, color .6s ease;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  overflow-x: hidden;
}
img, video { max-width: 100%; display: block; }
a { color: inherit; }
::selection { background: var(--red); color: var(--white); }
:focus-visible { outline: 2px solid var(--red); outline-offset: 3px; }

h1, h2, .display {
  font-family: var(--display);
  font-weight: 800;
  text-transform: uppercase;
  letter-spacing: -.015em;
  line-height: .92;
}
h2 { font-size: clamp(2.2rem, 5.5vw, 4rem); }

.wrap { max-width: var(--maxw); margin: 0 auto; padding-left: var(--gutter); padding-right: var(--gutter); }
.eyebrow {
  font-family: var(--mono);
  font-size: 12px;
  letter-spacing: .14em;
  text-transform: uppercase;
  color: var(--fg-dim);
}
/* Inventory sections (who we've worked with, not what the page argues):
   set as small mono labels so the diagnostic and booking headings are
   the only large type below the fold, and carry real rhythm. */
#clients h2,
#work h2,
#inside h2 {
  font-family: var(--mono);
  font-size: 12px;
  font-weight: 400;
  line-height: normal;
  letter-spacing: .14em;
  text-transform: uppercase;
  color: var(--fg-dim);
}
.visually-hidden {
  position: absolute; width: 1px; height: 1px; margin: -1px;
  clip-path: inset(50%); overflow: hidden; white-space: nowrap;
}
/* position: fixed, not absolute: the hero is pinned for ~2300px of
   scroll, so the page is almost never at scrollY 0 while the hero is on
   screen - an absolutely positioned link at top:8px would sit above the
   viewport for anyone who has scrolled at all, focused but invisible. */
.skip-link {
  position: fixed; top: 8px; left: 8px; z-index: 3000;
  padding: 10px 14px; border-radius: var(--radius);
  background: var(--red); color: #fff;
  font-family: var(--body); font-size: 14px; text-decoration: none;
  transform: translateY(-300%);
}
.skip-link:focus { transform: none; outline: 2px solid #fff; outline-offset: 2px; }
#page:focus { outline: none; }

/* Scroll reveal: applied via JS only (never baked into the static
   markup), so a no-JS or IntersectionObserver-less browser just sees the
   element in its normal, already-visible state instead of stuck hidden.
   :not(.is-visible) outranks a plain single-class rule like
   .outcomes-item's own opacity/transform reset regardless of source
   order, so this works whether it's the only class on the element or
   layered over one that already sets those properties. */
.reveal {
  transition: opacity .7s cubic-bezier(.16, .84, .44, 1), transform .7s cubic-bezier(.16, .84, .44, 1);
  transition-delay: var(--reveal-delay, 0s);
}
.reveal:not(.is-visible) {
  opacity: 0;
  transform: translateY(28px);
}
/* Opacity-only variant for elements whose transform is already spoken
   for - the orbit heading's translate(-50%,-50%) centering, and .ocard-
   undo's own --parallax-y translate - where a translateY from .reveal
   would replace that transform outright instead of composing with it. */
.reveal-fade {
  transition: opacity .6s ease;
  transition-delay: var(--reveal-delay, 0s);
}
.reveal-fade:not(.is-visible) { opacity: 0; }

/* ============ Buttons ============ */
.btn {
  display: inline-block;
  font-family: var(--body);
  font-weight: 400;
  font-size: 14px;
  text-transform: uppercase;
  letter-spacing: .04em;
  text-decoration: none;
  border: 0;
  border-radius: 4px;
  cursor: pointer;
  padding: 14px 26px;
  white-space: nowrap;
  transition: transform var(--dur-fast) ease, background-color var(--dur-base) ease, color var(--dur-base) ease;
}
.btn:active { transform: translateY(1px) scale(.985); }
.btn-primary {
  background: var(--charcoal);
  color: var(--white);
  border: 1px solid rgba(255, 255, 255, .12);
}
.btn-primary:hover { background: var(--red); border-color: transparent; }
.btn-quiet {
  background: none;
  color: var(--fg);
  padding: 14px 4px;
  text-decoration: underline;
  text-underline-offset: 5px;
  text-decoration-thickness: 1px;
}
.btn-quiet:hover { text-decoration-color: var(--red); }

/* ============ Glass (two surfaces on the whole page) ============ */
/* The nav chrome and the hero's CTA cluster. It was three until the booking
   form panel was unfrosted in 02ed046 - it is a solid panel now, because
   frosting it on top of an already-blurred backdrop photo read as haze.
   .ocard-glass is a separate recipe and does not use this class. */
.glass {
  position: relative;
  isolation: isolate;
  overflow: hidden;
  background: rgba(255, 255, 255, .08);
  -webkit-backdrop-filter: blur(28px) saturate(180%) contrast(1.05);
  backdrop-filter: blur(28px) saturate(180%) contrast(1.05);
  border: 1px solid rgba(255, 255, 255, .16);
  border-radius: var(--radius-lg);
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, .22),
    inset 0 -1px 0 rgba(255, 255, 255, .05),
    0 18px 44px rgba(0, 0, 0, .28);
}
/* A soft highlight catching the top-left edge, the thing that reads as
   "glass" rather than just "blurred panel". Sits behind the content, in
   front of the blur. */
.glass::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  background:
    radial-gradient(circle at 18% 0%, rgba(255, 255, 255, .30), transparent 38%),
    linear-gradient(115deg, rgba(255, 255, 255, .14), transparent 46%);
  pointer-events: none;
}
body[data-theme="paper"] .glass {
  background: rgba(233, 231, 227, .5);
  border-color: rgba(26, 26, 26, .1);
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, .6),
    inset 0 -1px 0 rgba(26, 26, 26, .04),
    0 18px 44px rgba(0, 0, 0, .1);
}
body[data-theme="paper"] .glass::before {
  background:
    radial-gradient(circle at 18% 0%, rgba(255, 255, 255, .5), transparent 38%),
    linear-gradient(115deg, rgba(255, 255, 255, .3), transparent 46%);
}
@media (prefers-reduced-transparency: reduce) {
  .glass { -webkit-backdrop-filter: none; backdrop-filter: none; background: var(--bg); }
  .glass::before { display: none; }
}

/* ============ Chrome bar ============ */
.chrome {
  position: fixed;
  top: max(4px, env(safe-area-inset-top));
  left: max(7px, env(safe-area-inset-left));
  right: max(7px, env(safe-area-inset-right));
  /* Above the orbit's depth-sort range (cards run ~570-1430, heading
     1000). The old stage perspective used to contain those z-indices
     in their own stacking context; the 2D-emulated orbit has no such
     container, deliberately - adding one via isolation/opacity/filter
     on the stage risks forming a backdrop root and re-killing the
     cards' frost - so the nav simply outranks them instead. */
  z-index: 2000;
  display: flex;
  align-items: center;
  justify-content: space-between;
  /* 50px / 15px logo, up from 42 / 12: the client asked for the bar and
     the wordmark to read a touch bigger. Still clears the hero's beat-1
     zone (top: 72px) with room to spare. */
  height: 50px;
  padding: 0 12px;
}
/* The nav bar reads as glass over moving video/photography behind it, so
   it gets a lighter fill than the other two glass surfaces (which sit
   over a single flat colour) and leans more on blur than on tint. */
/* 16px, not 28/20: this is the one glass surface that is position:fixed
   and repaints on every scroll frame (a documented iOS Safari cost for
   fixed + backdrop-filter), and 16px is the point past which a heavier
   blur reads as no more "glass" while still costing more GPU time every
   frame it is on screen. */
.chrome.glass {
  background: rgba(255, 255, 255, .04);
  -webkit-backdrop-filter: blur(16px) saturate(180%);
  backdrop-filter: blur(16px) saturate(180%);
}
body[data-theme="paper"] .chrome.glass {
  background: rgba(233, 231, 227, .22);
}
/* body[data-theme] only ever flips once, for the hero exit, and stays
   "dark" for the rest of the page - correct while everything below the
   hero was dark, but #outcomes onward is light now (see D2), so the
   chrome needs a second, independent light/dark signal. .chrome-on-light
   is toggled by an IntersectionObserver on #outcomes, not by the theme
   flip, and mirrors the same values as the paper-theme rules above. */
.chrome.chrome-on-light.glass {
  background: rgba(233, 231, 227, .22);
}
.chrome .mark { display: flex; align-items: center; }
.chrome .mark img { height: 15px; width: auto; }
body[data-theme="paper"] .chrome .mark img,
.chrome.chrome-on-light .mark img { filter: invert(1); }
.chrome-actions { display: flex; align-items: center; gap: 10px; margin-right: -8px; }
/* Visual padding stays compact; min-height keeps the actual tap target at
   the 44px floor regardless. Small text reads thin at 400, so the chrome
   buttons step back up to the font's other real weight (700, there is no
   real 500/600 face to land between them). */
/* One scale on every screen: the desktop buttons used to be 9px type in a
   30px pill against the phone's 12px in 35px, which read as two different
   bars when the client compared them (the funnel's bar is this rule,
   ported value for value, so both sites move together). */
.chrome .btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-height: 34px;
  padding: 8px 14px;
  font-size: 11px;
  font-weight: 700;
}
.chrome-quiet {
  background: none;
  border: 1px solid rgba(255, 255, 255, .5);
  color: var(--fg);
  text-decoration: none;
  white-space: nowrap;
}
.chrome-quiet:hover { border-color: var(--red); color: var(--red); }
body[data-theme="paper"] .chrome-quiet { border-color: rgba(26, 26, 26, .38); }
body[data-theme="paper"] .chrome-quiet:hover { border-color: var(--red); }
.chrome.chrome-on-light .chrome-quiet {
  border-color: rgba(26, 26, 26, .38);
  color: var(--ink-on-paper);
}
.chrome.chrome-on-light .chrome-quiet:hover { border-color: var(--red); color: var(--red); }
/* Below 560px both nav actions stay, with short labels: at 375px the full
   "Book a consultation" left the wordmark and the button touching edge to
   edge, and the old fix of hiding the assessment link removed the only
   low-commitment path from the nav on phones. Each link carries an explicit
   aria-label: with the long span display:none and the short one aria-hidden,
   name-from-content would otherwise come back EMPTY on small screens. */
.chrome-short { display: none; }
@media (max-width: 560px) {
  .chrome { padding: 0 12px; }
  .chrome-actions { gap: 8px; }
  .chrome .btn { padding: 9px 12px; font-size: 12px; }
  .chrome-long { display: none; }
  .chrome-short { display: inline; }
}

section { position: relative; }

/* ============ Hero: the scroll film ============ */
/* Reactive, not hardcoded to paper: by the time the exit completes (progress
   0.89 - see exitT's own range a few hundred lines down, in onUpdate) this
   has reached --black, and the video's feathered edges dissolve into that
   colour instead of cutting hard from video to the dark body that follows. */
/* --hero-bg is written from JS on every scroll frame, interpolated with
   the same eased exit value as the black plate. It deliberately does NOT
   use var(--bg): that one carries body's 600ms colour transition, so
   during a scroll it always lags behind the plate, leaving the hero
   sitting on a lighter colour than the (already black) section below it.
   That mismatch was the visible hard line under the video. */
#hero { background: var(--hero-bg, var(--hero-paper)); }
.hero-stage {
  position: relative;
  height: 100dvh;
  overflow: hidden;
}
.hero-stage video,
.hero-stage canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  will-change: transform;
  /* The exit (lift + fade to black) is driven from scroll progress in
     JS, not a fixed mask: a static gradient has no idea where the user
     actually is in the scrub, so it either fires too early, too late, or
     (as CSS-only attempts here kept doing) reads as a hard edge instead
     of motion. See initHero()'s exitT in the app script.

     This gradient is only for the film's own bottom edge, which the
     beats 2/3 lift pulls up INTO the stage (see computeMidLift): below
     it the stage shows --hero-bg. The feather exists so that seam is
     never a line.

     IT STARTS AT 92%, NOT 58%. The old taper began at 58% - right at the
     footage's wall/floor horizon - and ran to zero at 94%, which meant
     the table's front face (77-80% of the frame) was already
     semi-transparent and the legs (to ~91%) dissolved to nothing on the
     way down. Ghosted legs and a floor melting into a differently-toned
     paper ARE the "visible video crop": the fade was hiding the seam by
     erasing a third of the scene, and that erasure is what read as a
     crop. Measured against the frames themselves (px.mjs, every 20th
     frame, hi and lo sets): the tabletop and legs are static in every
     frame, the feet end at 0.91, and the floor either side of the
     table's shadow sits at 217-228 neutral grey all the way to the
     bottom row. So the frame is left FULLY OPAQUE through the feet and
     only the last 8% - plain floor, and the same tone as --hero-paper -
     tapers off. The shadow the table casts down the centre of that last
     12% (down to ~157 at the bottom row) is the one thing that does not
     match a flat fill; the scrub handles it separately, with a radial
     term it only adds while the film is lifted (see the dynamic mask in
     initHero), so at rest the shadow stays real and nothing is
     masked out of the composition.

     The scrub scales this element up to 1.08, and the stage's
     overflow:hidden then crops it at what is locally the element's
     ~96.3% line. That crop sits at the stage's own bottom edge - the
     viewport's, while pinned - so alpha remaining there is not a
     visible line, and the taper is allowed to run to 99%. During the
     exit the JS widens this dissolve to cover the lower third of the
     film (and holds zero at 94%, as the old static mask did), so the
     picture melts into the darkening plate with no boundary. */
  -webkit-mask-image: linear-gradient(to bottom, #000 0%, #000 92%, rgba(0,0,0,.7) 95%, rgba(0,0,0,.28) 97%, transparent 99%);
  mask-image: linear-gradient(to bottom, #000 0%, #000 92%, rgba(0,0,0,.7) 95%, rgba(0,0,0,.28) 97%, transparent 99%);
  /* The dynamic mask is two layers (the taper above, and the radial
     shadow-fade); intersect so a pixel has to pass both. Harmless on
     the single static layer. */
  -webkit-mask-composite: source-in;
  mask-composite: intersect;
  /* center BOTTOM, not center center. object-fit: cover crops the
     over-long axis, and on any viewport wider than the 16:9 footage
     that axis is vertical: at 2560x1080 the frame draws 1440px tall in
     a 1080px box, so a centred crop threw away 180px off the TOP and
     180px off the BOTTOM - and the bottom 180px is exactly where the
     legs end (feet at 91%) and the floor shadow lives. Cropped there,
     the legs were sliced off flat at the element's bottom edge, which
     the lift then pulled up into full view: a literal visible crop of
     the table. Anchoring the frame's bottom to the box's bottom keeps
     the whole table intact at every aspect ratio and puts all of the
     cropping into the empty wall at the top; it also lands the table
     higher in the box on wide screens, so the lift below has less to
     do. Horizontally the frame stays centred. On viewports narrower
     than 16:9 the frame fills the box's height exactly, so there is no
     vertical slack and this is identical to centred. The scrub's own
     gsap.set writes the same value every frame. */
  object-position: center bottom;
}
/* Exactly one of the two renderers is ever visible. The canvas only
   takes over once its frames are actually decoded and it has something
   to draw, so a slow connection shows the poster/video rather than an
   empty box. */
.hero-stage canvas { display: none; }
body.frames-ready .hero-stage canvas { display: block; }
body.frames-ready .hero-stage video { display: none; }
/* Fading the video's own opacity makes it look washed out, and it also
   raced the body's 600ms background-color transition (so the scene
   dissolved into a still-light background). A dedicated black plate
   above the video, driven by the same scroll progress, is a true fade
   to black: frame-accurate, and it darkens rather than thins the
   picture. It sits under .beats, so the copy stays fully readable. */
.hero-fade {
  position: absolute;
  inset: 0;
  background: var(--black);
  opacity: 0;
  pointer-events: none;
  will-change: opacity;
}
/* A white top wash used to sit here (.34 alpha fading to 0 by 38%) and has
   been removed. The wall it covered measures 190-195 brightness across the
   whole top third - i.e. genuinely flat - so a 34% white veil over it did
   not soften anything that was there, it painted a ~23-level vertical
   gradient onto a flat surface, which is exactly the kind of thing that
   reads as haze rather than as light. It was not carrying the chrome bar
   either: that has its own glass fill and backdrop blur, and its dark text
   sits on ~190 brightness with or without the wash.
   What remains over the film is deliberately only two things, neither of
   which can produce an edge: the black exit plate (.hero-fade, inset:0, so
   it darkens uniformly) and the bottom feather on the media itself (a
   taper that must reach true zero before the crop - see the mask comment
   above). Adding a third layer here is what has historically reintroduced
   the "harsh line", because every extra layer needs its own fade schedule
   and any mismatch between schedules shows up as a boundary. */
/* Horizontally the film is dead centre and stays there - the copy is
   centred at every width, so there is nothing to reframe out from under.
   An earlier version anchored this at 22% and panned it to 50% across
   beats 2/3, which read as the scene sliding sideways under stationary
   text. The vertical anchor is 'bottom' (set on the rule above, with the
   reason); if the horizontal one ever needs to move again, move it with
   object-position rather than a transform: it only re-anchors within the
   slack object-fit: cover already has, so it cannot expose an edge the
   way a translate can. */
.beats {
  position: absolute;
  inset: 0;
  display: grid;
  /* 1fr, not the implicit default: with no explicit track, grid auto-
     sizes its one column to the widest normal-flow item sharing this
     area - every OTHER beat's own max-width: 620px, since beat 1 is the
     only one taken out of flow (position:absolute, below). That silently
     capped beat 1's containing block at 620px too, well short of the
     viewport, which is what actually broke its one-line centering - not
     its own sizing rules, which were correct against that undersized box. */
  grid-template-columns: 1fr;
  align-items: center;
  padding: 0 var(--gutter);
  pointer-events: none;
  will-change: transform;
}
/* The paper scrim that used to live here is GONE, deliberately - do not
   reintroduce it. It was a left-to-right wash (opaque at the left edge,
   clear by 48%) built for copy that sat in a left-hand column. Every beat
   is centred now, so it washed one side of centred text and not the other,
   and it was itself one of the two stacked causes of the recurring "harsh
   line": a semi-opaque paper rectangle over a scene that dissolves to
   black has to be faded out on its own schedule, and any mismatch between
   that schedule and the plate's leaves a visible edge.
   It is not needed for legibility. The beat copy is near-black ink on a
   frame that measures ~187-230 in brightness everywhere the text sits,
   which is better than 10:1 - beat 1 already ran without any backing of
   its own for exactly this reason. */
.beat {
  grid-area: 1 / 1;
  max-width: 620px;
  /* Every beat is centred, at every width. Beat 1 was already centred and
     beats 2/3 were centred-ish, but beat 4 kept the original left-aligned
     block - so the copy visibly marched sideways from beat to beat as the
     hero scrubbed. Centring lives on the shared rule rather than per-beat
     so a new beat cannot reintroduce the drift.
     text-align centres the lines INSIDE the box; the auto margins centre
     the box itself. Both are needed - max-width above caps the box well
     below its container on wide phones, and without the margins that
     capped box sits flush left, putting the text ~21px off centre at 720px
     even though every line within it is perfectly centred. */
  text-align: center;
  margin-left: auto;
  margin-right: auto;
  /* Set from JS during the exit (dark ink reads fine over the paper
     scene, but goes near-invisible the moment that scene dissolves to
     black behind it): defaults to the paper-world colour everywhere
     else, including beats 1-3, which never enter the exit window. */
  color: var(--beat-color, var(--ink-on-paper));
  /* --beat-color runs ink (26,26,26) to white (245,244,242) while
     --hero-bg runs hero paper (220,221,222) to black (15,15,15) underneath it -
     opposite directions, overlapping ranges, both monotonic. By the
     intermediate value theorem alone that means they MUST cross somewhere
     no matter how each is individually timed/eased: confirmed by sweeping
     every acceleration factor from 2.2 (what this shipped with) up to 10,
     every one dips to 1.00-1.02:1 at some point in the exit, just at a
     different moment - measured, not a hunch. Retiming the two lerps
     cannot fix this; it can only move the coordinates of the collision.
     A dark halo behind the glyphs carries the copy through that moment.
     It is NOT on all the time: an always-on 14px dark blur at 30% is
     plainly visible as a grey haze around dark type on the light wall
     (the client flagged it), so its alpha is --beat-halo, written from
     JS as a bump centred on the crossing (eased ~0.3, where text and
     background luminance meet - see onUpdate) and zero at rest and by
     the time the copy is white on near-black. */
  text-shadow:
    0 1px 2px rgba(0, 0, 0, calc(.35 * var(--beat-halo, 0))),
    0 0 14px rgba(0, 0, 0, calc(.3 * var(--beat-halo, 0)));
  opacity: 0;
  /* visibility, not just opacity. opacity:0 still leaves a beat in the
     accessibility tree, in the tab order and hit-testable - and beat 4
     carries the two hero CTAs, so both of them were focusable and
     clickable the entire time they were invisible: tabbing from the nav
     landed on a "Book a consultation" nobody can see, and
     elementFromPoint over that area returned the link rather than what is
     actually on screen (.beats sets pointer-events:none, but .beat a sets
     it straight back to auto). visibility:hidden removes all three.
     The outgoing beat must still fade rather than cut, so visibility is
     switched with an explicit delay rather than by transitioning the
     keyword itself: leaving .on it flips to hidden only after the .28s
     opacity fade has finished (visibility 0s linear .28s), and entering
     .on it flips to visible immediately (visibility 0s, on .beat.on).
     Written as a delay, not as a transitioned keyword, so it does not
     depend on how a given engine interpolates visibility. */
  visibility: hidden;
  transform: translateY(28px);
  filter: blur(10px);
  transition: opacity .28s ease, transform .28s ease, filter .28s ease, visibility 0s linear .28s;
}
/* Beats share one grid cell and crossfade past each other during the
   ~50-450ms overlap window between bands. A blur on the outgoing/incoming
   text (instead of a plain opacity swap) reads as a depth-of-field shift
   rather than two different-sized headlines double-exposed on top of
   each other. */
/* No delay on the way IN - the incoming beat must be visible from the
   first frame of its fade, or it would pop in 280ms late. */
.beat.on {
  opacity: 1;
  visibility: visible;
  transform: none;
  filter: blur(0);
  transition: opacity .28s ease, transform .28s ease, filter .28s ease, visibility 0s;
}
.beat a { pointer-events: auto; }
.beat h1 {
  font-size: clamp(4.2rem, 15vw, 10rem);
  line-height: .88;
  margin: 14px 0 20px;
}
.beat1-kicker {
  font-family: var(--body);
  font-weight: 400;
  font-size: clamp(19px, 1.7vw, 24px);
  letter-spacing: .02em;
  color: #55524D;
  margin-bottom: 10px;
}
/* Beat 1 is a single centred statement sitting in the wall above the
   table, so it needs its own position and width. (Every beat is centred
   now; this rule predates that and stays because beat 1 is the only one
   that has to sit in the wall above the table rather than below it.)
   It used to also get a radial paper-wash backing for legibility,
   removed here: the h1 is #1A1A1A on a light, low-contrast product shot
   and reads with plenty of contrast on its own (confirmed by disabling
   the backing and comparing), and the wash had a real defect - an
   ellipse radial-gradient fit to a wide, short box (an aspect ratio this
   backing's own inset made more extreme) doesn't reach fully transparent
   at the same *distance* along every direction from centre, so its stops
   read as a soft but genuine rectangular edge rather than a clean fade.
   That edge, specifically its bottom edge crossing the plain tabletop
   below the headline, is the faint line under "UNFORGETTABLE" the client
   flagged. Confirmed by disabling the backing outright: the box-shaped
   edge disappears completely and the text stays fully legible. Desktop-
   only positioning is kept - the mobile stack (film above text) never
   used this rule either way. */
@media (min-width: 721px) {
  .beat[data-beat="1"] {
    /* Absolute, spanning an explicit zone (nav bottom to the table's top
       edge in the film - measured at rest as ~68% down the stage, since
       that's baked into the video's own composition and not something
       CSS can query) rather than a margin-top from the grid cell's own
       top: a fixed margin sits the block wherever it happens to land,
       not centred in the space it actually has to work with. Flex
       centers the kicker+h1 group inside that zone regardless of their
       combined height, so resizing either one (or the zone, on a very
       different aspect ratio) re-centers automatically instead of
       needing the margin re-tuned by hand. */
    position: absolute;
    /* calc(50vw - var(--gutter)), not left:50% or left:0;right:0: this
       element (position:absolute, inheriting grid-area:1/1 from the
       shared .beat rule) gets an asymmetric containing block from
       .beats's padding - confirmed by measuring the rendered rect at
       three different left/width/right combinations, all consistent
       with a box that starts at var(--gutter) but runs all the way to
       the far edge of .beats's own box (100vw), not the padded edge on
       that side too (100vw - var(--gutter), the expected symmetric
       match to the left side). Any %-based left inherits that skew,
       landing 28px off true centre at 1440px wide - and by a DIFFERENT
       number of px at every other width, since the skew scales with
       --gutter's own clamp(). calc(50vw - var(--gutter)) sidesteps it
       instead of chasing it: as a LENGTH (not a %) it is measured
       directly from the containing block's left edge, which sits at
       var(--gutter) from the true viewport edge - so adding
       (50vw - var(--gutter)) to that edge cancels the gutter term
       algebraically and lands on 50vw (true centre) at any viewport
       width or gutter value, not just the one this was measured at.
       The trailing "- var(--sbw, 0px)/2" cancels a second, unrelated
       skew: 50vw is 1% of window.innerWidth, which includes a classic
       scrollbar's own track, while the rendered page is the narrower
       clientWidth - so 50vw alone overshoots the page's true centre by
       half the scrollbar's width. Invisible with macOS/iOS's overlay
       scrollbars (where --sbw is 0), a real rightward drift on
       Windows/Linux otherwise. See the --sbw IIFE in the app script. */
    left: calc(50vw - var(--gutter) - (var(--sbw, 0px) / 2));
    /* The scale is the letterbox fit from refreshBeat1Zone (1 everywhere it
       does not need to engage). It has to come AFTER the centring translate
       in this one transform, not live in the standalone scale property:
       that property composes before transform, so the -50% would have
       been scaled too and the group drifted off-centre by exactly
       (1 - fit) * half its width - measured 658px off at 1920x560. */
    transform: translateX(-50%) scale(var(--beat1-fit, 1));
    top: 72px;
    /* Was a flat 32% ("the table's top edge, ~68% down the stage"). That
       68% is only true while the film is NOT cropped, i.e. at 16:9 or
       taller. Wider-and-shorter windows (a 1440x610 laptop with the dock
       showing, 1280x578 zoomed out) crop the frame's top - object-fit:
       cover, anchored bottom - so the tabletop sits far higher in the
       stage than 68%, and the group centred in this zone landed ON it,
       at rest, before a pixel had scrolled: measured 16px over at
       1440x610, 6px clear at 1280x578. The script computes where the
       tabletop's back edge actually is for the current viewport (same
       cover maths computeMidLift already uses for beats 2/3) and writes
       it here, so the zone always ends a margin above the real table. */
    bottom: var(--beat1-zone-bottom, 32%);
    /* Overrides the base .beat's max-width: 620px (a reading-width cap
       every other beat's paragraph copy needs and this one, a single
       centred headline, does not). max-width:none alone used to be enough
       here, back when the h1 was white-space:nowrap - nowrap let the
       rendered line ignore whatever width this box computed for itself
       and just overflow it, which never caused a visible problem only
       because the true rendered width (~78-82% of viewport, see the h1
       comment) stayed inside the actual viewport regardless.
       Now that the h1 has to be able to wrap (see there for why), that
       cover is gone, and it turns out max-width:none on its own leaves
       WIDTH genuinely ambiguous for a position:absolute flex container
       with only 'left' set: measured directly, this engine sizes it via
       shrink-to-fit against roughly (containing block width - left), i.e.
       about HALF the viewport - well under the h1's natural ~78-82%
       single-line width - so as soon as wrapping was possible at all, it
       wrapped every time, even at a plain, unzoomed 100%.
       An explicit width removes that ambiguity outright rather than
       relying on shrink-to-fit to land somewhere workable: 90vw, matching
       the h1's own max-width safety-net figure, so the two agree on where
       the real wrap boundary is. */
    max-width: none;
    width: 90vw;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    text-align: center;
  }
  .beat[data-beat="1"] h1 {
    margin: 0;
    /* Sentence case, Helvetica, off the base h1 rule's condensed/uppercase/
       800: this is the one headline on the page set in the body voice, not
       the display face. Medium (500), a step down from Bold: heavy enough
       to still carry as a hero headline, light enough to read as typed
       rather than shouted. */
    font-family: var(--body);
    font-weight: 500;
    text-transform: none;
    /* One line at every desktop width, not just the ~1440px this was
       tuned against: a pure vw size keeps the rendered text at a constant
       fraction of viewport width by construction - clamp() only trims the
       two ends, a floor so it's never illegibly small just above 721px, a
       ceiling high enough it only engages on a genuinely wide external
       monitor rather than a typical 1440-1920px desktop. Re-derived per
       weight, not assumed to carry over: a lighter cut is not guaranteed
       to share the heavier cut's px-of-text-per-px-of-font-size ratio, and
       in fact does not (10.001px/px measured at 500, against 10.318 at
       700, about 3% narrower) - re-measured live against Helvetica Neue
       500 specifically. Verified at 721/1024/1440/1920/2400px, all
       landing on 82.0% exactly. */
    font-size: clamp(3.7rem, 7.8vw, 12.3rem);
    /* NOT nowrap. The 82%-of-viewport vw math above already keeps this on
       one line for every normal case - nowrap was additionally forcing that
       even when it stops being true, which happens under any mechanism
       that grows text relative to the viewport without vw itself growing
       to match (WCAG 1.4.4's 200% text-resize test is the standard one,
       but an OS/browser minimum-font-size or Dynamic-Type-style override
       does the same thing). With nowrap, the line cannot break, so it
       overflows its box - and body's own overflow-x:hidden (kept, for the
       deliberately oversized marquee ribbon elsewhere on the page) does
       not scroll to reveal that overflow, it clips it: the tail of
       "Become unforgettable" went silently missing rather than wrapping to
       a second line. The container above centres on both axes with
       align-items:center regardless of how many lines the h1 ends up as,
       so a forced wrap still lands centred, just taller.
       white-space:normal alone does not actually get this to wrap, though:
       the container is deliberately max-width:none (see the comment on
       .beat[data-beat="1"] - it exists so this h1 can shrink-wrap to its
       own one-line width instead of always wrapping at two), and an
       absolutely-positioned flex container with width:auto sizes to its
       content's preferred (max-content) width in every engine tested here,
       not to the classic CSS2.1 shrink-to-fit-bounded-by-containing-block
       result a plain block would get. With nothing to wrap against, text
       just kept growing past the viewport with nowhere to break - measured
       directly: forcing an absurd font-size still rendered one line, at
       3200px wide inside a 365px viewport. This max-width is the actual
       wrap boundary: 90vw, headroom above the 82.0vw this renders at
       normally (see above), so it is inert at every real font-size and
       only engages once something is pushing the text past what the vw
       math accounted for. */
    max-width: 90vw;
    white-space: normal;
  }
  /* Types out once on load (see initHero()'s call to typeOutHeadline):
     each character starts hidden and un-blurs in on its own stagger,
     the same blur-in language .beat itself already uses for its own
     entrance, just carried down to the glyph level instead of the whole
     line arriving at once. */
  .beat1-kicker {
    opacity: 0;
    filter: blur(8px);
    transform: translateY(6px);
    transition:
      opacity 1.1s cubic-bezier(.22, 1, .36, 1),
      filter 1.1s cubic-bezier(.22, 1, .36, 1),
      transform 1.1s cubic-bezier(.22, 1, .36, 1);
  }
  .beat1-kicker.in {
    opacity: 1;
    filter: blur(0);
    transform: translateY(0);
  }
  /* Groups one word's .char spans into a single non-breaking unit - see
     the long comment in typeOutHeadline() for why this exists. Only
     needed here, at this breakpoint: .char is display:inline-block ONLY
     in this media query (mobile's .char has no rule of its own, so it
     stays plain display:inline and normal text wrapping already works
     there without any of this). */
  .beat[data-beat="1"] h1 .word { white-space: nowrap; }
  .beat[data-beat="1"] h1 .char {
    display: inline-block;
    white-space: pre;
    opacity: 0;
    filter: blur(9px);
    transform: translateY(.1em);
    transition:
      opacity .72s cubic-bezier(.22, 1, .36, 1),
      filter .82s cubic-bezier(.22, 1, .36, 1),
      transform .82s cubic-bezier(.22, 1, .36, 1);
  }
  .beat[data-beat="1"] h1 .char.in {
    opacity: 1;
    filter: blur(0);
    transform: translateY(0);
  }
  .beat[data-beat="2"],
  .beat[data-beat="3"] {
    position: absolute;
    /* NOT left: 50%. These are absolutely positioned inside .beats, whose
       containing block is asymmetric - it starts at var(--gutter) but runs
       to the full 100vw on the other side (the same skew documented at
       length on beat 1 below). A percentage inherits that skew, which put
       these two ~half a gutter right of true centre while beat 1, using
       the calc() form, sat dead centre - so the copy stepped sideways
       between beats. As a length this is measured from the containing
       block's own left edge, so the gutter term cancels and it lands on
       50vw at any width. */
    left: calc(50vw - var(--gutter) - (var(--sbw, 0px) / 2));
    bottom: clamp(52px, 8vh, 84px);
    width: min(720px, calc(100vw - (var(--gutter) * 2)));
    max-width: none;
    text-align: center;
    transform: translateX(-50%) translateY(28px);
  }
  .beat[data-beat="2"].on,
  .beat[data-beat="3"].on {
    transform: translateX(-50%) translateY(0);
  }
  .beat[data-beat="2"] .sub,
  .beat[data-beat="2"] .big,
  .beat[data-beat="3"] .sub,
  .beat[data-beat="3"] .big {
    margin-left: auto;
    margin-right: auto;
    line-height: 1.2;
  }
  /* Beat 4 had no desktop rule of its own, so it fell back to the shared
     620px block sitting at the grid column's left edge - the one beat that
     was not centred. It stays a normal grid item (its vertical centring
     via .beats's align-items is correct as-is); justify-self is only doing
     the horizontal half. The column here IS symmetric, unlike the absolute
     containing block beats 2/3 sit in, so this needs no calc(). */
  .beat[data-beat="4"] {
    justify-self: center;
  }
}
/* Both of these cap their own width for readability, so text-align on the
   beat is not enough on its own - without auto margins the capped box sits
   flush left inside a centred beat and the line looks off-centre against
   its neighbours. */
.beat .sub {
  font-size: clamp(1.1rem, 1.6vw, 1.4rem);
  line-height: 1.5;
  max-width: 48ch;
  margin-left: auto;
  margin-right: auto;
}
.beat .big {
  font-weight: 400;
  font-size: clamp(1.8rem, 4vw, 3.2rem);
  line-height: 1.15;
  letter-spacing: -.01em;
  max-width: 24ch;
  margin-left: auto;
  margin-right: auto;
}
/* inline-flex, so the beat's own text-align:center is what centres it. */
.cta-cluster {
  display: inline-flex;
  align-items: center;
  gap: 18px;
  margin-top: 30px;
  padding: 16px 20px;
}

/* ============ Beat 4: the Treatment Plan ============ */
/* The closing beat sells the deliverable, not only the outcome. Left: what
   you walk out with, that it is written down, that it is written AFTER
   the check-ins (the thing that separates this from an offsite), one line
   of proof, the two CTAs. Right: the document itself - the cover card the
   hero always had, now with its contents page fanned out from behind it,
   so it reads as a real, structured document rather than a prop.
   Everything on the right and in the chapter list comes from the Allpress
   plan on the Drive: the five chapters are that document's own contents
   page, the deck is its letter ("your own words, organised"), the proof
   line is its opening result with the client name removed.
   Layout: two columns from 900px up; stacked with the document first
   below that; the contents page is dropped (cover alone) where there is
   not the height for it, and the whole document goes on very short
   viewports (landscape phones), where only the copy fits. */
.beat[data-beat="4"] {
  max-width: 680px;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 18px;
}
.plan-copy {
  display: flex;
  flex-direction: column;
  align-items: inherit;
  min-width: 0;
  /* Above the document. The cover's shadow reaches ~56px past the sheet,
     and in the stacked layouts that is straight onto the headline: a
     positioned cover (z-index 2) paints over unpositioned text, so the
     shadow darkened the first line of the headline in patches. Seen
     directly at 390 and 768 wide. The copy sits at z 1, the document
     (an isolated context, so the cover's z-index stays inside it) at 0. */
  position: relative;
  z-index: 1;
}
.beat[data-beat="4"] .big {
  font-size: clamp(1.5rem, 2.7vw, 2.4rem);
  line-height: 1.12;
  max-width: 22ch;
}
.beat[data-beat="4"] .sub {
  margin-top: 14px;
  font-size: clamp(1rem, 1.25vw, 1.15rem);
  line-height: 1.5;
  max-width: 50ch;
}
.plan-after,
.plan-proof {
  margin-top: 14px;
  font-size: clamp(.9rem, 1.05vw, 1rem);
  line-height: 1.5;
  max-width: 52ch;
}
.plan-after { color: color-mix(in srgb, currentColor 74%, transparent); }
.plan-proof {
  padding-left: 14px;
  border-left: 2px solid var(--red);
  color: color-mix(in srgb, currentColor 86%, transparent);
}
.beat[data-beat="4"] .cta-cluster { margin-top: 26px; }
@media (min-width: 900px) {
  .beat[data-beat="4"] {
    max-width: none;
    width: min(100%, 1180px);
    display: grid;
    grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr);
    align-items: center;
    column-gap: clamp(32px, 5vw, 80px);
    text-align: left;
  }
  /* grid-row on both: with only columns given, sparse auto-placement
     moved the copy to a SECOND row (the document, ordered first for the
     stacked layout, had taken column 2, and an item whose column is
     lower than the cursor's is bumped down a row). Measured: the beat
     was 928px tall at 1440x900 and hung off the top of the stage. */
  .plan-copy { grid-column: 1; grid-row: 1; align-items: flex-start; }
  .plan-doc { grid-column: 2; grid-row: 1; order: 0; justify-self: end; }
  /* The shared .big/.sub rules centre themselves with auto margins for
     the other three beats; in a left-aligned column that would float
     them to the middle of it. */
  .beat[data-beat="4"] .big,
  .beat[data-beat="4"] .sub,
  .plan-after,
  .plan-proof {
    margin-left: 0;
    margin-right: 0;
  }
}

/* The document: two A4 sheets (1:1.414) - the contents page behind and to
   the left, the cover in front and to the right, overlapping by 40% of a
   sheet, so the spread is 1.6 sheets wide. Sized from its column via
   aspect-ratio rather than a fixed sheet width, so it can never overflow
   the column; --doc-max caps it against both viewport width and height
   (the height term keeps the sheet clear of the chrome bar on short
   viewports). Type inside each sheet is in cqw, so it scales with the
   sheet at every size instead of needing a breakpoint of its own. */
.plan-doc {
  --doc-max: min(clamp(340px, 35vw, 512px), calc((100dvh - 250px) * 1.13));
  position: relative;
  z-index: 0;
  isolation: isolate;
  width: min(100%, var(--doc-max));
  aspect-ratio: 1.6 / 1.414;
  order: -1;
}
/* The grid: a blueprint field behind the document that draws outward
   from the centre before the sheets arrive (clip-path inset 48% -> 0,
   feathered at its edges by a radial mask so it has no hard boundary),
   on the beat's own stagger clock ahead of .plan-doc's sheets. It tilts
   at half the document's angle (see --plan-grx), so the two layers
   separate on scroll - the depth is the point of it. */
.plan-grid {
  position: absolute;
  inset: -16% -14%;
  z-index: -1;
  pointer-events: none;
  background-image:
    linear-gradient(rgba(255, 255, 255, .14) 1px, transparent 1px),
    linear-gradient(90deg, rgba(255, 255, 255, .14) 1px, transparent 1px);
  background-size: 7.5% 8.5%;
  -webkit-mask-image: radial-gradient(ellipse 62% 58% at 50% 50%, #000 40%, transparent 100%);
  mask-image: radial-gradient(ellipse 62% 58% at 50% 50%, #000 40%, transparent 100%);
  opacity: 0;
  clip-path: inset(48% 48% 48% 48%);
  transform: perspective(1200px) rotateX(var(--plan-grx, 0deg)) rotateY(var(--plan-gry, 0deg));
  transition: clip-path .75s var(--ease-out), opacity .45s ease, transform .15s linear;
}
.beat[data-beat="4"].on .plan-grid {
  opacity: 1;
  clip-path: inset(0);
  transition-delay: 320ms, 320ms, 0ms;
}
/* The tilt layer carries the scroll-driven perspective (see onUpdate:
   --plan-rx/--plan-ry from the pin's progress) so it never fights the
   entrance transform on .plan-doc itself. */
.plan-tilt {
  position: absolute;
  inset: 0;
  transform-style: preserve-3d;
  transform: perspective(1200px) rotateX(var(--plan-rx, 0deg)) rotateY(var(--plan-ry, 0deg));
  transition: transform .15s linear;
  will-change: transform;
}
.plan-page {
  position: absolute;
  top: 0;
  width: 62.5%;
  height: 100%;
  color: var(--ink-on-paper);
  container-type: inline-size;
}
.plan-contents {
  left: 0;
  background: #F3F1EC;
  /* Flat (was rotate(-2deg)): the sheets lie square on the grid and the
     scroll tilt is the only angle they carry. */
  transform: none;
  box-shadow:
    0 1px 3px rgba(0, 0, 0, .2),
    0 12px 28px rgba(0, 0, 0, .3);
}
/* Padding lives on this inner box, NOT on .plan-contents: cqw on the
   container element itself resolves against the next container up (here:
   none, so the viewport), and container units are measured from the
   content box - so 7cqw of padding on the container ate its own content
   box and every cqw inside it shrank with it (the chapter list measured
   4px at 1440 wide, 0px at 2560). The cover already had this shape via
   .plan-card-inner, which is why it was fine. */
.plan-page-inner {
  width: 100%;
  height: 100%;
  padding: 7cqw;
}
.plan-toc {
  list-style: none;
  margin: 5cqw 0 0;
  /* Keeps every line inside the 60% of this sheet the cover leaves
     visible, so each chapter reads whole rather than disappearing under
     the cover mid-word. */
  padding: 0 40% 0 0;
  display: grid;
  gap: 3.2cqw;
}
.plan-toc li {
  display: grid;
  grid-template-columns: 8cqw 1fr;
  gap: 2.5cqw;
  font-family: var(--body);
  font-size: 3.7cqw;
  line-height: 1.3;
}
.plan-toc li span { color: #8A857D; }
/* The cover. z-index puts it over the contents page; perspective is for
   the inner sheet's ambient drift below. The permanent 1.5deg rotate is
   what the old absolutely-positioned card had, kept. */
.plan-card {
  right: 0;
  z-index: 2;
  transform: none;
}
.plan-card-inner {
  width: 100%;
  height: 100%;
  background: #FBFAF8;
  color: var(--ink-on-paper);
  padding: 7cqw;
  display: flex;
  flex-direction: column;
  /* Three stacked layers rather than one flat 50px/.5 blur: a tight,
     dark contact shadow plus a soft, lighter far one reads as a real
     object catching light above it, where a single shadow reads as a
     flat drop-shadow filter. */
  box-shadow:
    0 2px 4px rgba(0, 0, 0, .22),
    0 10px 20px rgba(0, 0, 0, .28),
    0 32px 56px rgba(0, 0, 0, .32);
  transform-style: preserve-3d;
  animation: plan-hover 9s ease-in-out infinite;
}
/* Continuous ambient drift, not a hover response (hover motion was
   deliberately cut from the whole page) - the card should read as
   quietly alive on the table, not reacting to the cursor. Animates the
   inner element only: .plan-card's own transform carries the permanent
   1.5deg rotate, and a transform animation on the same element would
   override it. */
@keyframes plan-hover {
  0%, 100% { transform: rotateY(-5deg) rotateX(2deg); }
  50%      { transform: rotateY(5deg)  rotateX(-2deg); }
}
@media (prefers-reduced-motion: reduce) {
  .plan-card-inner { animation: none; }
}
/* Helvetica rather than the mono face, for the same reason as the title:
   the hero is set in one family throughout. Letterspacing and uppercase
   are what make these read as document labels, not the family. */
.plan-card-kicker {
  font-family: var(--body);
  font-size: 3.3cqw;
  letter-spacing: .16em;
  text-transform: uppercase;
  color: #6A6660;
}
/* Helvetica, not the condensed display face the .display class would give
   it: this is the only piece of type in the hero that was set in a
   different family, and next to the beat copy it read as a different
   voice rather than the same document. Weight carries the emphasis the
   condensed cut used to. */
.plan-card-title {
  margin-top: auto;
  font-family: var(--body);
  /* Helvetica Medium, per the client (was 700). */
  font-weight: 500;
  letter-spacing: -.01em;
  font-size: 10.5cqw;
  line-height: 1.04;
  color: var(--ink-on-paper);
}
.plan-card-foot {
  margin-top: 4.5cqw;
  padding-top: 4cqw;
  border-top: 1px solid rgba(26, 26, 26, .18);
  font-family: var(--body);
  font-size: 3.2cqw;
  letter-spacing: .12em;
  text-transform: uppercase;
  color: #6A6660;
}

/* Staggered arrival. The beat itself still crossfades in as a whole
   (.beat.on, .28s); on top of that each piece rises into place in turn,
   the document last, so the close reads as a sequence rather than a
   slide. Delays only on the way IN - leaving, everything drops together
   with the beat's own fade, so scrolling back up never waits on a
   stagger. body.static / no-JS force these visible below. */
.beat[data-beat="4"] .plan-stagger {
  opacity: 0;
  transform: translateY(14px);
  transition: opacity var(--dur-base) ease, transform var(--dur-base) ease;
}
.beat[data-beat="4"].on .plan-stagger {
  opacity: 1;
  transform: none;
  transition:
    opacity .55s var(--ease-out) var(--d, 0ms),
    transform .55s var(--ease-out) var(--d, 0ms);
}
.plan-headline { --d: 0ms; }
.plan-deck { --d: 90ms; }
.plan-after { --d: 180ms; }
.plan-proof { --d: 270ms; }
.beat[data-beat="4"] .cta-cluster { --d: 340ms; }
/* After the grid (which starts at 320ms and takes 750ms to draw): the
   sheets rise onto a field that is already there. */
.plan-doc { --d: 760ms; }

/* Short viewports: the proof line is the first thing to go, then the
   document itself on landscape phones, where only the copy fits. The
   .plan-doc overrides are written at (0,3,0) on purpose: the phone rule
   further down sets --doc-max at (0,1,0) later in the source and would
   otherwise win over these. */
@media (max-height: 700px) {
  .plan-proof { display: none; }
  .beat[data-beat="4"] .cta-cluster { margin-top: 18px; }
}
/* Beat 1's headline is sized off viewport WIDTH alone (a pure vw clamp, so
   it stays one line); on a wide-and-short window that is a very tall group
   in a very short zone. Measured at 1600x600 the kicker+h1 group (149px)
   exactly filled its zone (153px, from 72px down to the tabletop) - one
   notch shorter and it would overflow both ways, under the chrome bar and
   onto the table. Below 620px tall, scale both down together (same ratio,
   so the pair keeps its proportions) by enough that a 560px window still
   fits with margin. Desktop-only: the mobile stack has its own layout. */
@media (min-width: 721px) and (max-height: 620px) {
  .beat[data-beat="1"] h1 { font-size: clamp(3.7rem, 6.2vw, 12.3rem); }
  .beat1-kicker { font-size: clamp(17px, 1.35vw, 24px); }
}
@media (max-height: 500px) {
  .beat[data-beat="4"] .plan-doc, .plan-after { display: none; }
  .beat[data-beat="4"] .cta-cluster { margin-top: 14px; }
  /* Centred in a 390px-tall stage the copy sat under the chrome bar
     (and .beats' own -5% parallax lifts it further); pin it below the
     bar instead. */
  .beats .beat[data-beat="4"] { align-self: start; margin-top: 86px; }
}
/* Stacked layouts: the sheets are smaller, so the chapter list gets a
   larger share of its sheet (it is a peek at the document, not body
   copy, but it should still read as words). */
@media (max-width: 899px) {
  .plan-toc { padding-right: 36%; gap: 2.6cqw; }
  .plan-toc li { font-size: 4.4cqw; grid-template-columns: 9cqw 1fr; }
  .plan-card-kicker { font-size: 3.8cqw; }
}
/* Stacked layouts without the height for two sheets: cover only. */
@media (max-width: 899px) and (max-height: 820px) {
  .plan-contents { display: none; }
  .beat[data-beat="4"] .plan-doc { --doc-max: 150px; aspect-ratio: 1 / 1.414; }
  .plan-card { width: 100%; }
}
/* Short phones (iPhone SE class): a smaller cover and no after-line, so
   the CTAs stay on screen. */
@media (max-width: 720px) and (max-height: 700px) {
  .beat[data-beat="4"] .plan-doc { --doc-max: 140px; }
  .plan-after { display: none; }
}
@media (max-width: 720px) {
  /* Phones: the film plays in the top 46% of the stage and the copy field
     is the 54% below it. The field used to start at 34% and cover the
     film's lower third with a 72px gradient - which cut straight through
     the table: the frame fills the 46% box's height exactly at phone
     aspect ratios, so the table's front edge sits at 80% of that box and
     the feet at 91%, i.e. the fade ran from the tabletop down. That is the
     "visible video crop" on mobile. The field now starts where the film
     ends. The seam is handled the same way as on desktop: the film's own
     mask tapers its last 8% (plain floor) into --hero-bg, which is the
     field's colour and is matched to that floor, so there is no gradient
     to draw here any more. The 31px of floor under the feet plus the
     field's padding is the clear space before the copy, not a gap. */
  .hero-stage video, .hero-stage canvas, .hero-fade { height: 46%; bottom: auto; }
  .beats {
    top: 46%;
    height: 54%;
    inset-inline: 0;
    align-items: start;
    padding-top: 28px;
    /* Same scroll-driven colour as #hero, for the same reason: it has to
       stay in lockstep with the plate, not lag on a CSS transition. */
    background: var(--hero-bg, var(--hero-paper));
  }
  .beats .beat {
    position: relative;
    z-index: 1;
  }
  .beat[data-beat="1"] {
    width: 100%;
    max-width: none;
    text-align: center;
  }
  .beat[data-beat="1"] h1 {
    margin: 0;
    font-family: var(--body);
    font-size: clamp(1.65rem, 8vw, 2.3rem);
    font-weight: 500;
    line-height: 1;
    text-transform: none;
    /* NOT nowrap - see the matching (and much longer) comment on the
       desktop version of this rule above. Same failure, same fix: with
       nowrap, oversized text (a 200%-style text-resize, or an OS minimum-
       font-size override) cannot wrap and gets clipped by body's own
       overflow-x:hidden instead. This container is width:100% with
       max-width:none, so - unlike the desktop flex container, which needed
       an explicit max-width added as a wrap boundary - a bounded width is
       already in place and normal wrapping is enough on its own. */
    white-space: normal;
  }
  .beat[data-beat="1"] .beat1-kicker {
    font-size: clamp(1rem, 4.5vw, 1.2rem);
    text-align: center;
    white-space: normal;
  }
  .beat .big {
    max-width: none;
    font-size: clamp(1.45rem, 6.2vw, 1.75rem);
    line-height: 1.24;
  }
  /* Once the film has faded to black, beat 4 no longer needs to remain
     below the table. Anchor the final composition directly beneath the
     fixed chrome instead of leaving the upper third of the stage empty. */
  .beat[data-beat="4"] {
    position: absolute;
    /* 84px from the stage top (the chrome bar ends at ~54px): the film
       is black by now, so the composition takes the whole stage. It used
       to start at 12dvh + 104px; the document needs that room. */
    top: calc(-46dvh + 84px);
    /* NOT left:0 + width:100%. Absolutely positioned inside .beats, this
       inherits the same asymmetric containing block described on beats 2/3
       (starts at var(--gutter), runs to the full 100vw), so left:0 pushed
       it half a gutter right of centre - measured 10px off at 375px while
       every other beat sat at 0. Anchoring to 50vw and pulling back half
       its own width cancels the gutter term at any viewport size. */
    left: calc(50vw - var(--gutter) - (var(--sbw, 0px) / 2));
    transform: translateX(-50%) translateY(28px);
    width: min(100%, calc(100vw - (var(--gutter) * 2)));
    max-width: none;
  }
  /* The shared .beat.on rule resets transform outright, which would drop
     the centring translate with it - so the on-state has to restate it. */
  .beat[data-beat="4"].on {
    transform: translateX(-50%) translateY(0);
  }
  .cta-cluster {
    display: flex;
    width: 100%;
    max-width: 100%;
    box-sizing: border-box;
    flex-direction: column;
    /* Centred to match the copy above it: as a full-width column flex
       container the beat's text-align cannot reach these, so the cross-axis
       alignment has to say so itself. */
    align-items: center;
    gap: 6px;
  }
  .beat[data-beat="4"] { gap: 16px; }
  .beat[data-beat="4"] .big {
    font-size: clamp(1.35rem, 5.6vw, 1.6rem);
    max-width: none;
  }
  .plan-proof { display: none; }
  /* Height term: the copy under the document needs ~430px; the sheet gets
     what is left above it. Below the cover-only threshold in the
     stacked rule above (max-height: 820px) this no longer applies. */
  .plan-doc { --doc-max: min(clamp(240px, 72vw, 300px), calc((100dvh - 540px) * 1.13)); }
}

/* Static fallback: reduced motion, no-JS, or video failure. */
body.static .hero-stage { height: auto; min-height: 62dvh; }
body.static .hero-stage video { position: static; height: auto; }
body.static .beats {
  position: static;
  display: block;
  background: none;
  padding: 40px var(--gutter) 56px;
  pointer-events: auto;
}
/* Beats have to be put back into normal flow here, not just made visible.
   Three of the four are absolutely positioned for the scrubbed hero (beat 1
   in its own zone above the table, beats 2/3 bottom-anchored, each with a
   left offset and a translateX(-50%) that centres them). This rule already
   killed the transform, which removed the -50% pull but left the left
   offset in place - so in the static layout beats 1-3 stacked on top of
   each other, all three 284px right of centre, while beat 4 (the only one
   still in flow) sat correctly centred. Reduced-motion users got that
   layout, so it was worth fixing even though nothing about it is new.
   Resetting the positioning properties as well returns all four to a plain
   centred column; the auto margins are what centre the 680px cap. */
body.static .beat {
  position: static;
  inset: auto;
  width: auto;
  transform: none;
  margin-left: auto;
  margin-right: auto;
  opacity: 1;
  visibility: visible;
  filter: none;
  margin-bottom: 36px;
  max-width: 680px;
}
/* .beat1-kicker's own base state is opacity:0/blur(8px) (its type-on-load
   entrance, a few hundred lines up), turned off only by a JS-added .in
   class that initHero() adds - so on this path, where initHero() never
   runs, "Define yourself, demand attention." stayed invisible forever.
   Nothing above resets it: body.static .beat sets opacity on the .beat
   container, not on this child, which carries its own separate opacity. */
body.static .beat1-kicker { opacity: 1; filter: none; transform: none; }
/* Beat 4's pieces start at opacity 0 for the staggered arrival and the
   two-column grid is for the pinned stage; on this path everything is
   simply visible, stacked, document first. KEEP IN SYNC WITH the
   <noscript> copy of these two rules. */
body.static .beat[data-beat="4"] { display: flex; }
body.static .beat[data-beat="4"] .plan-stagger { opacity: 1; transform: none; transition: none; }
/* The grid draws in on the beat's stagger clock, which never ticks here. */
body.static .plan-grid { opacity: 1; clip-path: none; transition: none; }

/* ============ Clients + Why ============ */
#clients { padding: var(--section-pad) 0; overflow: hidden; }
#clients h2 { margin-bottom: clamp(32px, 5vw, 56px); padding: 0 var(--gutter); }

/* One continuous ribbon, not a row of chips: a white band wider than the
   viewport (so its rotated corners never show a gap against the dark
   page) sitting on a slight angle, with the logos running along it in
   their own real colours. */
.clients-marquee-outer {
  position: relative;
  width: 130vw;
  margin-left: -15vw;
  transform: rotate(-2.2deg);
  background: var(--white);
  padding: clamp(32px, 4vw, 48px) 0;
  overflow: hidden;
  /* No box-shadow here on purpose: a shadow around the whole rotated
     rectangle gets hard-clipped the instant it meets #clients's own
     overflow:hidden, which reads as a visible seam right where the
     ribbon is supposed to look like it runs off the page uninterrupted. */
}

.clients-static { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 28px 48px; padding: 0 8vw; }

.clients-more { font-family: var(--body); font-size: 14px; color: var(--ink-on-paper); align-self: center; }

.clients-viewport { display: none; overflow: hidden; width: 100%; }

.clients-marquee-outer.animate .clients-static { display: none; }
.clients-marquee-outer.animate .clients-viewport { display: block; }

.clients-track { display: flex; align-items: center; gap: 80px; width: max-content; will-change: transform; }

.clients-track.animate { animation: clients-scroll 38s linear infinite; }

.clients-logo {
  flex: none;
  height: clamp(80px, 8vw, 108px);
  width: auto;
  object-fit: contain;
}

@keyframes clients-scroll {
  from { transform: translateX(0); }
  to { transform: translateX(-50%); }
}

@media (prefers-reduced-motion: reduce) {
  .clients-track.animate { animation: none; }
  .clients-marquee-outer.animate .clients-static { display: flex; }
  .clients-marquee-outer.animate .clients-viewport { display: none; }
}

@media (max-width: 640px) {
  /* A ~2deg rotation eats real horizontal room on a narrow viewport;
     flatten it there rather than let the ribbon's own edges intrude on
     the logos near the start/end of a row. */
  .clients-marquee-outer { transform: rotate(-1deg); width: 118vw; margin-left: -9vw; }
}

#why { padding: clamp(56px, 9vw, 96px) var(--gutter) clamp(36px, 5vw, 56px); }

/* No cap of its own: #why is already .wrap-constrained (--maxw), and a
   tighter 900px cap here was leaving ~270px of dead space at 1440 while
   simultaneously forcing the longest line to wrap at any readable size.
   See B2 in the revision plan for the measured numbers. */
.why-drum { max-width: none; }

/* Every line reads identically at rest (white, no weight or colour
   hierarchy); the distinction is entirely interactive, revealed on
   hover or tap rather than sitting on the page as permanent noise. */
.drum {
  margin-top: 0;
  padding: clamp(24px, 4vw, 36px) 0;
  border-radius: 0;
  position: relative;
  padding-right: 44px;
  cursor: pointer;
}

.drum:first-child { padding-top: 0; }
.drum + .drum { border-top: 1px solid var(--rule); }
/* Affordance: nothing signalled these rows expand, and on touch there is
   no hover to stumble into. A dim "+" on the right gives the row a
   recognisable toggle without adding permanent noise to the type. */
.drum::after {
  content: "+";
  position: absolute;
  right: 0;
  top: calc(clamp(24px, 4vw, 36px) + 3px);
  font-family: var(--mono);
  font-size: 22px;
  line-height: 1;
  color: var(--fg-dim);
  transition: color var(--dur-base) ease;
}
.drum:first-child::after { top: 3px; }
.drum.expanded::after { content: "\2212"; color: var(--red); }
@media (hover: hover) { .drum:hover::after { color: var(--red); } }

.drum-line {
  font-family: var(--body);
  font-weight: 400;
  font-size: clamp(1.2rem, 2vw, 1.8rem);
  line-height: 1.3;
  color: var(--fg);
  transition: color var(--dur-base) ease;
  max-width: 48ch;
  /* Fixed, not animated: replaces the old margin-top: 0 -> 12px that used
     to live on .drum-more itself (see there - a grid-sized row can't also
     carry an animated margin without the two fighting over the same
     visual space). A static gap here reads as "always a little room
     before the reveal could start", rather than the text snapping flush
     against the headline the instant it appears. */
  margin-bottom: 4px;
}

.drum-more {
  font-family: var(--body);
  font-weight: 400;
  /* Was clamp(14px, 1.2vw, 16px) / 60ch: capped at 16px from 1333px wide
     up, and 60ch of 16px is ~480px - a narrow block hugging the left under
     a headline that runs to ~690px (48ch of 1.8rem). The client's read:
     "bunched up on the left" on a wide screen. The size now keeps growing
     to 19px on wide viewports and the measure matches the headline's:
     78ch of 18px is ~700px, so the two edges line up instead of the copy
     stopping two-thirds of the way across. */
  font-size: clamp(15px, 1.25vw, 19px);
  line-height: 1.5;
  color: var(--fg-dim);
  max-width: min(100%, 78ch);
  /* grid-template-rows 0fr -> 1fr, not max-height/margin-top: max-height
     needs a guessed ceiling (200px, already raised once when the copy
     grew a second sentence) and margin is a layout property the animation
     rules above flag as expensive to animate directly. A 0fr row has no
     ceiling to outgrow, and margin-top is dropped rather than animated -
     see .drum-line's own margin-bottom for the fixed, non-animated gap
     that replaces it. */
  display: grid;
  grid-template-rows: 0fr;
  opacity: 0;
  overflow: hidden;
  transition: grid-template-rows var(--dur-slow) var(--ease-out), opacity var(--dur-base) var(--ease-out);
}
/* The grid ITEM, not the container above: a grid item's automatic min-
   height defaults to its content's own min-content size, which is enough
   to stop a 0fr row from ever actually reaching zero - confirmed live,
   the row sat at its full 72-136px rest height regardless of the 0fr
   spec. min-height: 0 overrides that floor. Needs a real element to
   target (grid-item sizing isn't addressable on bare text), hence the
   span wrapping each row's copy in the markup. */
.drum-more span { min-height: 0; }

@media (hover: hover) {
  .drum:hover .drum-line { color: var(--red); }
  .drum:hover .drum-more {
    grid-template-rows: 1fr;
    opacity: 1;
  }
}

.drum.expanded .drum-line { color: var(--red); }
.drum.expanded .drum-more {
  /* No ceiling to raise: unlike the max-height this replaced (twice
     raised already as the reveal copy grew), a 0fr/1fr grid row sizes
     itself to whatever content it holds. */
  grid-template-rows: 1fr;
  opacity: 1;
}

/* ============ Modules: the orbit ============ */
#modules {
  background: var(--black);
  padding-top: clamp(24px, 4vw, 48px);
  padding-bottom: clamp(40px, 6vw, 80px);
}

/* THE ORBIT IS 2D-EMULATED 3D, DELIBERATELY. It used to be real CSS 3D
   (a preserve-3d ring, rotateY/translateZ cards, counter-rotating
   billboard wrappers), and that is precisely why the cards' frosted
   glass never frosted anything: a nested 3D rendering context forms a
   backdrop root, so a backdrop-filter inside it cannot sample the
   heading or anything else outside the card - the blur silently
   computes against emptiness. Verified empirically with an invert()
   ladder: a plain element in the stage frosts fine, the same element
   inside the card's 3D chain goes dead, and flattening the chain
   brings it back.
   Since the cards were billboarded (always screen-parallel), the whole
   3D chain reduces exactly to translate(x,y) scale(s), which the orbit
   ticker computes per frame from the same angle/radius/tilt maths the
   CSS used to do, plus z-index for the depth sort against the heading.
   Do not reintroduce preserve-3d, rotateX tilt or per-card rotateY
   here: the frost dies again and nothing visibly errors. */
.orbit-stage {
  position: relative;
  height: clamp(780px, 82vh, 900px);
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
}

.orbit-heading {
  position: absolute;
  left: 50%;
  top: 50%;
  width: max-content;
  text-align: center;
  font-size: clamp(3.4rem, 11vw, 10rem);
  line-height: .86;
  color: var(--fg);
  pointer-events: none;
  text-shadow: 0 6px 40px rgba(0, 0, 0, .8);
  transform: translate(-50%, -50%);
  /* The z = 0 plane of the emulated orbit: cards get z-index above or
     below this from their computed depth each frame, which is what
     keeps the near half of the ring passing in front of the type and
     the far half behind it. */
  z-index: 1000;
}

.orbit-tilt {
  position: absolute;
}

.orbit-ring {
  /* Tied to the smaller of width/height, not width alone: a purely
     vw-driven radius inside a vh-driven stage let the ring run at full
     size on wide-but-short viewports (1440x800, the default MacBook Pro
     scaled resolution) regardless of how little vertical room the
     stage actually had, and the front card clipped against the stage's
     own overflow:hidden. See C1 in the revision plan for the measured
     numbers. The JS reads this resolved value through a probe element,
     so it stays the single source of truth for the radius. */
  --orbit-r: clamp(250px, min(30vw, 46vh), 440px);
  position: relative;
  width: 1px;
  height: 1px;
}

.ocard {
  --ocard-w: clamp(190px, 18vw, 260px);
  position: absolute;
  width: var(--ocard-w);
  aspect-ratio: 3 / 4;
  margin-left: calc(var(--ocard-w) / -2);
  margin-top: calc(var(--ocard-w) * 4 / 3 / -2);
  /* transform (translate+scale) and z-index are written by the orbit
     ticker every frame; the per-card rules below are only the no-JS
     resting pose. */
  will-change: transform;
  cursor: pointer;
  /* Depth for the half-turn flip, so the card turns like an object
     instead of squishing flat (an unperspectived rotateY is just a
     scaleX). Verified with the invert() probe that perspective here
     does NOT starve the glass's backdrop-filter: at rest the inner
     carries no 3D transform, so no persistent 3D rendering context
     forms around the frost. */
  perspective: 900px;
}
/* No-JS fallback poses: the ticker's projection frozen at ring angle 0
   (front, right, back, left), approximated in pure CSS since calc()
   cannot divide by a length for the true perspective term. With JS
   running these are immediately overridden by inline styles; in the
   grid fallback the media block's !important resets beat them. */
#ocard-mirror { transform: translate(0, calc(var(--orbit-r) * .25)) scale(1.18); z-index: 1420; }
#ocard-room   { transform: translate(var(--orbit-r), 0); z-index: 999; }
#ocard-field  { transform: translate(0, calc(var(--orbit-r) * -.18)) scale(.85); z-index: 580; }
#ocard-future { transform: translate(calc(-1 * var(--orbit-r)), 0); z-index: 999; }

.ocard-undo {
  /* A slight scroll-linked bob, distinct per card, set by the orbit
     ticker as it already tracks the stage's scroll position each frame.
     Lives here rather than on .ocard itself so it never touches the
     transform the ticker writes for the spin - --parallax-y is a plain
     inherited custom property, composing with that translate/scale
     instead of fighting it. */
  transform: translateY(var(--parallax-y, 0px));
}
.ocard-undo,
.ocard-billboard,
.ocard-untilt {
  width: 100%;
  height: 100%;
}

/* The flip is a HALF-TURN SWAP, not the classic preserve-3d two-face
   flip. The classic version needs preserve-3d on this element and
   backface-visibility on the faces, and both of those rebuild the 3D
   rendering context that kills the glass frost (see the orbit comment
   above). Instead the JS turns the card edge-on (rotateY 90), swaps
   which face is displayed, and turns it back - same read, and at rest
   the card carries no 3D transform at all, so the frost stays alive.
   The transition here times ONE half of that turn. */
.ocard-inner {
  position: relative;
  width: 100%;
  height: 100%;
  transition: transform .3s cubic-bezier(.4, .1, .4, 1);
}

.ocard-face {
  position: absolute;
  inset: 0;
  /* NO overflow HERE, and this is load-bearing. An overflow clip on an
     ancestor of the glass, inside the card's perspective subtree, is what
     made the folder notch fill in during a flip: with an overflow-clipping
     ancestor in the 3D subtree, Chromium composites the animating layer
     against that ancestor's rectangle and drops the glass's OWN clip
     entirely - not just the tab notch, the rounded corners too, so the
     card became a hard-edged rectangle for the length of the turn. It was
     also redundant: .ocard-glass is inset:0 (exactly this element's box)
     and clips its own contents, so nothing here ever needed containing.
     Isolated on the live page by freezing the flip mid-turn on a paused
     animation - still composited, but at a fixed angle instead of racing
     a screenshot against a transition - and diffing one property at a
     time at the identical angle. Three things each fixed it alone:
     dropping the backdrop-filter (loses the frost), dropping the
     perspective (loses the depth), or removing this overflow (loses
     nothing). Verified at 10/20/45deg and on the back face.

     DO NOT add filter, clip-path, mask, opacity or overflow to this
     element, and do not re-add backface-visibility: the first four make
     it a Backdrop Root, which silently disables the descendant glass's
     backdrop-filter - the tint keeps working, so it looks like a tuning
     problem while the frost does nothing - and overflow brings back the
     notch bug above. The folder clip lives directly on .ocard-glass, the
     same element as the backdrop-filter, which is the one place it can
     neither starve the frost nor be dropped by an ancestor's clip. */
}
.ocard-back { display: none; }
.ocard.showback .ocard-front { display: none; }
.ocard.showback .ocard-back { display: block; }
/* The open card is a fifth bigger as a LAYOUT box, not just a scaled
   picture of the same box: scale() magnifies but adds no room, and with
   the exercise list on the back the paragraph was being squeezed and
   silently clipped (overflow:hidden) at every desktop width - measured
   7 to 86px hidden. --ocard-w drives width, height and the centring
   margins together, so the card grows in place around its ring slot.
   It switches on .showback, which the flip toggles at the edge-on
   moment of the half-turn, so the size change happens while the card
   is a sliver and never reads as a jump. */
@media (min-width: 901px) {
  .ocard.showback { --ocard-w: clamp(228px, 21.6vw, 312px); }
}
/* Frosted glass tint, per module. Declared once per face via an id
   selector setting --m-tint, so .ocard-glass and .ocard-name only ever
   read the one custom property rather than repeating four rules each. */
#ocard-mirror .ocard-face { --m-tint: var(--m-mirror); }
#ocard-room .ocard-face { --m-tint: var(--m-room); }
#ocard-field .ocard-face { --m-tint: var(--m-field); }
#ocard-future .ocard-face { --m-tint: var(--m-future); }
/* Overrides --m-tint on the BACK face only (higher specificity than the
   rules above: two classes plus the id, vs. one), so the front face's
   hover/focus number colour - the other thing --m-tint drives - keeps the
   full-saturation --m-room/--m-field exactly as before. Only the value
   .ocard-back .ocard-glass's background actually composites changes. */
#ocard-room .ocard-back.ocard-face { --m-tint: var(--m-room-copy-safe); }
#ocard-field .ocard-back.ocard-face { --m-tint: var(--m-field-copy-safe); }
/* Frosted WHITE glass on every card, with the type in ink; the module
   colour only appears on hover/focus, where the card turns black and the
   type takes the module colour ("colour on black"). This is the client's
   brief after seeing the alternatives: the fully tinted "coloured glass
   folders" from the static build's history (b944c28) were tried again
   and rejected the same day. The back face keeps its tinted glass and
   white copy.
   The folder-tab clip lives HERE, on the same element as the
   backdrop-filter: an element's own clip-path shapes its output without
   starving its own backdrop sampling, whereas the same clip-path on any
   ancestor forms a Backdrop Root and kills the frost.

   Keep this a BASIC SHAPE rather than clip-path: url(#svg). In a
   side-by-side A/B under a slowed transition - same element, same
   backdrop-filter, only the clip syntax differing - a reference clip was
   dropped once the element was promoted to a compositor layer and the
   glass painted its full rectangular box, while polygon() held the
   silhouette. NOTE: that was a real but SEPARATE defect from the notch
   filling in on this page, which turned out to be an overflow clip on
   .ocard-face (see the comment there). Switching to polygon did not fix
   that bug; it is kept as independent hardening, not as its remedy.

   The polygon below is that same folder path, generated from it by
   sampling each quadratic segment (10 points per rounded corner,
   endpoints only along the straight runs). Percentages resolve against
   the element's own box exactly as objectBoundingBox did, so it still
   scales with the card at every breakpoint. To reshape the card, edit
   the source path in git history and re-sample - do not hand-tweak
   individual vertices. */
.ocard-glass {
  position: absolute;
  inset: 0;
  clip-path: polygon(5% 0%, 36% 0%, 36.77% 0.03%, 37.48% 0.1%, 38.13% 0.22%, 38.72% 0.4%, 39.25% 0.63%, 39.72% 0.9%, 40.13% 1.22%, 40.48% 1.6%, 40.77% 2.03%, 41% 2.5%, 41.23% 2.98%, 41.52% 3.4%, 41.87% 3.77%, 42.28% 4.1%, 42.75% 4.38%, 43.28% 4.6%, 43.87% 4.78%, 44.52% 4.9%, 45.23% 4.98%, 46% 5%, 95% 5%, 95.95% 5.05%, 96.8% 5.2%, 97.55% 5.45%, 98.2% 5.8%, 98.75% 6.25%, 99.2% 6.8%, 99.55% 7.45%, 99.8% 8.2%, 99.95% 9.05%, 100% 10%, 100% 95%, 99.95% 95.95%, 99.8% 96.8%, 99.55% 97.55%, 99.2% 98.2%, 98.75% 98.75%, 98.2% 99.2%, 97.55% 99.55%, 96.8% 99.8%, 95.95% 99.95%, 95% 100%, 5% 100%, 4.05% 99.95%, 3.2% 99.8%, 2.45% 99.55%, 1.8% 99.2%, 1.25% 98.75%, 0.8% 98.2%, 0.45% 97.55%, 0.2% 96.8%, 0.05% 95.95%, 0% 95%, 0% 5%, 0.05% 4.05%, 0.2% 3.2%, 0.45% 2.45%, 0.8% 1.8%, 1.25% 1.25%, 1.8% 0.8%, 2.45% 0.45%, 3.2% 0.2%, 4.05% 0.05%);
  /* Same recipe as the nav bar's .glass: one flat white fill at a low
     alpha, a single blur+saturate, nothing painted on the surface. */
  /* --phase is 1 everywhere except while the orbit ticker is carrying a
     card through the heading's z = 0 plane, where it runs to 0 at the
     crossing (see PHASE_BAND in the ticker). The depth sort has to swap
     the card from behind the type to in front of it in a single frame,
     and with a solid frosted card that swap was a visible cut: blurred
     type under the frost one frame, crisp type over the card the next.
     So for the frames either side of it the card's own fill thins to a
     quarter and its frost drops to almost nothing - the type reads nearly
     the same through the pane as it does over it, and the card ghosts
     through instead of clipping. Inherited from .ocard; where it is
     never set (no-JS poses, the grid fallback) everything here resolves
     to its normal value. rgb() with a calc() alpha rather than
     color-mix(): the fill has to be recomputed every frame and this is
     the cheapest form that takes a variable. */
  background: rgb(255 255 255 / calc(.68 * (.25 + .75 * var(--phase, 1))));
  -webkit-backdrop-filter: blur(calc(2px + 12px * var(--phase, 1))) saturate(180%);
  backdrop-filter: blur(calc(2px + 12px * var(--phase, 1))) saturate(180%);
  display: flex;
  flex-direction: column;
  transition: background-color var(--dur-base) ease;
}
/* Hover: the front glass goes black so the module-coloured type (below)
   reads as colour on black. Near-black at 82% over the black stage is
   effectively solid, which is what the type's contrast needs - all four
   module colours clear 4.5:1 against it, where against the white frost
   the lighter two (orange, green) sat close to 1:1 and vanished. */
/* Warm charcoal, not black: the first cut of this went to #0F0F0F and the
   client's note was that the cards "shouldn't turn all the way black".
   Over the black stage this composites to roughly rgb(40,39,36) - dark
   enough for every module colour to read (the weakest, blue, is ~3.1:1
   against it at the name's 34px), light enough to still be a card. */
/* The card charcoal: #2D2D2D, the client's own value. Not the near-black
   #2E2C29 this used to jump to, and not the buttons' warmer --charcoal:
   the hover is a step darker than the card, "just slightly", and the same
   value carries the back's upper part so the hover previews the turn. */
.orbit-stage { --ocard-charcoal: #2D2D2D; }
@media (hover: hover) {
  .ocard:hover .ocard-front .ocard-glass {
    background: color-mix(in srgb, var(--ocard-charcoal) 88%, transparent);
  }
}
.ocard:focus-visible .ocard-front .ocard-glass {
  background: color-mix(in srgb, var(--ocard-charcoal) 88%, transparent);
}
/* The type ghosts with the pane (see --phase on .ocard-glass): at the
   crossing it drops to 30% so the heading behind/over it wins for those
   frames. Opacity on these leaf elements is safe - the backdrop-root rule
   above is about ANCESTORS of the glass, and these sit inside it. */
.ocard-front .ocard-num,
.ocard-front .ocard-name,
.ocard-front .ocard-sub { opacity: calc(.3 + .7 * var(--phase, 1)); }
.ocard-num,
.ocard-name,
.ocard-copy { position: relative; }
.ocard-num {
  padding: 0;
  font-family: var(--mono);
  font-size: 12px;
  line-height: 1;
  letter-spacing: .14em;
  /* Ink on the white frost, same as the name and sub below it so the
     three read as one label. Over the resting glass (white 68% over the
     black stage, ~173 grey) full ink measures ~5.5:1, comfortably past
     the 4.5:1 this 12px text needs. Hover swaps all three to the module
     colour on the blackened glass. */
  color: var(--ink-on-paper);
  transition: color var(--dur-base) ease;
}
.ocard-front .ocard-glass {
  justify-content: flex-end;
  gap: 8px;
  /* 20% (of the card's width, which is how % padding resolves) puts the
     name about 15% of the card's height up from its bottom edge - the
     position the client pointed at as the one to match. The same value on
     every card, and the type moves WITH the glass under the scroll
     parallax (.ocard-undo carries it), so the distance is identical on all
     four at every scroll position. (The archived static build counter-
     moved the type against the glass, which is why its names sat at
     different heights per card - that is the bug in the screenshot, not
     a difference in these rules.) */
  padding: 16px 16px 20%;
}
.ocard-front .ocard-name {
  display: block;
  padding: 0;
  font-size: clamp(1.65rem, 2.4vw, 2.15rem);
  line-height: 1;
  white-space: nowrap;
  /* Full ink, not the 62% grey wash this used to be: the client asked for
     the type to read as black on the white frost. Hover swaps it to the
     module colour on the blackened glass. */
  color: var(--ink-on-paper);
  transition: color var(--dur-base) ease;
}
/* The module descriptor under the name (e.g. "Identity & Meaning"), from
   the Diagnostic Bible's module definitions. Body face, not display, so
   it reads as a subtitle rather than a second title. */
.ocard-front .ocard-sub {
  display: block;
  padding: 0;
  margin-top: -2px;
  font-family: var(--body);
  font-weight: 400;
  font-size: clamp(.8rem, .95vw, .95rem);
  line-height: 1.3;
  letter-spacing: .01em;
  white-space: nowrap;
  color: var(--ink-on-paper);
  transition: color var(--dur-base) ease;
}
@media (hover: hover) {
  /* Lightened a fifth toward white: on charcoal the pure module colours
     do not all clear AA at the name's size (blue measured 2.5:1), the
     mix clears it on every module while still reading as that module. */
  .ocard:hover .ocard-front .ocard-name,
  .ocard:hover .ocard-front .ocard-sub,
  .ocard:hover .ocard-front .ocard-num { color: color-mix(in srgb, var(--m-tint) 78%, #fff); }
}
.ocard:focus-visible .ocard-front .ocard-name,
.ocard:focus-visible .ocard-front .ocard-sub,
.ocard:focus-visible .ocard-front .ocard-num { color: color-mix(in srgb, var(--m-tint) 78%, #fff); }
.ocard-name {
  display: block;
  font-size: clamp(1.2rem, 1.9vw, 1.75rem);
  line-height: 1;
}
/* No rotateY(180deg) on the back face: the half-turn swap shows it
   un-rotated (and a 3D transform here would also mirror the text AND
   re-form the backdrop root that kills the frost). */
.ocard-back .ocard-glass {
  /* The back keeps the module tint (the front is white frost); blurring
     the black stage returns black, so the tint alpha is the direct
     saturation dial. */
  /* The same charcoal as the hover across the upper two-thirds, the
     module's colour only in the lower third, rising to full strength at
     the foot. Everything the visitor reads sits over the charcoal (white
     on it measures ~10:1); the list's last line reaches at most ~72% down,
     where the tint is still under a fifth. The gradient breathes: --g0
     (where the colour begins) and --gk (how strong it is at the foot)
     drift on a slow loop, registered so they interpolate - a living
     surface rather than a printed one. Static under reduced motion. The
     back is display:none until the turn, so the loop only runs while it
     is actually on screen. */
  /* Two elliptical blooms of the module colour anchored to the foot, each
     feathering out to nothing well before the copy, on two different
     slow loops: their heights breathe (the colour rises and falls) while
     their centres drift sideways, so the edge between colour and charcoal
     undulates rather than moving up and down as a flat line. Registered
     custom properties, so all four values interpolate. Kept low: the
     taller bloom peaks at a third of the card's height (the client's
     read on a half-height version was "further down"), so the copy,
     which is set to end around 70% down, sits over charcoal with only
     the feathered tail of the colour beneath its last line. */
  --wx1: 38%; --wr1: 26%; --wx2: 66%; --wr2: 20%;
  background:
    radial-gradient(130% var(--wr1) at var(--wx1) 100%,
      var(--m-tint) 0%,
      color-mix(in srgb, var(--m-tint) 55%, var(--ocard-charcoal)) 38%,
      transparent 100%),
    radial-gradient(110% var(--wr2) at var(--wx2) 100%,
      color-mix(in srgb, var(--m-tint) 70%, var(--ocard-charcoal)) 0%,
      transparent 100%),
    var(--ocard-charcoal);
  animation: ocard-wave-a 9s ease-in-out infinite, ocard-wave-b 13s ease-in-out infinite;
  /* Extra top padding clears the folder tab's notch, which cuts into
     the top-right of the silhouette and would otherwise clip the first
     line of type. */
  /* Top padding trimmed from clamp(30px, 4vw, 46px): the notch it clears
     is 5% of the card's height (~15px on the open card), the rest was
     air the exercise list now needs. */
  padding: clamp(24px, 3vw, 36px) clamp(14px, 1.5vw, 20px) clamp(14px, 1.5vw, 20px);
  gap: 7px;
}
.ocard-back .ocard-copy,
.ocard-back .ocard-name {
  color: #fff;
}
@property --wx1 { syntax: '<percentage>'; inherits: false; initial-value: 38%; }
@property --wr1 { syntax: '<percentage>'; inherits: false; initial-value: 26%; }
@property --wx2 { syntax: '<percentage>'; inherits: false; initial-value: 66%; }
@property --wr2 { syntax: '<percentage>'; inherits: false; initial-value: 20%; }
@keyframes ocard-wave-a {
  0%, 100% { --wx1: 38%; --wr1: 26%; }
  50% { --wx1: 58%; --wr1: 34%; }
}
@keyframes ocard-wave-b {
  0%, 100% { --wx2: 66%; --wr2: 20%; }
  40% { --wx2: 44%; --wr2: 29%; }
  70% { --wx2: 56%; --wr2: 24%; }
}
@media (prefers-reduced-motion: reduce) {
  .ocard-back .ocard-glass { animation: none; }
}
.ocard-back .ocard-name {
  /* Up from clamp(1.2rem, 1.9vw, 1.7rem): the name is the back's headline
     and was set at the front's caption size. It also carries the block's
     first pause below it (margin, not the glass's gap), so the copy sits
     lower and the name reads as a heading over it, not a label glued to
     it. */
  font-size: clamp(1.55rem, 2.5vw, 2.3rem);
  line-height: .95;
  white-space: nowrap;
  flex: 0 0 auto;
  margin-bottom: clamp(8px, 1.3vw, 18px);
}
.ocard-copy {
  /* Up from clamp(9.5px, .9vw, 12px) now the open card is a fifth
     bigger (see the showback --ocard-w rule) and the back carries a real
     paragraph plus the takeaway list below it. */
  font-size: clamp(10px, .95vw, 14px);
  line-height: 1.45;
  /* min-height:0 lets this flex child actually shrink so its own
     overflow:hidden applies, instead of the default min-content floor
     pushing the photo below it out of the card. */
  min-height: 0;
  overflow: hidden;
}
/* The three named exercises for the module, under the paragraph. Tight,
   white, a short rule for a marker rather than a dot: three small lines
   of evidence, not a second paragraph. */
.ocard-list {
  list-style: none;
  margin: 2px 0 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 7px;
  font-size: clamp(9.5px, .85vw, 12.5px);
  line-height: 1.32;
  color: #fff;
  flex: 0 0 auto;
  position: relative;
  margin-top: clamp(4px, .6vw, 8px);
}
.ocard-list li {
  position: relative;
  padding-left: 14px;
}
.ocard-list li::before {
  content: '';
  position: absolute;
  left: 1px;
  top: .42em;
  width: 5px;
  height: 5px;
  border-radius: 50%;
  background: var(--m-tint);
}
.ocard-list b { font-weight: 600; }

.ocard:focus-visible { outline: 2px solid var(--red); outline-offset: 6px; }

/* No orbit under 900px or reduced motion: a spinning ring is unreadable
   at phone width and is exactly the kind of motion that rule exists for.
   The same cards stay, as a plain grid that still flips. */
@media (max-width: 900px), (prefers-reduced-motion: reduce) {
  .orbit-stage {
    height: auto;
    display: block;
    padding: 0 var(--gutter) clamp(24px, 5vw, 48px);
    perspective: none;
    overflow: visible;
  }
  .orbit-heading {
    position: static;
    width: auto;
    text-align: left;
    margin-bottom: clamp(24px, 5vw, 44px);
    text-shadow: none;
    transform: none;
  }
  .orbit-tilt { position: static; transform: none; }
  .orbit-ring {
    position: static;
    width: auto;
    height: auto;
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: clamp(12px, 3vw, 24px);
    /* This same block is what a reduced-motion visitor gets at ANY
       viewport width, not only phones - the media query above is
       (max-width: 900px) OR (prefers-reduced-motion: reduce), and the
       second half has no width limit of its own. Fluid 1fr columns with
       nothing capping the grid's own width meant a reduced-motion visitor
       on an ordinary 1440px desktop got two 652x815px cards - 45% of the
       viewport EACH - dwarfing the plan card and most other content on
       the page. Measured directly by forcing this exact rule set to apply
       at 1440px, not assumed.
       max-width caps the grid itself (not each card individually, which
       would leave the fluid columns wide with a stranded card inside
       them); centred once it is narrower than its container. ~300px a
       card at two columns plus the gap - close to the normal 3D orbit's
       own 260px card cap (--ocard-w), so a reduced-motion visitor's cards
       read as roughly the same scale as everyone else's, not a phone
       layout stretched across a desktop screen. Inert below 900px: two
       fluid columns in a viewport already narrower than this cap were
       never going to hit it, so phones render exactly as before. */
    max-width: 624px;
    margin: 0 auto;
    transform: none !important;
  }
  .ocard {
    position: static;
    width: auto;
    margin: 0;
    aspect-ratio: 4 / 5;
    /* !important: the orbit ticker writes transform and z-index as
       inline styles, which would otherwise survive a resize into the
       grid and misplace the cards. */
    transform: none !important;
    z-index: auto !important;
  }
  /* Same reason: the flip choreography writes an inline transform on
     the inner while turning. */
  /* Same reason again: --parallax-y is only ever written while the
     ticker is running (desktop only), so crossing down into this grid
     mid-scroll can leave a stale value on both the wrapper and its
     text counter-transform - reset both explicitly rather than relying
     on the custom property's default. */
  .ocard-undo, .ocard-billboard, .ocard-untilt,
  .ocard-num, .ocard-name, .ocard-copy { transform: none !important; }
  .ocard-front .ocard-glass {
    gap: 6px;
    padding: 12px 12px 16px;
  }
  /* The two-column grid (601-900px, and reduced motion at any width):
     the cells run 270-300px wide, and the type was still the phone's
     10px in a card two-thirds empty. Scaled to the column now; the
     phone (single column, below 600) has its own sizes further down. */
  .ocard-front .ocard-name { font-size: clamp(1.3rem, 4.2vw, 2rem); }
  .ocard-back .ocard-name { font-size: clamp(1.2rem, 3.4vw, 1.7rem); margin-bottom: clamp(8px, 1.4vw, 14px); }
  .ocard-num {
    font-size: 11px;
  }
  .ocard-back .ocard-glass {
    gap: 7px;
    padding: 26px 14px 12px;
  }
  .ocard-copy {
    font-size: clamp(11px, 1.75vw, 13.5px);
    line-height: 1.4;
  }
  .ocard-list { font-size: clamp(10.5px, 1.55vw, 12.5px); }
}
/* Phones: one column. Two 170px columns could not hold the back's
   paragraph and exercise list (clipped by ~100px), and letting the open
   card expand across the row reflowed its siblings mid-turn - the
   "broken" mobile view. One 350px-wide card, a little shorter (5:4
   rather than 4:5, so the stack of four is not a mile long), turns in
   place with room for everything on its back. */
@media (max-width: 600px) {
  .orbit-ring {
    grid-template-columns: 1fr;
    gap: 12px;
    max-width: 440px;
  }
  .ocard { aspect-ratio: 5 / 4; }
  .ocard-front .ocard-glass { padding: 14px 16px 18px; }
  /* A 350px-wide card was carrying a 1.15rem name - the 2-column grid's
     size. On the single column the name is the card's whole front. */
  .ocard-front .ocard-name { font-size: clamp(1.7rem, 9vw, 2.4rem); }
  .ocard-back .ocard-name { font-size: clamp(1.5rem, 7.6vw, 2rem); margin-bottom: 10px; }
  .ocard-back .ocard-glass { padding: 26px 16px 14px; gap: 7px; }
  .ocard-front .ocard-sub { font-size: clamp(14px, 4vw, 16px); }
  .ocard-num { font-size: 12px; }
  .ocard-back .ocard-glass { padding: 26px 16px 14px; gap: 7px; }
  .ocard-copy { font-size: clamp(11px, 3vw, 12.5px); line-height: 1.35; }
  .ocard-list { font-size: clamp(10.5px, 2.8vw, 12px); gap: 4px; }
}
/* ============ Outcomes + Diagnostic + Work ============ */
/* Everything from here down runs light: paper hero -> black workshop
   (clients/why/modules stay dark, the orbit needs that field) -> light
   outcome. These are the same token values body[data-theme="paper"]
   already sets during the hero; scoping them per section rather than
   flipping the body theme again means no second scroll trigger and no
   JS change. The --black and rgba(255,255,255,*) values used below do
   NOT read from these tokens and are fixed by hand where they occur. */
#outcomes, #diagnostic, #who, #work, #inside, #book, .site-footer {
  --bg: var(--paper);
  --fg: var(--ink-on-paper);
  --fg-dim: #55524D;
  --rule: rgba(26, 26, 26, .16);
  background: var(--bg);
  color: var(--fg);
}
#outcomes {
  padding-top: var(--section-pad);
  padding-bottom: var(--section-pad);
}
/* start, not center. The media column is a fixed 16:9 box (~485px tall at
   desktop) and the copy column is the full outcomes list (~1100px), so
   centring the short one against the tall one hung it in mid-air with
   roughly 300px of dead space above AND below it, reading as a floating
   panel rather than a column. Sharing a top edge is what makes the two
   read as one row. */
.outcomes-grid {
  display: grid;
  grid-template-columns: 1.1fr 1fr;
  gap: clamp(40px, 6vw, 80px);
  align-items: start;
}
@media (max-width: 900px) {
  .outcomes-grid { grid-template-columns: 1fr; }
}
.outcomes-media {
  width: 100%;
  aspect-ratio: 16 / 9;
  overflow: hidden;
  border-radius: var(--radius-lg);
  background: var(--bg);
}
.outcomes-photo {
  width: 100%;
  height: 100%;
  object-fit: cover;
  filter: grayscale(.1) contrast(1.05);
}
.outcomes-copy {
  display: flex;
  flex-direction: column;
  gap: 24px;
}
.outcomes-lede {
  font-size: clamp(1.2rem, 2vw, 1.5rem);
  line-height: 1.4;
  color: var(--fg);
}
.outcomes-sub {
  font-size: 15px;
  font-weight: 700;
  color: var(--fg);
}
.outcomes-list {
  list-style: none;
  display: flex;
  flex-direction: column;
  gap: 16px;
}
.outcomes-item {
  display: flex;
  align-items: flex-start;
  gap: 16px;
  font-size: clamp(15px, 1.2vw, 17px);
  line-height: 1.5;
  color: var(--fg);
  opacity: 1;
  transform: none;
}
/* A dot, not a dash: the client's call for every bullet list on both
   sites (the module cards' takeaway lists use the same 6px dot in the
   module colour). Sized and offset to sit on the first line's x-height. */
.outcomes-mark {
  flex: 0 0 6px;
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--red);
  margin-top: .55em;
  margin-right: 10px;
}
/* The CTA is wrapped so the scroll reveal has something to animate that is
   NOT the button itself. .btn carries its own transition (transform for the
   press, background-color/color for hover), and a transition declaration on
   the same element replaces it wholesale - so revealing the button directly
   meant .btn's transition won and the CTA popped in with no fade while
   everything around it faded, while forcing .reveal to win instead would have
   stretched the button's 150ms press feedback to 700ms. Animating the wrapper
   keeps both behaviours intact. */
.outcomes-cta {
  align-self: flex-start;
  margin-top: 8px;
}

#diagnostic, #who {
  padding-top: var(--section-pad-lg);
  padding-bottom: var(--section-pad-lg);
}
.diagnostic-content {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 24px;
}
.diagnostic-content h2 {
  max-width: 18ch;
}
.diagnostic-copy {
  max-width: 52ch;
  font-size: clamp(16px, 1.5vw, 18px);
  line-height: 1.6;
  color: var(--fg-dim);
}
.diagnostic-content .btn {
  margin-top: 4px;
}
/* start, for the same reason as .outcomes-grid: these two columns are
   different heights and centring them against each other leaves dead
   space above the shorter one instead of a shared top edge. */
.media-split {
  display: grid;
  grid-template-columns: minmax(0, .9fr) minmax(280px, 1.1fr);
  gap: clamp(40px, 7vw, 104px);
  align-items: start;
}
/* Same treatment as .outcomes-photo, so the photographs in the light
   sections read as one set. Shared by #who and #diagnostic. */
.who-photo {
  width: 100%;
  aspect-ratio: 3 / 2;
  overflow: hidden;
  border-radius: var(--radius-lg);
  background: var(--bg);
}
.who-photo img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  filter: grayscale(.1) contrast(1.05);
}
@media (max-width: 767px) {
  .media-split {
    grid-template-columns: 1fr;
    gap: 32px;
  }
  /* Stacked, the text column is the full width, but the heading's 18ch
     and the copy's 52ch (reading-measure caps tuned for the two-column
     layout) left the right third of a phone empty - the client's
     "bunched on the left". Let both run to the width they have. */
  .diagnostic-content h2 { max-width: none; }
  .diagnostic-copy { max-width: none; }
}

#work {
  padding-top: var(--section-pad);
  padding-bottom: var(--section-pad);
}
#work h2 {
  margin-bottom: 8px;
}
.work-rows {
  margin-top: 40px;
}
.work-row {
  display: grid;
  grid-template-columns: minmax(140px, 200px) 1fr minmax(140px, 220px);
  column-gap: clamp(24px, 4vw, 48px);
  row-gap: 16px;
  align-items: start;
  padding: 40px 0;
  border-top: 1px solid var(--rule);
  opacity: 1;
  transform: none;
}
.work-row:first-child {
  border-top: 0;
  padding-top: 0;
}
@media (max-width: 900px) {
  .work-row {
    grid-template-columns: 1fr;
    row-gap: 16px;
    padding: 32px 0;
  }
}
.work-logo-wrap {
  /* No box: #work is now a light section (see D2), so the logos sit
     directly on it rather than inside individual white plates. Width
     and height stay only to keep the logos in a consistent column. */
  width: 100%;
  max-width: 180px;
  display: flex;
  align-items: flex-start;
  justify-content: flex-start;
}
@media (max-width: 900px) {
  .work-logo-wrap {
    max-width: 140px;
  }
}
.work-logo {
  max-width: 100%;
  height: auto;
  max-height: 44px;
  width: auto;
  object-fit: contain;
  object-position: left top;
  margin-top: 2px;
}
@media (max-width: 900px) {
  .work-logo { max-height: 36px; margin-top: 0; }
}
/* Capped measure. This sits in the 1fr middle column of .work-row, so
   without a cap the line length just tracks the viewport - measured 79
   characters per line at 1200px and longer above that, past the point
   where the eye reliably finds the next line. 68ch holds it in the
   comfortable 45-75 band at every width; the column itself still flexes,
   the text just stops using all of it. */
.work-summary {
  font-size: clamp(15px, 1.2vw, 17px);
  line-height: 1.55;
  max-width: 68ch;
  color: var(--fg-dim);
}
.work-attribution {
  font-family: var(--mono);
  font-size: 12px;
  letter-spacing: .04em;
  color: var(--fg);
  line-height: 1.5;
}
.work-credibility {
  margin-top: clamp(48px, 6vw, 80px);
  max-width: 64ch;
  font-size: clamp(15px, 1.5vw, 17px);
  line-height: 1.6;
  color: var(--fg-dim);
}

/* ============ Inside a session (real workshop photography) ============ */
#inside { padding: clamp(48px, 8vw, 104px) 0 clamp(40px, 6vw, 80px); }
/* Scroll-snapped rather than a grid: these are atmosphere, and a strip
   the visitor can push through keeps them from competing with the
   engagements above or the form below. */
.inside-strip {
  display: flex;
  gap: clamp(12px, 2vw, 24px);
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  padding: 0 var(--gutter) 24px;
  scroll-padding-inline: var(--gutter);
  -webkit-overflow-scrolling: touch;
  scrollbar-width: thin;
}
.inside-shot {
  flex: 0 0 auto;
  width: clamp(240px, 30vw, 420px);
  aspect-ratio: 4 / 3;
  overflow: hidden;
  border-radius: var(--radius);
  scroll-snap-align: start;
  background: var(--bg);
}
.inside-shot img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  /* brightness(.9) knocked the photos down to recede into the dark
     field this section used to sit on; #inside is light now (D2), so
     that darkening is dropped. The hover state already targets
     brightness(1) explicitly and needs no change. */
  filter: grayscale(.15) contrast(1.04);
  transition: filter var(--dur-slow) ease;
}
@media (hover: hover) {
  .inside-shot:hover img { filter: grayscale(0) contrast(1.04) brightness(1); }
}

/* ============ Book + Footer ============ */
/* Was a flat 96px 0 - the only section whose padding didn't clamp down
   on narrower viewports, so it stayed full-size on the exact widths
   where every neighbouring section was already shrinking. */
#book { padding: var(--section-pad) 0; background: var(--bg); }

.book-wrap {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: clamp(32px, 6vw, 80px);
  align-items: start;
}

@media (max-width: 767px) {
  .book-wrap { grid-template-columns: 1fr; }
}

.book-intro { padding-top: clamp(8px, 4vw, 40px); }

.book-lede {
  margin-top: 24px;
  font-size: clamp(16px, 1.5vw, 18px);
  line-height: 1.6;
  color: var(--fg-dim);
  max-width: 42ch;
}

.book-panel {
  position: relative;
  overflow: hidden;
  border-radius: var(--radius-lg);
  isolation: isolate;
}

.book-panel-bg {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  /* brightness(.5) darkened this behind a dark glass panel; #book is
     light now (D2), and the panel's own light-glass fill (below) gives
     the form text enough contrast regardless of the backdrop's tone. */
  filter: blur(32px);
}

.book-form-panel {
  position: relative;
  z-index: 1;
  padding: clamp(24px, 4vw, 40px);
  /* Solid, not glass: the blurred photo behind it (.book-panel-bg) is
     enough atmosphere for the section - a frosted form panel added a
     second layer of blur on top of an already-blurred backdrop and made
     the fields themselves look hazy instead of just the scene behind
     them. */
  background: var(--white);
  border-radius: var(--radius-lg);
  /* Layered, as the plan card above - a white panel on a light section
     needs less reach and far less darkness than a card over black, so
     every stop here is a fraction of that one's. */
  box-shadow:
    0 1px 2px rgba(15, 15, 15, .04),
    0 8px 20px rgba(15, 15, 15, .07),
    0 32px 64px rgba(15, 15, 15, .1);
}

.book-field { margin-bottom: 20px; }

.book-field label {
  display: block;
  font-size: 13px;
  color: var(--fg);
  margin-bottom: 8px;
}
.book-note { font-size: 13px; color: var(--fg-dim); margin-bottom: 18px; }
.req { color: var(--red); margin-left: 2px; }
.opt { color: var(--fg-dim); }

.book-field input {
  display: block;
  width: 100%;
  font-family: var(--body);
  font-size: 16px;
  color: var(--fg);
  /* Dark-alpha, not white-alpha: #book is a light section now (D2), so
     a white-tinted fill/border would have gone the wrong way and read
     as almost invisible against the paper background. */
  background: rgba(26, 26, 26, .04);
  /* 18% read as 1.45:1 against the panel's white fill - short of SC 1.4.11's
     3:1 minimum for a UI component's own boundary, computed via the WCAG
     relative-luminance formula against --white (#F5F4F2), the panel's
     actual background. 50% clears it with a small margin (3.26:1); the
     fill above is unchanged - 1.4.11 is about the border actually
     delineating the field, not the fill matching it. */
  border: 1px solid rgba(26, 26, 26, .5);
  border-radius: var(--radius);
  padding: 12px 14px;
}

.book-field input:focus {
  outline: 2px solid var(--red);
  outline-offset: 2px;
}

.book-field input.invalid {
  border-color: var(--red);
}

.field-error {
  font-family: var(--mono);
  font-size: 12px;
  color: var(--red);
  margin-top: 6px;
  min-height: 1em;
}

.book-actions {
  margin-top: 8px;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 4px;
}

.book-actions .btn-primary {
  width: 100%;
  text-align: center;
}

.book-actions .btn-primary:disabled {
  opacity: .6;
  cursor: not-allowed;
}

.book-actions #lead-general-error { margin-top: 4px; }

.book-actions .btn-quiet { margin-top: 8px; }

#lead-success {
  font-size: 18px;
  line-height: 1.5;
  color: var(--fg);
}

.site-footer {
  border-top: 1px solid var(--rule);
  padding: 24px 0;
}

.site-footer-inner {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 24px 32px;
  font-family: var(--mono);
  font-size: 13px;
  letter-spacing: .02em;
  color: var(--fg-dim);
}

.site-footer-brand {
  display: flex;
  align-items: center;
  gap: 14px;
}
.site-footer-sep {
  width: 1px;
  height: 20px;
  background: var(--rule);
}
.site-footer-milkman {
  height: 17px;
  width: auto;
  color: var(--ink-on-paper);
}
.site-footer-logo {
  height: 18px;
  width: auto;
  /* The source file is a white mark; the footer is now a light
     section, so it needs the same invert the chrome bar already
     applies to its own logo in the paper theme. */
  filter: invert(1);
}

.site-footer-links {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px 24px;
  text-transform: uppercase;
  letter-spacing: .08em;
  font-size: 12px;
}

.site-footer-links a {
  color: var(--fg-dim);
  text-decoration: none;
}

.site-footer-links a:hover { color: var(--fg); }

.site-footer-address { color: var(--fg-dim); }

@media (max-width: 480px) {
  .site-footer-inner { flex-direction: column; align-items: flex-start; }
  .site-footer-links { flex-direction: column; align-items: flex-start; gap: 8px; }
}
