/* Vitality Vault — iteration 1
   Brand: his own logo, black/white/silver. Marble texture deliberately dropped. */

/* ---------------------------------------------------------------
   DISPLAY FACE

   Target is Timmons NY. The awwwards entry describes it as compact / all-caps
   / editorial, and it is sold by Best Fonts: a PAID licence, and a desktop
   licence does not cover web embedding. There is no legitimate way to ship it
   until that licence is bought, so it is not shipped.

   Anton is here as the stand-in: SIL Open Font Licence, self-hosting and
   commercial use explicitly permitted, and it carries the same compact,
   heavy, all-caps editorial character. 12KB.

   TO SWAP IN THE REAL FACE, two lines:
     1. drop timmons-ny.woff2 into site/fonts/ and uncomment the block below
     2. put "Timmons NY" first in --f-display
   Nothing else changes: every headline already resolves through --f-display,
   and the uppercase / 0.85 leading / negative tracking treatment stays put.

@font-face {
  font-family: "Timmons NY";
  src: url("fonts/timmons-ny.woff2") format("woff2");
  font-weight: 400 900;
  font-display: swap;
}
--------------------------------------------------------------- */

@font-face {
  font-family: "Anton";
  src: url("fonts/anton.woff2?v=e57915c8") format("woff2");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

:root {
  --bg:        #08080A;
  --bg-2:      #1E1E24;
  --bg-3:      #141417;
  --line:      #232327;
  --line-2:    #303036;
  --white:     #F5F5F3;
  --muted:     #97979E;
  /* Lifted from #6E6E76 on 12 Aug. Against --bg it measured 3.96:1, under the
     4.5 AA floor for small text — and every use of it IS small text: eyebrows,
     the ledger numerals, macro labels, the arc captions. #86868F measures 5.48:1 there and 4.56:1 on the
     lighter band-c ground (#1E1E24), which is where the first fix at #7C7C85
     still failed at 4.01:1 — the darkest ground is not the hard case. The inverted bands hardcode their own #6E6E76 and are unaffected. */
  --muted-2:   #86868F;
  --silver:    #C9CBD1;

  /* Reading copy. Added 11 Aug because every paragraph on the dark grounds was
     running on --muted #97979E, which measures 5.72:1 against the #1E1E24 band —
     legal under AA and visibly washed out, especially in the About band where
     the greys are close in hue to the ground behind them.

     The mammoth study is the reason this is a token rather than a tweak: 598 of
     their 704 text elements are ONE ink, and their body copy is the same colour
     as their headings. They build hierarchy from size alone (164.5 vs 23.25),
     never from dimming. We had a three-step dimming ladder doing that job.

     Not pure --white though, and that is a deliberate correction rather than a
     hedge: mammoth is dark-ink-on-cream, and inverting a single-ink system to
     light-on-dark reintroduces halation on long paragraphs. --silver is the
     optical equivalent — 10.13:1, nearly double what it was.

     --muted survives for units and metadata only: the "g" after a macro, the
     "kg" in a delta, form status, the footer credit. Never for a sentence. */
  --body:      #C9CBD1;

  /* Headlines only. Put "Timmons NY" at the front once it is licensed. */
  --f-display: "Anton", "Haettenschweiler", "Arial Narrow Bold", Impact, sans-serif;
  /* Everything that is read rather than scanned. A compact display face at
     body size is genuinely harder to read, which works against the whole
     point of the site. */
  --f-ui:      -apple-system, "SF Pro Display", "Helvetica Neue", "Segoe UI", system-ui, sans-serif;

  /* Measured on mammothmurals: 4px on 53 boxes, and it is the ONLY radius in
     real use across 230 measured elements. Not 0, not 12, not rounded-lg. They
     also put it on the media itself, not only on the clipping frame. */
  --r:         4px;

  --shell:     72rem;
  --pad:       clamp(1.25rem, 5vw, 2.5rem);
  /* 160px floor / 256px ceiling: the band padding measured on supersolid. */
  --band-y:    clamp(5rem, 13vw, 16rem);

  /* Height of the header cluster. Was hardcoded as 3.4rem in four places —
     .hdr, .hdr-row, .hdr-panel's top offset and .phead's top clearance — which
     is four chances to drift. One token now; the JS needs no equivalent because
     it reads the panel's live offsetTop rather than a constant.

     On phones this drops to 2.6rem in the media query below, which is exactly
     .nav-brand's height, so the two chips on the top rail become identical
     objects rather than two plates that merely share a top edge. */
  --hdr-h:     3.4rem;
}

/* Phone: match the brand plate exactly. */
@media (max-width: 45.99rem) {
  :root { --hdr-h: 2.6rem; }
}

* { box-sizing: border-box; }

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

body {
  margin: 0;
  background: var(--bg);
  color: var(--white);
  font-family: var(--f-ui);
  font-size: 1rem;
  line-height: 1.6;
  -webkit-font-smoothing: antialiased;
  overflow-x: hidden;
}

img { max-width: 100%; height: auto; display: block; }

a { color: inherit; }

/* Container ladder, from the supersolid census: repeated content widths of
   1376px (95.6% vw), 1152, 796 (55.3%) and 564 (39.2%). The references vary
   the container per section rather than running one width down the whole page,
   and that variation is a large part of why the rhythm reads as considered. */
.shell { width: 100%; max-width: var(--shell); margin: 0 auto; padding: 0 var(--pad); }
.shell-wide { max-width: min(95.6vw, 92rem); }     /* 1376 at 1440 */
.shell-read { max-width: 49.75rem; }               /*  796 at 1440 */

.skip {
  position: absolute; left: -9999px; top: 0;
  background: var(--white); color: var(--bg);
  padding: 0.75rem 1.25rem; z-index: 100; font-weight: 700;
}
.skip:focus { left: 0; }

:focus-visible { outline: 2px solid var(--white); outline-offset: 3px; }

/* ---------------- buttons ---------------- */

.btn {
  display: inline-flex; align-items: center; justify-content: center;
  /* UI face, not the display face: a condensed all-caps button at 13px goes
     illegible fast, and the CTA is the one thing that must never be hard to read. */
  font-family: var(--f-ui);
  font-size: 0.8125rem; font-weight: 700;
  letter-spacing: 0.13em; text-transform: uppercase;
  text-decoration: none;
  --btn-px: 2.1rem;
  padding: 1.05rem var(--btn-px);
  border: 1px solid transparent;
  border-radius: var(--r);
  position: relative;
  transition: background-color .18s ease, color .18s ease, border-color .18s ease;
}
.btn .lbl { position: relative; z-index: 2; display: block; }

/* ---- the arrow, chosen 12 Aug from a six-way hover comparison ----
   What shipped before was a flat colour swap over 180ms and nothing else: white
   went silver, the ghost's border went white, and nothing moved. It read as
   inert because it was.

   The label steps aside and an arrow comes in behind it. Picked over a fill
   sweep and a lift because both these buttons LEAVE — Instagram, or a sent form
   — so pointing somewhere is truer than lighting up. It also costs the page no
   new vocabulary: the footer rows already move an arrow on hover, so this is
   the same idea applied to a second component.

   Live only where a real pointer exists. On touch :hover latches ON after a tap
   and never clears, so the arrow would stick out permanently; and since the
   treatment buys its room with extra right padding, running it on a phone would
   also leave every label sitting off-centre. Touch keeps today's button exactly. */
.btn-arw { display: none; }

@media (hover: hover) and (pointer: fine) {
  .btn { padding-right: calc(var(--btn-px) + 1.15rem); }
  .btn .lbl { transition: transform 420ms cubic-bezier(0.16, 1, 0.3, 1); }
  .btn-arw {
    display: block;
    position: absolute; right: var(--btn-px); top: 50%; z-index: 2;
    width: 1rem; height: 1px; background: currentColor;
    opacity: 0; transform: translate(-8px, -50%);
    transition: opacity 300ms ease, transform 420ms cubic-bezier(0.16, 1, 0.3, 1);
  }
  /* the head, drawn as two borders rather than a glyph so it inherits colour
     and stays a hairline at every size */
  .btn-arw::after {
    content: ""; position: absolute; right: 0; top: 50%;
    width: 6px; height: 6px;
    border-top: 1px solid currentColor; border-right: 1px solid currentColor;
    transform: translateY(-50%) rotate(45deg);
  }
  .btn:hover .lbl { transform: translateX(-0.6rem); }
  .btn:hover .btn-arw { opacity: 1; transform: translate(0, -50%); }
}
.btn-primary { background: var(--white); color: #0A0A0B; }
.btn-primary:hover { background: var(--silver); }
.btn-ghost { background: transparent; color: var(--white); border-color: var(--line-2); }
.btn-ghost:hover { border-color: var(--white); }
.btn-lg { --btn-px: 2.6rem; padding: 1.2rem var(--btn-px); font-size: 0.875rem; }
@media (hover: hover) and (pointer: fine) {
  .btn-lg { padding-right: calc(var(--btn-px) + 1.15rem); }
}

@media (prefers-reduced-motion: reduce) {
  .btn, .btn .lbl, .btn-arw { transition: none; }
  /* the arrow still appears, it just does not travel */
  .btn:hover .lbl { transform: none; }
}

/* ---------------- hero ---------------- */

/* Full-bleed photograph with the headline sitting in the dark wall to its left.
   Hero structure referenced from the supersolid/mammoth heroes; the asset,
   type and copy are ours. */
.hero {
  position: relative;
  min-height: 100svh;
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  padding: clamp(5rem, 12vw, 8rem) 0 0;
  overflow: hidden;
}

/* Framed window, matching mammoth's hero: the picture is inset from the page
   edges rather than bleeding off them, so it reads as something being looked
   THROUGH. That inset is also what gives window-parallax somewhere to travel;
   a full-bleed hero is exactly as tall as the viewport, and the component's
   own guard clamps its amplitude to zero in that case. */
.hero-media {
  position: absolute;
  inset: clamp(0.6rem, 1.4vw, 1.1rem);
  top: 0;                       /* flush to the top edge */
  z-index: 0;
  overflow: hidden;
  /* square where it meets the top edge, rounded where it does not */
  border-radius: 0 0 var(--r) var(--r);
}
/* <picture> is an inline box by default, so a percentage height on the img has
   nothing to resolve against and collapses. display:contents removes the
   wrapper from layout entirely. */
.hero-media picture { display: contents; }
.hero-media img {
  width: 100%; height: 100%;
  object-fit: cover;
  --wp-scale: 1.22;   /* hero-specific: a taller frame needs a deeper hidden band */
  /* This frame carries a genuine block of dark wall on the left, so it needs
     less bias than the previous render to sit the headline on shadow. */
  object-position: 52% 50%;
}
/* Phone framing. The portrait crop is 0.55:1 against a ~0.444:1 frame, so cover
   discards about 19% of its width — that surplus is deliberate panning room for
   object-position, not waste.

   --wp-scale drops to 1.02 from the shared 1.22, and on a phone that costs
   nothing at all. The pre-scale exists to buy a hidden band for the window
   parallax to travel through — but the parallax driver in main.js is gated
   behind `fine` (min-width: 721px) while .wp-img's scale() is unconditional, so
   on a phone the 1.22 was pure zoom-in with no motion to pay for it. Verified by
   sampling --wp-par down the page: it stays 0px at 390 and ramps 0 -> 54.5px at
   1440. Desktop keeps 1.22 and its drift is untouched.

   1.02 rather than 1.00: with an exact fit, sub-pixel rounding can flash a
   hairline of background at an edge. 1% of overscan costs 1% of zoom.

   Zoom on this hero has exactly two levers. `cover` always shows exactly
   frame-AR of the image and the height always fills, so crop WIDTH does nothing
   (its surplus is only object-position panning room) — what remains is crop
   HEIGHT, now at the master's full 10800 and therefore at its ceiling, and this
   number. Measured as a share of screen width taken by the subject:
     crop 9828 / scale 1.22  ->  75.6%   (what read as "too zoomed in")
     crop 10800 / scale 1.02 ->  57.5% */
@media (max-width: 45.99rem) {
  .hero-media img { object-position: 50% 50%; --wp-scale: 1.02; }
}

/* Scrim removed at Pravin's call on 10 Aug. The headline now sits directly on
   the photograph, which only works because this frame carries a genuine block
   of dark wall on its left. If the hero image is ever swapped for a brighter
   one, contrast has to be re-checked, not assumed. */
.hero-scrim { display: none; }

/* Split column, from mammoth's hero (recon stop 00): the headline sits hard
   against the TOP of the hero and the sub + CTA are anchored at the BOTTOM, with
   a large deliberately empty band between them — about 110px on their 900px
   first screen. Ours had all three in one centred stack with a 28px gap, which
   is why the headline read as floating in the middle of the photograph.

   flex-start plus margin-top:auto on .hero-sub rather than space-between,
   because space-between would also push the CTA away from the sub; those two
   belong together as one block, exactly as they are in their hero. */
.hero-inner {
  position: relative; z-index: 2;
  flex: 1 1 auto;
  display: flex; flex-direction: column; justify-content: flex-start;
  align-items: flex-start;   /* otherwise the CTA stretches the full column */
  /* Out of the centred 72rem shell and onto the page gutter, 11 Aug. On a wide
     screen the shell parked the headline ~220px in, which read as floating in
     the middle of the photograph. At var(--pad) it starts on the same vertical
     as the nav brand plate, so the two left edges line up down the page.
     Everything in the column moves together — the headline alone would leave
     the sub and the CTA orphaned against the old edge. */
  max-width: none;
}

/* Room under the CTA so it does not sit on the frame's bottom edge. Desktop
   only: the phone rule further down carries its own padding-bottom, and both
   would stack.

   The auto top margin that actually performs the split lives inside .hero-sub's
   own rule below, NOT here. It was a standalone `.hero-sub { margin-top: auto }`
   at this point in the file first, and it silently did nothing: .hero-sub's main
   block sits ~60 lines later and its `margin: 0 0 <clamp>` shorthand rewrites
   margin-top to 0. Same specificity, later wins. */
@media (min-width: 46rem) {
  .hero { padding-bottom: clamp(2.5rem, 5vh, 4.5rem); }
}


/* The display signature the recon measured on both reference sites: very tight
   leading (0.85), negative tracking, wide fluid range. That is what reads as
   expensive, not the licensed typeface. */
/* Leading and tracking measured off the Timmons NY specimen on the awwwards
   entry: cap height ~305px against ~320px baseline-to-baseline, so roughly
   0.76 line-height, with letters very nearly touching. That packed-block
   effect is the whole character of the face, and it transfers to any
   condensed grotesque. Set at 0.80 here because Anton's cap height sits
   slightly higher than Timmons' and 0.76 collides the descenders. */
.hero h1 {
  font-family: var(--f-display);
  /* Floor down from 3.4rem on 11 Aug: at 390px the floor won and the headline
     rendered 54.4px, so three lines took 144px of a 844px screen before the sub
     and the CTA got any. 13vw keeps the same presence proportionally and hands
     the phone back its vertical room. */
  font-size: clamp(3rem, 13vw, 9rem);
  font-weight: 400;              /* Anton ships one weight and it is already heavy */
  line-height: 0.88;
  letter-spacing: -0.022em;
  text-transform: uppercase;
  margin: 0 0 clamp(1.25rem, 2.5vw, 1.75rem);
  text-wrap: balance;
}

/* ---------------- hero sub ----------------
   Restyled 11 Aug against mammothmurals' hero (recon stop 00, type table in
   mammoth-structure/evidence.json). Three measured findings drove this, and the
   colour one is the opposite of what "change the text colour" usually means.

   1. THEY HAVE NO MUTED BODY TIER. 598 of their 704 text elements are the one
      colour, rgb(18,0,17) — the hero sub is the SAME ink as the 164.5px
      headline above it. Their hierarchy is built entirely from size (164.5 vs
      23.25, a 7.1x ratio), never from dimming. Ours was --muted #97979E, a
      greyed-down secondary. So this goes UP to full contrast, not sideways to
      another grey, and the h1:sub ratio does the work instead — 144:20 here,
      7.2x, which lands on theirs almost exactly.

   2. Their lead paragraphs are BIG. Body-size text on that site (15.75-17.75px)
      is for FAQ answers and captions; the hero sub is 23.25px and the services
      intro is 46px. Ours was 17px, sitting in their caption tier.

   3. Leading is 1.3 across every body size they use. Ours was 1.55.

   Measure is 437px — one of their repeated content widths (30.3vw, x7), and
   close to the ~440px the hero sub wraps at in stop 00.

   FONT DELIBERATELY UNCHANGED. Theirs is Suisse Int'l 500; ours resolves to
   SF Pro / system-ui. Both are neutral grotesques of the same family of forms,
   so at 20px the difference is nearly invisible — and Suisse is a commercial
   licence. The display face is where a real swap would show, and that hook
   already exists (--f-display, one line, see the Timmons NY block at the top). */
.hero-sub {
  max-width: 27.3rem;                                  /* 437px, their measured width */
  /* `auto` top is what performs the split: it absorbs the free space in the
     column, pushing this and the CTA after it to the foot while the headline
     stays at the top. It has to live in this shorthand rather than in a
     standalone rule earlier in the file, or this very shorthand resets it. */
  margin: auto 0 clamp(1.75rem, 4vw, 2.25rem);
  color: var(--white);
  font-size: clamp(1rem, 1.4vw, 1.28rem);              /* 20.2px at 1440 */
  line-height: 1.35;
  letter-spacing: -0.02em;
}

/* Phone keeps the single bottom-anchored stack, and the reason is measured
   rather than aesthetic: the portrait crop's only genuinely dark band is its
   lower third — black trackpants and floor. The whole column was moved down
   there on 11 Aug so white type lands on it, which measured 14.09:1. Splitting
   the column on a phone would send the headline back up onto the lit torso and
   white singlet. Placed after the rule above so the auto margin is actually
   cancelled. */
@media (max-width: 45.99rem) {
  .hero-inner {
    justify-content: flex-end;
    padding-bottom: clamp(2.75rem, 8vh, 4.5rem);
  }
  .hero-sub { margin-top: 0; }
}

/* ---------------- hero showreel ----------------
   Position was measured, not chosen. The reference's hero is flat yellow so its
   coordinates transfer blind; ours is a full-bleed photograph, so four candidate
   boxes were sampled for mean background luminance at 1440x900 with the type
   hidden. Bottom-right won at 0.0365, against 0.0393 mid-right, 0.0408 higher
   and 0.0467 inset — and it is also where theirs sits, so evidence and reference
   agree. On a phone the same test put the left gutter darkest at 0.0804 against
   0.2654 top-right, which again matches their narrow layout.

   Width 24vw is theirs exactly (348px of 1440 = 24.2%). The bottom offset is the
   hero's own padding-bottom, so the reel's foot lands on the same line as the
   CTA's at 855px rather than near it. */
.hero-reel {
  position: absolute;
  z-index: 2;
  right: var(--pad);
  bottom: clamp(2.5rem, 5vh, 4.5rem);
  width: clamp(11rem, 24vw, 22rem);
  /* 16:9 from 12 Aug, with the landscape cut. This is their ratio exactly
     (348x196 measured = 1.778) where the portrait source needed a 9:16 box of
     its own. It also shrinks the element hard: same width, but 196px tall
     against 614, so under a third of the area it occupied. */
  aspect-ratio: 16 / 9;
  overflow: hidden;
  border-radius: var(--r);
  /* Their reel is square-cornered; ours takes the site's 4px token, because the
     recon rule is to transfer architecture and not composition, and every other
     framed thing on this page already carries it. */
}
.hero-reel video {
  width: 100%; height: 100%;
  object-fit: cover;
  display: block;
}

/* ---------------- reel entrance ----------------
   "Animate in once, like the contact me button does." Measured first: the chip
   has NO load entrance — it is simply uncovered when the preloader lifts, at
   opacity 1 the whole time. The animation Pravin means is the chip's OPEN
   sequence, which is the site's signature move and is measured off otherlife:
   width 368ms, a 133ms pause, then height 417ms, all on the same ease-out.

   Same choreography here, same three numbers, so the two objects move alike:
     368 / 918 = 40.1%   width done
     501 / 918 = 54.6%   pause over
     918ms               height done

   clip-path rather than width/height, because the chip is an empty container
   with overflow hidden while this one holds a video: animating the box would
   squash the footage as it opens. Clipping reveals it undistorted, and it
   stays off the layout thread. The corner it grows from follows the corner it
   is anchored to — bottom-right on desktop, top-left on the phone — so it
   opens out of its own edge rather than across the page. */
@keyframes reel-open {
  0%     { clip-path: inset(100% 0 0 100%); animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1); }
  40.1%  { clip-path: inset(100% 0 0 0);    animation-timing-function: linear; }
  54.6%  { clip-path: inset(100% 0 0 0);    animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1); }
  100%   { clip-path: inset(0 0 0 0); }
}
@keyframes reel-open-tl {
  0%     { clip-path: inset(0 100% 100% 0); animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1); }
  40.1%  { clip-path: inset(0 0 100% 0);    animation-timing-function: linear; }
  54.6%  { clip-path: inset(0 0 100% 0);    animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1); }
  100%   { clip-path: inset(0 0 0 0); }
}
.js .hero-reel { clip-path: inset(100% 0 0 100%); }
.js .hero-reel.reel-in { animation: reel-open 918ms both; }

