/* ============================================================================
   screen.css — website layout. EDITORIAL case pages + BENTO homepage.
   Builds on tokens.css. Loaded only by build/site/*.html.
   ========================================================================== */

* { box-sizing: border-box; }
/* The horizontal-clip safety net (hover-grow states — .card/.zoomable/.frag-cell
   scale to 1.04, transform-origin center — push ~2% of a tile's width past its
   own box; the --gutter margin already gives that room on ordinary layouts,
   but this still catches any edge case before it can force a horizontal
   scrollbar) is `overflow-x: clip` on BODY. The full reasoning:
   1. It must NOT be `overflow-x:hidden` on html — that breaks position:sticky
      for every descendant in this engine (confirmed: an inert sticky div stops
      sticking the moment html carries overflow-x:hidden).
   2. It must NOT be `overflow:hidden` on body (the previous approach). Because
      html's overflow is `visible`, the UA PROPAGATES body's overflow to the
      viewport — so `overflow:hidden` on body made the VIEWPORT non-scrollable.
      Desktop hid this (Lenis drives scrolling), but on touch Lenis is gated OFF
      (native scroll only) so mobile scrolling was completely dead — you could
      only pan while pinch-zoomed (a visual-viewport gesture that sidesteps the
      locked scroll container). This bit us hard; do not reintroduce it.
   3. `overflow-x: clip` clips the horizontal overflow WITHOUT any of that: it
      creates no scroll container, does not trigger the paired-axis→auto quirk
      (that quirk is specific to hidden/scroll/auto, not clip), does not
      propagate a hidden overflow to the viewport, and leaves position:sticky
      working. overflow-y stays at its default `visible` → native scroll lives. */
/* Native scrollbar hidden site-wide — .scroll-progress (the red hairline,
   further down) is the visual replacement, GSAP-scrubbed 1:1 to real scroll
   position. Hiding the chrome scrollbar doesn't touch actual scrolling: real
   document scroll (and Lenis, where it's active) is untouched, this only
   suppresses the OS/browser-drawn track+thumb. Three rules for three engines
   — there's no single standard property that covers all of them. */
html { -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility;
  scrollbar-width: none;        /* Firefox */
  -ms-overflow-style: none; }   /* legacy Edge/IE */
html::-webkit-scrollbar { display: none; }   /* Chrome/Safari/Chromium-Edge */
/* THE PAGE BACKGROUND LIVES ON <html>, NOT ON BODY — and that is load-bearing.
   The problem it solves: body::after (the hairline grid, z-index:-1) has to
   paint ABOVE the page's own background but BELOW all content. Per CSS painting
   order an unpositioned box's background paints in a later step than ANY
   negative-z-index element, so while body carried the background it covered the
   hairlines completely — a total block, not a subtlety.
   That used to be fixed with `isolation: isolate` on body, which made body its
   own stacking context so its background became step 1 of a LOCAL context and
   body::after (-1) landed above it. It worked, but it also walled the whole page
   off from compositing: mix-blend-mode on any descendant (the nav, see
   .site-head below) could only ever blend within body, i.e. against nothing.
   Moving the background up to <html> removes the conflict at the source: body
   paints no background at all, so body::after at -1 sits directly above the root
   canvas with nothing to cover it, no isolation required, and descendants can
   blend against real page content. --bg is on :root, so theming is unaffected. */
html { background: var(--bg); }
body { margin: 0; background: transparent; color: var(--ink); overflow-x: clip;
  font-family: var(--font-sans); font-size: var(--t-body); line-height: var(--leading-body); }
img { max-width: 100%; height: auto; display: block; }
a { color: var(--accent); text-decoration: none; }
/* Text-flow finesse (progressive enhancement, ignored where unsupported):
   balance evens the rag on multi-line display lines; pretty stops prose
   paragraphs ending on a single-word orphan. Single-line text unaffected. */
h1, h2, h3, blockquote { text-wrap: balance; }
p, li { text-wrap: pretty; }

/* Lenis smooth-scroll (required for correct behaviour) */
html.lenis, html.lenis body { height: auto; }
.lenis.lenis-smooth { scroll-behavior: auto !important; }
.lenis.lenis-smooth [data-lenis-prevent] { overscroll-behavior: contain; }
.lenis.lenis-stopped { overflow: hidden; }

/* ---- Site-wide 48-column grid lines (ported from the Webflow reference's
   hero_grid/intro_grid). A single fixed overlay instead of the reference's
   per-section divs — same visual result, one rule for the whole site. 24
   columns (double the width) on tablet/mobile, matching the reference.

   z-index -1 (2026-07-11, was 1): pushes the hairlines BEHIND every piece of
   real content — text, photos, cards — instead of drawing over them. Every
   element on the page (cards at z-index:1, the sticky header at 1000, the veil
   at 1900, …) resolves against the root stacking context, and -1 sits below all
   of them without needing to out-rank each one individually. It also sits above
   the page background, because that background is on <html> now and body paints
   none (see the note on `html { background }` above — that is exactly why).
   NOTE: while the entrance transition runs, `.js body` briefly carries
   opacity+transform, which makes body a stacking context for those ~0.5s; the
   hairlines are unaffected (body has no background to hide behind), it only
   suspends descendant blending mid-fade. The only other z-index:-1 rules in this
   file (.card-idx::before) is scoped INSIDE its own positioned ancestor
   (.card), which establishes its own stacking context — a local "behind my own
   siblings" declaration, not a competitor for this global one. (The nav's red
   hover curtain used to be a third; it is a body-level element at z-index 998
   now, so it could leave the header's blend group — see .nav-curtain.)
   mix-blend-mode dropped with it: difference blended the lines against
   whatever was already painted so a flat near-white line would still read
   over a photo OR the light-mode background — necessary before, since the
   lines used to draw on top of everything including --bg itself. Now that
   nothing paints over the lines, that same flat rgba(241,241,241,…) is
   literally light mode's --bg (#f1f1f1) — a white line on white, invisible.
   Using var(--ink) instead (dark-on-light / light-on-dark, whichever the
   active scheme already resolves it to) fixes that for both themes at once;
   opacity on the pseudo-element itself (not baked into the color) supplies
   the faintness so --ink's hex doesn't need its own rgba() tuple. ---- */
body::after { content: ""; position: fixed; inset: 0; z-index: -1; pointer-events: none;
  opacity: .03;   /* 2026-07-15: was .08 — Gian wants the columns to almost blend into the bg, a whisper of structure rather than visible stripes */
  background-image: repeating-linear-gradient(to right, var(--ink) 0 1px, transparent 1px calc(100% / 48)); }
@media (max-width: 991px) {
  body::after { background-image: repeating-linear-gradient(to right, var(--ink) 0 1px, transparent 1px calc(100% / 24)); }
}

/* ---- Page transitions: every internal link click fades the page out before the
   real (hard) navigation, and every new page fades in on load — a soft rise, not a
   flash-cut. JS-only (see app.js "0) Page transitions"); no-JS/no-motion just shows
   the page immediately (no .js class, or .pt-ready added straight away). ---- */
.js body { opacity: 0; transform: translateY(10px); }
.js body.pt-ready {
  opacity: 1; transform: none;
  transition: opacity .5s cubic-bezier(.16,.8,.24,1), transform .5s cubic-bezier(.16,.8,.24,1);
}
.js body.pt-leaving {
  /* The veil/fly/clone ARE the exit animation now. NO body opacity: they are
     body children and opacity applies to the whole subtree — fixed children
     cannot escape it (the old fade was silently suppressing the leave veil).
     opacity/transform re-asserted explicitly because removing .pt-ready
     re-applies the `.js body` pre-hide above, and translateY(10px) would
     become the fixed veil's containing block — the invariant this comment
     always guarded. pointer-events: no second click can start a second leave. */
  opacity: 1; transform: none; pointer-events: none;
}

/* ---- Static veil — internal-navigation page transition (app.js "0b"). The same
   black + TV-static + 48-col hairlines language as the loader, one level below it
   (.ld 2000 > .ptv 1900 > .site-head 1000 > dots 800 > lightbox 100). State is
   decided PRE-PAINT by the same inline end-of-body script that adds .js:
   html.ptv-on = arrived via internal nav (veil paints opaque, app.js dissolves it);
   html.ptv-done = no veil / dissolve finished. Destination-state transition rules:
   removing ptv-done (leave) fades IN over .28s, adding it (enter) dissolves over
   .55s with visibility deferred to the end. ---- */
html:not(.js) .ptv { display: none; }                 /* the deciding script never ran */
html.ld-active:not(.ld-done) .ptv { display: none; }  /* loader owns homepage first visits;
                                                         after ld-done the LEAVE veil works */
/* The veil itself is TRANSPARENT now — the opaque cover is painted by .ptv-cell
   (the curtain) over .ptv-over (a black scrim). Keeping a background here would
   cover the page instantly and there would be nothing for the curtain to sweep
   over. See "0b) Static veil" in app.js. */
/* .65s (was .8s, 2026-07-11 retime) — the no-GSAP/reduced-motion fallback
   fade. Paired with app.js's raiseVeil() scheduleNav(minTotalMs, 650) and
   dissolve()'s 950ms static-stop buffer; move all three together. */
.ptv { position: fixed; inset: 0; z-index: 1900; background: transparent;
  pointer-events: none; opacity: 1; visibility: visible;
  display: grid; place-items: center;
  transition: opacity .65s cubic-bezier(.4,0,1,1), visibility 0s 0s; }
html.ptv-done .ptv { opacity: 0; visibility: hidden;
  transition: opacity .65s cubic-bezier(.4,0,.55,1), visibility 0s .65s; }

/* ---- Page-transition curtain (mechanics rebuilt from the sondaven.com/en
   reference; timing/easing/stagger derived by instrumenting it, no code or
   assets lifted). 12 columns x 24 rows (288 cells) — a rectangular grid, each
   cell scaleX(0) at rest, swept to scaleX(1) on leave with a 2D grid stagger
   (see app.js). Rows doubled from 12->24 for finer stepping; a blur feather
   that briefly lived here (softening the cell-to-cell step at 12 rows) was
   removed once rows went finer — resolution instead of a post-hoc soften.
   Columns stay locked at 12 — that count is load-bearing for the grid-line
   alignment (see app.js's stagger comment), rows never touched it.
   margin:-1px overlaps neighbours so seams never show page bleeding through. ---- */
.ptv-over { position: absolute; inset: 0; z-index: 0; background: var(--veil-scrim); opacity: 0; }
.ptv-grid { position: absolute; inset: 0; z-index: 1; display: flex; }
.ptv-col  { flex: 1 1 0; display: flex; flex-direction: column; }
.ptv-cell { flex: 1 1 0; margin: -1px; background: var(--bg); transform: scaleX(0); }
/* Promote only while a transition is actually on screen — 288 permanently
   promoted layers is exactly the kind of idle compositor cost this codebase
   just spent a whole pass removing from mobile. */
html.ptv-anim .ptv-cell, html.ptv-anim .ptv-over,
html.ptv-on:not(.ptv-done) .ptv-cell { will-change: transform; }

/* Veiled ARRIVAL, decided pre-paint by the inline gate: the curtain is already
   closed before the first frame, so there is no flash of the new page. app.js
   then sweeps the cells back OPEN to reveal it (or, under reduced-motion / no
   GSAP, dissolves the whole veil). Both are overridden by GSAP's inline styles
   the moment a LEAVE starts on this same page — inline beats these rules,
   which is why a leave from a veil-arrived page doesn't snap shut. */
html.ptv-on:not(.ptv-done) .ptv-cell { transform: scaleX(1); }
html.ptv-on:not(.ptv-done) .ptv-over { opacity: 1; }
/* A leave must not also run the .8s opacity fade above — the curtain IS the
   animation, and a simultaneous fade would wash it out. */
html.ptv-anim .ptv { opacity: 1 !important; visibility: visible !important; transition: none; }
/* While the veil is actually up (arrival hold, or a leave from a veiled-arrived
   page) it eats the pointer — nothing under an opaque veil is clickable. */
html.ptv-on:not(.ptv-done) .ptv { pointer-events: auto; }
/* the same 48-col hairlines as .ld::after — the brand tie. z-index 2 keeps them
   ABOVE the curtain cells (z1) so the grid reads across the closed veil, and
   below the word (z3). */
.ptv::after { content: ""; position: absolute; inset: 0; pointer-events: none; z-index: 2;
  opacity: .03;   /* alpha lives here, not baked into the gradient, so the LINE
                     COLOUR can be a theme token — same split body::after uses */
  background-image: repeating-linear-gradient(to right, var(--ink) 0 1px, transparent 1px calc(100% / 48)); }
/* 24 columns on tablet/mobile so the veil hairlines track the global
   body::after grid (which already halves at this breakpoint) — otherwise a
   page transition on a phone shows 48 lines over a 24-line site.
   .ld::after is NOT overridden here on purpose — its own base rule is
   defined later in this file (search ".ld::after"), and a same-specificity
   rule positioned AFTER this one would win the cascade at every viewport,
   silently reverting the loader back to 48 columns on mobile regardless of
   this media query. Its mobile override lives immediately after its base
   rule instead, where source order actually lets it win. */
@media (max-width: 991px) {
  .ptv::after {
    background-image: repeating-linear-gradient(to right, var(--ink) 0 1px, transparent 1px calc(100% / 24)); }
}
/* Sits ABOVE the curtain cells so the TV-static grain reads on the closed veil.
   Starts invisible: on a LEAVE the static must not appear over the live page
   before the cells have swept across it (app.js fades it in alongside the
   curtain). On a veiled ARRIVAL the veil is already closed pre-paint, so the
   grain is simply on. */
.ptv-static { position: absolute; inset: 0; width: 100%; height: 100%; z-index: 2;
  image-rendering: pixelated; opacity: 0; display: var(--veil-grain, block); }
html.ptv-on:not(.ptv-done) .ptv-static { opacity: .55; }
/* Veiled arrivals: the veil is a BODY CHILD — the .js body pre-hide would hide it
   too, and body's transform would become its fixed-position containing block. Same
   counter-rule as html.ld-active body below; the dissolve IS the entrance.
   .js body.pt-leaving (0,2,1) still wins for the exit fade. */
html.ptv-on body { opacity: 1; transform: none; }

/* ---- Veil word + leave choreography (app.js 0/0b). One typography class shared
   by the arrival veil word and the leave fly — same class, same grid centering,
   same viewport = the same rendered rect on both sides of the hard navigation.
   The word is BAKED into each page's veil server-side (render.mjs page()), so a
   veiled arrival paints it centered before any JS runs. */
/* z-index 3: above the curtain cells (1) and the hairlines/static (2). */
.ptv-wordmask { position: relative; z-index: 3; overflow: hidden;
  padding-bottom: .06em; max-width: 92vw; opacity: 0; }
html.ptv-on .ptv .ptv-wordmask { opacity: 1; }  /* word shows only on veiled arrivals */
/* Hard-load arrivals (typed URL / bookmark / refresh) get the same veil with NO
   word — nothing flew it here from an origin label, so a word materialising in
   the middle of the screen would read as a glitch rather than a hand-off.
   Declared after the rule above and equally specific on .ptv-wordmask, so it
   wins on source order. `visibility` (not just opacity) so the word can never
   be picked up by a screen reader or hit-tested during the veil. */
html.ptv-noword .ptv .ptv-wordmask { opacity: 0; visibility: hidden; }
/* Typography matches .nav-link exactly (font-weight, uppercase, tracking) so the
   word genuinely reads as that nav item scaling up, not a different display
   face taking over — only font-size and color differ (giant + light-on-dark). */
.ptv-word { margin: 0; font-family: var(--font-sans); font-weight: 400; color: var(--ink);
  letter-spacing: .2em; line-height: 1.05; text-align: center; text-transform: uppercase;
  font-size: clamp(32px, 7vw, 120px); }
/* Image-card arrivals: the title sits over the dimmed PHOTO curtain, not a UI
   surface, so it doesn't track the theme — same reasoning as .ptv-photo::after's
   fixed black dim, mirrored to white. Fixed rather than conditional because dark
   mode already renders this white via --ink; this only changes light mode's
   result. Specificity (0,2,1) beats the base rule above (0,1,0) without !important. */
html.ptv-imgcurtain .ptv-word { color: #fff; }
.ptv-word .pl { display: inline-block; will-change: transform; } /* per-WORD spans (GSAP path) —
  kept at word granularity, not per-letter, so kerning within each word stays
  intact and matches the resting text exactly (see app.js wordOut comment) */
/* CSS-only word-out (no-GSAP fallback) — ALSO the persistent "already played"
   state: ptv-on never gets removed, so a later leave re-fading this veil must
   find the word already risen out of its overflow-hidden mask.
   .5s (was .6s) — paired with app.js wordRiseOut()'s RISE constant; the two
   are not derived from each other, they're independent constants that must
   move together by hand. */
.ptv-word.ptv-word-out { transform: translateY(-112%);
  transition: transform .5s cubic-bezier(.6,.05,.2,1); }
/* Brand "Paulo" insert (leave fly only): GIAN [collapsed PAULO] CRUZ — the middle
   span starts at width:0 (so the fly's initial rect = "GIAN CRUZ") and is grown
   by app.js once centered, revealing PAULO as GIAN/CRUZ reflow apart in-line. */
.ptv-word .pmid { display: inline-block; overflow: hidden; width: 0; vertical-align: bottom; }
.ptv-word .pin { display: inline-block; will-change: transform; }
/* Leave-side fly: full-viewport grid identical to .ptv's centering, above the
   veil (1950 > 1900). The MASK animates on the way in — a transform on the inner
   word would be clipped by its own overflow:hidden. */
/* contain:layout scopes the .pmid width tween's reflow (brand click, below) to
   this fixed island instead of the whole document — without it, that reflow
   fights the canvas static loop's own rAF draws on the main thread and reads
   as a stutter right as the fly-to-center transform hands off. */
.ptv-fly { position: fixed; inset: 0; z-index: 1950; display: grid;
  place-items: center; pointer-events: none; contain: layout; }
.ptv-fly .ptv-wordmask { opacity: 1; }
/* Card-image expansion clone — UNDER the veil (1850 < 1900) so the static fades
   in over it. Always fullscreen (inset:0) from creation — its LAYOUT size
   never changes; app.js only tweens `transform` (GPU-composited) on this box
   AND on its inner img (a counter-scale FLIP zoom — see expandCard() in
   app.js) to grow it from the card's rect to fullscreen. contain:layout
   stays as a cheap extra guard against ANY of its own paint work leaking
   into an ancestor reflow. transform-origin:0 0 is load-bearing: app.js's
   translate(r.left,r.top) math assumes the box's own top-left corner as the
   scale origin, not the CSS default (center), or the box would land
   off-position. */
.ptv-expand { position: fixed; inset: 0; z-index: 1850; overflow: hidden; contain: layout;
  background: var(--tag-bg); pointer-events: none; transform-origin: 0 0; will-change: transform; }
.ptv-expand img { width: 100%; height: 100%; object-fit: cover; display: block;
  transform-origin: center; will-change: transform; }

/* ---- Card transition: the image IS the curtain ---------------------------
   A card leave ends on that card's image, fullscreen and dimmed 50% toward
   black. The destination page bakes the SAME image (the card's hero_image —
   see render.mjs page()) into .ptv-photo and opens on the identical frame, so
   the hard navigation is invisible. app.js then slices it into the same
   PTV_COLS x PTV_ROWS grid the solid curtain uses and sweeps the tiles open on
   the identical ptvDelay()/PTV_EASE schedule — the photo doesn't sit behind a
   curtain, it IS the curtain.

   In this mode the solid cells and the black scrim are both suppressed: the
   per-tile dim below does the scrim's job, and it travels WITH each tile as it
   shrinks, so the page is revealed already-undimmed rather than sitting under
   a full-screen black layer that has to be cross-faded off separately.

   The MODE class on <html> is `ptv-imgcurtain` — deliberately NOT `ptv-photo`.
   It was ptv-photo at first, same as the element, and that collision was a
   real shipped bug: `<html class="ptv-photo">` matches the bare `.ptv-photo`
   selectors as an ANCESTOR, so `.ptv-photo img {width:100%;height:100%;
   object-fit:cover}` grabbed EVERY image on the page (leads visibly stretched/
   washed) and `.ptv-photo::after` dimmed the entire document 50% black — and
   since failure paths could leave the class stuck, it poisoned normal browsing,
   not just the transition. Keep the two names distinct, and keep every element
   selector scoped under .ptv as a second fence. ---- */
html.ptv-imgcurtain .ptv-cell { display: none; }   /* the image replaces the solid curtain */
/* Suppress the scrim: the per-tile dim replaces it. Must carry .ptv-on:not(
   .ptv-done) — matching the arrival rule above and beating it on specificity —
   because `html.ptv-on:not(.ptv-done) .ptv-over {opacity:1}` is (0,3,1) and a
   bare `html.ptv-imgcurtain .ptv-over` is only (0,2,1): the scrim stayed
   opaque black through the whole photo arrival and the image curtain was
   simply invisible behind it. */
html.ptv-imgcurtain.ptv-on:not(.ptv-done) .ptv-over { opacity: 0; }
/* Backstop during the pre-open HOLD only: if the veil image hasn't decoded
   yet, the tiles are transparent and (scrim suppressed) the raw page would
   show through. Opaque site-black until the open sweep starts (ptv-anim),
   at which point the veil must be transparent so the tiles reveal the page. */
html.ptv-imgcurtain.ptv-on:not(.ptv-done):not(.ptv-anim) .ptv { background: var(--bg); }

/* .ptv-photo = the single pre-paint image, painted before any JS runs so the
   first frame after the nav matches the last frame before it. app.js removes it
   the moment the tile grid is built (both render identical pixels, so the swap
   is invisible). .ptv-tiles = that grid. Same z as .ptv-cell (1) — under the
   hairlines and static (2) and the word (3). */
.ptv .ptv-photo, .ptv .ptv-tiles { position: absolute; inset: 0; z-index: 1; }
.ptv .ptv-photo { display: none; }
html.ptv-imgcurtain .ptv .ptv-photo { display: block; }
.ptv .ptv-photo img { width: 100%; height: 100%; object-fit: cover; display: block; }
.ptv .ptv-tiles { display: flex; }
.ptv .ptv-tile-col { flex: 1 1 0; display: flex; flex-direction: column; }
/* No transform-origin override — .ptv-cell doesn't set one either (CSS default,
   center). The tiles must shrink from the same anchor the solid cells grow from,
   or a future mixed mode would drift mid-tween rather than only matching at the
   two endpoints. */
.ptv .ptv-tile { flex: 1 1 0; overflow: hidden; position: relative; transform: scaleX(1); }
/* Each tile is an overflow:hidden window onto a FULL-VIEWPORT clone of the same
   image, offset by its own negative left/top (app.js sets those inline). Every
   clone therefore performs the identical object-fit:cover crop the fullscreen
   .ptv-photo did, so no per-aspect-ratio math is needed to slice it. Without
   object-fit the clone stretches to its box and the image visibly warps. */
.ptv .ptv-tile img { position: absolute; max-width: none; display: block; object-fit: cover; }
/* The 50% dim, per tile — lives INSIDE the tile so it shrinks away with it.
   Stays BLACK in both themes on purpose, unlike the rest of the veil: this dims
   a PHOTOGRAPH, not a UI surface. A white scrim in light mode would wash the
   image out rather than mirror anything. Deliberate exception, not a miss. */
.ptv .ptv-photo::after, .ptv .ptv-tile::after { content: ""; position: absolute; inset: 0;
  background: #000; opacity: .5; pointer-events: none; }

/* The word is NOT pre-painted in image-curtain mode: it fades in over the
   settled image (app.js afterReady), so it must start hidden even though the
   arrival is a WORDED veil (the gate treats card arrivals as worded — the
   title is beat 2 of the sequence, not absent). Same specificity as the
   html.ptv-on rule above, declared later, wins on source order. */
html.ptv-imgcurtain .ptv .ptv-wordmask { opacity: 0; }

/* Card-image expansion clone on the LEAVE side — see .ptv-expand above (z-1850,
   under the veil at 1900, so .ptv-over's dim tween and the static land on top
   of it). No shutter any more: the dimmed image is itself the page cover, and
   the navigation happens straight off it. */

/* ---- Initial loader — homepage, first visit per session. html.ld-active is set
   by an inline <head> gate script (JS-on implied; reduced-motion and repeat visits
   never get it). The DISSOLVE is pure CSS keyed to html.ld-done so the gate's
   inline 5s failsafe can clear the overlay even if app.js never runs. The loader
   sits above everything (.site-head z-1000, .lightbox z-100). ---- */
html.ld-active { background: var(--bg); }              /* no light-mode first-paint flash */
html.ld-active body { opacity: 1; transform: none; } /* the loader covers the pt entrance;
                                                        .js body.pt-leaving (0,2,1) still wins */
html.ld-active:not(.ld-done) { overflow: hidden; }   /* no scrolling out from under it */
html:not(.ld-active) .ld { display: none; }          /* no-JS / repeat visits: inert markup */
.ld { position: fixed; inset: 0; z-index: 2000;
  background: var(--bg); display: grid; place-items: center;
  transition: opacity 1.2s cubic-bezier(.4,0,.55,1), visibility 0s 1.2s; }
html.ld-done .ld { opacity: 0; visibility: hidden; pointer-events: none; }
/* the same 48-col hairlines as body::after — "ALIGNING 48 COLUMNS", literally */
.ld::after { content: ""; position: absolute; inset: 0; pointer-events: none;
  opacity: .03;   /* see .ptv::after — alpha on the element, colour from --ink */
  background-image: repeating-linear-gradient(to right, var(--ink) 0 1px, transparent 1px calc(100% / 48)); }
/* Positioned AFTER the base rule above on purpose — same specificity, so
   source order decides the winner. This must come last to actually take
   effect on mobile (see the comment near the top of this file, search
   "silently reverting the loader"). */
@media (max-width: 991px) {
  .ld::after { background-image: repeating-linear-gradient(to right, var(--ink) 0 1px, transparent 1px calc(100% / 24)); }
}
.ld-static { position: absolute; inset: 0; width: 100%; height: 100%;
  image-rendering: pixelated; opacity: .55; display: var(--veil-grain, block); }
.ld-inner { position: relative; z-index: 1; display: flex; flex-direction: column;
  align-items: center; gap: 20px; transition: opacity .4s ease; }
.ld.ld-leaving .ld-inner { opacity: 0; }
.ld-sq { width: 66px; height: 10px; display: block; overflow: visible; }
.ld-sq rect { fill: var(--accent); opacity: .22; animation: ld-pulse 1.2s cubic-bezier(.4,0,.6,1) infinite; }
.ld-sq rect:nth-child(2) { animation-delay: .15s; }
.ld-sq rect:nth-child(3) { animation-delay: .3s; }
.ld-sq rect:nth-child(4) { animation-delay: .45s; }
@keyframes ld-pulse { 0%, 55%, 100% { opacity: .22; } 15% { opacity: 1; } }
.ld-copy { margin: 0; font-family: var(--font-mono); font-size: 11px; letter-spacing: .18em;
  text-transform: uppercase; color: var(--ink-mute); min-height: 1.2em; text-align: center; }
.ld-copy.is-final { color: var(--accent); }
/* Nav entrance — held only under the loader, released (staggered) by ld-done.
   Never matches on non-loader loads or other pages. */
html.ld-active:not(.ld-done) .site-head .nav-brand,
html.ld-active:not(.ld-done) .site-head .nav-link,
html.ld-active:not(.ld-done) .nav-scroll { opacity: 0; transform: translateY(-6px); }
html.ld-active .site-head .nav-brand,
html.ld-active .site-head .nav-link,
html.ld-active .nav-scroll {
  transition: opacity .8s cubic-bezier(.16,.8,.24,1), transform .8s cubic-bezier(.16,.8,.24,1); }
html.ld-active .site-head .nav-brand { transition-delay: .12s; }
html.ld-active .site-head .nav-link:nth-child(1) { transition-delay: .24s; }
html.ld-active .site-head .nav-link:nth-child(2) { transition-delay: .34s; }
html.ld-active .site-head .nav-link:nth-child(3) { transition-delay: .44s; }
html.ld-active .site-head .nav-link:nth-child(4) { transition-delay: .54s; }
html.ld-active .nav-scroll { transition-delay: .7s; }

/* Column-locked gutter: 1 column of the site grid on each side (--col, tokens.css)
   instead of a fixed px padding — so .wrap's edges always land exactly on a grid
   line (col 2 / col 47 of 48 desktop, col 2 / col 23 of 24 tablet+mobile), same
   as the nav brand/scroll-bar and hero content, rather than floating independently
   of the grid overlay at in-between viewport widths. */
.wrap { width: 100%; margin: 0 auto; padding: 0 var(--gutter); }

/* ---- Header — 48-col grid nav, ported from the Webflow reference (nav_bar +
   navhover.js + navswap.js): text-swap on hover, a per-link "curtain" that drops
   from above in that link's own grid column, an underline draw, and a looping
   scroll-indicator bar. Deliberately no background/blur/border — just the text
   floating over whatever's beneath (2026-07-13: Gian chose transparent-only
   over a legibility scrim; this also removes the backdrop-filter case that was
   implicated in an intermittent mobile Safari crash during the card-click
   transition). Kept sticky (not fixed) so it still scrolls with intent on
   short pages. ------------------------------------------------------------- */
.site-head { position: sticky; top: 0; z-index: 1000; }
/* Difference-blended nav: the header composites against whatever scrolls under
   it, so the wordmark and links invert themselves — legible over the dark
   cinematic hero, over a light case-study page, and over the middle of a photo,
   without any scroll-position colour switching.
   Three things make this work, and all three are load-bearing:
   1. The blend is on .site-head, not on the individual links. An element blends
      with its BACKDROP, and .site-head (z-index:1000) is a stacking context —
      so blending a child would composite it against the header's own empty
      interior and do visibly nothing. Blending the header as a group composites
      that whole group against the page.
   2. The page background is on <html>, not body, so body is not a stacking
      context and does not wall the header off from the content (see the long
      note at the top of this file).
   3. The text is #fff on purpose. difference(white, x) = 255 − x, i.e. a true
      inversion of the backdrop. Leaving it var(--ink) would give
      difference(#131313, #f1f1f1) = a light grey on a light page — invisible.
   @supports keeps the white confined to browsers that will actually blend it;
   anywhere else the nav keeps its normal token colour rather than going white
   on a white page.
   4. Gated on body.pt-settled (app.js, set when the entrance transition ends).
   While `.js body` is mid-fade it carries opacity/transform and IS a stacking
   context, which suspends the blend — and white nav text on an unblended light
   page is invisible. Until settled the nav keeps var(--ink), which is already
   the correct colour over a flat background, so the handover doesn't show.
   5. Desktop only. Below 992px the inline links are gone and the header holds
      just the brand, while the burger lives at BODY level (it has to, to paint
      over the z-2000 overlay) and so cannot join the blend — it also owns its
      own open/closed bar colours. Blending the header there would leave an
      inverted brand and theme icon sitting beside a non-inverted burger. */
@supports (mix-blend-mode: difference) {
  @media (min-width: 992px) {
    body.pt-settled .site-head { mix-blend-mode: difference; }
    body.pt-settled .site-head .nav-brand,
    body.pt-settled .site-head .nav-link,
    body.pt-settled .site-head .nav-theme { color: #fff; }
    /* Difference-blend is correct against the site's own near-grayscale page
       tokens (#131313/#f1f1f1) — difference(white, bg) inverts cleanly there.
       It breaks over the hero: difference() runs per-channel, so a real photo
       (not grayscale) comes out as a spectrum of inverted hues, not black or
       white. Rather than trying to read back a composited blend result (not
       possible — mix-blend-mode has no script-visible output), short-circuit
       using a signal that already exists: html.at-hero (app.js, purely
       geometric — hero's own bottom edge vs nav height). Color is a fixed
       #f1f1f1, not var(--ink): the hero's scrim is now permanently dark
       regardless of theme (2026-07-21, Gian's call — see
       .home-hero--cinematic's --hero-scrim-* above), so the nav pins white to
       match, same as the tagline/title. No pixel sampling, no new JS: an
       existing class plus a fixed color. .nav-theme's sun/moon icon
       (stroke: currentColor) follows for free since it inherits this color. */
    html.at-hero .site-head { mix-blend-mode: normal; }
    html.at-hero .site-head .nav-brand,
    html.at-hero .site-head .nav-link,
    html.at-hero .site-head .nav-theme { color: #f1f1f1; }
  }
}
.nav-grid { display: grid; grid-template-columns: repeat(48, 1fr); align-items: center;
  padding: 20px 0; position: relative; }
.nav-brand { grid-column: 2 / span 4; justify-content: center; align-items: center; display: flex;
  font-family: var(--font-sans); font-weight: 400; font-size: 13px; letter-spacing: .2em;
  text-transform: uppercase; color: var(--ink); padding: 14px 0; white-space: nowrap; }
.nav-menu { grid-column: 17 / span 16; display: grid; grid-template-columns: repeat(4, 1fr); align-items: center; }
.nav-link { position: relative; justify-content: center; align-items: center; display: inline-flex;
  font-family: var(--font-sans); font-size: 13px; letter-spacing: .2em; text-transform: uppercase;
  color: var(--ink); padding: 14px 10px; white-space: nowrap; }
/* Theme switch — the brand's counterweight. `44 / span 4` + justify-self:end
   puts its right edge on grid line 48, the exact mirror of .nav-brand's left
   edge on line 2 (both one column in from their side). That is also where the
   scroll cue sat, which is the slot it takes over past the hero.
   Which icon shows is pure CSS off <html data-theme>/the OS query, so it is
   already correct at first paint; app.js only owns click + persistence. The
   icon shown is the theme you'd GET by clicking (moon while light, sun while
   dark) — the common convention, and it keeps the control self-explanatory. */
.nav-theme { grid-column: 44 / span 4; justify-self: end; display: inline-flex;
  align-items: center; justify-content: center; width: 38px; height: 38px; padding: 0;
  background: none; border: 0; border-radius: var(--r-pill); color: var(--ink);
  cursor: pointer; -webkit-tap-highlight-color: transparent;
  /* --shift is composed into every transform below instead of each state
     setting `transform` outright: :active sets scale(.92), and a bare
     `transform: scale()` there would silently drop the slide translate (last
     declaration wins on the whole property, not per-function). */
  --shift: 0px;
  transform: translateX(var(--shift));
  transition: background-color .25s ease,
              transform .45s cubic-bezier(.625,.05,0,1); }
/* Over the hero the scroll cue owns line 48, so the button waits exactly one
   grid column to its left; clearing .at-hero slides it home. --col keeps that
   offset grid-locked at any viewport width. */
html.at-hero .nav-theme { --shift: calc(-1 * var(--col)); }
.nav-theme:hover { background: var(--tag-bg); }
.nav-theme:active { transform: translateX(var(--shift)) scale(.92); }
.nav-theme:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
.nav-theme .ic { width: 17px; height: 17px; display: block; fill: none;
  stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
.nav-theme .ic-moon { fill: currentColor; stroke: none; }
/* Default (light OS, no override): offer dark → show the moon. */
.nav-theme .ic-sun { display: none; }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .nav-theme .ic-sun { display: block; }
  :root:not([data-theme="light"]) .nav-theme .ic-moon { display: none; }
}
:root[data-theme="dark"] .nav-theme .ic-sun { display: block; }
:root[data-theme="dark"] .nav-theme .ic-moon { display: none; }
:root[data-theme="light"] .nav-theme .ic-sun { display: none; }
:root[data-theme="light"] .nav-theme .ic-moon { display: block; }

/* Text swap: default label slides up/out, the hover alternate rises in from below. */
.nav-swap-clip { position: relative; display: inline-block; overflow: hidden; line-height: 1; vertical-align: bottom; }
.nav-swap-size { visibility: hidden; display: inline-block; }
.nav-swap-d, .nav-swap-h { position: absolute; left: 0; right: 0; top: 0; text-align: center;
  transition: transform .6s cubic-bezier(.625,.05,0,1), opacity .35s ease; }
.nav-swap-h { transform: translateY(100%); opacity: 0; pointer-events: none; }
.nav-link:hover .nav-swap-d { transform: translateY(-100%); opacity: 0; }
.nav-link:hover .nav-swap-h { transform: translateY(0); opacity: 1; }
/* Roll back to the default label on click (app.js adds .is-reverting) — reverses
   the hover swap even though the cursor is still over the link. Same specificity
   as the :hover rules above, declared later, so it wins; reuses the .6s transition
   so the label animates back to the initial word before the page transition flies it. */
.nav-link.is-reverting .nav-swap-d { transform: translateY(0); opacity: 1; }
.nav-link.is-reverting .nav-swap-h { transform: translateY(100%); opacity: 0; }
/* Underline draw on hover. */
.nav-link::after { content: ""; position: absolute; left: 10%; right: 10%; bottom: 8px; height: 1px;
  background: var(--ink); transform: scaleX(0); transform-origin: left center;
  transition: transform .55s cubic-bezier(.625,.05,0,1); pointer-events: none; }
.nav-link:hover::after { transform: scaleX(1); }
/* Red curtain: a fixed, column-aligned panel that drops from above the viewport on
   hover. Column math matches the reference's 4-link nav (cols 17-32 of 48).
   These are BODY-LEVEL elements (render.mjs NAV_CURTAINS), not ::before on the
   links, because .site-head carries mix-blend-mode and a child of a blended
   group cannot opt out — the group composites as one, which turned this red
   gradient cyan over a light page. Out here it paints as true --accent.
   Geometry is unchanged (it was always position:fixed on viewport coordinates);
   only the trigger moved to :has(), since the panel is no longer a descendant
   of the thing being hovered. z-index sits just under .site-head (1000) so the
   panel drops BEHIND the nav text, exactly as z-index:-1 did inside it.
   No :has() support = no curtain: a hover flourish, safe to lose. */
.nav-curtain { position: fixed; top: 0; height: 50dvh; z-index: 998;
  background: linear-gradient(to bottom, var(--accent), transparent);
  transform: translateY(-100%); transition: transform .7s cubic-bezier(.625,.05,0,1);
  pointer-events: none; }
.nc-b { left: 2.0833vw; right: 89.5833vw; }
.nc-1 { left: 33.3333vw; right: 58.3333vw; }
.nc-2 { left: 41.6667vw; right: 50vw; }
.nc-3 { left: 50vw; right: 41.6667vw; }
.nc-4 { left: 58.3333vw; right: 33.3333vw; }
html:has(.nav-brand:hover) .nc-b,
html:has(.nav-menu .nav-link:nth-child(1):hover) .nc-1,
html:has(.nav-menu .nav-link:nth-child(2):hover) .nc-2,
html:has(.nav-menu .nav-link:nth-child(3):hover) .nc-3,
html:has(.nav-menu .nav-link:nth-child(4):hover) .nc-4 { transform: translateY(0); }
/* Right-side scroll indicator: a 4x93 bar with a looping gradient sweep
   (reference: nav_scroll). 2026-07-15: the blinking "SCROLL DOWN" text label
   that used to sit beside it is gone — a labelled scroll cue is redundant
   chrome (the fold makes scrolling self-evident) and the kinetic bar alone
   carries the same downward gesture without shouting. */
/* Homepage only, and a BODY-LEVEL sibling rather than a header child (see
   render.mjs). Both facts are deliberate: a child of .site-head cannot opt out
   of the header's mix-blend-mode — blending applies to that group as a whole —
   and this bar read as an inverted stripe under it. Out here it composites
   normally. `right: var(--col)` is the same 1-column inset it always had
   (2.0833vw), now expressed in the grid's own unit so it tracks the mobile
   breakpoint's wider column. z-index sits below the header (1000) and above
   page content; the header is transparent, so the bar still shows through.
   position:fixed matches its old behaviour exactly — it was absolute inside a
   full-width sticky header, which pinned it to the viewport either way. */
/* background is a fixed #f1f1f1, not var(--ink): only ever visible while
   html.at-hero (hidden otherwise, below), and the hero it sits over is now
   permanently dark-scrimmed regardless of theme — same pin as the hero
   copy/nav above, same reasoning. */
.nav-scroll { position: fixed; top: 20px; right: var(--col); width: 4px; height: 93px; z-index: 999;
  background: #f1f1f1; overflow: hidden; pointer-events: none;
  transition: opacity .45s cubic-bezier(.625,.05,0,1), transform .45s cubic-bezier(.625,.05,0,1); }
/* Past the hero the cue has done its job: it lifts out as the theme button
   slides into the slot it was holding. Reversible — scrolling back re-arms it. */
html:not(.at-hero) .nav-scroll { opacity: 0; transform: translateY(-10px); }
.nav-scroll::after { content: ""; position: absolute; left: 0; right: 0; top: -93px; height: 93px;
  background: linear-gradient(180deg, var(--bg), var(--accent), var(--bg));
  animation: nav-scroll-sweep 2.6s cubic-bezier(.625,.05,0,1) infinite; }
@keyframes nav-scroll-sweep { 0% { top: -93px; } 100% { top: 93px; } }
@media (prefers-reduced-motion: reduce) {
  .nav-scroll::after { animation: none; }
  /* The handoff still HAPPENS under reduced motion — the cue leaves, the button
     takes the slot — it just cuts instead of sliding. */
  .nav-scroll, .nav-theme { transition: none; }
}

/* Burger toggle + full-screen overlay menu — desktop-hidden; both only exist
   below 991px (see the media block). The burger is a <button>, deliberately NOT
   an <a>, so the global click interceptor / page-transition machinery in app.js
   never sweeps it up. Two bars that cross into an X when html carries .nav-open. */
.nav-burger { display: none; }
.nav-overlay { display: none; }

/* Tablet + mobile: the 48-col curtain math and the SCROLL DOWN/scroll-bar chrome
   only have room to breathe at true desktop widths, so both simplify together
   here (same breakpoint the global grid lines switch to 24 columns at). The four
   inline links collapse into a burger + full-screen overlay. */
@media (max-width: 991px) {
  .nav-grid { padding: 16px 0; grid-template-columns: 1fr; }
  /* justify-self:start (+ fit-content width) keeps the brand link's actual
     clickable box at its text width. Grid items stretch to fill their track
     by default, so without this the invisible <a> box spans the WHOLE header
     row — overlapping the burger's tap area and swallowing taps that land
     just outside its 44px box (the burger sits body-level, on top visually,
     but the brand link's box was still there underneath catching the event
     first for anything not squarely inside the burger). */
  .nav-brand { grid-column: 1; justify-content: flex-start; justify-self: start; width: fit-content;
    padding-left: var(--col); }
  .nav-menu { display: none; }              /* header inline links → overlay */
  .nav-curtains { display: none; }          /* hover flourish — no hover here */
  .nav-scroll { display: none; }

  /* Burger button: two bars in a fixed box at the right gutter, body-level so it
     always paints ABOVE the overlay and stays tappable to close — but ONLY
     while the menu is actually open (html.nav-open .nav-burger below bumps it
     to 2001 > .nav-overlay's 2000). At rest it sits at 1001 (just above
     .site-head's 1000, same tier as .scroll-progress) — a flat 2001 base used
     to also out-rank .ld (the first-visit loader, 2000) and .ptv (the
     internal-nav page-transition veil, up to 1950), which put the burger
     visibly ON TOP of the entrance transition on every initial load and
     refresh. Bars are ink when closed (over the header), light when open
     (over the dark overlay), and cross into an X via html.nav-open.
     Vertically centered on the header's brand baseline (16px pad + ~14px).
     The BOX is 44x44 (WCAG minimum touch target) while the bars stay 24px —
     align-items centers them, and the top/right offsets are the old 22px /
     var(--col) minus the 10px of new padding on each side, so the visible
     bars sit exactly where they always did. safe-area inset keeps it out of
     the status bar on notched phones in landscape/PWA contexts. */
  /* The nav grid collapses to one column here, so the switch can't hold a
     column slot — it goes fixed beside the burger (which is body-level and
     fixed for the stacking reasons above), offset by one 44px touch target so
     the two never overlap. Same vertical centering as the burger. It hides
     while the overlay is open: the overlay is its own full-screen surface and a
     floating theme control over it reads as a stray artifact. */
  .nav-theme { position: fixed; top: calc(12px + env(safe-area-inset-top));
    right: calc(var(--col) - 10px + 44px); z-index: 1001; width: 44px; height: 44px; }
  /* --shift: 0 — the hero handoff is a desktop grid behaviour. Here the button
     is fixed to a px offset beside the burger, so leaving the translate applied
     drags it off that mark (the scroll cue is display:none anyway).
     Written as `html.at-hero .nav-theme`, not a bare `.nav-theme`: it has to
     match the specificity of the rule it is overriding (0,2,1) or it loses
     regardless of being inside a media query — which is exactly what happened,
     and left the homepage button 15.6px adrift on phones. */
  html.at-hero .nav-theme { --shift: 0px; }
  html.nav-open .nav-theme { opacity: 0; pointer-events: none; }
  .nav-burger { position: fixed; top: calc(12px + env(safe-area-inset-top)); right: calc(var(--col) - 10px); z-index: 1001;
    display: flex; flex-direction: column; justify-content: center; align-items: center; gap: 6px;
    width: 44px; height: 44px; padding: 0; background: none; border: 0; cursor: pointer; }
  html.nav-open .nav-burger { z-index: 2001; }
  .nav-burger span { display: block; width: 24px; height: 1.5px; background: var(--ink);
    transition: transform .4s cubic-bezier(.625,.05,0,1), background-color .3s ease;
    transform-origin: center; }
  html.nav-open .nav-burger span { background: #f1f1f1; }
  html.nav-open .nav-burger span:first-child { transform: translateY(3.75px) rotate(45deg); }
  html.nav-open .nav-burger span:last-child { transform: translateY(-3.75px) rotate(-45deg); }

  /* Overlay: fixed full-screen dark panel, cinematic. Hidden (fade + slight
     rise + non-interactive) until html.nav-open. Links are large serif, staggered
     in. Body scroll is locked via html.nav-open below. */
  .nav-overlay { display: flex; flex-direction: column; justify-content: center;
    align-items: flex-start; gap: var(--s-3);
    padding: env(safe-area-inset-top) var(--col) env(safe-area-inset-bottom);
    position: fixed; inset: 0; z-index: 2000; background: #131313;
    opacity: 0; visibility: hidden; transform: translateY(-1.5rem);
    transition: opacity .55s cubic-bezier(.625,.05,0,1),
      transform .55s cubic-bezier(.625,.05,0,1), visibility 0s .55s; }
  html.nav-open .nav-overlay { opacity: 1; visibility: visible; transform: none;
    transition: opacity .55s cubic-bezier(.625,.05,0,1),
      transform .55s cubic-bezier(.625,.05,0,1), visibility 0s 0s; }
  .nav-overlay .nav-link { display: block; position: relative; padding: var(--s-2) 0;
    font-family: var(--font-serif); font-weight: 500; text-transform: none;
    letter-spacing: -.02em; font-size: clamp(2rem, 9vw, var(--t-h1));
    color: #f1f1f1; white-space: nowrap;
    opacity: 0; transform: translateY(1.25rem);
    transition: opacity .5s ease, transform .5s cubic-bezier(.625,.05,0,1); }
  /* The swap-clip box (~line 425) is sized to the LONGER of each link's two
     labels, and .nav-swap-d/.nav-swap-h default to text-align:center (for the
     desktop header, where that centering is correct). In the overlay every
     link sits in an align-items:flex-start column, so a short default label
     (e.g. "Ethos") centered inside its longer-label-width box floated away
     from the shared left edge — the ragged look. Left-align both swap states
     here so every link's visible text starts at the same x regardless of
     which of the two labels is showing. */
  .nav-overlay .nav-swap-d, .nav-overlay .nav-swap-h { text-align: left; }
  html.nav-open .nav-overlay .nav-link { opacity: 1; transform: none; }
  .nav-overlay .nav-link:nth-child(1) { transition-delay: .10s; }
  .nav-overlay .nav-link:nth-child(2) { transition-delay: .16s; }
  .nav-overlay .nav-link:nth-child(3) { transition-delay: .22s; }
  .nav-overlay .nav-link:nth-child(4) { transition-delay: .28s; }
  /* Text-context auto-cycle: there's no hover on touch, so instead of a static
     short label (Ethos, Index...) app.js periodically toggles .swap-alt on
     each link, on a slow staggered loop, sliding to the descriptive alternate
     (About Me, Case Studies...) and back. Reuses the SAME clip/position/
     transition CSS the desktop hover-swap already declares globally (~line
     418-424) — .swap-alt here just mirrors what :hover does there, so the
     motion is identical, only the trigger differs. .nav-swap-size stays in
     flow, sized to the LONGER of the two labels (see navSwap() in
     render.mjs), so the cycle never reflows the menu. */
  .nav-overlay .nav-link.swap-alt .nav-swap-d { transform: translateY(-100%); opacity: 0; }
  .nav-overlay .nav-link.swap-alt .nav-swap-h { transform: translateY(0); opacity: 1; }
  /* Reduced motion: skip the cycle (app.js never adds .swap-alt) and pin the
     plain short label instead, same as before this feature existed. */
  @media (prefers-reduced-motion: reduce) {
    .nav-overlay .nav-swap-size, .nav-overlay .nav-swap-h { display: none; }
    .nav-overlay .nav-swap-d { position: static; transform: none !important; opacity: 1 !important; }
  }
  /* Tap feedback: on tap (app.js adds .is-tapped on pointerdown) the link
     presses in slightly and a GC-red underline sweeps across it left→right —
     confirms the tap landed before the page transition begins. The link is a
     flex item under align-items:flex-start, so it's already text-width and the
     0/0 inset underline spans exactly the word. */
  .nav-overlay .nav-link::after { content: ""; position: absolute; left: 0; right: 0;
    bottom: .18em; height: 2px; background: var(--accent);
    transform: scaleX(0); transform-origin: left center;
    transition: transform .2s cubic-bezier(.625,.05,0,1); pointer-events: none; }
  html.nav-open .nav-overlay .nav-link.is-tapped { transform: scale(.97); transition: transform .14s ease; }
  html.nav-open .nav-overlay .nav-link.is-tapped::after { transform: scaleX(1); }

  html.nav-open { overflow: hidden; }       /* lock scroll behind the overlay */
}

.eyebrow { font-family: var(--font-mono); font-size: var(--t-eyebrow); letter-spacing: .14em;
  text-transform: uppercase; color: var(--ink-mute); }

/* Capability chips — ported from print.css's .chip (PDF-only until now); the
   Ethos page's "what I do" line and PDF cover both read from the same chips()
   helper in render.mjs, so the site gets its own screen equivalent here. */
.chips { display: flex; flex-wrap: wrap; gap: 8px; }
.chip { display: inline-block; font-family: var(--font-mono); font-size: 12px; letter-spacing: .02em;
  color: var(--ink-soft); background: var(--tag-bg); padding: 6px 14px; border-radius: var(--r-pill); }

/* ---- Homepage: cinematic parallax hero, ported from the Webflow reference
   (hero_component). Two DIFFERENT photographs (not a re-tint) light/dark the
   hero the same way the rest of the site themes — see the img src-swap script
   in render.mjs's heroSection template. Same doubled-selector pattern as
   tokens.css's dark-mode block (comment at tokens.css:105-112): the base rule
   below is the LIGHT values (tokens.css's own default-light convention), the
   two blocks after it override to dark. Keep those two byte-identical. ---- */
.home-hero--cinematic {
  --bg: #f1f1f1; --ink: #131313; --ink-soft: #4a4a4a; --ink-mute: #85807c; --hairline: rgba(19,19,19,.18);
  /* Scrim is deliberately NOT themed like --bg/--ink above (2026-07-21, Gian's
     call): a permanently-dark wash only reads correctly with permanently-white
     text on top of it, and the tagline/title/nav-over-hero below are pinned
     white for exactly that reason — this and those are one coherent decision,
     not independent choices. Set once here (not per-theme) so both dark-mode
     override blocks below no longer need to repeat it. */
  --hero-scrim-0: rgba(0,0,0,.6); --hero-scrim-45: rgba(0,0,0,.15); --hero-scrim-70: rgba(0,0,0,0);
  /* Pulled up under the sticky, transparent .site-head (91px CSS fallback matches
     .hero-top's own top offset below; app.js's setNavH() overwrites --nav-h with
     the real measured height on load/resize) so the hero paints flush to the true
     viewport top instead of leaving a gap the size of the nav row. Height grows by
     the same amount so the VISIBLE hero (below the nav) stays a full 100svh — the
     negative margin only pulls the extra height up under the header, it doesn't
     shrink what's on screen.
     This section must stay on the NORMAL layer — do not give it a negative
     z-index. It carried z-index:-2 for eight days (to sandwich it under the
     sitewide grid overlay's -1) and that silently sank the whole subtree below
     body's in-flow content level: the root background still painted the hero so
     it LOOKED right, but body won every hit test over it, so the mousemove
     parallax, the WebGL hover FX, and the "Let's talk" CTA all stopped receiving
     pointer events. .hero-content's z-index:10 does not rescue it — that index is
     trapped inside this element's own stacking context. The hairlines over the
     hero now come from its own ::after below instead. */
  position: relative; overflow: hidden;
  margin-top: calc(-1 * var(--nav-h, 91px));
  height: calc(100svh + var(--nav-h, 91px));
  background: #f1f1f1; color: #131313;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .home-hero--cinematic {
    --bg: #131313; --ink: #f1f1f1; --ink-soft: #d6d6d6; --ink-mute: #9a9a9a; --hairline: rgba(241,241,241,.18);
    background: #131313; color: #f1f1f1;
  }
}
:root[data-theme="dark"] .home-hero--cinematic {
  --bg: #131313; --ink: #f1f1f1; --ink-soft: #d6d6d6; --ink-mute: #9a9a9a; --hairline: rgba(241,241,241,.18);
  background: #131313; color: #f1f1f1;
}
/* The hero's own copy of the sitewide column hairlines (body::after above), so
   the lines still read over the photo now that the section sits on the normal
   layer rather than beneath body::after. Same gradient/opacity; var(--ink)
   resolves to this section's scoped #f1f1f1, so it needs no second color rule.
   absolute (not fixed) keeps it inside the hero's overflow:hidden clip — the
   columns still line up with body::after's because the hero is full-bleed and
   both divide the same width from x=0, and the lines are vertical so the hero's
   negative margin-top can't shift them. Sits above .hero-layer (1) and below
   .hero-content (10); pointer-events:none so it never hit-tests. */
.home-hero--cinematic::after {
  content: ""; position: absolute; inset: 0; z-index: 5; pointer-events: none;
  opacity: .03;
  background-image: repeating-linear-gradient(to right, var(--ink) 0 1px, transparent 1px calc(100% / 48)); }
@media (max-width: 991px) {
  .home-hero--cinematic::after {
    background-image: repeating-linear-gradient(to right, var(--ink) 0 1px, transparent 1px calc(100% / 24)); }
}
/* Single clip boundary for the whole hero: .home-hero--cinematic's own
   overflow:hidden above. Layers/images below are deliberately oversized so their
   own edges never reach that boundary at any scroll/mouse offset — nothing else
   in this stack clips. */
.hero-layers { position: absolute; inset: 0; pointer-events: none; width: 100%; height: 100%; }
.hero-layer { position: absolute; inset: 0; width: 100%; height: 100%; will-change: transform; }
/* Box size is now PER-LAYER, each sized to that layer's own scroll-shift
   magnitude — the previous shared box (one size for bg+subject) had ZERO
   bottom slack, and since scroll-shift moves each .hero-layer div UP (negative
   yPercent, see the parallax JS below: bg -6%, subject -11%, shadow-1 -16%,
   shadow-2 -21%), zero bottom slack meant the image's bottom edge scrolled
   ABOVE the hero's own bottom edge, exposing the hero's flat #131313 background
   as a hard-edged bar while scrolling — confirmed with an actual screenshot
   mid-scroll, not just theory. Each layer below gets bottom slack comfortably
   exceeding ITS OWN |scroll-shift| + margin; top slack stays modest (just
   covers the small mouse-parallax drift, which can go either direction).
   The vertical ANCHOR (object-position) is a fallback ONLY — on desktop/WebGL2
   the bg+subject <img>s get swapped for a <canvas> (the dither/distortion pass
   in render.mjs) which ignores CSS object-position entirely; the crop for that
   path is instead set by the shader's own `uAnchor` uniform (render.mjs,
   `anchor: [0.5, 0.4]`) — the two MUST be kept in sync (same fraction) or the
   plain-img fallback (no-WebGL2/reduced-motion) and the canvas path will
   visibly disagree on framing. 0.25/25% (biased toward the top) clipped the
   subject's lower body/feet on wide-short viewports where cover-fit needs to
   crop more vertically; 0.75/75% overcorrected and clipped the head instead;
   0.4/40% keeps the face clear of the nav while accepting some foot-crop only
   at extreme ultrawide ratios — re-check both this value AND the shader
   uAnchor together if the source photos ever change.
   Offsets are inset offsets, NOT a transform — the mouse-parallax JS (app.js)
   sets this same img's inline transform for the cursor-driven drift, which
   would otherwise silently replace (not add to) a transform-based centering
   trick and blow the image wildly off-position. max-width:none overrides the
   site-wide `img{max-width:100%}` reset, which would otherwise clamp this back
   down to 100% regardless of the width below. */
.hero-layer img, .hero-layer canvas { pointer-events: none; -webkit-user-select: none; user-select: none;
  object-fit: cover; object-position: center 40%; max-width: none; position: absolute; }
.hero-layer.is-bg { z-index: 1; }
.hero-layer.is-bg img, .hero-layer.is-bg canvas { width: 104%; height: 118%; top: -6%; left: -2%; }
.hero-layer.is-subject { z-index: 2; }
.hero-layer.is-subject img, .hero-layer.is-subject canvas { width: 104%; height: 124%; top: -6%; left: -2%; }
.hero-layer.is-shadow-1, .hero-layer.is-shadow-2 { pointer-events: none; mix-blend-mode: multiply; opacity: .3; }
.hero-layer.is-shadow-1 { z-index: 3; width: 150%; height: 170%; top: -20%; left: 35%; }
/* shadow-2's own scroll-shift is the largest of all four layers (-21%), but its
   old box (120%/-20%) had ZERO bottom slack — same gap-exposure bug as bg/subject,
   just less visible since this layer is a low-opacity multiply blend, not a hard
   photo edge. Height raised to comfortably cover it; top/left/width untouched
   (already had ample slack in those directions). */
.hero-layer.is-shadow-2 { z-index: 4; width: 120%; height: 180%; top: -20%; left: -50%; }
.hero-layer.is-shadow-1 img, .hero-layer.is-shadow-2 img {
  filter: blur(4px); object-fit: contain; object-position: center; overflow: visible;
  width: 100%; height: 100%; }
.hero-scrim { position: absolute; inset: 0; z-index: 5;
  background: linear-gradient(to top, var(--hero-scrim-0) 0%, var(--hero-scrim-45) 45%, var(--hero-scrim-70) 70%); }
/* Hero "static": no CSS overlay here. The effect is a WebGL dither pass in
   app.js ("Hero static (dither)") that replaces the bg/subject <img>s with
   canvases — the img/canvas rule above sizes both identically. Ported from the
   reference hero's published shader: the image's own colors are quantized with
   per-pixel hash noise, so the static lives inside the photo's gradients
   instead of sitting on top of it as a film. */
.hero-content { z-index: 10; flex-direction: column; justify-content: center; width: 100%; height: 100%;
  padding: 2rem; display: flex; position: relative; }

.hero-top { z-index: 11; flex-direction: column; align-items: flex-start; gap: 25px; display: flex;
  position: absolute; top: 91px; left: var(--gutter); right: var(--gutter); }
/* color is a fixed #f1f1f1 (the site's established "white"), not var(--ink):
   the scrim behind this text is permanently dark (see .home-hero--cinematic's
   --hero-scrim-* above) regardless of theme, so the text pins white to match —
   same reasoning as .hero-talk-cta's --accent-ink pin just below. */
.hero-tagline { max-width: min(396px, 100%); font-family: var(--font-sans); color: #f1f1f1; letter-spacing: -.03em;
  margin: 0; font-size: 40px; font-weight: 200; line-height: 1.3; }
.hero-talk-cta { justify-content: center; align-items: center; width: 160px; height: 34px;
  /* color is --accent-ink (fixed #ffffff, both themes — tokens.css:17), not --ink:
     this pill sits on --accent (dark red), not the hero photo. --ink used to work
     by accident because the hero was always-dark and --ink happened to be white
     there too; now the hero themes normally, that accident would put near-black
     text on dark red in light mode. Same reasoning already pins .footer-cta
     explicitly a few hundred lines down — same class, reused outside the hero. */
  padding: 12px 24px; background: var(--accent); color: var(--accent-ink); text-decoration: none; display: inline-flex;
  font-family: var(--font-sans); text-align: center; letter-spacing: .2em; text-transform: uppercase;
  white-space: nowrap; font-size: 14px; font-weight: 300; line-height: 1; transition: opacity .3s ease; }
.hero-talk-cta:hover { opacity: .85; }

.hero-name { z-index: 11; flex-direction: column; align-items: center; gap: 30px; display: flex;
  position: absolute; bottom: 60px; left: var(--gutter); right: var(--gutter); max-width: 100%; }
.hero-name-dots { justify-content: space-between; align-items: center; width: 100%; display: flex; }
.hero-dot { background: var(--accent); flex-shrink: 0; width: 18px; height: 18px; }
/* Fixed #f1f1f1, same reasoning as .hero-tagline above — pinned white to match
   the hero's permanently-dark scrim, not tracking the site theme. */
.hero-title { font-family: var(--font-sans); color: #f1f1f1; text-align: center; letter-spacing: -.03em;
  white-space: nowrap; margin: 0; font-size: clamp(48px, 13.5vw, 260px); font-weight: 300; line-height: 1; }
/* app.js wraps each rendered line as .hero-line-mask > .hero-line and rises them on load */
.js .hero-title { opacity: 0; }                 /* hidden until the split runs (no flash) */
.hero-line-mask { display: block; overflow: hidden; padding-bottom: .06em; }
.hero-line { display: block; }

@media (max-width: 991px) {
  .hero-tagline { max-width: 60vw; font-size: 32px; }
  .hero-title { white-space: normal; font-size: clamp(40px, 14vw, 130px); line-height: .95; }
}
@media (max-width: 767px) {
  .hero-top { top: 70px; }
  .hero-tagline { font-size: 26px; }
  .hero-talk-cta { width: 132px; padding-left: 18px; padding-right: 18px; }
}
@media (max-width: 479px) {
  .hero-top { top: 60px; }
  .hero-tagline { font-size: 22px; }
  .hero-talk-cta { width: 120px; }
  .hero-title { font-size: clamp(36px, 18vw, 80px); }
}

/* ---- Scroll progress — a hairline-thin, precise read of position in the page.
   Fixed above the header; GSAP scrubs its scaleX exactly to scroll (see app.js).
   Static (scaleX 0, invisible) with no GSAP/reduced-motion — never a stray mark.
   z-index 1001 (was 30, 2026-07-11 fix): the comment already said "above the
   header" but 30 sat well BELOW .site-head's 1000 — the bar was scrubbing
   correctly the whole time, just fully hidden under the header's own
   background the entire span it overlaps (0-74px, i.e. always, since both
   are pinned to the viewport top). Bumped just above the header; still well
   under the veil (1900) and loader (2000), so it never fights either. ---- */
.scroll-progress { position: fixed; top: 0; left: 0; width: 100%; height: 2px; z-index: 1001;
  background: var(--accent); transform: scaleX(0); transform-origin: 0 50%; }

/* ---- Homepage: equal work grid (every card identical) --------------------- */
/* #work is .wrap-combined — padding-top/-bottom only, never the shorthand (see
   .home-caps above). No min-height:100svh / justify-content:center here: with 9
   cards the grid is already taller than a viewport, so centering added nothing
   but empty top space that stacked onto the capabilities section's own trailing
   gap — "Selected work" ended up ~half a screen below the last capability row.
   Top-aligned with a normal section-opening pad means it follows the caps
   section immediately instead. */
#work { display: flex; flex-direction: column;
  padding-top: var(--s-8); padding-bottom: var(--s-10); }
/* Desktop pinned only: the caps sequence lands the last row (Decks) at viewport
   center, so ~half a viewport of the pinned stage sits empty below it before the
   pin releases — a long dead scroll into "Selected work". #work immediately
   follows .home-caps in the DOM and .caps-pinned is added by JS only on desktop
   (>991px), so this adjacent-sibling rule is self-scoping: pull #work up over
   that trailing empty half-viewport (minus a normal section gap) so it covers the
   dead space instead of the user scrolling through it. The pinned stage scrolls
   up to reveal #work right after Decks. */
.home-caps.caps-pinned + #work { margin-top: calc(-50svh + 160px); }
.work-label { position: relative; display: inline-block; margin: var(--s-6) 0 var(--s-4); padding-bottom: 10px; }
.work-label::after { content: ""; position: absolute; left: 0; bottom: 0; width: 44px; height: 1px;
  background: var(--ink-mute); transform: scaleX(0); transform-origin: 0 50%;
  transition: transform .6s cubic-bezier(.16,.8,.24,1) .3s; }
.work-label.revealed::after { transform: scaleX(1); }
.work-grid { position: relative; display: grid; grid-template-columns: repeat(3, 1fr);
  gap: calc(var(--grid-gap) * 1.3) var(--grid-gap); padding: var(--s-4) 0 var(--s-6); }
/* Reserves a full viewport of height regardless of how few cards a filter
   leaves visible, so the footer (and the "← Home" link below the grid) never
   snaps upward when a filter narrows the results, then snaps back down when
   it's reset — the empty space just sits below the remaining cards instead.
   Homepage's plain .work-grid isn't filtered and doesn't need this. */
.work-grid.is-filterable { min-height: 100svh; }
/* "See all case studies" below the homepage grid — .frag-more's own rule assumes
   a flex parent (margin-left:auto to push right); here it's a plain block link
   under the grid, so it gets its own display/spacing instead. */
#work > .frag-more { display: block; margin: 0 0 var(--s-9); }

.card { position: relative; z-index: 1; display: flex; flex-direction: column; color: inherit; will-change: transform; }
.card-media { position: relative; overflow: hidden; border-radius: var(--r-lg);
  border: 1px solid var(--hairline); background: var(--tag-bg); aspect-ratio: 4 / 3; }
.card-media img, .card-media video { width: 100%; height: 100%; object-fit: cover; display: block; }
/* Mouse-following highlight on the border stroke (same as the case-study images; app.js feeds
   --mx/--my): a thin masked gradient ring on the 1.5px border + a soft accent glow, on hover. */
.card-media::before { content: ""; position: absolute; inset: 0; border-radius: inherit; padding: 1.5px; z-index: 3;
  background: radial-gradient(150px circle at var(--mx,50%) var(--my,50%),
              color-mix(in srgb, var(--accent) 90%, #fff 20%), transparent 60%);
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor; mask-composite: exclude;
  filter: drop-shadow(0 0 5px color-mix(in srgb, var(--accent) 50%, transparent));
  opacity: 0; transition: opacity .35s ease; pointer-events: none; }
.card:hover .card-media::before { opacity: 1; }
/* Hover (driven in app.js): the IMAGE keeps its on-screen size while the CONTAINER (.card-media)
   expands a touch; the image overscans the frame in both states so it always fully covers — no
   gap. The whole card lifts above its neighbours so the expanded frame is NOT clipped or
   overlapped (.card has overflow:visible + no clip-path, so nothing cuts it off). */
.card:hover { z-index: 3; }

.card-body { display: flex; flex-direction: column; gap: 6px; padding: var(--s-4) 2px 0; }
.card-top { display: flex; align-items: center; justify-content: space-between; }
.card-idx { position: relative; font-family: var(--font-mono); font-size: var(--t-meta);
  color: var(--ink-mute); letter-spacing: .06em; transition: color .3s ease; }
/* A quiet glow behind the index number — the card "waking up" under the cursor,
   same radial-glow recipe as .card-media::before, just static (no mouse-tracking
   needed for a number this small). */
.card-idx::before { content: ""; position: absolute; inset: -7px -11px; z-index: -1; border-radius: var(--r-pill);
  background: radial-gradient(circle, color-mix(in srgb, var(--accent) 32%, transparent), transparent 72%);
  opacity: 0; transition: opacity .4s cubic-bezier(.2,.7,.2,1); }
.card:hover .card-idx { color: var(--ink); }
.card:hover .card-idx::before { opacity: 1; }
.card-cue { width: 22px; height: 22px; color: var(--ink-mute); opacity: 0;
  transform: translate(-5px, 5px); transition: opacity .3s ease, transform .35s cubic-bezier(.2,.7,.2,1), color .3s ease; }
.card-cue svg { width: 100%; height: 100%; display: block; }
.card:hover .card-cue { opacity: 1; transform: translate(0, 0); color: var(--ink); }
/* one compact line of disciplines, clamped to a single line → every card body equal height */
.card-caps { font-family: var(--font-mono); font-size: var(--t-eyebrow); letter-spacing: .1em; text-transform: uppercase;
  color: var(--ink-mute); margin: 0; max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.card-title { font-family: var(--font-serif); font-weight: 500; font-size: var(--t-h3); line-height: 1.12;
  letter-spacing: -.01em; margin: 2px 0 0; color: var(--ink); transition: color .3s ease;
  display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; }
.card:hover .card-title { color: var(--ink); }
.card-view { font-family: var(--font-mono); font-size: 11px; letter-spacing: .08em; text-transform: uppercase;
  color: var(--accent); margin-top: 2px; opacity: 0; transform: translateY(4px);
  transition: opacity .4s cubic-bezier(.16,.8,.24,1), transform .4s cubic-bezier(.16,.8,.24,1); }
.card:hover .card-view { opacity: 1; transform: translateY(0); }

/* ---- Homepage: capabilities (scroll-locked sequence) ----------------------
   .home-caps is the OUTER scroll region — natural height by default (no-JS/
   reduced-motion/mobile fallback: everything below just flows normally, one
   viewport-ish tall thanks to .caps-stage's own min-height). When JS enables
   the lock (desktop + motion allowed) it adds .caps-pinned and sets an inline
   height = 100svh + extra travel distance (sized to row count), which is what
   gives .caps-stage's sticky position somewhere to actually pin *through*.
   .home-caps itself never carries .wrap (nothing to gutter-pad at that level)
   — .caps-head/.caps-body carry .wrap individually instead. */
.home-caps { position: relative; }
.caps-stage { min-height: 100svh; display: flex; flex-direction: column; justify-content: center;
  padding-top: var(--s-10); padding-bottom: var(--s-10); }
/* Only takes effect once JS has sized .home-caps with real extra scroll
   distance (see SITE_JS) — sticky with nothing to scroll through just behaves
   like the static fallback above, so this is safe to leave unguarded by media
   query; JS itself never adds .caps-pinned on mobile or under reduced-motion. */
.home-caps.caps-pinned .caps-stage { position: sticky; top: 0; height: 100svh; min-height: 0;
  padding-top: 0; padding-bottom: 0; overflow: hidden; justify-content: center; }
/* Compositor hint only while actually pinned — the per-frame translateY (see
   SITE_JS) is scrubbed inline every scroll tick, and without this the browser
   was deciding paint strategy fresh each frame instead of promoting the layer
   once. Scoped to .caps-pinned so mobile (never pinned) doesn't pay for a
   promoted layer it has no use for. */
.home-caps.caps-pinned .caps-list { will-change: transform; }
.caps-head { margin-bottom: var(--s-6); }
/* While pinned, the head leaves the centered flex column and pins to the top
   of the stage. In-flow it was CLIPPED: the stage is height:100svh +
   overflow:hidden + justify-content:center, and head + 12-row list lay out
   taller than a viewport (~1160px at 1440x900), so centered overflow ate
   ~130px off BOTH ends — and the top 130px is exactly the eyebrow + half the
   h2. The rows never had this problem (the scrub translates them through the
   viewport and measures their rects after the pin applies — see setupPin in
   app.js); only this static head did. --nav-h comes from setNavH() in the
   same app.js module (real header height), 72px fallback; .caps-head already
   carries .wrap so left/right:0 keeps its own gutter padding on the grid. */
.home-caps.caps-pinned .caps-head { position: absolute; top: calc(var(--nav-h, 72px) + var(--s-5));
  left: 0; right: 0; z-index: 1; }
.caps-head .eyebrow { display: block; margin-bottom: var(--s-2); }
.caps-head h2 { font-family: var(--font-serif); font-weight: 500; font-size: var(--t-h2);
  letter-spacing: -.02em; margin: 0; max-width: 20ch; }
/* Intro lede under the h2. margin-top only spaces it; the frag-intro type rules
   (size/weight/colour) are inherited untouched. */
.caps-head .frag-intro { margin-top: var(--s-3); }
/* Desktop (where .caps-body is the 2-col grid and the head pins over the rows):
   cap the intro to a quarter of the content width (12 of 48 grid columns) so it
   reads as a short, tight line rather than spanning the full left column. This
   stays well inside the LEFT column's width, so it still can't extend under the
   rows in the right column. The calc mirrors .caps-body's own track math —
   (content width − the --s-8 column gap) / 4 — and 100% here resolves to the
   same gutter-inset content box the grid uses. On mobile the head is
   single-column normal flow (max-width:991px below), so this cap deliberately
   does not apply there. */
@media (min-width: 992px) {
  .caps-head .frag-intro { max-width: calc((100% - var(--s-8)) / 4); }
}
/* Two equal columns — the split itself lands exactly on the grid's center line
   (col 24/48), since .wrap's content box is symmetric within the viewport. Left
   half holds the mono description, right half holds the label stack, which
   left-aligns from that center line rather than sitting centered on the page.
   Stacks to one column on mobile — description moves above the list, no sticky
   positioning (nothing to lock to on a phone). */
.caps-body { display: grid; grid-template-columns: 1fr 1fr; gap: var(--s-8); align-items: start; }
.cap-desc { position: sticky; top: 40%; padding-top: var(--s-2); }
.cap-desc-text { margin: 0; font-family: var(--font-mono); font-size: var(--t-meta); line-height: 1.5;
  color: var(--ink-mute); max-width: 32ch; }
/* No transition on transform here — while pinned, .caps-list's own position is
   driven 1:1 from scroll progress each frame (inline style, see SITE_JS), same
   "scrubbed not eased" rule as each row's own properties below. */
.caps-list { display: flex; flex-direction: column; align-items: flex-start; text-align: left; }
/* Resting state is legible (muted, not hidden) — never scroll-gated, only
   style-emphasized. app.js finds whichever ONE row is nearest the viewport's
   vertical center (with a hysteresis margin so it doesn't flicker between
   two rows right at the midpoint) and toggles .is-active on that row alone —
   every other row always sits at this resting, readable-but-quiet contrast.
   The glide between states is owned entirely by the `transition` below, not
   by JS, which is what makes the handoff read as smooth rather than scrubbed.
   No JS at all (or reduced-motion) just leaves every row at rest — a plain
   list, nothing missing. */
.cap-row { margin: 0; padding: var(--s-3) 0; font-family: var(--font-serif); font-weight: 500;
  font-size: clamp(1.75rem, 4vw, var(--t-h1)); letter-spacing: -.02em; line-height: 1.08;
  color: var(--ink-soft); opacity: .16;
  transform-origin: left center;
  transition: opacity .5s cubic-bezier(.4,0,.2,1), transform .5s cubic-bezier(.4,0,.2,1),
    letter-spacing .5s cubic-bezier(.4,0,.2,1), color .3s ease, filter .9s ease var(--reveal-delay, 0s); }
.cap-row.is-active { color: var(--ink); opacity: 1; transform: scale(1.035); letter-spacing: -.03em; }
.cap-row:nth-child(2) { --reveal-delay: .06s; }
.cap-row:nth-child(3) { --reveal-delay: .12s; }
.cap-row:nth-child(4) { --reveal-delay: .18s; }
.cap-row:nth-child(5) { --reveal-delay: .24s; }
.cap-row:nth-child(6) { --reveal-delay: .3s; }
.cap-row:nth-child(7) { --reveal-delay: .36s; }
.cap-row:nth-child(8) { --reveal-delay: .42s; }
@media (max-width: 991px) {
  /* --s-10 bottom padding on .caps-stage is sized for the desktop pinned/
     centered state — on mobile (never pinned, content just flows) it left
     128px of dead air before #work. Trimmed to --s-6 so "Selected work"
     follows the last capability row closely. Top padding stays: that's normal
     section-opening space before the "Capabilities" heading. */
  .caps-stage { padding-bottom: var(--s-6); }
  /* flex, not grid: .cap-desc and .caps-list need to share ONE container tall
     enough (driven by the list) for the sticky child to have room to stick —
     a grid with a single 1fr column would instead put each in its own
     short row track, capping sticky travel to that row's own height. */
  .caps-body { display: flex; flex-direction: column; gap: var(--s-5); }
  /* The description LOCKS below the header and stays put the whole section
     while the titles scroll under it — sticky travel spans the full (tall)
     .caps-body. Opaque bg + a hairline under it so rows read as sliding
     beneath a fixed lane, not colliding with it. top is --nav-h (app.js
     measures the real .site-head height) rather than a guessed px value —
     a mismatch there let the sticky header's own z-index:1000 paint over
     the top slice of this lane, which read as "blocked" text.
     NOTE: app.js no longer anchors the active-row highlight to this band's
     bottom edge (that made the highlighted title read as crowded against
     the lock) — it now uses the same dead-center reading line as desktop,
     independent of where this description sits.
     min-height is load-bearing, not cosmetic: .cap-desc and .caps-list share
     ONE flex column (.caps-body, flex-direction:column) so they can occupy a
     single tall container for the sticky child to travel through. Without a
     reserved height here, swapping in a longer/shorter description on every
     row handoff changes THIS element's own rendered height, which shifts
     every row in .caps-list up/down — and since that shift can itself flip
     which row is nearest the center reading line, it created a feedback
     loop (row becomes active → its description grows → rows shift → a
     DIFFERENT row is now nearest → hands back → description shrinks → rows
     shift back → repeat), which is exactly what read as "locking"/jank on
     mobile. 160px comfortably covers the longest sub text's wrapped height
     measured down to a 320px-wide phone (worst case measured: 151px) with a
     small safety margin — verify against the actual copy in build/data.mjs
     (CONTACT_INTERESTS's neighbor, CAPABILITY_ROWS) if a much longer sub is
     ever added. */
  .cap-desc { position: sticky; top: var(--nav-h, 64px); z-index: 1;
    background: var(--bg); padding-top: var(--s-3); padding-bottom: var(--s-3);
    border-bottom: 1px solid var(--hairline); min-height: 160px; }
  .cap-desc-text { max-width: none; }
  /* More breathing room per title so each gets its own scroll moment and the
     highlight hands off one row at a time under the locked description. */
  .cap-row { padding: var(--s-4) 0; }
}

/* Entrance reveal, positional/focus only — opacity and color stay entirely owned
   by the spotlight rules above, so a row is NEVER at true opacity:0, before or
   after JS runs. Gated behind .js: no-JS visitors get rows at rest immediately,
   no scroll dependency. Once a row is .revealed, JS takes over its transform/
   opacity inline every frame (see above) — this rule only owns the pre-reveal
   entrance state. */
.js .cap-row { transform: translateY(22px); filter: blur(6px); }
.js .cap-row.revealed { transform: none; filter: blur(0); }

/* ---- Homepage: testimonials (scroll-scrubbed grow-into-focus) -------------
   padding-top/-bottom only — same reasoning as .home-caps above. min-height
   keeps it at least one viewport tall (its own content is short) without
   capping it if more testimonials are added later. */
/* Hidden until real client quotes replace the placeholder set in data.mjs
   (TESTIMONIALS) — display:none, not deleted, so it's a one-line revert once
   real testimonials are ready. */
.home-testi { display: none; min-height: 100svh; flex-direction: column; justify-content: center;
  padding-top: var(--s-10); padding-bottom: var(--s-10); border-top: 1px solid var(--hairline); }
.home-testi .eyebrow { display: block; margin-bottom: var(--s-6); }
.testi-list { display: flex; flex-direction: column; gap: var(--s-9); }
.testi-quote { margin: 0; text-align: center; }
.testi-quote p { font-family: var(--font-serif); font-style: italic; font-weight: 400;
  font-size: 2rem; line-height: 1.35; color: var(--ink); max-width: 26ch; margin: 0 auto;
  letter-spacing: -.01em; }
.testi-quote cite { display: block; margin-top: var(--s-4); font-family: var(--font-mono);
  font-style: normal; font-size: var(--t-eyebrow); letter-spacing: .08em; text-transform: uppercase;
  color: var(--ink-mute); }
@media (max-width: 991px) { .testi-quote p { font-size: 1.5rem; max-width: 22ch; } }

/* ---- Editorial case page -------------------------------------------------- */
/* Case-head/case-body used to zero out their own horizontal padding (relying
   solely on max-width+auto-margin to center the reading column) — but that
   silently canceled the .wrap gutter they're combined with (same specificity,
   declared later, so its padding:0 always won), leaving non-text children
   (galleries, spreads, images — anything without its own max-width) free to
   span edge-to-edge with zero grid margin instead of respecting it. Now both
   keep the gutter as their own padding — the *container's* outer bound, which
   images/galleries inherit directly — and push the narrower reading measure
   down onto the individual text children instead of the container itself
   (max-width is a border-box cap, so setting it on the padded container would
   have eaten the reading measure out of the gutter instead of adding to it). */
/* Head rhythm (2026-07-15): art-gallery air — the head is the page's
   establishing shot, so it carries the most whitespace on the page, stepping
   down through media → section breaks → paragraph rhythm below. */
.case-head { padding: var(--s-10) var(--gutter) var(--s-6); }
.case-head > .eyebrow, .case-head > h1, .case-head > .lead, .case-head > .case-meta {
  max-width: var(--maxw-read); margin-left: auto; margin-right: auto; }
.case-head .eyebrow { margin-bottom: var(--s-4); }
.case-head h1 { font-family: var(--font-serif); font-weight: 500; font-size: var(--t-h1);
  line-height: 1.04; letter-spacing: -.03em; margin-top: 0; margin-bottom: var(--s-5); }
/* The 32ch measure is narrower than the shared 660px reading column, and the
   inherited auto margins were re-centering the narrower box — putting the
   lead's left edge ~83px right of the h1 directly above it. Pin it to the
   column's left edge instead: (100% - maxw-read)/2 is exactly where the
   centered 660px column starts inside case-head's content box; max(0px, …)
   keeps narrow viewports (content < 660px) at today's flush-left behavior. */
.case-head .lead { font-family: var(--font-serif); font-weight: 400; font-size: var(--t-lead);
  line-height: 1.45; color: var(--ink); margin-top: 0; margin-bottom: 0; max-width: 32ch;
  margin-left: max(0px, calc((100% - var(--maxw-read)) / 2)); margin-right: 0; }
.case-head .case-meta { display: flex; flex-wrap: wrap; gap: var(--s-7); margin-top: var(--s-8);
  padding-top: var(--s-5); border-top: 1px solid var(--hairline); font-size: var(--t-meta); }
.case-head .case-meta small { display: block; font-family: var(--font-mono); font-size: 11px;
  letter-spacing: .08em; text-transform: uppercase; color: var(--ink-mute); margin-bottom: 5px; }

.case-body { padding: var(--s-5) var(--gutter) var(--s-10); }
/* Editorial measure: text stays in a narrow centered column; media goes as
   wide as the gutter-bound container (see the comment above .case-head). */
.case-body > h2, .case-body > h3, .case-body > p, .case-body > ul, .case-body > blockquote, .case-body > .chips {
  max-width: var(--maxw-read); margin-left: auto; margin-right: auto; }
/* Section labels inside a case study — the same mono eyebrow language every
   other section label on the site uses (.eyebrow), kept in the case pages'
   accent red. (2026-07-15: was 15px DM Sans at .04em — close enough to prose
   styling that a "heading" rendered SMALLER than its own body text without
   reading as a deliberate label.) */
.case-body h2 { font-family: var(--font-mono); font-weight: 400; font-size: var(--t-eyebrow); letter-spacing: .14em;
  text-transform: uppercase; color: var(--accent); margin: var(--s-10) auto var(--s-5); }
.case-body h3 { font-family: var(--font-serif); font-weight: 500; font-size: var(--t-h3); margin: var(--s-9) auto var(--s-4); }
/* Paragraph rhythm ~0.8 of a text line (24px against the 30px line box) —
   editorial long-form spacing, not app-UI stacking (was 1rem). */
.case-body p  { font-size: 1.18rem; color: var(--ink-soft); margin: 0 auto var(--s-5); }
.case-body ul { color: var(--ink-soft); padding-left: 1.2em; margin: 0 auto var(--s-5); }
.case-body li { margin-bottom: var(--s-2); }
.case-body blockquote { font-family: var(--font-serif); font-style: italic; font-weight: 400;
  font-size: 2.2rem; line-height: 1.3; text-align: center; color: var(--ink);
  margin: var(--s-10) auto; max-width: 18ch; letter-spacing: -.01em; }

.proof-stats { display: grid; gap: var(--s-4); margin: var(--s-8) auto var(--s-8); max-width: 65ch; }
.proof-stat { font-size: 1.1rem; color: var(--ink-soft); line-height: 1.5; }
.proof-stat strong { color: var(--ink); font-weight: 500; }

/* ---- Ethos page: dynamic editorial layout (2026-07-13, second pass — replaces
   the first pass's case-head/case-body reuse entirely; that shared treatment is
   untouched for every other case-study page). Departs from the site's normal
   centered reading column on purpose: a full-bleed hero, a zig-zag body spine,
   an oversized Proof bento, a photo grid. Every structural grid here
   (.ethos-hero, .ethos-sec) is an edge-to-edge repeat(48,1fr) with NO
   column-gap — the exact pattern .nav-grid already uses (screen.css:414) — so
   column LINES land on the sitewide hairline grid (body::after). Nesting one
   inside another doesn't break that alignment as long as neither adds
   horizontal padding/margin along the way; insets come from where a child's
   own grid-column span starts, not from container padding. Below 991px
   (the same breakpoint --col itself switches at) every grid collapses to a
   single column with .wrap-equivalent side padding restored explicitly, since
   the desktop version had none to spare (see the media query at the bottom). */

/* A HARD height (not min-height) pinned to one viewport minus the sticky nav
   — deliberately, after min-height first caused the opposite problem it was
   meant to fix. With min-height, the portrait's real <img> carries width/
   height HTML attributes (1728x2304, written by imgAttrs for CLS
   prevention) which the UA stylesheet turns into an implicit intrinsic
   aspect-ratio; because .ethos-portrait's height:100% is circular (it
   needs the row's height, which with min-height is still content-derived
   until proven otherwise), the browser breaks that circularity by falling
   back to the img's OWN intrinsic ratio to size it — 533px column width x
   (2304/1728) = ~710px — which THEN became the row's own auto-height
   contribution, inflating the whole hero to ~800px and pushing the
   headline below the fold on short viewports, the exact bug this block
   exists to prevent. A definite `height` sidesteps the circularity
   entirely: the row's size is fixed before any child is measured, so
   height:100% on the portrait resolves cleanly against it instead of
   bouncing off the image's intrinsic ratio. Trade-off: on a genuinely
   extreme short+narrow combination the copy could in principle overrun
   this fixed box — the trimmed title ceiling below (7.5rem, was 8.5rem)
   and the lead's own modest size keep that from happening at any
   realistic viewport this site targets. --nav-h is the same live-measured
   var the homepage hero uses (app.js setNavH), 91px CSS fallback matching
   its own fallback. */
.ethos-hero { display: grid; grid-template-columns: repeat(48, 1fr); column-gap: 0;
  align-items: stretch; padding: var(--s-7) 0; height: calc(100svh - var(--nav-h, 91px)); min-height: 560px; }
.ethos-portrait { grid-column: 1 / 21; overflow: hidden; }
.ethos-portrait .media { height: 100%; }
.ethos-portrait .media img { width: 100%; height: 100%; object-fit: cover; display: block; }
.ethos-hero-copy { grid-column: 25 / 49; align-self: end; padding-right: var(--gutter); padding-bottom: var(--s-3); }
.ethos-hero-copy .eyebrow { margin-bottom: var(--s-4); }
/* Oversized cover line — clamped up near the homepage .hero-title's own
   register (screen.css:685) instead of the case-study --t-h1, which reads as
   a section header, not a cover line. Reuses .hero-line-mask/.hero-line
   verbatim (already defined globally) — app.js's new Ethos line-rise block
   targets those same classes, no new selectors needed on the JS side.
   Ceiling trimmed 8.5rem->7.5rem (2026-07-14): the taller min-height fix
   above means this now has to fit two lines + eyebrow + lead inside a
   SHORTER available column on short-but-wide viewports than the old
   loosely-sized hero did; the lower ceiling keeps that combination safe
   without needing JS-measured auto-fit (the homepage title's fitTitle()
   trick) for what's still an author-fixed two-word string. */
.ethos-title { font-family: var(--font-serif); font-weight: 500; color: var(--ink);
  font-size: clamp(2.75rem, 1rem + 7.5vw, 7.5rem); line-height: .92; letter-spacing: -.03em; margin: 0; }
.ethos-lead { font-family: var(--font-serif); font-weight: 400; font-size: var(--t-lead);
  line-height: 1.4; color: var(--ink-soft); max-width: 32ch; margin: var(--s-5) 0 0; }

.ethos-body { padding-bottom: var(--s-10); }
.ethos-sec { display: grid; grid-template-columns: repeat(48, 1fr); column-gap: 0;
  padding: var(--s-9) 0; align-items: start; }
.ethos-sec--left .ethos-sec-head, .ethos-sec--left .ethos-sec-text { grid-column: 4 / 28; }
.ethos-sec--right .ethos-sec-head, .ethos-sec--right .ethos-sec-text { grid-column: 26 / 46; }
.ethos-sec--right .ethos-sec-media { grid-column: 3 / 24; }
.ethos-sec--full > * { grid-column: 4 / 46; }
.ethos-sec--full .ethos-oc-grid { grid-column: 2 / 48; }

.ethos-idx { display: block; font-family: var(--font-mono); font-size: 12px; letter-spacing: .1em;
  color: var(--accent); margin-bottom: var(--s-3); }
.ethos-h2 { font-family: var(--font-serif); font-weight: 500; color: var(--ink);
  font-size: clamp(1.7rem, 1.1rem + 2.2vw, 2.75rem); line-height: 1.05; letter-spacing: -.02em;
  margin: 0 0 var(--s-5); }
/* Both forms: a wrapper div with <p> children (multi-paragraph sections) AND
   a lone <p class="ethos-sec-text"> (Proof/Now's single-line copy) need the
   same type treatment — the flat p.ethos-sec-text case doesn't match a
   descendant selector, so it's listed explicitly alongside it. */
.ethos-sec-text p, p.ethos-sec-text { font-size: 1.1rem; line-height: 1.6; color: var(--ink-soft);
  margin: 0 0 var(--s-4); max-width: 38ch; }
.ethos-sec-text p:last-child { margin-bottom: 0; }
.ethos-sec-text a { color: var(--ink); }
.ethos-sec-media .media { margin-top: var(--s-2); }

/* Pull-quote — kept centered (not column-precise like the structural grids
   above): a floating editorial break doesn't need hairline snapping the way
   the body spine does, and margin:auto centering already reads intentional. */
.ethos-quote { margin: var(--s-9) auto; padding: 0 var(--gutter); max-width: 26ch;
  font-family: var(--font-serif); font-style: italic; font-weight: 400; font-size: 1.6rem;
  line-height: 1.3; text-align: center; color: var(--ink); letter-spacing: -.01em; }
.ethos-quote .q-lead { display: block; font-style: normal; font-weight: 500; font-size: 1.7em;
  line-height: 1.12; letter-spacing: -.02em; color: var(--ink); margin-bottom: var(--s-4); }
.ethos-quote .q-sub { display: block; color: var(--ink-soft); }

/* Proof — oversized numeric bento instead of a sentence list. */
.ethos-proof { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--grid-gap);
  margin: var(--s-6) 0; }
.proof-tile { display: flex; flex-direction: column; gap: var(--s-2); padding: var(--s-5) 0;
  border-top: 1px solid var(--hairline); }
.proof-num { font-family: var(--font-serif); font-weight: 500; letter-spacing: -.02em;
  font-size: clamp(2rem, 1.2rem + 3vw, 3.5rem); line-height: 1; color: var(--ink); }
.proof-cap { font-family: var(--font-mono); font-size: 12px; line-height: 1.5; color: var(--ink-mute); }

/* Off the Clock — 4-up grid of live 3D tiles (ethos-3d.js). */
.ethos-oc-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--grid-gap); }

/* 3D tile: an always-dark #131313 field (the model reads monochrome on it,
   nothing darker than the field itself — matches the hero portrait's low-key
   vibe in BOTH themes, so it's a fixed color, not a token). The <canvas>
   fills it; ethos-3d.js fades it in via .has-webgl once a model is actually
   drawn. Until then (and on no-WebGL / no-JS / load failure) the .ph-label
   sits centered exactly like the old placeholder did. */
.media.oc3d { position: relative; overflow: hidden; border-radius: var(--r-lg);
  border: 1px solid var(--hairline); background: #131313; }
.oc3d-canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block;
  opacity: 0; transition: opacity .6s ease; }
.media.oc3d.has-webgl .oc3d-canvas { opacity: 1; }
.media.oc3d.has-webgl { cursor: pointer; }
/* Placeholder / fallback label — centered on the dark tile by default while
   the model loads (or on no-WebGL / no-JS / load failure); once the canvas is
   live the render speaks for itself, so the label is hidden entirely rather
   than demoted to a caption. Fixed light ink since the tile field is always dark. */
.oc3d .ph-label { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
  color: rgba(241,241,241,.55); transition: opacity .5s ease; pointer-events: none; }
.media.oc3d.has-webgl .ph-label { opacity: 0; }

/* Placeholders (still used elsewhere / as the pre-WebGL look) — tinted like
   every other .media, labeled. */
.media.ph { position: relative; display: flex; align-items: center; justify-content: center;
  border-radius: var(--r-lg); border: 1px dashed var(--hairline); }
.ph-label { font-family: var(--font-mono); font-size: 11px; letter-spacing: .08em;
  text-transform: uppercase; color: var(--ink-mute); }

@media (max-width: 991px) {
  /* height:auto (not just min-height:0) is load-bearing here: the desktop
     rule's fixed `height` otherwise still applies at this breakpoint too
     (only min-height was reset), forcing the grid to compress BOTH the
     portrait's and the copy's auto rows to fit that one fixed total — since
     neither row's content actually shrinks to match (align-items:start, not
     stretch), the portrait's real height overflowed past its undersized row
     straight into the copy row, visually overlapping the headline. Natural
     stacking needs each row sized to its own content, not a shared fixed
     budget. */
  .ethos-hero { grid-template-columns: 1fr; align-items: start; padding: var(--s-7) var(--gutter) var(--s-8);
    height: auto; min-height: 0; row-gap: var(--s-6); }
  .ethos-portrait, .ethos-hero-copy { grid-column: 1 / -1; padding-right: 0; padding-bottom: 0; }
  /* height:100% (desktop) has nothing to resolve against once the hero drops
     to natural single-column stacking here — give the portrait its own
     height via aspect-ratio instead (the placeholder already carries one
     inline; the real <img> below picks up this fallback too). */
  .ethos-portrait { aspect-ratio: 4 / 5; }
  .ethos-portrait .media { height: 100%; aspect-ratio: unset; }
  .ethos-title { font-size: clamp(2.5rem, 1.5rem + 10vw, 4.5rem); }

  .ethos-sec { grid-template-columns: 1fr; padding: var(--s-7) var(--gutter); row-gap: var(--s-4); }
  .ethos-sec--left .ethos-sec-head, .ethos-sec--left .ethos-sec-text,
  .ethos-sec--right .ethos-sec-head, .ethos-sec--right .ethos-sec-text,
  .ethos-sec--right .ethos-sec-media, .ethos-sec--full > * { grid-column: 1 / -1; }
  .ethos-sec--full .ethos-oc-grid { grid-column: 1 / -1; }
  .ethos-sec-text p, p.ethos-sec-text { max-width: none; }

  .ethos-quote { font-size: 1.3rem; padding: 0 var(--gutter); }
  .ethos-proof { grid-template-columns: repeat(2, 1fr); }
  .ethos-oc-grid { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 479px) {
  .ethos-proof { grid-template-columns: 1fr; }
}

/* Image pipeline spec, Phase 2c — a genuinely transparent raster whose visible
   content is a floating mark (not a full-bleed photo) gets letterboxed instead
   of the usual parallax/hover treatment: background-color comes from an inline
   style (TRANSPARENCY_BG in data.mjs, never composited into the file), content
   sits at ~64% of the frame (80% x 80%, per spec 2c), centered. Mirrors
   .gallery.fit's existing letterbox pattern one level up in the case body. */
.case-body .media.tp-frame { aspect-ratio: 4 / 3; display: grid; place-items: center;
  border-radius: var(--r-lg); border: 1px solid var(--hairline); }
.case-body .media.tp-frame img { max-width: 64%; max-height: 64%; width: auto; height: auto; }

figure { margin: var(--s-9) 0; }
/* Lead image — the large cinematic opener under the title block. */
.case-lead { margin: var(--s-6) 0 var(--s-10); }
.case-lead .media { border-radius: var(--r-lg); border: 1px solid var(--hairline); }
.case-lead .media img { width: 100%; display: block; }
/* The art-gallery air above is desktop-calibrated — on a phone the same 8rem
   voids read as hollow gaps, not luxury. Step the biggest breaks down one
   token; the paragraph/subhead rhythm (s-5/s-9) is fine at both widths. */
@media (max-width: 991px) {
  .case-head { padding-top: var(--s-8); }
  .case-lead { margin-bottom: var(--s-8); }
  .case-body h2 { margin-top: var(--s-8); }
  .case-body blockquote { margin-top: var(--s-8); margin-bottom: var(--s-8); }
}
.media { display: block; overflow: hidden; background: var(--tag-bg); } /* clips parallax overscan; tint shows behind transparent art */
/* Parallax-vertical mode (.pv — case-study page images, since 2026-07-18):
   render.mjs bakes an inline aspect-ratio 0.86× the image's true height, and
   app.js drifts yPercent 0→−14 with NO scale — full image width always visible,
   the ~14% vertical excess is the drift allowance. The .px child MUST be
   absolutely positioned here: aspect-ratio is only a PREFERRED size, and a
   taller in-flow child would stretch the box right past it, silently restoring
   the full image height and killing both the crop and the drift headroom.
   Scoped to .pv so legacy media (homepage cards, ethos, videos) keeps its
   flow-derived height. GSAP's translateY on .px is transform-only — position
   context doesn't affect it. */
.media.pv { position: relative; }
/* The .px is sized to 1/0.86 of the box so the overflow IS the drift allowance,
   and the image COVERS that taller box. Previously the img was width:100%/
   height:auto and only *happened* to overflow by the right amount — if the
   inline aspect-ratio ever failed to apply (unreadable dims, a stretched grid
   track) the image silently restored full height and both the crop and the
   drift headroom vanished. Sizing it explicitly makes the two numbers agree by
   construction: --pv-allow here and yPercent -14 in app.js are the same 14%. */
/* +2px is rounding slack, not styling: at exactly 100%/0.86 the drift travel
   equals the overflow to the pixel, so sub-pixel rounding left a ~0.7px hairline
   of background tint at one end of the sweep (measured on envelope.jpg). The
   extra 2px makes the overflow strictly greater than the travel. */
.media.pv .px { position: absolute; top: 0; left: 0; width: 100%;
  height: calc(100% / var(--pv-allow) + 2px); }
.media.pv .cs { height: 100%; }
.media.pv img { width: 100%; height: 100%; object-fit: cover; display: block; }

/* Vector art is a logo/mark: never crop it, never drift it. Its box carries the
   art's exact ratio, so `contain` fills the frame with no letterbox either. */
.media.is-vector .px, .media.is-vector .cs { height: 100%; }
.media.is-vector img { width: 100%; height: 100%; object-fit: contain; display: block; }
figure .media { border-radius: var(--r-lg); border: 1px solid var(--hairline); }
figure .media img, figure .media video { width: 100%; display: block; }
.px { display: block; }
.parallax .px { will-change: transform; }
figcaption { font-family: var(--font-mono); font-size: 12px; color: var(--ink-mute);
  margin-top: var(--s-3); text-align: center; letter-spacing: .02em; }

/* ---- Contact page: split card (2026-07 redesign) --------------------------
   Left panel = raised --surface block carrying the headline over a REAL
   WebGL-rendered red knight chess piece — a GLB with a chrome/clearcoat
   material and studio lighting rig (see contact-3d.js), replacing an earlier
   2.5D CSS-transform fake. Right panel = boxed form on --bg, matching the
   bordered/radius language of case-study card tiles elsewhere on the site.
   Progressive enhancement: BOTH a <canvas> and the original knight.webp
   <img> always render (see render.mjs); contact-3d.js flips `.has-webgl` on
   `.contact-graphic` once the model has actually loaded, crossfading canvas
   in / image out. No-WebGL, reduced-motion, or a failed GLB fetch just leave
   the static image at full opacity — nothing here depends on script success
   to become visible.
   The whole article fills ~one viewport (min-height:100svh) so the footer
   starts just below the fold, and the card is vertically CENTRED in that
   space with generous margin — not stretched edge to edge. (A too-thin gap
   here once exposed the site-wide vertical grid as a dense band of very
   short ticks that read as a false horizontal line at a glance; --s-9 is
   tall enough that the same ticks read as ordinary grid, matching the tall
   gutters either side of the card — the fix is proportion, not zero-gap.) */
.contact-page { min-height: 100svh; display: flex; align-items: center; }

.contact-split { display: grid; grid-template-columns: 1fr 1fr; border-radius: var(--r-lg);
  overflow: hidden; border: 1px solid var(--hairline); margin: var(--s-9) 0; width: 100%; }
.contact-panel-left { background: var(--surface); position: relative; padding: var(--s-8);
  display: flex; flex-direction: column; justify-content: flex-end; min-height: 600px;
  overflow: hidden; perspective: 900px; }
.contact-panel-right { background: var(--bg); padding: var(--s-8); display: flex;
  flex-direction: column; justify-content: center; }

/* Knight layer — canvas + fallback image share this positioning box, offset
   toward the right edge so the headline sits over the emptier/darker left
   side. contact-3d.js's rendered scene handles its own on-cursor tilt
   (perspective on .contact-panel-left above is a legacy no-op now the tilt
   is real 3D, harmless to leave). */
.contact-graphic { position: absolute; inset: 0; z-index: 0; pointer-events: none;
  display: flex; align-items: center; justify-content: flex-end; }
.contact-knight-canvas { position: absolute; inset: 0; width: 100%; height: 100%;
  opacity: 0; transition: opacity .6s ease; pointer-events: auto; }
.contact-graphic.has-webgl .contact-knight-canvas { opacity: 1; }
.contact-graphic.has-webgl .contact-knight-fallback { opacity: 0; }
.contact-knight-fallback { height: 92%; width: auto; margin-right: -8%; object-fit: contain;
  transform-origin: center; opacity: .55; transition: opacity .6s ease; }
/* Soft scrim so the headline stays legible over the knight's brighter reds. */
.contact-copy { position: relative; z-index: 1; }
.contact-copy::before { content: ""; position: absolute; inset: -40% -60% -40% -40%;
  background: radial-gradient(120% 90% at 0% 100%, var(--surface) 30%, transparent 72%);
  z-index: -1; pointer-events: none; }
.contact-copy .eyebrow { margin-bottom: var(--s-3); }
/* Page-head role → page-head size (2026-07-15: was --t-h2, leaving Contact the
   one page whose h1 rendered a full step smaller than its siblings). */
.contact-copy h1 { font-family: var(--font-serif); font-weight: 500; font-size: var(--t-h1);
  line-height: var(--leading-tight); max-width: 11ch; margin: 0 0 var(--s-4); }
.contact-copy .lead { font-family: var(--font-serif); font-weight: 400; font-size: var(--t-body);
  color: var(--ink-soft); max-width: 32ch; line-height: var(--leading-body); margin: 0; }

.contact-form { display: flex; flex-direction: column; gap: var(--s-5); width: 100%; }
.contact-form .field { display: flex; flex-direction: column; gap: var(--s-2); }
.contact-form label { font-family: var(--font-mono); font-size: 10px; letter-spacing: .08em;
  text-transform: uppercase; color: var(--ink-mute); }
.contact-form input, .contact-form select, .contact-form textarea {
  font-family: var(--font-sans); font-size: .95rem; color: var(--ink); background: var(--surface);
  border: 1px solid var(--hairline); border-radius: var(--r-md); padding: 11px 13px;
  transition: border-color .25s ease; }
.contact-form select { appearance: none; -webkit-appearance: none;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%2385807c' stroke-width='1.5' fill='none' fill-rule='evenodd'/%3E%3C/svg%3E");
  background-repeat: no-repeat; background-position: right 14px center; }
.contact-form select:invalid { color: var(--ink-mute); }
.contact-form textarea { resize: vertical; min-height: 90px; font-family: inherit; line-height: 1.5; }
.contact-form input:focus, .contact-form select:focus, .contact-form textarea:focus {
  outline: none; border-color: var(--accent); }
.contact-submit { align-self: flex-start; padding: 12px 26px; background: var(--accent);
  color: #f1f1f1; cursor: pointer; border: none; border-radius: var(--r-md); font-size: 13px;
  transition: opacity .2s ease; }
.contact-submit:hover { opacity: .85; }
.contact-submit:disabled { opacity: .55; cursor: default; }
.contact-status { font-family: var(--font-mono); font-size: var(--t-meta); color: var(--ink-mute);
  min-height: 1.2em; margin: 0; }
.contact-status.is-ok { color: var(--accent); }
@media (max-width: 900px) {
  /* Stack: knight on TOP (still a real 3D render — no width gate, see
     contact-3d.js), copy + full-width form below. min-height relaxes so
     short phones don't strand the card; the article no longer forces a
     full viewport. `position:relative` (not `static`) on .contact-graphic
     is deliberate: it stays a normal flex-flow item (order:-1 still works)
     but ALSO becomes the containing block the canvas's `inset:0` sizes
     against — `static` would let the canvas escape to size against
     .contact-panel-left's full height instead of this 240px slot. */
  .contact-page { min-height: 0; display: block; }
  .contact-split { grid-template-columns: 1fr; margin: var(--s-5) 0 var(--s-9); }
  .contact-panel-left { min-height: 0; perspective: none; padding-bottom: var(--s-6); }
  .contact-graphic { position: relative; order: -1; height: 240px; margin-bottom: var(--s-5); }
  .contact-knight-fallback { height: 100%; width: auto; margin-right: 0; margin: 0 auto;
    display: block; opacity: 1; }
  .contact-copy::before { display: none; }
  .contact-form { margin: 0; }
}

/* ---- Galleries — aligned, premium tiles. Modifiers set columns / crop behaviour. */
.gallery { display: grid; gap: var(--grid-gap); margin: var(--s-9) 0; }
.gallery.cols-2 { grid-template-columns: repeat(2, 1fr); }
.gallery.cols-3 { grid-template-columns: repeat(3, 1fr); }
.gallery.cols-4 { grid-template-columns: repeat(4, 1fr); }
.gallery .media { border-radius: var(--r-lg); border: 1px solid var(--hairline); }
.gallery .media img { width: 100%; display: block; }
/* cover: natural-ratio masonry — every image keeps its true shape, uncropped, and
   the tiles pack into columns (photo / mockup walls). Column count from cols-N. */
.gallery.cover { display: block; column-gap: var(--grid-gap); }
.gallery.cover.cols-2 { column-count: 2; }
.gallery.cover.cols-3 { column-count: 3; }
.gallery.cover.cols-4 { column-count: 4; }
.gallery.cover .media { break-inside: avoid; margin: 0 0 var(--grid-gap); width: 100%; }
/* Height comes from the tile's baked aspect-ratio, not from the image — the
   .pv/.is-vector rules above own how the image fills it. */
.gallery.cover .media img { width: 100%; display: block; }
/* fit: a mark inset on a soft surface, never cropped. The box takes the art's
   EXACT ratio (baked inline by render.mjs) rather than a hardcoded 4/3 — that
   4/3 is what letterboxed every non-4:3 slide with dead bands. The inset is
   sized in % rather than `width:auto` so a not-yet-loaded lazy image still
   reserves its slot; with `auto` an unloaded tile measured 0x0. */
.gallery.fit .media { background: var(--surface); display: grid; place-items: center; }
.gallery.fit .media img { width: 82%; height: 82%; object-fit: contain; }
/* feature: first tile leads at 2x2 — a hero shot anchoring supporting detail crops */
.gallery.feature { grid-auto-rows: 1fr; }
.gallery.feature .media:first-child { grid-column: span 2; grid-row: span 2; }
.gallery.feature .media:first-child .px,
.gallery.feature .media:first-child .cs { height: 100%; }
.gallery.feature .media:first-child img { width: 100%; height: 100%; object-fit: cover; }

/* Square / portrait video (social formats) sits at post scale, centered — at
   full column width a 1:1 film renders as a ~1300px tower that overpowers every
   landscape figure on the page. Landscape video is untouched. */
.is-portrait { max-width: 620px; margin-left: auto; margin-right: auto; }

/* Tall standalone raster figures (h/w > 1.0, tagged .is-tall by render.mjs's
   standalone-image branch — the image cousin of the video rule above): a
   portrait photo at full wrap width is a 1.5-viewport tower. Desktop-up cap
   to a centered column; the mobile base state stays untouched full width
   (a portrait image on a portrait viewport is fine — mobile-first, and media
   queries add no specificity so the cap must be the ADDED state). Vertical
   rhythm keeps the base figure margin; only the horizontal part is overridden. */
@media (min-width: 992px) {
  .case-body figure.is-tall { max-width: min(65%, 700px); margin-left: auto; margin-right: auto; }
}

/* ---- Swap: hard-cut frame cycle (::: swap). Frame 1 stays in normal flow so it
   defines the box height and survives JS-off / print; later frames stack on top
   absolutely. Visibility is toggled with NO transition — the cut is the point
   (a cross-fade would read as a dissolve, not as two printed variants). */
.swap { position: relative; display: block; }
.swap .swap-f { width: 100%; display: block; }
.swap .swap-f:not(:first-child) { position: absolute; inset: 0; height: 100%; object-fit: cover; }
/* Frame 1 ships with .on from the server, so JS-off holds a valid still. */
.swap .swap-f { visibility: hidden; }
.swap .swap-f.on { visibility: visible; }
/* Inside a gallery tile the BOX is already sized (baked aspect-ratio), so the
   cycle fills it and every frame covers identically — otherwise frame 1 would
   size the tile from its own ratio and the cut would jump between frames. */
.media.pv .swap, .media.is-vector .swap { height: 100%; }
@media (prefers-reduced-motion: reduce) {
  /* Hold frame 1: the cycle is decorative, and a 2s hard cut is exactly the kind
     of repeating motion this query exists to stop. */
  .swap .swap-f:not(:first-child) { visibility: hidden !important; }
  .swap .swap-f:first-child { visibility: visible !important; }
}

/* ---- Glyph grid (::: glyphs) — brand-elements marks with name + rationale.
   Marks are inline SVG inheriting currentColor, so they invert with the theme. */
.glyph-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--grid-gap); margin: var(--s-9) 0; }
.glyph { border: 1px solid var(--hairline); border-radius: var(--r-lg); padding: var(--s-6) var(--s-5) var(--s-5); background: var(--surface); }
.glyph-mark { height: 96px; display: grid; place-items: center; margin-bottom: var(--s-5); color: var(--ink); }
/* Size by HEIGHT, not a width/height box: these four marks have very different
   aspect ratios (Phi is wide, Sigma/Delta are tall), and capping both axes made
   the wide ones render visibly smaller than the tall ones. A common cap height
   is what makes a set of letterforms look like one set. */
.glyph-mark svg { height: 64px; width: auto; max-width: 100%; display: block; }
.glyph-mark svg path, .glyph-mark svg polygon, .glyph-mark svg rect { fill: currentColor; }
.glyph-name { font-family: var(--font-mono); font-size: 12px; letter-spacing: .08em; text-transform: uppercase;
  color: var(--ink); margin: 0 0 var(--s-2); font-weight: 500; }
.glyph-note { font-size: 14px; line-height: 1.5; color: var(--ink-mute); margin: 0; }
@media (max-width: 991px) {
  .glyph-grid { grid-template-columns: repeat(2, 1fr); }
  .glyph-mark { height: 72px; }
}
@media (max-width: 560px) { .glyph-grid { grid-template-columns: 1fr; } }

.spread { margin: var(--s-9) calc(50% - 50vw); }
.spread .media { overflow: hidden; background: none; }
.spread .media img, .spread .media video { width: 100vw; display: block; }
.spread figcaption { margin-top: var(--s-3); }

/* ---- Zoomable images: glowing mouse-following border stroke + squarish expand
   icon; hover affordances only, signalling the click-to-lightbox. The container
   -grow/counter-scale hover enlargement this block used to carry was REMOVED on
   request (2026-07-18) — case-study images no longer scale on hover; homepage
   .card and .frag-cell keep their own separate grow. Scroll parallax (GSAP on
   .px, inline) is a different mechanism and is untouched. The .cs transition
   stays: it exists so ANY future transform handoff on .cs eases rather than
   snaps, and removing it is not required for the no-hover-scale behavior.
   EXCEPTION (2026-07-22): the scrubbed mask-wipe reveal (app.js 2a-ii) inline-
   kills this transition on case-BODY tiles only — it drives .cs scale every
   scroll frame and the .5s ease would smear the scrub into mush. ---- */
.zoomable { position: relative; cursor: pointer; }
.cs { display: block; transition: transform .5s cubic-bezier(.33,1,.68,1); }
/* the glow lives ON the border stroke — a gradient ring that follows the mouse and blooms */
.zoomable::before {
  content: ""; position: absolute; inset: 0; border-radius: inherit; padding: 1.6px; z-index: 3;
  background: radial-gradient(160px circle at var(--mx, 50%) var(--my, 50%),
              color-mix(in srgb, var(--accent) 92%, #fff 20%), transparent 60%);
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor; mask-composite: exclude;
  filter: drop-shadow(0 0 6px color-mix(in srgb, var(--accent) 60%, transparent));  /* the glow */
  opacity: 0; transition: opacity .35s ease; pointer-events: none; }
/* squarish expand icon */
.zoom { position: absolute; top: 12px; right: 12px; width: 38px; height: 38px; padding: 0; z-index: 4;
  display: grid; place-items: center; border: 0; border-radius: var(--r-md); color: #fff; cursor: pointer;
  background: rgba(18,18,20,.45); -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px);
  opacity: 0; transform: scale(.8); transition: opacity .3s ease, transform .3s cubic-bezier(.2,.7,.2,1); }
.zoom svg { width: 17px; height: 17px; overflow: visible; }
.zoom .a1, .zoom .a2 { transition: transform .3s cubic-bezier(.2,.7,.2,1); }

/* All of the above's hover-triggered glow/icon-reveal is DESKTOP/MOUSE ONLY —
   on touch, tapping an image would otherwise trigger (and often "stick" via sticky
   :hover) the glow, which reads as a stuck highlight. Gated to
   (hover:hover)+(pointer:fine), the same test already used at app.js for
   Lenis/nav-swap. Tap-to-lightbox (app.js .zoomable click handler) is unconditional
   and unaffected — it keeps working on touch regardless of this gate. */
@media (hover: hover) and (pointer: fine) {
  .zoomable:hover::before { opacity: 1; }
  .zoomable:hover .zoom { opacity: 1; transform: scale(1); }
  .zoomable:hover .zoom .a1 { transform: translate(1.5px, -1.5px); }   /* arrows expand outward */
  .zoomable:hover .zoom .a2 { transform: translate(-1.5px, 1.5px); }
}
/* No real hover (touch): the expand icon has no other way to become visible, so
   force-show it as a permanent affordance — matches the existing precedent for
   .frag-note/.card-cue/.card-view below. */
@media (hover: none) {
  .zoom { opacity: 1; transform: scale(1); }
}

/* ---- Stream (::: stream ::: — scroll-offset column showcase) --------------
   Columns are CSS-staggered at rest (nth-child margins) and app.js scrubs
   each at a different yPercent so they drift past each other on scroll.
   Tiles are PLAIN media: full image, no parallax class, no crop — the motion
   is the columns themselves. Stays 3-up on mobile on purpose (it's a texture
   section; small tiles read fine, and 15 stacked full-width slides wouldn't). */
.stream { display: grid; gap: var(--grid-gap); margin: var(--s-9) 0; align-items: start; }
.stream.cols-2 { grid-template-columns: repeat(2, 1fr); }
.stream.cols-3 { grid-template-columns: repeat(3, 1fr); }
.stream.cols-4 { grid-template-columns: repeat(4, 1fr); }
.stream-col { display: flex; flex-direction: column; gap: var(--grid-gap); }
.stream-col:nth-child(2) { margin-top: var(--s-9); }
.stream-col:nth-child(3) { margin-top: var(--s-6); }
.stream-col:nth-child(4) { margin-top: var(--s-8); }
.stream .media { border-radius: var(--r-md); border: 1px solid var(--hairline); }
.stream .media img { width: 100%; display: block; }

/* ---- Cascade (::: cascade ::: — angled "scroll to surf" card fan) ----------
   Written MOBILE-FIRST on purpose. The obvious shape — fan by default, unwind
   it in a max-width block — does not work: media queries add no specificity, so
   a `.cascade-card { rotate: none }` reset loses to `.cascade-card:nth-child(1)`
   and the cards stay rotated and negatively-margined on phones (measured: they
   did). Building the fan additively inside min-width instead means the mobile
   layout is just the plain default, with nothing to override.
   The 992px line matches app.js's own gate exactly, so below it there is no
   module running and nothing can be left half-transformed. */
.cascade { margin: var(--s-8) 0; }
.cascade-card { position: relative; }
.cascade-card + .cascade-card { margin-top: var(--grid-gap); }
.cascade .media { border-radius: var(--r-md); border: 1px solid var(--hairline);
  overflow: hidden; }
.cascade .media img { width: 100%; display: block; }

@media (min-width: 992px) {
  /* Full-bleed by the same calc(50% - 50vw) idiom as .spread. Vertical padding
     is deliberately generous: a rotated box's bounding box is taller than its
     layout box, and without the slack the outer cards' corners reach the
     paragraph above. */
  .cascade { position: relative; margin: var(--s-9) calc(50% - 50vw);
    padding: var(--s-9) 0; display: flex; justify-content: center; align-items: center;
    /* The fan is deliberately wider than the viewport and the outer cards travel
       further out still as they scrub. body's `overflow-x: clip` does NOT contain
       that: body's overflow propagates to the viewport, so body itself computes
       to visible and clips nothing — verified, the page gained 116px of real
       horizontal panning before this line existed. Clipping on the section is
       the fix, and `clip` specifically (not `hidden`): it creates no scroll
       container and, unlike `hidden`, does not coerce the other axis to `auto`,
       so the cards' vertical travel still overflows freely into the padding. */
    overflow-x: clip; }
  /* The overlap is a NEGATIVE MARGIN, not a gap — flexbox `gap` rejects negative
     values outright (silently dropped, which reads as "the cards just overflow").
     Sized so the fan spans a little wider than the viewport: enough bleed to feel
     like it continues past the edges, not so much that the outer cards lose a
     third of their content. */
  .cascade-card { flex: 0 0 auto; width: min(40vw, 620px); will-change: transform; }
  .cascade-card + .cascade-card { margin-top: 0; margin-left: clamp(-140px, -8vw, -70px); }
  /* Base fan angles + resting stagger — the NO-JS / reduced-motion composition.
     Once app.js's cascade module runs it takes these elements over completely:
     GSAP writes `rotate: none; translate: none` and drives everything through
     `transform` instead, so these values are replaced, never composed with. They
     are duplicated in app.js's LANES table by necessity — keep the two in sync,
     or the fan visibly jumps the moment the scrub attaches. Depth order puts the
     middle card on top so the fan reads as a held hand of cards, not a flat row. */
  .cascade-card:nth-child(1) { translate: 0 -6%; rotate: -10deg; z-index: 1; }
  .cascade-card:nth-child(2) { translate: 0 4%;  rotate: -6.5deg; z-index: 3; }
  .cascade-card:nth-child(3) { translate: 0 -3%; rotate: -12deg; z-index: 2; }
  .cascade .media { box-shadow: 0 40px 80px rgba(0,0,0,.45); }
}

/* ---- Card lock chip (locked studies — homepage/index/next-case cards) ----
   Deliberately quiet: a small mono padlock in a hairline chip, top-right of
   the card media. Sits above the image (z over .card-media's img/px) but
   below the lightbox/overlay layers. */
.card-lock { position: absolute; top: 12px; right: 12px; z-index: 4;
  display: grid; place-items: center; width: 34px; height: 34px;
  border-radius: var(--r-md); color: #fff;
  background: rgba(18,18,20,.45); border: 1px solid rgba(255,255,255,.22);
  -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); }
.card-lock svg { width: 15px; height: 15px; }

/* ---- Gate page (locked/index.html — served by worker.js at locked URLs) --- */
.gate-wrap { min-height: 72svh; display: grid; place-items: center; }
.gate-card { width: min(560px, 100%); border: 1px solid var(--hairline);
  border-radius: var(--r-lg); padding: var(--s-8) var(--s-7); }
.gate-card h1 { margin: 0 0 var(--s-4); }
.gate-card .lead { margin-bottom: var(--s-6); }
.gate-card .contact-status { display: block; margin-top: var(--s-3); color: var(--accent); }

/* ---- Lightbox (fullscreen enlarge) ---- */
.lightbox { position: fixed; inset: 0; z-index: 100; display: grid; place-items: center; padding: 4vmin;
  background: rgba(10,10,12,.86); -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px);
  opacity: 0; pointer-events: none; transition: opacity .35s ease; }
.lightbox.open { opacity: 1; pointer-events: auto; }
.lightbox img { max-width: 94vw; max-height: 92vh; border-radius: var(--r-md); cursor: default;
  box-shadow: 0 30px 90px rgba(0,0,0,.55); transform: scale(.95);
  transition: transform .4s cubic-bezier(.2,.7,.2,1); }
.lightbox.open img { transform: scale(1); }
/* z-index:1 on every control button — the image sits later in DOM order (see
   the lb.innerHTML template in SITE_JS) and would otherwise paint over any
   button it happens to overlap (buttons overlap the image edge by design on
   narrow viewports, see .lb-prev/.lb-next mobile rule below). */
.lb-close { position: fixed; top: calc(20px + env(safe-area-inset-top)); right: calc(24px + env(safe-area-inset-right)); width: 44px; height: 44px; z-index: 1; display: grid; place-items: center;
  border: 0; border-radius: 50%; color: #fff; cursor: pointer; background: rgba(255,255,255,.1);
  -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); transition: background .2s ease; }