/* Hover lift. 520ms on the house ease-out — the same curve as the chip and the
   case zoom, so it belongs to the same set of movements. Guarded on a real
   pointer: a phone has no hover, and :hover on touch latches ON after a tap
   and stays there, which would leave the reel stuck 10px up. */
.hero-reel { transition: transform 520ms cubic-bezier(0.16, 1, 0.3, 1),
                         box-shadow 520ms cubic-bezier(0.16, 1, 0.3, 1); }
@media (hover: hover) and (pointer: fine) {
  .hero-reel:hover {
    transform: translateY(-10px);
    box-shadow: 0 18px 38px rgba(0, 0, 0, 0.45);
  }
}

/* Phone: to the left gutter, the way theirs moves — and now at their width too.
   The portrait box could not take their 67.4% of viewport, because 263px wide
   meant 467px tall and the free band between the header rail and the headline
   is only 433px. At 16:9 that same 263px is 148px tall, so the reference's own
   proportion finally fits with room to spare. */
/* Sized and lifted 12 Aug. At 67vw it measured 261x147 sitting y74-221, and the
   hero photo's subject begins right there — the reel's bottom edge landed on his
   hairline, which reads as cropping his head even though nothing overlaps.

   54vw puts it at 211x118 spanning y59-177, clearing the top of his head by
   about 45px. Checked against every piece of hero type, not just the headline:
   the headline occupies y487-621, the sub 641-706 and the CTA 734-776, and this
   box touches none of them. An earlier candidate that filled the band under the
   CTA passed a headline-only check and buried the sub and the CTA completely. */
@media (max-width: 45.99rem) {
  .hero-reel {
    right: auto;
    left: var(--pad);
    bottom: auto;
    top: calc(var(--hdr-h) + 1.1rem);
    /* 38vw / 9.25rem, 12 Aug. The 54vw above it was measured against the
       WRONG thing — the note claimed "clears the top of his head by about
       45px", but that was the reel's bottom edge against the hero BAND, not
       against where the subject actually lands. Mapping his head through
       object-fit: cover (it occupies x .46-.64, y .215-.335 of hero-p1500)
       shows the box covering his head at four of five iPhone widths: 50x39px
       on the SE, then 55x8, 54x7 and 50x2, clear only on the Pro Max.

       Clearing it vertically would need the box under 133px wide, because on
       the short SE viewport his head rides high. Clearing it HORIZONTALLY
       needs only 148px and holds at every width, since an overlap needs both
       axes — so the reel now stops short of his head rather than above it.
       Verified by probes/headroom.mjs, which computes the intersection
       instead of trusting the eye. */
    width: min(38vw, 9.25rem);
  }
  .js .hero-reel { clip-path: inset(0 100% 100% 0); }
  .js .hero-reel.reel-in { animation-name: reel-open-tl; }
}

/* Reduced motion: swap the video for its own first frame as a still.

   The first attempt paused the <video> and relied on it having decoded a frame
   to hold. That is not dependable — raising .preload after the element has
   already begun fetching under preload="metadata" does not send the browser back
   for frame data, and readyState stuck at 1 (HAVE_METADATA) with the box
   painting empty. Measured twice on 12 Aug, including after an explicit load().

   A real extracted frame needs no decode and cannot fail. main.js still pauses
   the element so it stops fetching and playing behind the still. */
@media (prefers-reduced-motion: reduce) {
  .hero-reel {
    background: url("video/reel-poster.jpg?v=8f231745") center / cover no-repeat var(--bg-3);
  }
  .hero-reel video { display: none; }
  .js .hero-reel, .js .hero-reel.reel-in {
    clip-path: none; animation: none; transition: none; transform: none;
  }
}

/* ---------------- page header (subpages) ----------------
   Added 11 Aug. The two subpages opened two different ways and neither worked.

   how.html cloned the landing hero WHOLE — same photograph, same STRONG /
   JACKED / ATHLETIC h1, same sub-paragraph, and a "Join the Vault" button
   pointing at #join, an anchor that does not exist on that page. So it spent a
   full viewport saying nothing about how coaching works, repeated the landing
   headline to anyone who had just read it, and its only call to action was
   dead. wins.html had the opposite fault: no header at all, opening cold on a
   band, and no <h1> anywhere in the document.

   One component for both, so the two pages are recognisably the same object:
   clears the fixed top rail, carries the site's eyebrow/heading pair, and the
   heading is a real <h1>. No photograph — the landing page owns that moment and
   spending it again here is what made the subpage feel like a copy. */
.phead {
  position: relative;
  /* --hdr-h is the header cluster's height; the clamp is the gap below the rail,
     so this tracks the rail automatically instead of restating 3.4rem. */
  padding: calc(var(--hdr-h) + clamp(3.25rem, 9vw, 6rem)) 0 clamp(2.25rem, 5vw, 3.25rem);
  border-bottom: 1px solid var(--line);
}

.phead-back {
  display: inline-flex; align-items: center; gap: 0.5rem;
  margin: 0 0 clamp(1.75rem, 4vw, 2.5rem);
  font-family: var(--f-ui); font-weight: 600; font-size: 0.6875rem;
  letter-spacing: 0.13em; text-transform: uppercase;
  color: var(--muted-2); text-decoration: none;
  transition: color .18s ease;
}
.phead-back:hover { color: var(--white); }
.phead-back svg { width: 1.15rem; height: 0.7rem; flex: none; }

.phead h1 {
  font-family: var(--f-display); font-weight: 400;
  /* Rides the same ladder as every other heading; the rung is chosen per page
     with .h-anchor / .h-major on the element itself. */
  font-size: var(--h2, clamp(1.875rem, 7.4vw, 4.75rem));
  /* Same 0.88 as h2 and the hero, and for the same measured reason: "Three
     steps and you're training." wraps to two lines on a phone. */
  line-height: 0.88; letter-spacing: -0.02em;
  text-transform: uppercase;
  margin: 0; text-wrap: balance;
}

.phead-sub {
  margin: clamp(1rem, 2.5vw, 1.4rem) 0 0;
  max-width: 34rem; color: var(--body);
  font-size: clamp(0.9375rem, 2.2vw, 1.0625rem); line-height: 1.55;
}



/* ---------------- preloader ----------------
   Measured on mammothmurals: a full-viewport panel at rgb(18,0,17) that holds
   ~500ms and then fades opacity 1 to 0.

   Two wrong curves before this one, for opposite reasons:
     420ms ease-IN-out  held near full opacity for its first third, then
                        dropped steeply. Read as a snap.
     760ms ease-OUT     front-loaded instead. easeOutCubic puts half the
                        opacity change in the first ~12% of its duration, so a
                        declared 760ms fade LOOKED like about 200ms. Pravin
                        clocked it as too fast, and he was right.
   Now 900ms on a symmetric gentle curve. Most of the change sits in the middle
   of the window rather than at either end, which is what an even dissolve
   needs. A black panel revealing a page has no reason to accelerate. No
   transform, no clip-path; the whole effect is that you never watch the page
   assemble. Body scroll is locked while it sits.

   The animation-delay below is a SAFETY NET, not the intended timing. JS lifts
   the panel as soon as the hero image has decoded, which is normally well
   inside 2.2s. If JS never runs, or an image stalls, the CSS still clears it. */
.preload {
  position: fixed; inset: 0; z-index: 10000;
  background: var(--bg);
  animation: preload-out 1170ms cubic-bezier(0.4, 0, 0.6, 1) 2200ms forwards;
}
/* This MUST animate a different @keyframes name from the safety net above.
   Both rules once used `preload-out`, and that quietly broke the whole fade:
   when the animation-name list does not change, the browser UPDATES the
   running animation instead of starting a new one, keeping its original start
   time. The safety net's clock starts at parse time, JS adds .is-out ~700ms
   later, and retiming that same animation to delay:0/900ms recomputed the
   playhead against t=0 — so the panel jumped to ~78% faded and finished in the
   remainder. Any hero that decoded after 900ms was already past 100% and the
   panel disappeared on a single frame.

   That, not the curve, is what the two earlier "too fast" tunings were really
   chasing. A separate name cancels the pending safety net and starts clean, so
   the duration below is now the duration you actually see. */