.lb-close:hover { background: rgba(255,255,255,.2); }
.lb-close svg { width: 20px; height: 20px; }
/* Prev/next — same blurred-glass circular button as .lb-close, just repositioned
   to the vertical middle of each edge. One shared arrow SVG (pointing left);
   .lb-next mirrors it with a horizontal flip instead of a second markup string. */
.lb-prev, .lb-next { position: fixed; top: 50%; transform: translateY(-50%); width: 48px; height: 48px; z-index: 1;
  display: grid; place-items: center; border: 0; border-radius: 50%; color: #fff; cursor: pointer;
  background: rgba(255,255,255,.1); -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px);
  transition: background .2s ease; }
.lb-prev:hover, .lb-next:hover { background: rgba(255,255,255,.2); }
.lb-prev svg, .lb-next svg { width: 22px; height: 22px; }
.lb-prev { left: calc(20px + env(safe-area-inset-left)); }
.lb-next { right: calc(20px + env(safe-area-inset-right)); }
.lb-next svg { transform: scaleX(-1); }
/* Single-image pages (edge case — every case study has many, but keep it graceful):
   hide nav entirely so the lightbox looks identical to before this feature. */
.lightbox.lb-single .lb-prev, .lightbox.lb-single .lb-next { display: none; }
@media (max-width: 991px) {
  /* Tighter inset on narrow viewports (image claims up to 94vw), but the hit
     target stays at the 44px WCAG minimum — these arrows are used almost
     exclusively BY touch here, so this is the last place to shrink below it.
     The z-index:1 rule above already guarantees they win any image overlap. */
  .lb-prev, .lb-next { width: 44px; height: 44px; }
  .lb-prev { left: calc(6px + env(safe-area-inset-left)); }
  .lb-next { right: calc(6px + env(safe-area-inset-right)); }
  .lb-prev svg, .lb-next svg { width: 18px; height: 18px; }
}
.lb-count { position: fixed; bottom: calc(26px + env(safe-area-inset-bottom)); left: 50%; transform: translateX(-50%);
  font-family: var(--font-mono); font-size: 12px; letter-spacing: .08em; color: rgba(255,255,255,.65);
  opacity: 0; transition: opacity .3s ease .15s; pointer-events: none; }
.lightbox.open .lb-count { opacity: 1; }

.back { display: inline-block; margin: var(--s-7) auto 0; max-width: var(--maxw-read);
  color: var(--ink-mute); font-family: var(--font-mono); font-size: var(--t-meta); }

/* ---- Next case study (end of every project page) — reuses cardTile/.card
   verbatim (same markup as the homepage grid + case-studies listing), so the
   hover glow, parallax and click-to-expand page transition are the exact same
   code path — nothing new to wire up. Only the sizing differs here: a wide
   cinematic frame instead of the grid's 4:3, and a bigger title, since this is
   presented as one large closing moment rather than a tile in a grid. Sits in
   its own .wrap (not the prose-width .case-body) so it can run the full
   container width, same scale as .case-lead above. */
.case-next { margin: var(--s-10) 0 var(--s-9); }
.case-next-label { margin-bottom: var(--s-4); }
.case-next-grid { display: grid; grid-template-columns: repeat(2, 1fr);
  gap: calc(var(--grid-gap) * 1.3) var(--grid-gap); }
.case-next .card-media { aspect-ratio: 4 / 3; }
.case-next .card-title { font-size: var(--t-h2); }
@media (max-width: 767px) {
  .case-next-grid { grid-template-columns: 1fr; }
}

/* Quiet floating nav for long case pages — fades in past a scroll threshold. */
.case-minibar { position: fixed; left: 50%; bottom: 28px; z-index: 25;
  display: flex; align-items: center; gap: var(--s-5); padding: 10px 20px; border-radius: var(--r-pill);
  background: color-mix(in srgb, var(--bg) 88%, transparent); border: 1px solid var(--hairline);
  -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
  font-family: var(--font-mono); font-size: var(--t-meta); color: var(--ink-mute);
  opacity: 0; transform: translate(-50%, 10px); pointer-events: none;
  transition: opacity .4s cubic-bezier(.16,.8,.24,1), transform .4s cubic-bezier(.16,.8,.24,1); }