.preload.is-out { animation: preload-lift 900ms cubic-bezier(0.4, 0, 0.6, 1) forwards; }


@keyframes preload-out {
  to { opacity: 0; visibility: hidden; pointer-events: none; }
}

@keyframes preload-lift {
  from { opacity: 1; }
  to { opacity: 0; visibility: hidden; pointer-events: none; }
}

/* Locked only while the panel is up. Removed by JS, and by the safety net
   below if JS never runs. */
html.is-preloading, html.is-preloading body { overflow: hidden; }

/* Anyone who asked for less motion gets no preloader at all: it is pure
   decoration and it delays their content. */
@media (prefers-reduced-motion: reduce) {
  .preload { display: none; }
  html.is-preloading, html.is-preloading body { overflow: visible; }
}

/* ---------------- nav ----------------
   Was mix-blend-mode: difference, on the reasoning that the blend "never needs
   a background plate: white text over the dark photograph, dark text if it ever
   passes over a light band". Swapped for a plate on 10 Aug, structure from
   mammothmurals.

   The two approaches solve the SAME problem — the brand crosses two .band-inv
   white sections (#who, #how) and has to stay legible over both — and they
   cannot be combined, because a plate under difference blending gets inverted
   along with everything else. A plate is the more predictable half of that
   trade: it looks identical over every ground instead of computing a colour per
   band. Cheap to do here because .nav holds nothing but the brand; .nav-links
   is declared below but used zero times on either page.

   Vertical padding now matches .hdr's `top`, so the brand and the contact
   cluster sit on one line as a matched pair of chips in opposite corners. */
.nav {
  position: fixed; inset: 0 0 auto 0;
  z-index: 200;
  pointer-events: none;               /* only the links themselves are hittable */
  padding: clamp(0.75rem, 2vw, 1.15rem) 0;
}
/* Full width, so the brand hugs the page gutter at var(--pad) instead of the
   centred 72rem shell edge, which on a wide screen parked it ~200px inside.
   That puts it exactly opposite .hdr, which is pinned to right: var(--pad). */