.case-minibar.is-visible { opacity: 1; transform: translate(-50%, 0); pointer-events: auto; }
.case-minibar a { color: inherit; transition: color .2s ease; }
.case-minibar a:hover { color: var(--ink); }

/* ---- Footer --------------------------------------------------------------- */
.site-foot { border-top: 1px solid var(--hairline); }
/* padding-top/-bottom only (not the padding shorthand) — these divs carry
   class="wrap ..." for the grid-locked side gutter (--gutter); a shorthand
   padding:X 0 Y here would zero that horizontal component right back out,
   same bug already fixed once for .case-head/.case-body (see the comment
   there). */
.footer-grid { display: grid; grid-template-columns: 2fr 1fr 1fr; gap: var(--s-6);
  padding-top: var(--s-9); padding-bottom: var(--s-7); }
.footer-brand { display: flex; flex-direction: column; align-items: flex-start; }
/* Left-aligned, not centered — matches the site's established convention (hero
   tagline, case-body h2) for a standalone statement line, rather than adding
   another centered "moment" alongside the hero name / blockquotes. */
/* Display role → display treatment (2026-07-15: was --font-sans weight 300, the
   one big statement line on the site not set in the serif-500 display voice). */
.footer-tagline { font-family: var(--font-serif); font-weight: 500; font-size: var(--t-h2);
  letter-spacing: -.02em; color: var(--ink); margin: 0 0 var(--s-5); }