.nav-inner {
  display: flex; align-items: center; gap: 1.5rem;
  justify-content: space-between;
  max-width: none;
}
.nav a { pointer-events: auto; text-decoration: none; color: #fff; }

/* The plate. Same material as .hdr on the opposite corner — same ground, same
   hairline, same 4px token — so the two read as one system rather than as a
   brand chip and an unrelated menu. Height is pinned to .hdr's 3.4rem for the
   same reason. */
.nav-brand {
  display: inline-flex; align-items: center; gap: 0.55rem;
  /* 2.6rem, down from the 3.4rem that matched .hdr. That height was set when
     the plate carried a 1.55rem mark beside the wordmark; with the mark gone
     it left the type swimming. The two chips no longer match in height, but
     they still share a top edge, which is what actually reads as alignment. */
  height: 2.6rem;
  padding: 0 0.9rem;
  /* Collapse the leading, or the wordmark sits low in the plate. The brand is a
     .casc target, and .casc-in is a 1em box with vertical-align:bottom. Inheriting
     body's 1.6 made the flex item 24px tall around 15px of ink, so the ink parked
     at the bottom of it and centring the ITEM left the TEXT 9px low (measured:
     17.8px above, 8.8px below). At line-height 1 the item is the ink. */
  line-height: 1;
  background: var(--bg-2);
  border: 1px solid var(--line-2);
  border-radius: var(--r);
  font-family: var(--f-display);
  font-size: 0.9375rem; letter-spacing: 0.02em; text-transform: uppercase;
}
/* Wordmark only, from 11 Aug. The mark is pulled from the plate for two
   reasons, neither of them taste:

   1. favicon.png is a TIGHT CROP — the outer chevron's left arm runs off the
      canvas edge. Correct for a 16px browser tab, visibly clipped at 25px in a
      lockup beside type.
   2. It is a grey gradient sitting next to a solid --white wordmark, so the
      two read as separate assets rather than one mark.

   The mammoth plate this element was measured from is wordmark-only too, so
   this is also the more faithful reading of the reference.

   Restoring it is one line: delete the display:none. Do that only once there
   is an uncropped, single-colour export — then `filter: brightness(0) invert(1)`
   would flatten it to match the wordmark. The file is untouched and still
   serves as the favicon, the apple-touch-icon and the OG image. */
.nav-mark { display: none; }
.nav-links { display: none; gap: clamp(1.25rem, 3vw, 2.25rem); }
@media (min-width: 46rem) { .nav-links { display: flex; } }
.nav-links a, .nav-cta {
  font-family: var(--f-ui); font-weight: 600;
  font-size: 0.6875rem; letter-spacing: 0.12em; text-transform: uppercase;
}
.nav-links a { opacity: 0.72; }
.nav-links a:hover, .nav-cta:hover { opacity: 1; }
.nav-cta { padding-bottom: 2px; }
.nav a:focus-visible { outline: 2px solid #fff; outline-offset: 4px; }

/* ---------------- bands ----------------
   Band padding pushed into the 160-256px range the recon measured on
   supersolid, which is most of what makes those pages feel unhurried. */
/* No divider rule. Every band changes ground by 20 to 237 points of
   luminance, so the colour change is already the division; a 1px grey line
   on top of that just reads as grime. */
.band { padding: var(--band-y) 0; }
.band-alt { background: var(--bg-2); }
.band-join { background: var(--bg-2); text-align: center; }

.eyebrow {
  font-family: var(--f-ui); font-weight: 600; font-size: 0.6875rem;
  letter-spacing: 0.14em; text-transform: uppercase;
  color: var(--muted-2); margin: 0 0 0.9rem;
}

h2 {
  font-family: var(--f-display);
  /* The authoritative value is the ladder below, which re-declares this with
     the --h2 hook. Kept in sync deliberately: two different numbers here is how
     the scale drifts. */
  font-size: clamp(1.875rem, 7.4vw, 4.75rem); font-weight: 400;
  /* 0.88, up from 0.82. Measured 11 Aug: Anton's capitals occupy 0.875em of ink
     from cap-top to baseline, so a 0.82em line box makes consecutive lines of a
     multi-line heading OVERLAP by 0.055em — and a comma on the upper line
     descends 0.115em, taking the intrusion to 0.162em. "Coaching, built /
     around you." was overlapping by a measured 4.2px at 390 and ~10px at the
     desktop cap. Single-line headings never showed it, which is why it survived.

     0.88 is not a new number: it is what .hero h1 already uses, set there for
     exactly this reason and shipped. Caps now clear by 0.005em. Still packed —
     the tight-leading signature is intact — just no longer colliding. */
  line-height: 0.88; letter-spacing: -0.02em;
  text-transform: uppercase;
  margin: 0 0 1.35rem; text-wrap: balance;
}

/* The big statistic numbers borrow the display face too: they are scanned,
   not read, and they are the most persuasive thing on the page. */
.delta { font-family: var(--f-display); font-weight: 400; }

.band-sub {
  max-width: 36rem; margin: 0 0 clamp(2.5rem, 6vw, 3.5rem);
  color: var(--body); font-size: clamp(1rem, 2.2vw, 1.125rem);
}
.band-join .band-sub { margin-left: auto; margin-right: auto; }

/* For a band that has no sub-paragraph. .band-sub carried the whole gap down
   to the content (every grid below one sits at margin:0), so a heading left on
   its own would otherwise collide with the next block. Restores the same
   distance the intro block had, less the paragraph's own text height. */
h2.h-nosub { margin-bottom: calc(1.35rem + clamp(2.5rem, 6vw, 3.5rem)); }

/* ---------------- results ---------------- */

/* ---------------- cases (the receipts) ----------------
   Rebuilt 10 Aug. The nine before/afters used to be his pre-composited
   Instagram cards with TRANSFORMATION, BEFORE/AFTER, the weights and the weeks
   band burnt into the pixels. That blocked two things at once: the numbers were
   invisible to search and to screen readers, and neither parallax nor the hover
   zoom could touch them because any crop ate the labels.

   The two photographs are now cut out of each card and the type is real HTML,
   which is why this section can carry the reconned hover zoom and why the
   figures are finally indexable. Source crops are 390x506, the true resolution
   inside a 1080px Instagram export, so they are displayed around that size
   rather than blown up. */

.cases {
  list-style: none; margin: 0; padding: 0;
  display: grid; gap: clamp(1.5rem, 3vw, 2.5rem);
  grid-template-columns: 1fr;
}
@media (min-width: 40rem) { .cases { grid-template-columns: repeat(2, 1fr); } }
@media (min-width: 64rem) { .cases { grid-template-columns: repeat(3, 1fr); } }

.case { background: var(--bg-3); border: 1px solid var(--line); border-radius: var(--r); overflow: hidden; }

/* Butted together with a hairline between: the eye compares them instantly,
   which a gutter would slow down. */
.case-pair { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; background: var(--line); }

.shot { position: relative; margin: 0; aspect-ratio: 3 / 4; overflow: hidden; background: var(--bg); }
.shot img { border-radius: 0; }
.shot picture { display: contents; }
.shot img { width: 100%; height: 100%; object-fit: cover; }
.shot figcaption {
  position: absolute; left: 0; bottom: 0; z-index: 2;
  font-family: var(--f-ui); font-weight: 600; font-size: 0.5625rem;
  letter-spacing: 0.13em; text-transform: uppercase;
  color: var(--white);
  padding: 0.4rem 0.55rem;
  background: linear-gradient(to top, rgba(10,10,11,0.85), rgba(10,10,11,0));
  width: 100%;
}

.case-foot { padding: 1rem 1.05rem 1.15rem; border-top: 1px solid var(--line); }

.delta {
  display: flex; align-items: baseline; gap: 0.45rem;
  margin: 0 0 0.3rem;
  font-size: clamp(1.5rem, 3.6vw, 2rem);
  letter-spacing: -0.02em; font-variant-numeric: tabular-nums;
  line-height: 1;
}
.delta i { font-style: normal; color: var(--muted-2); font-size: 0.6em; letter-spacing: -0.08em; }
.delta em { font-style: normal; font-size: 0.5em; font-weight: 600; color: var(--muted); }

/* ---- caption: mammoth's row, our figure scale ----
   Structure only, 12 Aug. Their card caption is ONE row 36px tall, display:flex,
   justify-content:space-between: project name hard left, small metadata tags
   hard right. Ours was a 94px stacked block — a 32px display figure with a grey
   sentence under it.

   What transfers is the row. What does NOT is their scale: their left-hand item
   is a project name, ours is the weight change, which is the entire argument of
   this section. Demoting it to their ~18px metadata tier would make the cards
   more elegant and the evidence quieter. So the figure keeps the display face
   and the sentence becomes tags — the same take-the-architecture-not-the-
   composition call as the band grounds and the palette. */
.case-foot {
  display: flex; align-items: baseline; justify-content: space-between;
  gap: 0.9rem; flex-wrap: wrap;
}
.case-tags {
  list-style: none; display: flex; flex-wrap: wrap; justify-content: flex-end;
  gap: 0.35rem; margin: 0; padding: 0; flex: 1 1 auto;
}
.case-tags li {
  font-family: var(--f-ui); font-weight: 600; font-size: 0.625rem;
  letter-spacing: 0.11em; text-transform: uppercase; white-space: nowrap;
  color: var(--body);
  border: 1px solid var(--line-2); border-radius: var(--r);
  padding: 0.28rem 0.5rem;
}
/* The handle is the person, not a measurement — quieter than the figures it
   sits beside, the way .who was before this became tags. */
.case-tags li:last-child:not(:nth-child(-n+2)) { color: var(--muted); }

.band-inv .case-tags li { color: #3A3A40; border-color: #C2C2BE; }


/* ---------------- band rhythm ----------------
   Measured on mammoth: nine bands and EVERY one changes ground from the one
   above it (yellow, cream, blue, cream, yellow, near-black, cream). We cannot
   take the palette, that would be wearing their look. What transfers is the
   rule: no two consecutive bands share a ground.

   Three dark values plus one inverted band, standing in for their near-black
   CTA block. */
.band-a   { background: var(--bg); }
.band-b   { background: var(--bg-2); }
.band-c   { background: #1E1E24; }
.band-inv { background: var(--white); color: #0A0A0B; }
.band-inv .eyebrow { color: #6E6E76; }
.band-inv .band-sub,
.band-inv p { color: #3A3A40; }
.band-inv .btn-primary { background: #0A0A0B; color: var(--white); }
.band-inv .btn-primary:hover { background: #2A2A30; }
/* Without this the ghost button is white text on a white ground: invisible. */
.band-inv .btn-ghost { color: #0A0A0B; border-color: #C2C2BE; }
.band-inv .btn-ghost:hover { border-color: #0A0A0B; }
.band-inv :focus-visible { outline-color: #0A0A0B; }
.band-inv .gap { border-color: #C9C9C4; background: rgba(10,10,11,0.03); }
.band-inv .gap p { color: #3A3A40; }
.band-inv .gap-tag { color: #6E6E76 !important; }
.band-inv .gap strong { color: #0A0A0B; }
.band-inv .fors li { border-top-color: #C2C2BE; }
.band-inv .fors h3, .band-inv .steps h3 { color: #0A0A0B; }
.band-inv .fors p, .band-inv .steps p { color: #3A3A40; }
.band-inv .step-n { color: #6E6E76; }
.band-inv .band-sub { color: #3A3A40; }
.band-inv h2 { color: #0A0A0B; }

/* ---- .band-inv coverage for the offer ledger and the meal strip ----
   Added 12 Aug when #offer became the inverted band. Everything below was
   hardcoded for a dark ground and had no inverted counterpart, so the band
   would have shipped white-on-white. .band-inv previously covered 30 selectors
   and not one of them was a ledger or meal element.

   The <p> elements in here (.lrow-copy, .lh-right, .price-note, .meal-name,
   .meal-cal) are already caught by `.band-inv p` at (0,1,1), which outranks
   their own (0,1,0) rules. Only the non-paragraphs need naming. */
.band-inv .lrow-name      { color: #0A0A0B; }   /* h3, was var(--white) */
.band-inv .lrow-i         { color: #6E6E76; }
.band-inv .meal-macros dd { color: #0A0A0B; }   /* dd, was var(--white) */
.band-inv .meal-macros dd span,
.band-inv .meal-macros dt { color: #6E6E76; }

/* The drawn rules and the photo frames. --line-2 is a dark hairline and would
   read as a hard black line on cream; these are the values the rest of
   .band-inv already uses for the same job. */
.band-inv .ledger-head::after,
.band-inv .lrow::after    { background: #C2C2BE; }
.band-inv .meal-shot      { border-color: #C2C2BE; background: rgba(10,10,11,0.04); }
.band-inv .meal-shot[data-pending] { border-color: #C9C9C4; }
.band-inv .meal-shot[data-pending]::after { color: #6E6E76; }

/* PROSE goes full black in this band, 12 Aug at Pravin's call. `.band-inv p`
   sets #3A3A40 for every inverted band; here the reading copy goes to the same
   ink as the headings so the band reads as one weight rather than a hierarchy
   of greys.

   Held back deliberately: the eyebrow (the band's own title), the 01./02./03.
   numerals, and the macro labels, units and calorie lines. Those are metadata,
   and dimming them is what lets the prose read as the content. */
#offer.band-inv .lh-right,
#offer.band-inv .lrow-copy,
#offer.band-inv .price-note,
#offer.band-inv .meal-name { color: #0A0A0B; }

/* ---------------- heading scale ladder ----------------
   Their headings run 164.5 baseline with detonations at 529 and 401, and one
   deliberately dropped to 46. A single fluid size down the whole page is the
   thing that makes a layout read as flat, and it is what we had. */
/* Rebuilt 11 Aug. The floors, not the ceilings, were the problem: measured at
   390px every rung sat on its own floor, so the ladder rendered 52 / 44 / 40 /
   36 — four "different" sizes inside 16px, which is the flat page the ladder
   exists to prevent, at sizes too large for the screen on top of that.

   Floors are now low enough that the vw term drives the whole phone range and
   the ratios between rungs survive. Measured at 390px after: 47 / 35 / 29 / 23
   / 20. Same ceilings, so nothing above 46rem moves. */
h2 { font-size: var(--h2, clamp(1.875rem, 7.4vw, 4.75rem)); }
.h-anchor  { --h2: clamp(2.75rem, 12vw, 9rem); }   /* the 529 moment */
.h-major   { --h2: clamp(2.125rem, 9vw, 6rem); }   /* the 401 moment */
.h-base    { --h2: clamp(1.625rem, 6vw, 4rem); }
.h-quiet   { --h2: clamp(1.25rem, 3.2vw, 2.1rem); } /* their 46px band */


/* ---------------- scroll drift ----------------
   Probed our own site with the recon instrument: 14 step-shaped signals and
   ZERO continuous ones, against 9 and 3 on the two references. Everything here
   fired once on entry and then froze, which is precisely what reads as static.
   This is the missing continuous layer.

   Uses the INDEPENDENT translate/scale properties, not the transform
   shorthand. That matters: the case photographs already carry a 750ms scale
   transition for the hover zoom, and a shared `transform` would mean the
   parallax's per-frame writes and the hover's transition fighting over one
   property. Separate properties, separate transitions, no conflict. */
[data-drift] { will-change: translate; }
/* The hero runs window-parallax now, not the drift engine, so it must NOT
   also carry a translate: the component writes `transform` and two properties
   moving the same image is the trap this build has hit three times already. */

/* No `translate` here any more. The client-win photographs carried
   data-drift="0.16" — 32.2px of travel measured at 390 and 39.5px at 1440 —
   and it came off at Pravin's call on 12 Aug: twenty-four framed photographs
   all sliding inside their frames is the page fidgeting, not moving. The 1.03
   stays, because it is the hover zoom's resting state and hides a hairline. */
.shot img {
  scale: 1.03;
  transition: scale 975ms cubic-bezier(0.16, 1, 0.3, 1);
}
@media (hover: hover) and (pointer: fine) {
  .zoom-hit:hover .shot img { scale: 1.1; }
}

/* ---------------- cycling wins ----------------
   Only where data-cycle is set, so wins.html — which already shows all nine at
   once — is untouched by any of this.

   Two stacked layers per frame. The incoming one fades UP over a fully opaque
   outgoing one rather than the pair cross-dissolving in opposite directions.
   Visually identical on an ease-out curve (the two opacities sum to 1 the whole
   way), but it cannot flash the background if a frame is dropped or a decode
   lands late, which a true two-way dissolve can.

   900ms, against hiss.sydney's measured 284ms. Their stack is a logo rail and
   284ms is UI speed; these are the photographs the page's whole argument rests
   on, and "not that fast" is Pravin's note. cubic-bezier(0.33,1,0.68,1) is
   easeOutCubic, which is what the reference measured at (RMS 0.0318 against
   0.0662 for its nearest rival). */
.cases[data-cycle] .shot img { position: absolute; inset: 0; }
.cases[data-cycle] .shot .cyc-top { opacity: 0; }
.cases[data-cycle] .shot .cyc-top.is-in {
  opacity: 1;
  /* 1600ms, up from 900. Change this and FADE in main.js together — the script
     commits the layer swap on that timer and a shorter JS value would cut the
     fade off mid-way. */
  transition: opacity 1600ms cubic-bezier(0.33, 1, 0.68, 1);
}

/* The numbers are hidden across the swap, never shown against the wrong photo.
   Out in 300ms, text replaced at the trough, back over the rest of the fade. */
.cases[data-cycle] .case-foot { transition: opacity 420ms ease; }

/* The three slots change together now, so any height difference between wins
   moves the whole row at once instead of one card. The tag list is capped at
   two in the cycle for that reason (see main.js); this floor is the belt to
   that braces — if a future win ever carries a longer delta, the row still
   cannot resize under the visitor. 2 tags measured 73.9px desktop / 66.9 phone. */
.cases[data-cycle] .case-foot { min-height: 4.6rem; }
.cases[data-cycle] .case-foot.is-swap { opacity: 0; }

@media (prefers-reduced-motion: reduce) {
  .cases[data-cycle] .shot .cyc-top,
  .cases[data-cycle] .case-foot { transition: none; }
}

@media (prefers-reduced-motion: reduce) {
  [data-drift], .hero-media img, .meal-shot img { translate: none !important; }
  .shot img { transition: none; }
}

/* ---------------- image hover zoom ----------------
   Measured off supersolid: the frame's contents go to scale(1.1) over 750ms on
   cubic-bezier(0.16,1,0.3,1), with the media sitting permanently at 1.03 so the
   resting state already hides a hairline and the zoom can never reveal an edge.
   The whole card is the hit area, not just the picture.

   The zoom and window-parallax can never share an element: parallax writes
   transform every frame from JS, and this needs a 750ms transition on
   transform. Put one on a given image, never both. The `:not(.wp-img)` guard
   makes that a mistake the CSS refuses rather than one you debug later. */
.zoom-frame { position: relative; overflow: hidden; border-radius: var(--r); }
.zoom-frame > video {
  transform: scale(1.03);
  transition: transform 975ms cubic-bezier(0.16, 1, 0.3, 1);
  will-change: transform;
}
@media (hover: hover) and (pointer: fine) {
  .zoom-hit:hover .zoom-frame > video,
  .zoom-frame:hover > video { transform: scale(1.1); }
}
@media (prefers-reduced-motion: reduce) {
  .zoom-frame > video { transition: none; transform: scale(1.03); }
}

/* The transformation cards are excluded from the zoom on purpose: their labels
   are baked into the pixels and a 1.1 zoom inside a clip crops them. They get a
   hover that moves the card, not its contents. */
.card { transition: border-color 300ms ease, transform 450ms cubic-bezier(0.16,1,0.3,1); }
@media (hover: hover) and (pointer: fine) {
  .card:hover { border-color: var(--line-2); transform: translateY(-4px); }
}
@media (prefers-reduced-motion: reduce) { .card { transition: none; } .card:hover { transform: none; } }

/* ---------------- training clips ---------------- */

.band-media { background: var(--bg-2); }

.clips {
  display: grid; gap: clamp(0.75rem, 2vw, 1.5rem);
  grid-template-columns: repeat(2, 1fr);
  max-width: 46rem;
}
.clip {
  position: relative;
  aspect-ratio: 9 / 16;
  overflow: hidden;
  border: 1px solid var(--line);
  border-radius: var(--r);
  background: var(--bg-3);
}
.clip video {
  width: 100%; height: 100%;
  object-fit: cover; display: block;
}


/* ---------------- pull quote ----------------
   A real client's words, lifted verbatim from the story he posted. Set at
   reading size, not display size: it has to be believed, and display type at
   this length reads as a poster rather than as somebody talking. */
.quote {
  margin: clamp(3rem, 7vw, 5rem) 0 0;
  max-width: 46rem;
  border-top: 1px solid var(--line);
  padding-top: clamp(1.75rem, 4vw, 2.5rem);
}
.quote blockquote { margin: 0; }
.quote blockquote p {
  margin: 0 0 1rem;
  font-size: clamp(1.15rem, 2.6vw, 1.6rem);
  line-height: 1.4;
  letter-spacing: -0.015em;
  color: var(--white);
}
.quote blockquote p strong { font-weight: 700; }

/* The punchline's weight, moved off a <strong> onto the paragraph. data-split
   rebuilds the element from textContent, so the tag and its emphasis would have
   been dropped on the floor. */
.quote-punch { font-weight: 700; }

/* "Way later" made explicit. The word reveal already staggers each unit by 58ms
   via --d; this pushes the whole run back so the punchline lands well after the
   first line has settled rather than chasing it. 900ms is the gap, the stagger
   still runs on top of it. */
.quote-late .split-char {
  transition-delay: calc(var(--d, 0ms) + 900ms);
}

/* ---------------- testimonial scroll fill ----------------
   Pravin's manifesto-fill component. Prefix is `vq` for this deployment: the
   component's own rule is one prefix per instance, so a second fill on this
   site can never collide with this one.

   The quote sits dim and lights character-by-character in reading order as a
   pure function of scroll position, so scrubbing back up un-lights it along
   exactly the same path.

   The fill drives OPACITY only, never colour or weight. That is what lets the
   <strong> second line keep its 700 through the whole sweep instead of being
   flattened into the rest of the paragraph. */
.vqw { display: inline-block; white-space: nowrap; }

/* PERFORMANCE, measured 12 Aug against mammothmurals under identical scripted
   wheel input, 5-8 runs per configuration.

     mammothmurals          worst frame  9.6-11.9ms   mean 10.5
     this page              worst frame 15.9-22.4ms   mean 20.4
     this page, quote off   worst frame  9.4-10.6ms   mean  9.9

   So this component IS the whole difference: switch it off and the page matches
   the reference exactly. Everything else measured identical already — median
   frame 8.3ms on both, scroll evenness 0.36 vs 0.37.

   The cost is the count, not the technique: 130 characters each own a span, and
   a dozen of them change class on the same frame, each starting its own
   transition on a large paragraph. One dropped frame per scroll pass.

   If this is ever rebuilt, the fix is fewer animated elements — words instead
   of characters, or a single background-clip gradient sweep over the whole
   paragraph, which is one element and one animated property. */
.vq--js .vqc {
  display: inline-block;
  opacity: var(--vq-unlit, 0.2);
  transform: translate3d(var(--vq-popx, -0.17em), 0, 0);
  transform-origin: 50% 50%;
  transition: opacity var(--vq-opd, 0.3s) linear,
              transform var(--vq-popd, 0.3s) cubic-bezier(0.34, 1, 0.64, 1);
}
.vq--js .vqc.lit { opacity: 1; transform: none; }

/* Promote once while the band is live rather than per character mid-sweep;
   promote/demote churn on individual chars is what spikes frames. */
/* will-change removed 12 Aug — but NOT because it was measurably costing
   anything, and the first version of this comment claimed otherwise on three
   noisy runs. Establishing the noise floor first (eight identical runs: worst
   frame 15.9-22.4ms, sd 1.9) showed that any A/B under ~4ms here is measuring
   nothing, and the with/without difference was 4.3ms then 2.1ms on a repeat.

   It stays removed on principle rather than evidence: the property was on all
   130 characters at once, which is the case it is explicitly not for. If it is
   ever wanted back, promote only the characters mid-transition and drop the
   hint as they land.

   The real cost in this component is measured below. */

/* The tail's opacity is INSTANT, deliberately. A timed fade on the moving
   boundary inverts brightness under fast scroll — older characters caught
   mid-fade end up brighter than newer ones. The spatial ramp IS the fade;
   only the transform keeps a timed transition, so the pop still slides in. */
.vq--js .vqc.vqz,
.vq--js.vq--ramp .vqc {
  transition: transform var(--vq-popd, 0.3s) cubic-bezier(0.34, 1, 0.64, 1);
}
.quote figcaption {
  display: flex; align-items: baseline; gap: 0.75rem; flex-wrap: wrap;
  font-family: var(--f-ui); font-weight: 600; font-size: 0.6875rem;
  letter-spacing: 0.14em; text-transform: uppercase;
  margin-top: 0.6rem;
}
.quote-who { color: var(--silver); }

.who { color: var(--muted-2); }

.creds {
  margin: 1.75rem 0 0 !important;
  padding-top: 1.25rem;
  border-top: 1px solid var(--line);
  font-size: 0.9375rem !important;
  color: var(--body) !important;
  max-width: 32rem;
}
.creds strong { color: var(--white); }
.band-inv .creds { border-top-color: #C2C2BE; }

/* A line lifted straight from his own post. Set between the heading and the
   grid so it reads as the section's thesis rather than as body copy. */
.manifesto {
  font-family: var(--f-display); font-weight: 400; text-transform: uppercase;
  font-size: clamp(1.05rem, 2.4vw, 1.6rem);
  line-height: 1.1; letter-spacing: -0.005em;
  margin: 0 0 clamp(2.25rem, 5vw, 3rem);
  max-width: 34rem;
}
.band-inv .manifesto { color: #0A0A0B; }

/* ---------------- who for ---------------- */

.fors {
  list-style: none; margin: 0; padding: 0;
  display: grid; gap: clamp(1.5rem, 3vw, 2rem);
  grid-template-columns: 1fr;
}
@media (min-width: 52rem) { .fors { grid-template-columns: repeat(3, 1fr); } }

.fors li { border-top: 2px solid var(--line-2); padding-top: 1.4rem; }
.fors h3 {
  font-size: 1.0625rem; font-weight: 700; letter-spacing: -0.015em;
  margin: 0 0 0.6rem; line-height: 1.3;
}
.fors p { margin: 0; color: var(--body); font-size: 0.9375rem; }

/* ---------------- gaps (placeholders) ---------------- */

.gap {
  border: 1px dashed var(--line-2);
  border-radius: var(--r);
  background: rgba(255,255,255,0.02);
  padding: 1.4rem 1.5rem;
  margin: clamp(2rem, 5vw, 3rem) 0 0;
  text-align: left;
}
.gap-lg { padding: clamp(2rem, 5vw, 3rem); margin-top: 0; }
.gap-sm { padding: 1.1rem 1.25rem; margin-top: 1.75rem; }
.gap p { margin: 0 0 0.6rem; color: var(--muted); font-size: 0.9375rem; max-width: 40rem; }
.gap p:last-child { margin-bottom: 0; }
.gap strong { color: var(--white); }
.gap-tag {
  font-family: var(--f-ui); font-weight: 600; font-size: 0.625rem;
  letter-spacing: 0.13em; text-transform: uppercase;
  color: var(--silver) !important;
}
.gap-note { font-size: 0.8125rem !important; color: var(--muted-2) !important; }
.band-join .gap { text-align: left; max-width: 34rem; margin-left: auto; margin-right: auto; }

/* ---------------- about ---------------- */

.about {
  display: grid; gap: clamp(2rem, 5vw, 3.5rem);
  grid-template-columns: 1fr; align-items: start;
}
@media (min-width: 56rem) { .about { grid-template-columns: 1.05fr 0.95fr; } }

/* :not(.eyebrow) is load-bearing. `.about-copy p` is (0,1,1) and `.eyebrow` is
   (0,1,0), so the bare descendant selector was beating the eyebrow class and
   the "Who I am" label rendered at 16px/25.6 in --muted while every other
   eyebrow on the site sits at 11px/17.6 in --muted-2. Measured 11 Aug; it was
   the only broken rung in the whole label system. */
.about-copy p:not(.eyebrow) { color: var(--body); font-size: clamp(1rem, 2.2vw, 1.125rem); margin: 0 0 1.1rem; max-width: 34rem; }

/* ---------------- view-all ledger link ----------------
   Structure measured on mammothmurals (recon/mammoth-viewall/spec.md §3): the
   label at heading scale, a bracketed superscript count riding its shoulder, a
   rule spanning the whole container beneath both, and an arrow triplet parked
   at the far right of that rule. The whole row is the target.

   It is not a button, it is a ledger row, which is why it sits on this site
   without reading as an import: it is the same object as the #offer rows.

   The label carries .casc, so the hover is the site's own cascade text at the
   tuning every other link uses. Copying the reference's stagger would have made
   this the one link on the site that behaves differently from the rest. */
.viewall {
  position: relative;
  display: flex; align-items: flex-end; justify-content: space-between;
  gap: 1rem;
  margin: clamp(2.5rem, 6vw, 4rem) 0 0;
  padding: 0 0 clamp(0.9rem, 2vw, 1.3rem);
  text-decoration: none; color: var(--white);
}

/* Same drawn-line signature as every other rule on the site: one clean scale on
   easeInOutQuint, slow off the mark, fast through the middle, decelerating in. */
/* Right-aligned variant. A destination you move toward sits at the END of the
   rule; the rule still spans the container, so the ledger vocabulary holds.
   Scoped rather than applied to .viewall itself because wins.html's "Back to
   the site" uses the same component, and a return belongs on the left. */
.viewall--right { justify-content: flex-end; gap: 1.6rem; }

/* Hero variant. The component is built for the foot of a band, where it spans a
   full container and carries a big top margin; in the hero it is a third item in
   a left-aligned column that sizes to its own content, so three things change
   and nothing else does.

   width: fit-content, because .hero-inner is align-items: flex-start. Without it
   the link stretches the column and the drawn rule runs to the viewport edge.

   The label drops off .h-base's 64px desktop cap. The hero h1 is already 144px,
   and two display sizes that close together read as a competition rather than a
   hierarchy; 34px sits it clearly under the headline while staying the same
   object as the band-foot links.

   The rule keeps its drawn-in reveal, which is the whole reason this reads as
   the site's own component and not a text link with an underline. */
.hero-cta {
  width: fit-content;
  margin: 0;
  gap: 2rem;
  padding-bottom: clamp(0.7rem, 1.4vw, 0.95rem);
}
.hero-cta .viewall-label { --h2: clamp(1.5rem, 2.4vw, 2.125rem); }
.hero-cta .viewall-arrows svg { width: clamp(0.95rem, 1.5vw, 1.15rem); }

/* On the inverted bands the component's white ink would land on white ground.
   Same values the rest of .band-inv uses, so it stays one system. */
.band-inv .viewall,
.band-inv .viewall-label { color: #0A0A0B; }
.band-inv .viewall-count { color: #6E6E76; }
.band-inv .viewall::after { background: #C2C2BE; }

.viewall::after {
  content: ""; position: absolute; left: 0; right: 0; bottom: 0;
  height: 1px; background: var(--line-2);
  transform: scaleX(0); transform-origin: 0 50%;
  transition: transform 950ms cubic-bezier(0.83, 0, 0.17, 1);
}
.viewall.in::after { transform: scaleX(1); }
html:not(.js) .viewall::after { transform: scaleX(1); transition: none; }

/* Block, not flex: the count uses vertical-align: super to ride the label's
   shoulder, and vertical-align does nothing to a flex item. */
.viewall-wrap { display: block; }
.viewall-label {
  font-family: var(--f-display); font-weight: 400;
  font-size: var(--h2, clamp(2.25rem, 7vw, 4rem));   /* .h-base supplies --h2 */
  line-height: 0.85; letter-spacing: -0.03em; text-transform: uppercase;
}
.viewall-count {
  font-family: var(--f-ui); font-weight: 700;
  font-size: 0.6875rem; letter-spacing: 0.08em;
  color: var(--muted-2);
  /* top, not super. `super` raises the count relative to its OWN small
     baseline, which against a 64px cap parks it at the label's foot instead of
     on its shoulder where the reference has it. */
  vertical-align: top; margin-left: 0.2rem;
  position: relative; top: 0.15em;
}

/* The row owns the hover, not the label: the rule spans the container and the
   whole thing is the click target, so hovering the arrows must move the text. */
.viewall:hover .casc-c,
.viewall:focus-visible .casc-c { transform: translateY(-1em); }

.viewall-arrows { display: inline-flex; align-items: center; gap: 0.3rem; flex: none; }
.viewall-arrows svg {
  width: clamp(1.05rem, 2.4vw, 1.5rem); height: auto; display: block;
  transition: transform 700ms cubic-bezier(0.16, 1, 0.3, 1);
}
/* Staggered so the triplet reads as travel rather than three things sliding. */
.viewall:hover .viewall-arrows svg:nth-child(1) { transform: translateX(4px); }
.viewall:hover .viewall-arrows svg:nth-child(2) { transform: translateX(7px); }
.viewall:hover .viewall-arrows svg:nth-child(3) { transform: translateX(10px); }

@media (max-width: 40rem) {
  .viewall { align-items: center; }
  /* The font-size override that used to sit here (clamp(1.75rem, 8.5vw, 2.5rem))
     is gone. It bypassed --h2 entirely, so the label ignored the .h-base rung it
     is supposed to ride and rendered 33.1px at 390 — larger than the h2 of the
     band it sat in, which is what made "VIEW ALL WINS" read as a headline
     instead of a link. It exists because the ladder's old floors were too big on
     a phone; the ladder fixes that at the source now. */
}
@media (prefers-reduced-motion: reduce) {
  .viewall::after { transform: scaleX(1); transition: none; }
  .viewall-arrows svg { transition: none; }
  .viewall:hover .viewall-arrows svg { transform: none; }
}

/* ---------------- window parallax ----------------
   Pravin's component, applied verbatim. The photo is pre-scaled so a band of
   it hangs outside the frame; the frame then slides over it on scroll, like
   walking past a window. Travel is derived from live geometry in main.js so an
   edge can never show.

   NOT applied to the transformation or arc cards: those are composited
   graphics with their labels baked into the pixels, and drifting them inside a
   frame would crop the text off. Parallax suits a photograph, not a chart. */
.wp-frame:not(.hero-media) { position: relative; aspect-ratio: 4 / 5; overflow: hidden; border: 1px solid var(--line); border-radius: var(--r); }
.wp-frame picture { display: contents; }
.wp-img {
  width: 100%; height: 100%; object-fit: cover; display: block;
  /* Dialled back from 1.15 on 10 Aug: too strong. The pre-scale IS the travel
     budget, so lowering it shrinks the drift and tightens the crop less. */
  --wp-scale: 1.10;
  transform: translateY(var(--wp-par, 0px)) scale(var(--wp-scale));
}

/* The portrait drifts harder than the default. It was measured running the
   whole time Pravin asked where it had gone — 44px at 1440p up to 124px on a
   13in Air — so the problem was never that it was off, it is that the frame
   crops a dark, low-detail scene and there is nothing near the edges for the
   eye to notice movement against. The pre-scale is the travel budget, so this
   raises the amount without touching the engine's edge-safety maths: +60% of
   drift for 6% more crop. */
.about-photo .wp-img { --wp-scale: 1.16; }
@media (min-width: 721px) and (prefers-reduced-motion: no-preference) {
  .wp-img { will-change: transform; }
}

.arc {
  list-style: none; margin: clamp(2.5rem, 6vw, 4rem) 0 0; padding: 0;
  display: grid; gap: clamp(0.75rem, 2vw, 1.25rem);
  grid-template-columns: repeat(2, 1fr);
}
@media (min-width: 46rem) { .arc { grid-template-columns: repeat(4, 1fr); } }

/* Resolved 11 Aug. These four shipped at 214x310 for a day and rendered at a
   2.37x upscale, because the only sources on disk were the pre-composited
   Instagram cards (assets-master/arc-originals/, 448x560) whose inner
   photograph is itself only ~214px wide. Re-deriving from those cards was tried
   and reverted: it re-introduced the date and weight burnt into their pixels,
   which the HTML caption already states.

   Pravin supplied Higgsfield regenerations at 1696x2528 instead. Shipped at
   800px wide, which is not a compromise on his compress-don't-downscale rule:
   .arc is four columns above 46rem inside the 72rem shell, so the slot is 253px
   at 1440 and needs 759 device px at DPR 3. 800 clears every device at >=1.0x
   while keeping four thumbnails off a megabyte. Sources kept in
   assets-master/arc-originals-hf/.

   These are GENERATIVE reconstructions of a 214px source, not recovered detail.
   Checked against the originals frame by frame before shipping: pose, setting,
   tattoo, the RIO / TUESDAY / WEDNESDAY waistbands and the background all hold,
   and the man reads as the same person in all four. Worth re-checking with Hesh
   anyway, because the one thing an upscale must not invent on a transformation
   timeline is the physique, and that is exactly the detail a 214px source
   cannot carry. */
.arc img { width: 100%; border: 1px solid var(--line); border-radius: var(--r); }
/* The four cards arrived as pre-composited Instagram exports: marble ground,
   logo, date band, and the weight burnt into the pixels. Exactly the problem
   the .cases section had, and the same fix — the photograph is cut out of the
   card and the type is real HTML, so the numbers are selectable, searchable
   and readable to a screen reader instead of being pixels.

   The hierarchy is deliberately inverted from the old caption, where the date
   and the weight were the same size and the weight was the dimmest thing on
   the card. The weight is the reason this row exists: read across the four,
   67-73-78-85 IS the six-year story. So it takes the display face, on the same
   argument the .delta numbers make higher up the page (scanned, not read), and
   the date drops back to an eyebrow. */
.arc-cap {
  display: grid; gap: 0.2rem;
  margin: 0.85rem 0 0;
  font-family: var(--f-ui);
}
.arc-when {
  font-size: 0.5625rem; font-weight: 600; letter-spacing: 0.14em;
  text-transform: uppercase; color: var(--muted-2);
}
.arc-kg {
  font-family: var(--f-display); font-weight: 400;
  font-size: clamp(1.75rem, 4.4vw, 2.75rem);
  line-height: 0.85; letter-spacing: -0.02em;
  color: var(--white);
}
/* The unit rides at UI size so the numeral keeps the whole of the display
   face's weight; "67KG" set solid in Anton reads as one word, not a figure. */
.arc-kg span {
  font-family: var(--f-ui); font-weight: 600;
  font-size: 0.625rem; letter-spacing: 0.14em; text-transform: uppercase;
  color: var(--muted-2); margin-left: 0.3rem;
}

/* ---------------- steps ---------------- */

.steps {
  list-style: none; margin: 0; padding: 0;
  display: grid; gap: clamp(1.75rem, 4vw, 2.5rem);
  grid-template-columns: 1fr; counter-reset: none;
}
@media (min-width: 52rem) { .steps { grid-template-columns: repeat(3, 1fr); } }

.step-n {
  display: block; font-family: var(--f-ui); font-weight: 600;
  font-size: 0.75rem; letter-spacing: 0.13em; color: var(--muted-2);
  margin-bottom: 0.9rem;
}
.steps h3 { font-size: 1.125rem; font-weight: 700; letter-spacing: -0.015em; margin: 0 0 0.55rem; }
.steps p { margin: 0; color: var(--body); font-size: 0.9375rem; }

/* ---------------- how: the room ----------------
   .wp-frame's own rule fixes 4/5 and is written for the portraits, so this
   overrides the ratio only — the border, radius, overflow and the drift budget
   all still come from there, which is what keeps it in the same family as the
   about photo rather than looking like a different component. */
/* .wp-frame.how-shot, not .how-shot: the ratio it has to beat is set by
   `.wp-frame:not(.hero-media)`, and :not() carries specificity, so that
   selector is (0,2,0) and a lone class is (0,1,0). The bare class lost
   silently and the frame rendered 785x982 — the portrait 4/5 — which
   cover-cropped the sides straight back off the 3:2 crop and grew the band
   from 783px to 1742px. Matching specificity and winning on order fixes it. */
.wp-frame.how-shot {
  margin: clamp(1.75rem, 4vw, 2.5rem) 0 clamp(2rem, 5vw, 3rem);
  /* 16/9, matching the source exactly, so `cover` has nothing to crop. Was 3/2
     for the previous photograph, which was a 4284x5712 portrait and had to lose
     its top and bottom to sit in a landscape frame. */
  aspect-ratio: 16 / 9;
}
/* The band is inverted, so the frame's dark hairline would sit almost invisibly
   on it. Same value the other inverted-band frames already use. */
.band-inv .how-shot { border-color: #C2C2BE; }

/* ---------------- join ---------------- */

.join-actions {
  display: flex; flex-wrap: wrap; justify-content: center;
  gap: 0.85rem; margin-bottom: 0.5rem;
}

/* ---------------- footer ---------------- */

/* The bottom value used to be clamp(5.5rem, 10vw, 3.5rem) — a malformed clamp
   whose min exceeds its max, so it always resolved to 5.5rem/88px. That was
   reserving room for the sticky Join bar to sit over the footer without burying
   the credit line. With the bar removed the reservation is dead space, so the
   bottom now matches the top. env() keeps it clear of a phone's home indicator. */
.foot {
  padding: clamp(2.5rem, 6vw, 3.5rem) 0
           calc(clamp(2.5rem, 6vw, 3.5rem) + env(safe-area-inset-bottom));
  border-top: 1px solid var(--line);
}
.foot-brand {
  margin: 0 0 0.3rem; font-weight: 800; letter-spacing: 0.14em;
  text-transform: uppercase; font-size: 0.875rem;
}
.foot-meta { margin: 0 0 1.1rem; color: var(--muted-2); font-size: 0.8125rem; }
.foot-credit { margin: 0; color: var(--muted-2); font-size: 0.75rem; }
.foot-credit a { color: var(--muted); text-decoration: none; border-bottom: 1px solid var(--line-2); }
.foot-credit a:hover { color: var(--white); }

/* ---------------- footer: the closing ledger ----------------
   Structure from bymonolog, measured 12 Aug: their footer rows are ledger rows,
   and .viewall already IS that object — label left, a 1px rule spanning the
   container that draws itself in, arrow triplet hard right. So this footer adds
   no component. It adds spacing. Their row is 73px desktop / 55px phone; ours
   lands 54.8px on a phone and deliberately taller on desktop, because their row
   carries an 18px nav label and ours carries a 64px display one. The type
   ladder is ours and wins; the row metric is theirs. */

.foot-group + .foot-group { margin-top: clamp(2.25rem, 5vw, 3.25rem); }
.foot .eyebrow { margin: 0 0 0.7rem; }

/* Flush stack. The component's clamp(2.5rem,6vw,4rem) top margin is right when
   one row closes a band; here it would put 64px of air between rules. */
.foot .viewall {
  margin: 0;
  padding: clamp(0.6rem, 1.2vw, 0.8rem) 0 clamp(0.75rem, 1.6vw, 1rem);
}

/* ---- fact rows: a value where the arrows go ---- */
.foot-facts { margin: 0; }
.foot-facts dt, .foot-facts dd { margin: 0; }
.foot-facts dt { line-height: 1; }

/* Scoped .foot .viewall--fact, (0,2,0), NOT the bare class. At <=40rem
   `.viewall { align-items: center }` is (0,1,0) and sits later in the file, so
   an unscoped (0,1,0) rule here loses the tie and drops the 11px value about
   12px off the label's baseline — at exactly the width this was measured at. */
.foot .viewall--fact { align-items: baseline; cursor: default; }

/* The cascade engine targets .viewall-label globally and .viewall:hover drives
   it, which a <div> still matches. Killed on both non-link rows: text that
   moves under the cursor promises a click these rows do not honour. */
.foot .viewall--fact:hover .casc-c,
.foot .foot-mark:hover .casc-c { transform: none; }

.foot-val {
  flex: none; text-align: right; line-height: 1;
  font-family: var(--f-ui); font-weight: 600;
  font-size: 0.6875rem; letter-spacing: 0.14em; text-transform: uppercase;
  /* --muted is the units-and-metadata ink and that is exactly what these are.
     The labels stay --white and drop a rung to .h-quiet instead, per the --body
     note: hierarchy comes from size, never from dimming. */
  color: var(--muted);
}

/* Current page. wins.html and how.html carry aria-current="page". */
.foot .viewall[aria-current="page"] { pointer-events: none; }
.foot .viewall[aria-current="page"] .viewall-label { color: var(--muted-2); }
.foot .viewall[aria-current="page"] .viewall-arrows { display: none; }

/* ---- closing line ---- */
/* .foot .viewall is (0,2,0), so repadding the mark row needs (0,3,0). */
.foot .viewall.foot-mark {
  align-items: flex-end;
  padding: clamp(2.25rem, 6vw, 3.5rem) 0 clamp(0.9rem, 2vw, 1.35rem);
}
/* NOT display:contents, which is what every other picture on this site uses.
   Inside a flex row, contents promotes the picture's children to flex items —
   and that includes the <source>. The row therefore had THREE items, not two:
   a zero-width <source> at the left, the logo, and the credit. Under
   space-between the zero-width item takes the left slot and the logo lands in
   the middle, which is why the mark read as deliberately centred when nothing
   ever centred it. Measured: row left 184, logo left 617, row width 1072.

   Making the picture itself the flex item puts the row back to two items, so
   space-between does what it says: mark hard left, credit hard right. */
.foot-mark picture { display: block; flex: none; font-size: 0; }
.foot-logo {
  display: block; flex: none;
  /* Sized down again 12 Aug, and the reason is the asset rather than the layout.
     logo.png is a SOFT raster — 25.2% of its pixels are mid-tone, where a
     geometric two-tone mark should be near zero, and the favicon is worse at
     30.1%. Sharpening recovers nothing (25.2% -> 24.7% after an upscale-sharpen-
     downsample pass), so the information genuinely is not there: it was upscaled
     or recompressed before it reached us.

     At 168px the 363px source was only being downsampled 0.93x at DPR 2, so
     every soft edge showed at full size. 7rem forces a ~1.6x downsample at DPR 2
     and ~1.1x at DPR 3, which compresses the mush into the pixel grid and reads
     visibly crisper. This is mitigation, not a fix. The fix is the vector. */
  width: clamp(5.5rem, 8vw, 7rem);
  height: auto;
}
.foot-mark .foot-credit {
  flex: none;
  /* Same fix .nav-brand documents: the credit is a .casc target and .casc-in is
     a 1em box on vertical-align:bottom, so body's 1.6 leading would park the ink
     low in its item and float it off the closing rule. */
  line-height: 1;
}

/* No .foot a:focus-visible override. The winning design added
   outline-offset: 6px, a seventh number in a system that already has a focus
   treatment at :root — 2px solid var(--white), offset 3px. That one inherits. */

/* ---- phone: hit the reference's 55px row ---- */
@media (max-width: 46rem) {
  .foot .viewall { padding: 0.95rem 0 0.85rem; }
  .foot .viewall.foot-mark { padding: clamp(1.75rem, 9vw, 2.5rem) 0 0.85rem; }
  .foot-group + .foot-group { margin-top: 2.25rem; }
}

/* ---------------- sticky mobile cta — REMOVED 11 Aug ----------------
   The fixed bottom "Join the Vault" bar is gone at Pravin's call. Its markup,
   these styles and its scroll driver in main.js were all deleted rather than
   hidden, so no code remains watching for an element that never renders. */

/* ---------------- split-text reveal ----------------
   Mammoth's structure, measured: .lines-split-mask (overflow:clip) wraps
   .lines-split wraps per-character inline-blocks, revealed on an expoOut with
   a stagger. Rebuilt here in vanilla; they drive theirs with GSAP.

   The mask is what sells it. Without overflow:clip the letters slide over the
   background instead of growing out of nothing. */
/* clip-margin was 0.16em, which at a 144px headline is 23px of bleed per line.
   With 0.80 leading the bleed regions of adjacent lines overlapped and the
   words visibly collided. 0.05em still clears a descender without letting one
   line reach into the next. */
.split-line { display: block; overflow: clip; overflow-clip-margin: 0.05em; }
.split-char {
  display: inline-block;
  white-space: pre;                 /* preserves the spaces between words */
  transform: translateY(100%);
  transition:
    transform 806ms cubic-bezier(0.16, 1, 0.3, 1) var(--d, 0ms),
    opacity   494ms cubic-bezier(0.16, 1, 0.3, 1) var(--d, 0ms);
  opacity: 0;
}
.split.in .split-char { transform: translateY(0); opacity: 1; }

/* No JS, or reduced motion: the text is simply there. */
html:not(.js) .split-char,
.no-split .split-char { transform: none; opacity: 1; transition: none; }
@media (prefers-reduced-motion: reduce) {
  .split-char { transform: none; opacity: 1; transition: none; }
}

/* ---------------- reveal ---------------- */

.reveal { opacity: 1; }

.js .reveal {
  opacity: 0;
  transform: translateY(14px);
  transition: opacity .42s ease, transform .42s cubic-bezier(.22,.61,.36,1);
}
.js .reveal.in { opacity: 1; transform: none; }

@media (prefers-reduced-motion: reduce) {
  .js .reveal { opacity: 1; transform: none; transition: none; }
}

/* ---------------- grain ----------------
   Fixed film grain across the whole viewport, the way supersolid does it. On a
   near-black page this is what stops big flat areas from banding.
   pointer-events:none so it never eats a click. */
.grain {
  position: fixed; inset: -50%;
  z-index: 9999;
  pointer-events: none;
  opacity: 0.04;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='140' height='140' filter='url(%23n)'/%3E%3C/svg%3E");
  animation: grain-shift 910ms steps(1) infinite;
}
@keyframes grain-shift {
  0%   { transform: translate(0, 0); }
  20%  { transform: translate(-3%, 2%); }
  40%  { transform: translate(2%, -3%); }
  60%  { transform: translate(-2%, -2%); }
  80%  { transform: translate(3%, 1%); }
  100% { transform: translate(0, 0); }
}
@media (prefers-reduced-motion: reduce) { .grain { animation: none; } }

/* ---------------- header cluster ----------------
   Measured off otherlife.xyz, spec in recon/otherlife/spec.md.

   Their header: logo left; one rounded container top-right, overflow hidden,
   holding a burger (three 20x2 bars) and a CONTACT US pill. Container 207x60,
   radius 10px, near-white ground.

   Two behaviours out of one object:
     burger  -> bars morph to an X and the menu opens
     contact -> the SAME container expands into the form

   Contact expansion, measured with the click at 0:
     width  207 -> 477   starts 639ms, runs 368ms, ease-out
     pause                       133ms
     height  60 -> 671   starts 1140ms, runs 417ms, ease-out
   The 133ms gap is what stops it reading as one diagonal stretch.

   Burger morph, decoded from the end-state matrices:
     bar 1  scaleX(1.35) rotate(45deg)  translateY(8px)
     bar 2  scaleX(1.35), fades out
     bar 3  scaleX(1.35) rotate(-45deg) translateY(-4px)
     ~480ms

   Ours: 4px radius not 10 (our token), and dark ground not near-white, since
   the header sits over a black photograph. Their green is their brand. */

.hdr {
  position: fixed;
  top: clamp(0.75rem, 2vw, 1.15rem);
  right: var(--pad);
  z-index: 300;
  width: 12.9rem;
  height: var(--hdr-h);
  overflow: hidden;
  border-radius: var(--r);
  background: var(--bg-2);
  border: 1px solid var(--line-2);
  /* Closing runs the opening in reverse: height collapses, then width. */
  transition:
    height 417ms cubic-bezier(0.16, 1, 0.3, 1),
    width  368ms cubic-bezier(0.16, 1, 0.3, 1) 417ms,
    transform 700ms cubic-bezier(0.16, 1, 0.3, 1);
}
/* Sped up 11 Aug, and the new numbers are not invented — they are the ones
   measured off otherlife.xyz in the spec above: width 368ms, a 133ms pause,
   then height 417ms. The implementation had drifted to 478/173/542, which ran
   1193ms against the reference's 918ms, so the fix was to go back to what was
   measured rather than pick a new tempo. 23% quicker, same choreography, and
   the 133ms gap that stops it reading as one diagonal stretch is intact.

   The focus timeout in main.js is tied to the end of this sequence and moved
   with it — 1235ms to 960ms. Change one, change the other. */
.hdr.is-open {
  width: min(27rem, calc(100vw - (var(--pad) * 2)));
  transition:
    width  368ms cubic-bezier(0.16, 1, 0.3, 1),
    height 417ms cubic-bezier(0.16, 1, 0.3, 1) 501ms,   /* 368 + the 133ms pause */
    transform 700ms cubic-bezier(0.16, 1, 0.3, 1);
}

/* ---------------- header hide on scroll ----------------
   Measured on mammothmurals (recon/mammothmurals.com/summary.md): a link inside
   their fixed 77px navbar steps from top:85 to top:-5 between f0 and f0.11 and
   holds at -5 for the rest of the page. Classified `step`, not `scrub`, so it
   snaps between two states rather than sliding with the scroll.

   Both chips move together because they now read as a matched pair in opposite
   corners. Desktop only: below 46rem the .hdr is bottom-anchored and doubles as
   the way into the contact form, so hiding it would take the main entry point
   off the screen.

   NOT measured: the probe drives scroll downward only, so it could not tell
   whether their header comes back on the way up. Returning on scroll-up is the
   assumption here, and it is the reason the contact button stays reachable.

   They leave SIDEWAYS, not upward (11 Aug): the brand exits left, the contact
   cluster exits right, each toward the edge it already sits against. The
   transform is on .nav-brand rather than .nav, because .nav is a full-width bar
   and sliding the whole thing would move 1500px to hide a 118px chip.
   Each travels its own width plus the page gutter, so neither leaves a sliver. */
/* No longer desktop-only (11 Aug). The min-width: 46rem gate existed because
   the header used to be bottom-anchored on phones and doubled as the only way
   into the contact form, so hiding it took the entry point off the screen. Now
   that it rides the top rail like every other width, the same hide-down /
   return-up behaviour is correct on a phone too — and the sticky Join bar keeps
   an action on screen the whole time regardless. The JS gate was lifted with
   it; both had to move or the class would toggle against no transition. */
.nav-brand { transition: transform 700ms cubic-bezier(0.16, 1, 0.3, 1); }
.nav.is-gone .nav-brand { transform: translateX(calc(-100% - var(--pad) - 1rem)); }
.hdr.is-gone { transform: translateX(calc(100% + var(--pad) + 1rem)); }
@media (prefers-reduced-motion: reduce) {
  .nav.is-gone .nav-brand, .hdr.is-gone { transform: none; }
}

/* calc(var(--hdr-h) - 2px) — the closed bar's INNER height, and both halves of
   that expression are load-bearing.

   Minus 2px because .hdr is border-box with a 1px border: a row set to the full
   outer height overhangs the bottom border and gets clipped by overflow:hidden.
   That was invisible while the button was inset, but the moment the button fills
   the row on phones it would cover the bottom hairline while the top one still
   showed.

   Fixed rather than height:100% because .hdr ANIMATES ITS HEIGHT to open — 41.6px
   closed, 237px for the menu, 558px for the form. A percentage row grows with it
   and takes the full-height contact button down the panel with it, painting a
   white slab over the menu links. Caught in a headless open test, not by eye. */
.hdr-row {
  position: absolute; top: 0; left: 0; right: 0;
  height: calc(var(--hdr-h) - 2px);
  display: flex; align-items: center; gap: 0.25rem;
  padding: 0 0.45rem 0 0.75rem;
}

/* Burger. Three 20x2 bars, same as theirs. */
.hdr-burger {
  flex: none; width: 2.6rem; height: 2.6rem;
  position: relative; background: none; border: 0; cursor: pointer; padding: 0;
}
.hdr-burger span {
  position: absolute; left: 50%; width: 20px; height: 2px;
  background: var(--white); margin-left: -10px;
  /* 480ms, down from 624ms. Same correction as the container sequence above and
     from the same source: the spec at the top of this block decoded the morph at
     ~480ms off otherlife's end-state matrices, and the implementation had drifted
     30% slower. The middle bar's fade scales with it, 390ms -> 300ms, so it still
     clears just before the outer two finish rotating rather than lingering behind
     the X. */
  transition: transform 480ms cubic-bezier(0.16, 1, 0.3, 1), opacity 300ms ease;
}
.hdr-burger span:nth-child(1) { top: calc(50% - 6px); }
.hdr-burger span:nth-child(2) { top: calc(50% - 1px); }
.hdr-burger span:nth-child(3) { top: calc(50% + 4px); }

.hdr.menu-open .hdr-burger span:nth-child(1) { transform: translateY(5px) rotate(45deg)  scaleX(1.35); }
.hdr.menu-open .hdr-burger span:nth-child(2) { transform: scaleX(1.35); opacity: 0; }
.hdr.menu-open .hdr-burger span:nth-child(3) { transform: translateY(-5px) rotate(-45deg) scaleX(1.35); }

.hdr-contact {
  flex: 1 1 auto; height: 2.45rem;
  background: var(--white); color: #0A0A0B;
  border: 0; border-radius: var(--r); cursor: pointer;
  font-family: var(--f-ui); font-weight: 700;
  font-size: 0.6875rem; letter-spacing: 0.12em; text-transform: uppercase;
  transition: background-color 195ms cubic-bezier(0.4, 0, 0.2, 1),
              color 195ms cubic-bezier(0.4, 0, 0.2, 1),
              transform 320ms cubic-bezier(0.16, 1, 0.3, 1);
}

/* The press, added 12 Aug. Both chips had hover styling and NOTHING for the
   press itself, so the button went from idle straight to a panel unfolding
   with no acknowledgement in between — which is what reads as abrupt rather
   than slow, and it is worse on a phone where there is no hover state at all
   to soften the gap.

   Driven by a class from a pointerdown/pointerup pair rather than by :active.
   iOS Safari only honours :active on an element it considers interactive-with-
   a-listener, so :active alone is exactly the platform Pravin called out.

   Release is 320ms against the press's 110ms: quick to acknowledge, unhurried
   coming back, so it never fights the 918ms panel sequence that follows. */
.hdr-burger, .hdr-contact {
  -webkit-tap-highlight-color: transparent;
  touch-action: manipulation;
}
.hdr-contact.is-press { transform: scale(0.955); transition-duration: 195ms, 195ms, 110ms; }
.hdr-burger { transition: transform 320ms cubic-bezier(0.16, 1, 0.3, 1); }
.hdr-burger.is-press { transform: scale(0.9); transition-duration: 110ms; }
@media (prefers-reduced-motion: reduce) {
  .hdr-contact, .hdr-burger { transition: none; }
  .hdr-contact.is-press, .hdr-burger.is-press { transform: none; }
}
.hdr-contact:hover { background: var(--silver); }
.hdr.form-open .hdr-contact { background: var(--bg-3); color: var(--white); }

.hdr-panel {
  position: absolute; left: 0; right: 0; top: var(--hdr-h);
  padding: 0.25rem 1.1rem 1.2rem;
  opacity: 0;
  /* Held at the same point in the sequence it always sat at — 61% of the way
     through the open — so the contents arrive as the height finishes rather
     than after it. Scaled with the rest: was 390ms/728ms against a 1193ms open. */
  transition: opacity 300ms ease 560ms;
}
.hdr.is-open .hdr-panel[data-shown] { opacity: 1; }
.hdr-panel:not([data-shown]) { pointer-events: none; visibility: hidden; }

.hdr-panel nav { display: flex; flex-direction: column; }
.hdr-panel nav a {
  display: flex; align-items: baseline; gap: 0.7rem;
  padding: 0.55rem 0; text-decoration: none; color: var(--white);
  font-family: var(--f-display); font-weight: 400; text-transform: uppercase;
  font-size: 1.5rem; line-height: 1; letter-spacing: -0.02em;
  border-bottom: 1px solid var(--line);
}
.hdr-panel nav a:last-child { border-bottom: 0; }
.hdr-i {
  font-family: var(--f-ui); font-weight: 600; font-size: 0.5625rem;
  letter-spacing: 0.14em; color: var(--muted-2);
}
.hdr-panel nav a:hover { color: var(--silver); }

/* ---------------- contact panel ----------------
   None of this was styled. .cpanel-form and every .cf- class fell through to
   browser defaults, so the labels and their inputs ran together as one inline
   paragraph — "Name [box] Email [box] What do you want out of it [box]" —
   wrapping mid-phrase. That is what looked compacted; it was never laid out.

   The form shares its container with the menu, and the header note above says
   why that matters: it is one object either way. So the fields take the menu's
   hairline ladder rather than arriving as boxed inputs. Each input IS the rule,
   same 1px, same rhythm, so CONTACT reads as the same drawer carrying different
   content instead of a form pasted into a nav. */
.cpanel-title {
  margin: 0 0 1.15rem;
  font-family: var(--f-display); font-weight: 400;
  font-size: 1.5rem; line-height: 1; letter-spacing: -0.02em;
  text-transform: uppercase;
}

.cpanel-form { display: grid; gap: 0.2rem; }

.cf-row { display: grid; gap: 0.3rem; padding: 0.45rem 0; }

.cf-row > span {
  font-family: var(--f-ui); font-weight: 600;
  font-size: 0.5625rem; letter-spacing: 0.14em; text-transform: uppercase;
  color: var(--muted-2);
}

.cf-row input {
  width: 100%;
  background: none;
  border: 0; border-bottom: 1px solid var(--line-2);
  border-radius: 0;                    /* iOS applies its own otherwise */
  padding: 0.3rem 0 0.5rem;
  color: var(--white);
  font-family: var(--f-ui);
  /* 16px floor, deliberately. Below it iOS Safari zooms the page on focus, and
     with a fixed header that throws the rest of the form off screen mid-entry. */
  font-size: 1rem; line-height: 1.4;
  transition: border-color 195ms cubic-bezier(0.4, 0, 0.2, 1);
}
.cf-row input::placeholder { color: var(--muted-2); opacity: 1; }
/* Always-on cue, so a pointer user sees the active field too. Keyboard users
   additionally get the global :focus-visible outline. */
.cf-row input:focus { border-bottom-color: var(--white); }

/* Chrome paints autofilled fields pale blue, which on a near-black panel reads
   as a rendering fault. The inset shadow is the only way to override it. */
.cf-row input:-webkit-autofill,
.cf-row input:-webkit-autofill:hover,
.cf-row input:-webkit-autofill:focus {
  -webkit-text-fill-color: var(--white);
  -webkit-box-shadow: 0 0 0 100px var(--bg-2) inset;
  caret-color: var(--white);
}

/* The panel is 27rem at its widest, so the button and the Instagram alternative
   side by side is what made this cramped. Stacked, the primary action gets the
   full width and the fallback sits under it. */
.cf-actions {
  display: grid; justify-items: center; gap: 0.9rem;
  margin-top: 1.4rem;
}
.cf-actions .btn { width: 100%; }

.cf-alt {
  /* Uppercase is load-bearing here, not taste. .cf-alt is in the cascade-text
     selector list, and .casc-in clips at exactly 1em, so in lower case the
     descender on the g in "Instagram" gets sliced off. */
  font-family: var(--f-ui); font-weight: 600;
  font-size: 0.625rem; letter-spacing: 0.14em; text-transform: uppercase;
  color: var(--muted); text-decoration: none;
}

/* role="status", empty until JS writes to it. Left as-is it holds open a gap
   under the button for every visitor who has not submitted yet. */
.cf-note { margin: 0.9rem 0 0; font-size: 0.8125rem; line-height: 1.5; color: var(--muted); }
.cf-note:empty { display: none; }

/* Was bottom-anchored below 46rem, full-bleed across the gutter. Two things
   were wrong with it and both were measured on 11 Aug at 390x844:

   1. It sat at top:778 of an 844px viewport, and .sticky (the Join bar) sits at
      inset:auto 0 0 0. Once the sticky armed they overlapped by 54.4px — the
      header's ENTIRE height — so the mobile CTA spent the whole page hidden
      behind the contact chip. Two competing bottom bars, one invisible.
   2. It left the brand plate alone in the top-left with the whole rail empty
      beside it, which is the "header sits low" reading.

   Top-anchored now at every width, so the brand and the contact cluster are the
   matched pair in opposite corners that the desktop comments already describe,
   and the bottom belongs to the sticky CTA alone. Only the CLOSED width needs
   overriding here; .hdr.is-open already resolves to the viewport-minus-gutters
   arm of its own min(). */
@media (max-width: 45.99rem) {
  /* Sized so it never reaches the brand plate. Worst case is the 320px floor,
     where the chips clear each other by ~35px once the rule below shrinks the
     wordmark. */
  .hdr { width: clamp(10.25rem, 44vw, 12.9rem); }

  /* The contact button fills its plate on phones (11 Aug). It used to be a
     2.45rem button floating inside a 3.4rem container, so a ~7.6px dark rim ran
     above and below it while the brand plate opposite was a solid 2.6rem block —
     two chips of different heights, one of them hollow. With --hdr-h at 2.6rem
     the container now matches the brand plate exactly, and the button spans the
     row's full height and runs to the right edge.

     border-radius: 0 is deliberate. .hdr already carries overflow:hidden and the
     4px token, so it clips the button's outer corners to the plate's own curve;
     a second radius inside that would read as a button-in-a-box again, which is
     the thing being removed. The 1px border survives on all four sides because
     .hdr-row is height:100% of the padding box. */
  .hdr-row { padding: 0 0 0 0.5rem; gap: 0.3rem; }
  .hdr-burger { width: 2.15rem; height: 100%; }
  .hdr-contact { height: 100%; border-radius: 0; }
}

/* Below 360px the two chips genuinely compete for the rail. Shrink both rather
   than let either wrap or collide. */
@media (max-width: 22.5rem) {
  .nav-brand { font-size: 0.8125rem; padding: 0 0.7rem; }
  .hdr-contact { font-size: 0.625rem; letter-spacing: 0.08em; }
}
@media (prefers-reduced-motion: reduce) {
  .hdr, .hdr.is-open, .hdr-panel, .hdr-burger span { transition: none; }
}


/* ---------------- cascade text (clickable text) ----------------
   Ported from a React/Tailwind component by hand, because this project has no
   React, no Tailwind, no TypeScript and no build step, and adding all four to
   ship one hover effect would cost more than the effect is worth.

   The mechanic, which is the clever part: each character renders a ghost copy
   of itself 1em below via `text-shadow: 0 1em currentColor`. A 1em-tall
   overflow-hidden wrapper hides it. On hover the character translates up by
   exactly 1em, so the ghost arrives in the space the original just left and
   the swap is seamless. The offset and the travel must stay identical or the
   letter appears to jump.

   Deviation: the original hovers to #b2c73a, an acid lime. Hesh's brand has no
   colour and we settled on monochrome, so this goes to white. */
.casc { display: inline-block; text-decoration: none; }
.casc-in {
  display: inline-flex;
  height: 1em;
  line-height: 1;
  overflow: hidden;
  vertical-align: bottom;
}
.casc-c {
  display: inline-block;
  white-space: pre;
  will-change: transform;
  text-shadow: 0 1em currentColor;
  transition: transform 200ms ease-in-out;
  transition-delay: calc(var(--ci, 0) * 18ms);
}
.casc:hover .casc-c,
.casc:focus-visible .casc-c { transform: translateY(-1em); }

/* Colour shift runs on the link, so every character moves together. */
.casc { transition: color 220ms ease; }
.nav-links a.casc:hover, .foot-credit a.casc:hover, .cf-alt.casc:hover { color: var(--white); }

/* The wrapper clips at exactly 1em, so a descender would be cut. Every piece
   of clickable text here is uppercase, which has none. The footer credit is
   the one exception and is uppercased to match. */
.foot-credit a.casc { text-transform: uppercase; letter-spacing: 0.08em; font-weight: 600; }

@media (prefers-reduced-motion: reduce) {
  .casc-c { transition: none; text-shadow: none; }
  .casc:hover .casc-c, .casc:focus-visible .casc-c { transform: none; }
}

/* ---------------- offer ledger ----------------
   Replaced the three equal cards on 10 Aug. Structure measured on mammoth's
   services band (recon/mammoth-structure/spec.md §4, and stops/05.jpg shows it
   whole): an asymmetric head setting a short left block against the statement,
   a full-width rule under it, then one row per item carrying its index left,
   its name very large, and its copy right.

   This is the first section on the site built on a REAL 12-column grid with
   spans of a half and a third. The recon's own transfer table lists that shared
   column system as the single structural item still outstanding here, because
   every other section runs its own ad-hoc grid. Gap is their measured 16.

   Only the architecture transfers. Mammoth is a light site on yellow, cream and
   blue; this stays inside the black/white/silver set.

   The names sit at .h-major, not .h-anchor, on purpose: the receipts band owns
   the page's single anchor moment and two detonations cancel each other out. */
.ledger { position: relative; z-index: 1; }

.ledger-head,
.lrow {
  display: grid;
  grid-template-columns: repeat(12, 1fr);
  gap: 1rem;
  position: relative;
}

/* The drawn rule, Pravin's line signature. A border cannot be animated along
   its length, so each rule is a scaled pseudo-element on easeInOutQuint: creeps
   off the mark, races the middle, decelerates into the stop. The same curve
   carries every drawn line on the site, and the steps are kept tight so four
   rules read as one gesture down the section rather than four separate events.

   One clean scale is the ceiling of drama a 1px line can carry. Multi-leg
   keyframes read as a stutter at this height, not as weight. */
.ledger-head::after,
.lrow::after {
  content: ""; position: absolute; left: 0; right: 0; bottom: 0;
  height: 1px; background: var(--line-2);
  transform: scaleX(0); transform-origin: 0 50%;
  transition: transform 950ms cubic-bezier(0.83, 0, 0.17, 1);
}
.ledger.in .ledger-head::after,
.ledger.in .lrow::after { transform: scaleX(1); }
.ledger.in .lrow:nth-child(1)::after { transition-delay: 160ms; }
.ledger.in .lrow:nth-child(2)::after { transition-delay: 320ms; }
.ledger.in .lrow:nth-child(3)::after { transition-delay: 480ms; }

/* Without JS the .in class never arrives, so the rules would sit at scaleX(0)
   and the section would lose every divider. html.js is only set when motion is
   allowed, so this covers the reduced-motion path too. */
html:not(.js) .ledger-head::after,
html:not(.js) .lrow::after { transform: scaleX(1); transition: none; }

.ledger-head { padding-bottom: clamp(2rem, 4vw, 3rem); }
/* Five columns, not four. Mammoth's left block is a 9-character stat; ours is a
   26-character sentence, and at .h-base its longest line runs 403px against the
   344px a third would give. */
.lh-left  { grid-column: 1 / span 5; }
/* .lh-right removed 12 Aug with the intro paragraph it styled. The ledger head
   is now the left block alone, so .lh-left's 5-column span is the whole row. */
.lh-left .eyebrow { margin-bottom: 0.9rem; }
.lh-left h2 { margin: 0; }

.ledger-rows { list-style: none; margin: 0; padding: 0; }

.lrow { align-items: start; padding: clamp(1.75rem, 3.5vw, 2.75rem) 0; }

.lrow-i {
  grid-column: 1 / span 2;
  font-family: var(--f-display); font-weight: 400;
  font-size: clamp(1.5rem, 3vw, 2.25rem); line-height: 0.9;
  color: var(--muted-2);
}
.lrow-name {
  /* 7 columns, not the 6 a strict half would give. Measured: "ACCOUNTABILITY"
     needs 529px at the .h-major cap and a half-column is 528px, so a half
     overflows by a hair at every viewport wide enough to hit both caps. The
     type scale is the fixed constraint here (it is what keeps this band under
     the receipts anchor), so the grid yields instead. The head row still runs
     a third against seven. */
  grid-column: 3 / span 7;
  margin: 0;
  font-family: var(--f-display); font-weight: 400;
  /* .h-major on the element supplies --h2, so the scale stays on the site's
     existing ladder instead of a number invented for this block. */
  font-size: var(--h2, clamp(2.75rem, 9.5vw, 6rem));
  /* -0.03em, tighter than the site's -0.02em headings. "ACCOUNTABILITY" is 14
     characters and at the 6rem cap it runs 542px against a 536px half-column,
     so the site default overflows by 14px. The extra tracking buys 13px and the
     brutalist register wants the tighter setting regardless. Measured, not
     guessed: re-check if a longer pillar name is ever added. */
  line-height: 0.82; letter-spacing: -0.03em;
  text-transform: uppercase; color: var(--white);
}
.lrow-copy {
  grid-column: 10 / span 3;
  margin: 0; color: var(--body);
  font-size: 0.9375rem; line-height: 1.55;
}

/* ---------------- meal proof strip (Nutrition row) ----------------
   Mammoth's measured thirds grid — `grid · 3 col · gap 16 · 427.672px` in
   recon/mammoth-structure/evidence.json — held to their 16px gap rather than a
   fluid clamp, because 16 and 24 are the only gap values that band actually
   uses (25 and 22 occurrences against 3 for 32).

   Columns 3-12 so the strip's left edge lands on the row name's, not on the
   number's. The number column is a margin in their layout and in ours. */
/* Two columns, not three: two meals exist. An empty third slot would read as a
   broken grid rather than as a gap waiting to be filled, and the strip is
   supposed to be evidence. Goes back to repeat(3, 1fr) the moment a third
   arrives — the 12-column span divides cleanly either way. */
.meals {
  grid-column: 3 / span 10;
  list-style: none; margin: clamp(1.5rem, 3vw, 2.25rem) 0 0; padding: 0;
  display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem;
}
.meal { margin: 0; }

.meal-shot {
  margin: 0 0 0.7rem;
  /* 3:2, set 11 Aug. Mammoth's services-row thumbnails measure 216x147 off
     recon stop 05 — 1.469:1 — so 3:2 (1.5) is nearer their proportion than the
     4:3 (1.333) this started as, and it is a native aspect ratio on the image
     model generating the photographs, which keeps generation and slot identical
     with no cover-crop throwing pixels away. */
  aspect-ratio: 3 / 2;
  border-radius: var(--r);
  overflow: hidden;
  background: var(--bg-3);
  border: 1px solid var(--line);
}
/* 1.05 of overscan is the travel budget, exactly as on .shot: the drift can
   only ever move the image by half the hidden band, so an edge cannot appear.
   data-drift is 0.05 here against the win photos' old 0.16 — "slightly", which
   on a 3:2 frame this size works out to about 6px of travel. */
.meal-shot img {
  width: 100%; height: 100%; object-fit: cover; display: block;
  scale: 1.05;
  translate: 0 var(--par, 0px);
}

/* Pending state. Visibly unfinished on purpose — this block is live scaffolding
   until the photographs arrive, and a slot that looks finished while empty is
   how a placeholder ships by accident. */
.meal-shot[data-pending] {
  border-style: dashed; border-color: var(--line-2);
  display: grid; place-items: center;
}
.meal-shot[data-pending]::after {
  content: "PHOTO";
  font-family: var(--f-ui); font-weight: 600; font-size: 0.625rem;
  letter-spacing: 0.14em; color: var(--muted-2);
}

.meal-name {
  margin: 0 0 0.45rem;
  font-size: 0.9375rem; font-weight: 600; line-height: 1.3;
  color: var(--white);
}

/* A <dl> because each macro is genuinely a label/value pair — and because the
   figures then read to a screen reader and to search, which is the entire point
   of not shipping them baked into a photograph. */
.meal-macros { display: flex; gap: 0.9rem; margin: 0; }
.meal-macros div { display: flex; align-items: baseline; gap: 0.3rem; }
.meal-macros dt {
  margin: 0;
  font-family: var(--f-ui); font-weight: 600; font-size: 0.625rem;
  letter-spacing: 0.14em; text-transform: uppercase; color: var(--muted-2);
}
.meal-macros dd {
  margin: 0;
  font-family: var(--f-display); font-weight: 400; font-size: 1.125rem;
  line-height: 1; color: var(--white); font-variant-numeric: tabular-nums;
}
.meal-macros dd span { font-size: 0.55em; color: var(--muted); margin-left: 0.05em; }

/* Calories sit under the macro row as context, not as a headline. His own cards
   put the calorie ring first; for a coaching page the protein figure is the one
   that carries the claim, so the emphasis is inverted here on purpose. */
.meal-cal {
  margin: 0.4rem 0 0;
  font-family: var(--f-ui); font-weight: 600; font-size: 0.6875rem;
  letter-spacing: 0.13em; text-transform: uppercase; color: var(--muted-2);
}

.meals-gap { grid-column: 3 / span 10; }


/* One column below the grid breakpoint. Twelve columns at phone width would put
   three characters of a name per line. */
@media (max-width: 47.99rem) {
  .ledger-head, .lrow { grid-template-columns: 1fr; gap: 0.55rem; }
  .lh-left, .lrow-i, .lrow-name, .lrow-copy,
  .meals, .meals-gap { grid-column: 1 / -1; }
  .lrow-copy { margin-top: 0.35rem; }

  /* One per row on a phone, not three. Thirds of a 350px column give 106px
     slots, and a 106px photograph of a plate is a thumbnail, not evidence —
     which defeats the only reason this block exists. Full width also sets the
     source-resolution floor: 350 CSS x DPR 3 = 1050 device px. */
  .meals { grid-template-columns: 1fr; gap: 1.25rem; }
}

@media (prefers-reduced-motion: reduce) {
  .ledger-head::after, .lrow::after { transform: scaleX(1); transition: none; }
}

/* The photograph behind this block was removed 12 Aug, along with the gradient
   scrim that existed only to hold it down. It was images/cases/c01-after.jpg at
   16% opacity — a real client's after-shot used as wallpaper. Everywhere else on
   the site that image appears it is evidence with figures beside it; here it was
   decoration, and the person in it agreed to the former.

   The band now carries the inverted ground instead, which is also what returns
   the white band the page lost when #who was deleted. */
#offer { position: relative; }
#offer > .shell { position: relative; z-index: 1; }

/* Pricing is quoted on contact, not published. This line makes that read as a
   decision rather than a hole where a price list should be. */
/* Lives in #join since 12 Aug, not the ledger foot. .band-join is centred, so
   the measure needs auto side margins it never needed inside a grid cell. */
.price-note {
  max-width: 34rem;
  margin: clamp(1.25rem, 3vw, 1.75rem) auto 0;
  font-size: clamp(1rem, 2.2vw, 1.125rem);
  color: var(--body);
}

/* ---------------- marble ----------------
   Hesh's own stone, shipped 12 Aug after Pravin picked it from a six-way
   preview. 2752x1536 at native resolution — encoding only, no resize.

   WHERE IT GOES, and just as importantly where it does not: every dark band
   plus the footer. The light bands (.band-inv — the offer ledger and how-it-
   works) stay white. Turning those too would collapse the band ladder the whole
   page is built on, and the page would read as one unbroken slab rather than as
   sections. Alternating ground is the structure; the marble rides on it.

   This block sits at the END of the file on purpose. `.band-a` sets the
   `background` shorthand at line ~894, which resets background-image, so an
   earlier rule here would be silently wiped. Equal specificity, later position.

   Each band takes a DIFFERENT slice of the same slab. Scrolling then reads as
   travelling down one piece of stone instead of meeting the same photograph
   four times, which is the difference between marble and a tiled background. */
.band-a,
.foot {
  background-color: var(--bg);
  background-image:
    linear-gradient(to bottom, rgba(8,8,10,0.45) 0%, rgba(8,8,10,0.18) 40%, rgba(8,8,10,0.45) 100%),
    url("images/marble.webp?v=03bd7a38");
  background-size: auto, cover;
  background-repeat: no-repeat, no-repeat;
}
#results { background-position: center, center top; }
#about   { background-position: center, center 35%; }

/* The join/footer junction. These are the only two marbled sections that touch,
   and butting two different crops of one slab against each other produced a
   hard tonal edge — measured a 5.42/255 step across the boundary against 2.38
   typical inside the stone. `cover` scales each box independently so the crops
   cannot be made to line up, so instead the stone RECEDES into flat --bg at the
   foot of #join and emerges again inside the footer. Junction is black-to-black
   and there is no edge to see. Step measured 0.28/255 after.

   It also absorbs the ~160px of empty stone below the CTA, which flat black had
   been hiding and which the material turned into visible dead space.

   Re-measuring this later: the sharpest row-to-row jump at that boundary is
   ~26/255 both before AND after the fix, and it is NOT the seam — it is
   .foot's border-top: 1px solid, one row of deliberate furniture. Exclude that
   row or the metric reports the fix as a regression. It did once. */
#join {
  /* position must be restated here: the shared rule above does not set one, and
     #results / #about set their own, so without this #join and .foot both fell
     back to 0% 0% and showed the identical top-left slice — the repetition the
     different-slices decision exists to avoid. */
  background-position: center, center 70%;
  background-image:
    linear-gradient(to bottom,
      rgba(8,8,10,0.55) 0%,
      rgba(8,8,10,0.18) 30%,
      rgba(8,8,10,0.30) 62%,
      rgba(8,8,10,0.92) 88%,
      var(--bg) 100%),
    url("images/marble.webp?v=03bd7a38");
}
.foot {
  background-position: center, center bottom;
  background-image:
    linear-gradient(to bottom,
      var(--bg) 0%,
      rgba(8,8,10,0.90) 10%,
      rgba(8,8,10,0.30) 34%,
      rgba(8,8,10,0.22) 70%,
      rgba(8,8,10,0.55) 100%),
    url("images/marble.webp?v=03bd7a38");
}

/* ---------------- about: mobile order ----------------
   Added 12 Aug. On a phone the portrait sat at the very bottom of the band,
   roughly 900px below the heading it belongs to, because .about is a single
   column and .about-copy is one grid item holding the eyebrow, the h2, both
   paragraphs and the creds. The photograph could only ever come after all of
   it.

   display:contents promotes those children to grid items so the photo can be
   ordered between the h2 and the copy. The gap has to go to 0 in the same
   breath: .about's gap is clamp(2rem, 5vw, 3.5rem) and it was only ever
   spacing two items, so leaving it would apply 3.5rem between every paragraph.
   Spacing comes back from the items' own margins, which they already carry. */
@media (max-width: 55.99rem) {
  .about { gap: 0; }
  .about-copy { display: contents; }
  .about-copy .eyebrow { order: 1; }
  .about-copy h2 { order: 2; }
  .about-photo { order: 3; margin: 0.35rem 0 1.9rem; }
  .about-copy p:not(.eyebrow):not(.creds) { order: 4; }
  .about-copy .creds { order: 5; }
}

/* Phone drift, dialled back. 1.16 was chosen for desktop, where Pravin could
   not see the effect at all; on a phone the same number measured 69.1px of
   travel, 15.8% of the frame's own height, and read as too strong. The
   pre-scale IS the travel budget, so 1.08 halves the movement — about 35px,
   ~8% of the frame — and tightens the crop less at the same time. Desktop is
   untouched at 1.16. */
@media (max-width: 45.99rem) {
  .about-photo .wp-img { --wp-scale: 1.08; }
}

/* ---------------- meals: enter from both sides ----------------
   The same gesture as the top rail, and the same numbers: .nav-brand leaves to
   the left by its own width plus the gutter, .hdr leaves to the right by the
   same, both on 700ms cubic-bezier(0.16,1,0.3,1). Here it runs once on entry
   rather than on the scroll-hide, so the two dishes arrive from opposite edges
   and meet.

   overflow: clip on the list, not hidden: `clip` contains the off-screen start
   position without creating a scroll container, so nothing can escape to the
   right and trip the page's horizontal-overflow check, and no stray scrollable
   box appears in the tab order. */
.meals { overflow: clip; }

/* The trigger is on the LIST, not on the items, and that is load-bearing.
   Putting .reveal on each .meal deadlocked: `overflow: clip` clips the items
   at their off-screen start position, IntersectionObserver honours clipping by
   an ancestor, so the observer never saw them intersect, never added .in, and
   they sat at -453px and +453px permanently. Measured exactly that.

   The list never moves, so it is always observable; the items ride its state. */
.js .meals.reveal { opacity: 1; transform: none; transition: none; }
.js .meals.reveal .meal {
  opacity: 0;
  transition: opacity 420ms ease, transform 700ms cubic-bezier(0.16, 1, 0.3, 1);
}
.js .meals.reveal .meal:nth-child(1) { transform: translateX(calc(-100% - 1rem)); }
.js .meals.reveal .meal:nth-child(2) { transform: translateX(calc(100% + 1rem)); }
.js .meals.reveal.in .meal { opacity: 1; transform: none; }

@media (prefers-reduced-motion: reduce) {
  .js .meals.reveal .meal { opacity: 1; transform: none; transition: none; }
}