/* .hero-talk-cta already sets color:var(--accent-ink) (fixed white, both themes)
   since it sits on --accent, not the page's --ink — this rule only needs to
   reset the hero's own margin for its footer reuse, not re-pin color. */
.footer-brand .footer-cta { margin: 0; }
.footer-label { font-family: var(--font-mono); font-size: 11px; letter-spacing: .08em;
  text-transform: uppercase; color: var(--ink-mute); margin: 0 0 var(--s-3); }
.footer-col { display: flex; flex-direction: column; align-items: flex-start; }
.footer-col a { display: block; color: var(--ink-soft); font-size: var(--t-meta);
  margin-bottom: var(--s-2); transition: color .2s ease; }
.footer-col a:hover { color: var(--accent); }
.footer-bottom { display: flex; flex-wrap: wrap; justify-content: space-between; gap: var(--s-3) var(--s-6);
  padding-top: var(--s-5); padding-bottom: var(--s-7); border-top: 1px solid var(--hairline);
  color: var(--ink-mute); font-size: var(--t-meta); }
@media (max-width: 740px) {
  .footer-grid { grid-template-columns: 1fr; gap: var(--s-6); padding-top: var(--s-8); }
  .footer-bottom { flex-direction: column; gap: var(--s-2); }
}

/* reveal-on-scroll. JS-capable browsers pre-hide; app.js reveals on scroll
   (IntersectionObserver, class-toggled) and a safety-net timer guarantees nothing
   stays hidden. A soft rise + focus-pull (blur→sharp) — elegant, not a "pop".
   IMPORTANT — images are never pre-hidden. Gating actual photo/case-study content
   behind "opacity:0 until JS says otherwise" is exactly what caused case-study and
   fragments images to go missing: if the reveal script is ever slow, blocked, or its
   IntersectionObserver/timeout doesn't fire for any reason, the content it was
   supposed to reveal stays invisible forever — worst possible failure mode for
   content that matters. So figures/galleries/fragment cards render at full opacity
   unconditionally; only lightweight TEXT (.reveal, blockquotes) gets the fade-in,
   where getting stuck slightly late is a minor cosmetic issue, not missing content. */
/* Reveal model — SINGLE SOURCE OF TRUTH for the whole system. app.js reads
   --rv-stagger / --rv-gate-offset (the two numbers it schedules on) straight off
   :root via getComputedStyle; duration + easing below are pure CSS. Replaces the
   three old disagreeing systems (hero JS 140ms, cap-row nth-child 60ms, general
   = none). The reveal-tune.js dev overlay writes these same names, so its Copy
   output pastes back here verbatim.
   NOTE: .cap-row (capabilities) is a scroll-SCRUBBED sequence with its own
   per-frame JS ownership + nth-child micro-stagger below — deliberately left on
   its own timing this pass; folding it in safely needs the scrub reworked. */
:root {
  --rv-stagger: 120ms;   /* gap between consecutive in-view reveals at the gate */
  --rv-duration: 900ms;  /* per-element opacity/transform duration              */
  --rv-ease: cubic-bezier(.16,.8,.24,1);
  --rv-gate-offset: 200ms; /* delay after the entrance gate before reveal #1    */
}
.reveal { opacity: 1; }
.js .reveal,
.js .case-body blockquote {
  opacity: 0; transform: translateY(22px); filter: blur(6px);
  transition: opacity var(--rv-duration) var(--rv-ease),
              transform calc(var(--rv-duration) + 50ms) var(--rv-ease),
              filter calc(var(--rv-duration) * .9) ease;
}
.js .reveal.revealed,
.js .case-body blockquote.revealed { opacity: 1; transform: none; filter: blur(0); }

/* Same "never scroll-gate real content" principle as .reveal above, for the
   testimonial scroll-SCRUBBED "grow into focus" effect: resting state is the
   FINISHED state. JS only sets the animated start state and scrubs back to this
   when GSAP + ScrollTrigger are actually available — reduced-motion and no-GSAP
   visitors see full, legible quotes with zero scroll dependency. (Capabilities'
   own resting/dim state lives directly on .cap-row above — no separate fallback
   needed since it was never a scrub effect to begin with.) */
.testi-quote { opacity: 1; transform: scale(1); }

/* ---- Fragments — brand/logo showcase ------------------------------------- */
/* Home teaser: edge-faded seamless marquee (GSAP drives the track). Not itself
   .wrap-combined (only .frag-home-head is), but kept to padding-top/-bottom for
   consistency with every other homepage section rather than the shorthand it
   used to carry. min-height keeps it at least one viewport tall. */
.frag-home { min-height: 100svh; display: flex; flex-direction: column; justify-content: center;
  padding-top: var(--s-10); padding-bottom: var(--s-10); border-top: 1px solid var(--hairline); }
.frag-home-head { display: flex; align-items: baseline; gap: var(--s-5); flex-wrap: wrap; margin-bottom: var(--s-6); }
.frag-home-head .eyebrow { width: 100%; margin-bottom: var(--s-2); }
.frag-home-head h2 { font-family: var(--font-serif); font-weight: 500; font-size: var(--t-h2); letter-spacing: -.02em; margin: 0; }
.frag-more { font-family: var(--font-mono); font-size: var(--t-meta); color: var(--accent); margin-left: auto; }
/* padding gives the expanding cell (and its glow) vertical room — the marquee still clips the
   scrolling track, but the cell can grow into the padding instead of being cut off. */
.frag-marquee { overflow: hidden; padding: 14px 0;
  -webkit-mask-image: linear-gradient(90deg, transparent, #000 6%, #000 94%, transparent);
          mask-image: linear-gradient(90deg, transparent, #000 6%, #000 94%, transparent); }
.frag-track { display: flex; gap: 18px; width: max-content; will-change: transform; }
/* Same hover logic as the homepage cards: the CONTAINER (cell) expands a touch (1.04) while the
   image counter-scales (1.08/1.04) so its on-screen size stays the same; the image overscans
   (scale 1.08) so it covers BOTH states. Matching transitions keep them frame-locked → no gap. */
.frag-cell { position: relative; flex: 0 0 auto; width: 360px; border-radius: var(--r-lg); overflow: hidden;
  border: 1px solid var(--hairline); background: var(--surface);
  transition: transform .5s cubic-bezier(.2,.7,.2,1); }
.frag-cell img { width: 100%; aspect-ratio: 2000 / 1300; object-fit: cover; display: block;
  transform: scale(1.08); transition: transform .5s cubic-bezier(.2,.7,.2,1); }
/* Scale-grow is desktop/mouse only — same reasoning as .zoomable above, no sticky
   grow-on-tap on mobile. */
@media (hover: hover) and (pointer: fine) {
  .frag-cell:hover { transform: scale(1.04); z-index: 2; }
  .frag-cell:hover img { transform: scale(1.0385); }   /* 1.08 / 1.04 → image keeps its on-screen size */
}
/* mouse-following border-stroke highlight (same as the cards; app.js feeds --mx/--my) */
.frag-cell::before { content: ""; position: absolute; inset: 0; border-radius: inherit; padding: 1.5px; z-index: 3;
  background: radial-gradient(150px circle at var(--mx,50%) var(--my,50%),
              color-mix(in srgb, var(--accent) 90%, #fff 20%), transparent 60%);
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor; mask-composite: exclude;
  filter: drop-shadow(0 0 5px color-mix(in srgb, var(--accent) 50%, transparent));
  opacity: 0; transition: opacity .35s ease; pointer-events: none; }
.frag-cell:hover::before { opacity: 1; }

/* Page: filterable grid with 3D tilt cards.
   Head + filters sit LEFT on the wrap gutter (2026-07-15: the head was a
   centered maxw-read column and the filters were centered rows — the only
   text on the site whose left edge floated between column lines instead of
   landing on the 48-col grid like every other page. Left-set on the gutter,
   with max-width kept purely as a reading measure). */
.frag-page { padding-top: var(--s-8); padding-bottom: var(--s-10); }
.frag-head { max-width: var(--maxw-read); margin: 0 0 var(--s-6); }
.frag-head .eyebrow { margin-bottom: var(--s-3); }
.frag-head h1 { font-family: var(--font-serif); font-weight: 500; font-size: var(--t-h1); letter-spacing: -.03em; margin: 0 0 var(--s-4); }
.frag-intro { font-size: 1.18rem; color: var(--ink-soft); margin: 0; }
.frag-filter { display: flex; flex-direction: column; gap: 8px; margin: 0 0 var(--s-4); }
.frag-filter:last-of-type { margin-bottom: var(--s-6); }
/* Small caption naming each filter row's axis (Type / Industry on Case Studies;
   Fragments' single untagged row never renders one). */
.frag-filter-label { text-align: left; font-family: var(--font-mono);
  font-size: 11px; letter-spacing: .1em; text-transform: uppercase; color: var(--ink-mute); }
.frag-pill-row { display: flex; flex-wrap: wrap; gap: 10px; justify-content: flex-start; }
.frag-count { font-family: var(--font-mono); font-size: var(--t-meta); letter-spacing: .02em;
  color: var(--ink-mute); text-align: left; margin: 0 0 var(--s-7); }
.frag-pill { position: relative; font-family: var(--font-mono); font-size: 13px; letter-spacing: .02em;
  padding: 11px 16px; border-radius: var(--r-pill); cursor: pointer;
  border: 1px solid var(--hairline); background: var(--surface); color: var(--ink-soft);
  transition: background .25s, color .25s, border-color .25s, transform .2s; }
.frag-pill:hover { transform: translateY(-2px); color: var(--ink); }
/* Lighter than a hard invert — the pill keeps its surface, an accent underline
   draws in to mark the active filter (echoes the eyebrow/work-label underlines). */
.frag-pill.on { color: var(--ink); border-color: color-mix(in srgb, var(--ink) 30%, var(--hairline)); }
.frag-pill::after { content: ""; position: absolute; left: 16px; right: 16px; bottom: 6px; height: 2px;
  background: var(--accent); transform: scaleX(0); transform-origin: 50% 50%;
  transition: transform .3s cubic-bezier(.2,.7,.2,1); }
.frag-pill.on::after { transform: scaleX(1); }
/* Mobile: the desktop wrap-and-center pill layout (Case Studies ships TWO
   groups, Type with 6 pills + Industry with 9) turns into ~10 ragged
   centered rows on a narrow viewport — two-plus screens of pills before any
   actual work is visible. Rather than compress that into a swipeable row
   (tried, still cost real vertical space up front), every .frag-filter group
   is wrapped in a .filter-sheet (render.mjs's filterSheet()) that's inert on
   desktop — see the desktop base rules right below — and becomes a bottom
   sheet, revealed by a single .filter-toggle button, below 991px. */
.filter-toggle { display: none; }
/* Desktop: the sheet wrapper is a no-op — display:contents lets its .frag-filter
   children merge straight into the page flow exactly as before this existed,
   but its OWN chrome (title + close button) has no desktop meaning, so that's
   suppressed explicitly rather than also being exposed unstyled by :contents. */
.filter-sheet { display: contents; }
.filter-sheet-head, .filter-backdrop { display: none; }

/* ---- Case Studies index: monumental head (2026-07-22). The head owns most of
   the opening viewport so the card grid starts at/below the fold and every
   card gets its scrubbed reveal (nothing sits pre-revealed on arrival). Title
   lines are baked .hero-line-mask spans risen by app.js 1b-ii during the
   curtain sweep; resting CSS is fully visible (no-JS/reduced-motion safe).
   NO padding shorthand here — .frag-page is .wrap-combined (gutter trap);
   height comes from min-height + margins only. .csx-title carries its own
   clamp so the 900px --t-h1 pin below never shrinks it. */
.csx-head { min-height: 58svh; display: flex; flex-direction: column;
  justify-content: flex-end; margin: 0 0 var(--s-6); }
.csx-head-top { display: flex; justify-content: space-between; align-items: baseline;
  gap: var(--s-4); margin-bottom: var(--s-6); }
.csx-head-top .eyebrow { margin: 0; }
.csx-meta { font-family: var(--font-mono); font-size: var(--t-meta); letter-spacing: .02em;
  color: var(--ink-mute); margin: 0; }
.csx-title { font-family: var(--font-serif); font-weight: 500; color: var(--ink);
  font-size: clamp(3.5rem, 0.5rem + 10vw, 10rem); line-height: .96; letter-spacing: -.04em;
  margin: 0 0 var(--s-5); }
.csx-intro { max-width: var(--maxw-read); }
/* Per-pill result count — tiny mono superscript, same mute as the meta line. */
.frag-pill-n { font-family: var(--font-mono); font-size: 10px; color: var(--ink-mute);
  margin-left: 6px; vertical-align: super; line-height: 0; }

@media (max-width: 991px) {
  /* Toggle: sits where the pill rows used to start, same type language as
     the pills themselves (mono, pill-shaped, hairline border). */
  .filter-toggle { display: inline-flex; align-items: center; gap: 8px;
    font-family: var(--font-mono); font-size: 12px; letter-spacing: .02em; color: var(--ink-soft);
    padding: 10px 16px; border-radius: var(--r-pill); border: 1px solid var(--hairline);
    background: var(--surface); cursor: pointer; margin-bottom: var(--s-6); }
  .filter-toggle svg { width: 15px; height: 15px; flex: none; }
  /* Small solid dot, shown + given a digit by app.js the moment any group's
     active tag isn't "all" — visible proof a filter is applied without
     opening the sheet to check. */
  .filter-count { display: inline-flex; align-items: center; justify-content: center;
    min-width: 16px; height: 16px; padding: 0 4px; border-radius: 999px;
    background: var(--accent); color: #f1f1f1; font-size: 10px; line-height: 1; }
  .filter-count[hidden] { display: none; }

  /* .filter-sheet stops being a layout no-op and becomes an actual bottom
     sheet. z-index 1500: above the sticky header (1000) but below the
     mobile nav overlay (2000) / its open-state burger (2001) — the two are
     mutually exclusive UI (a user won't have both open), so the sheet only
     needs to clear the header, not contend with the nav. .filter-backdrop
     sits one below it (1499). */
  .filter-sheet, .filter-backdrop { display: block; }
  .filter-backdrop { position: fixed; inset: 0; z-index: 1499;
    background: rgba(10,10,12,.86); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
    opacity: 0; pointer-events: none; transition: opacity .4s ease; }
  .filter-backdrop.is-open { opacity: 1; pointer-events: auto; }
  .filter-sheet { position: fixed; inset: auto 0 0 0; z-index: 1500;
    max-height: 80dvh; overflow-y: auto; background: #131313;
    border-radius: var(--r-lg) var(--r-lg) 0 0;
    padding: var(--s-5) var(--col) calc(var(--s-5) + env(safe-area-inset-bottom));
    transform: translateY(100%);
    transition: transform .55s cubic-bezier(.625,.05,0,1); }
  .filter-sheet.is-open { transform: translateY(0); }
  .filter-sheet-head { display: flex; align-items: center; justify-content: space-between;
    margin-bottom: var(--s-5); font-family: var(--font-serif); font-weight: 500; font-size: 1.3rem; color: #f1f1f1; }
  .filter-sheet-close { width: 32px; height: 32px; display: flex; align-items: center; justify-content: center;
    padding: 0; border: 0; background: none; color: #f1f1f1; cursor: pointer; }
  .filter-sheet-close svg { width: 18px; height: 18px; }
  /* Inside the sheet there's full vertical room, so pills go back to
     wrapping normally (no horizontal scroll) — just left-aligned instead of
     centered, and on the dark sheet surface rather than the page bg. */
  .filter-sheet .frag-filter { margin: 0 0 var(--s-5); }
  .filter-sheet .frag-filter:last-of-type { margin-bottom: 0; }
  .filter-sheet .frag-filter-label { color: rgba(241,241,241,.5); }
  .filter-sheet .frag-pill-row { justify-content: flex-start; }
  .filter-sheet .frag-pill { background: rgba(241,241,241,.06); border-color: rgba(241,241,241,.14); color: rgba(241,241,241,.75); }
  .filter-sheet .frag-pill.on { color: #f1f1f1; border-color: rgba(241,241,241,.4); }
  /* .frag-filter carries .reveal (screen.css ~1510: opacity:0 until an
     IntersectionObserver adds .revealed on scroll-into-view). The sheet sits
     translateY(100%) off-screen at load and only slides up via a JS class
     toggle, not a scroll — so that observer's target rect never intersects
     the viewport and .revealed never lands, leaving every pill invisible
     even once the sheet opens. Content that's gated behind an explicit user
     action doesn't need a scroll-reveal on top of it; force it always-on
     here (mobile only — desktop's inline, actually-scrolled-past pills keep
     their normal reveal-on-scroll fade). */
  .filter-sheet .reveal { opacity: 1 !important; transform: none !important; filter: none !important; }

  @media (prefers-reduced-motion: reduce) {
    .filter-sheet { transition: opacity .3s ease; transform: none; opacity: 0; visibility: hidden; }
    .filter-sheet.is-open { opacity: 1; visibility: visible; }
  }
}
.frag-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: var(--grid-gap); }
.frag-card { width: 100%; display: block; padding: 0; border: 0; background: none; color: inherit; font: inherit; text-align: left;
  cursor: pointer; perspective: 1100px; }
.frag-card-in { display: block; transform-style: preserve-3d; will-change: transform; border-radius: var(--r-lg); overflow: hidden;
  border: 1px solid var(--hairline); background: var(--surface); transition: box-shadow .4s ease; }
.frag-card:hover .frag-card-in { box-shadow: 0 30px 60px rgba(0,0,0,.18); }
.frag-media { display: block; position: relative; }
.frag-media img { width: 100%; aspect-ratio: 2000 / 1300; object-fit: cover; display: block; }
.frag-meta { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 12px; padding: 15px 18px 17px; }
.frag-name { font-family: var(--font-serif); font-weight: 500; font-size: 1.15rem; color: var(--ink); }
/* The name stays put (a tile should always identify itself at a glance) — the note
   and tags are a quieter, staggered reveal on hover, a small discovery moment rather
   than everything landing on screen at once. */
.frag-note { font-size: var(--t-meta); color: var(--ink-mute);
  opacity: 0; transform: translateY(3px); transition: opacity .35s cubic-bezier(.16,.8,.24,1), transform .35s cubic-bezier(.16,.8,.24,1); }
.frag-tags { width: 100%; display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px;
  opacity: 0; transform: translateY(3px); transition: opacity .35s cubic-bezier(.16,.8,.24,1) .06s, transform .35s cubic-bezier(.16,.8,.24,1) .06s; }
.frag-card:hover .frag-note, .frag-card:focus-visible .frag-note,
.frag-card:hover .frag-tags, .frag-card:focus-visible .frag-tags { opacity: 1; transform: none; }
.frag-tags span { font-family: var(--font-mono); font-size: 10px; letter-spacing: .08em; text-transform: uppercase; color: var(--ink-mute);
  border: 1px solid var(--hairline); border-radius: var(--r-pill); padding: 3px 9px; }
/* Touch devices have no :hover, and a tap doesn't reliably trigger :focus-visible —
   the hover-reveal above would otherwise permanently hide this content on mobile.
   (hover:none) is the reliable "no real hover" signal, independent of screen width. */
@media (hover: none) {
  .frag-note, .frag-tags { opacity: 1; transform: none; }
  .card-cue, .card-view { opacity: 1; transform: none; }
}
/* min() caps the marquee cell on the very narrowest phones (320px viewport):
   the strip itself clips, so this is about the cell not dwarfing the screen,
   not about page overflow. */
@media (max-width: 740px) { .frag-grid { grid-template-columns: 1fr; } .frag-cell { width: min(280px, 84vw); } }

/* ---- Responsive ----------------------------------------------------------- */
/* Large/ultrawide screens: --maxw now scales past the old 1180px flat cap, so grids
   pick up extra columns instead of just stretching 3 (or 2) tiles into oversized,
   sparse cards — this is what makes the wider shell read as "optimized", not just
   "stretched". Fragments goes 2→3→4 in step. The work grid is the exception: it
   stays capped at 3 columns on every desktop width so the case-study set always
   reads as a deliberate 3×2 block rather than reflowing into 4-up rows. */
@media (min-width: 1600px) {
  .frag-grid { grid-template-columns: repeat(4, 1fr); }
}
@media (min-width: 900px) and (max-width: 1599px) {
  .frag-grid { grid-template-columns: repeat(3, 1fr); }
}
@media (max-width: 900px) {
  :root { --t-h1: 2.5rem; }
  .work-grid { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 560px) {
  .work-grid { grid-template-columns: 1fr; gap: var(--s-7) var(--s-5); }
  /* Two links + gap can feel tight at 375px, and needs room for the home-indicator
     gesture area on notched phones. */
  .case-minibar { gap: var(--s-4); padding: 9px 16px; bottom: calc(18px + env(safe-area-inset-bottom)); }
  /* Full-bleed "spread" images are a deliberate, dramatic device on desktop — on a
     375px screen the same edge-to-edge treatment just reads as a missing margin.
     Retire the bleed on mobile: it behaves like any other in-wrap image instead,
     matching the 22px gutter everything else on the page already gets. */
  .spread { margin: var(--s-8) 0; }
  .spread .media img, .spread .media video { width: 100%; }
}
@media (max-width: 991px) {
  /* No more side-by-side galleries on mobile — every gallery becomes a single
     stacked column so each image gets the full mobile width. Placed after the
     740/560 blocks above (which used to reintroduce 2 columns at their own
     widths) so this always wins as the final word on gallery column count
     anywhere ≤991px, replacing those narrower overrides entirely. */
  .gallery.cols-2, .gallery.cols-3, .gallery.cols-4, .gallery.fit { grid-template-columns: 1fr; }
  .gallery.cover.cols-2, .gallery.cover.cols-3, .gallery.cover.cols-4 { column-count: 1; }
  /* .gallery.feature's first tile is the one real crop in the gallery system (a
     2x2 hero tile, object-fit:cover) — on mobile there's no 2-column grid for it
     to anchor to, so it collapses to a normal true-ratio stacked tile like every
     other tile. This does NOT touch .px/.cs's GSAP scroll-parallax transform or
     .media{overflow:hidden} — that overscan-crop is a separate mechanism and
     stays exactly as-is. */
  .gallery.feature .media:first-child { grid-column: span 1; grid-row: span 1; }
  .gallery.feature .media:first-child .px, .gallery.feature .media:first-child .cs { height: auto; }
  .gallery.feature .media:first-child img { height: auto; object-fit: unset; }
}
