/* ==========================================================================
   NUVORM — components.css (Phase 1b — §34 client-feedback revision)
   Display devices (giant words + §31.3 per-group heading rolls), §34.12
   photo-only tiles + scrim overlays, motion grammar CSS (construct reveals —
   app.js only toggles classes; tiles/disciplines carry §34 entry beats), the
   loader (§29.8 fixes applied, §35.5 slower), the §34.13 lightbox (full-height
   side-scroll frames; §35.12 X close — the o-ring
   exit and the arrows are RETIRED; §48: the §35.11 dot navigation is REMOVED
   — client request), the §35.3 menu-glyph morph, contact
   reveal slots, socials, and the reduced-motion parity block.
   The §21–§25 animated mechanic is RETIRED (§30.4): header/footer wordmarks
   render static — no dock/chip/guide states exist in this file.
   §34.8: NO rounded corners anywhere (tokens are 0) — the ONLY circle
   exemptions are the hero slide dots
   and the favicon mark. (§35.12: the o-exit button retires — the close
   control is a square X. §48: the lightbox dot navigation retired with
   its §35.11 directive — client request.)
   ========================================================================== */

/* --------------------------------------------------------------------------
   GIANT WORDS — decorative display devices (special ruling: intentional
   viewport-edge bleed allowed; each ships with a real sr-only heading so no
   information is crop-gated; §18 heading-integrity protects REAL headings).
   Inter 800, uppercase, single line, tracking −0.02em, line-height .8.
   -------------------------------------------------------------------------- */
.giant-word {
  display: block;
  width: max-content;
  margin: 0;
  font-family: var(--font-display);
  font-weight: 800;
  text-transform: uppercase;
  letter-spacing: -0.02em;
  line-height: 0.8;
  white-space: nowrap;
  user-select: none;
}

/* WORK — the head seat of the §34.11 quad grid; sizes off the section's
   --head-f (seat-derived: the 2-col seat / the word's measured 3.1695em box —
   single-sourced in layout.css). No bleed offsets; the word is contained. */
.giant-work {
  font-size: var(--head-f, clamp(96px, 17.5vw, 320px));
}

/* NEWS — same sizing grammar (∩ = the §30.6 glyph; §32.1 rebuilds the UU
   faces from the EXACT ∩ path inverted). Right-side seat (cols 3–4). */
.giant-news {
  font-size: var(--head-f, clamp(96px, 17.5vw, 320px));
}

/* CONTACT — white on black; §31.4 CONTAINED: the §30/§30.11 intentional-crop
   allowance (negative left offset) is SUPERSEDED — the word sits at the left
   margin edge and must end inside the right margin at every breakpoint (the
   §31.5 companion mark is smaller + centred, freeing the tail room). The
   font-size itself is declared on .contact-head (layout.css) so the companion
   mark sizes in em against the same measure. */
.giant-contact {
  font-size: inherit;
}

/* --------------------------------------------------------------------------
   §31.3 HEADING ROLLS — per-group two-face letter rolls composed with brand
   glyphs. Every .giant-word is a SINGLE-LAYER composition (the §29.7
   layer-pair morph is retired — no .m-af exists): a rolling group is one or
   more .m-roll cells, each holding .roll-face (the EN letter, in flow — it
   sizes the cell) + .roll-face.roll-alt (the AF face, absolutely parked below
   the clip — overflow-hidden on WORK/CONTACT cells, a horizontally-slack
   clip-path on the NEWS cells, see §32.1). Confirmed groups: WORK O→E (only);
   NEWS EW→UU (the UU = the EXACT N∩ path inverted, §32.1; N∩ and S static);
   CONTACT C→K + the trailing CT→K (type K, left-seated to the C's
   pre-roll x-position §32.2; ONTA middle static); About inert. app.js
   (rollSystem) arms the cells and toggles .is-rolled on the WORD — the
   group's cells roll as ONE composed motion (±105%, 260ms,
   cubic-bezier(.22,1,.36,1); no stagger). JS-off / reduced-motion: the alt
   faces never display — static EN words. Glyph cells (.m-glyph) are SVG
   letterforms seated on the text baseline via --baseline-gap and sized to
   Inter cap-height exactly via --cap-h (§30.7 — both empirically measured,
   see base.css).
   -------------------------------------------------------------------------- */
.m-layer { display: flex; align-items: flex-end; }
.m-en { position: relative; }
.m-cell {
  min-width: 0;
  overflow: hidden;
  line-height: 1;
  letter-spacing: 0; /* tracking is carried by the cell gap below, not glyph spacing */
}
.m-cell + .m-cell { margin-left: -0.02em; } /* the −0.02em tracking as cell gap */
/* §31.3 explicit kerning — the marks must read as composed symbols, not
   letters. .kern-g opens a composed gap at every glyph/symbol boundary
   (N|EU, WU|S, C|O, A|CT); .kern-p is the tight intra-pair gap of the UU
   mark (serving the E|W cell pair at rest). Declared after the tracking
   rule so the override wins the tie. */
.m-cell.kern-g { margin-left: 0.045em; }
.m-cell.kern-p { margin-left: 0.015em; }
/* §37.6 WORK W→O EVEN-KERN (Dillon: "check the spacing of the 'O' in work,
   slightly close to W"). MEASURED ink gaps at 1440 (em = 198.517px):
   W→O 4px (0.0227em) · O–R 11px (0.0554em) · R–K 10px (0.0504em) — the O
   sits ~0.033em too close to the W-glyph (the svg cell is ink-flush, while
   the text cells carry Inter's side bearings on top of the −0.02em tracking
   gap). The O cell (WORK's only roll cell) opens the gap to the mean of the
   other two: margin −0.02em → +0.0127em (Δ +0.0327em → W→O ≈ 10.5px ink at
   the desktop register ≈ O–R/R–K ±6% em). The cell shift carries BOTH roll
   faces (O and the alt E ride the same box) — no §34.11 roll constant
   changes; the word box grows 0.0327em → WORK's seat divisor re-derived
   3.1695 → 3.2 in layout.css (the word still fills the 2-col seat). */
.giant-work .m-cell.m-roll { margin-left: 0.0127em; }
.m-glyph { flex: none; margin-bottom: var(--baseline-gap); }
.m-glyph svg {
  display: block;
  height: var(--cap-h); /* §30.7: ink height = Inter cap-height exactly (was width:0.72em) */
  width: auto;          /* width follows the viewBox ink ratio */
  fill: currentColor;
}

/* --------------------------------------------------------------------------
   §31.3 ROLL MECHANICS (all three words share the grammar; WORK's O→E keeps
   its §30.5 semantics). The cell (.m-roll) holds .roll-inner > two faces:
   .roll-face (EN, in flow) + .roll-face.roll-alt (AF, absolutely positioned
   over the cell at 100% cell height, parked at +105% below the clip).
   Default (JS-off / reduced-motion / un-armed): .roll-alt is display:none —
   the word renders static EN. app.js (rollSystem) adds .roll-on to every
   .m-roll cell when motion is allowed, then toggles .is-rolled on the WORD
   for hover/focus — ALL of the word's roll cells roll together (the group
   moves as one composed mark). Text faces centre in the in-flow face's
   width (optical spacing, e.g. the narrower E in the O-width cell); SVG
   faces centre via auto margins and seat their ink on the baseline exactly
   like .m-glyph (cap-height ink, --baseline-gap offset). Transform-only →
   zero CLS.
   -------------------------------------------------------------------------- */
.m-roll .roll-inner { display: block; position: relative; }
.m-roll .roll-face { display: block; letter-spacing: -0.02em; }
.m-roll .roll-alt { display: none; }
.m-roll.roll-on .roll-alt {
  display: block;
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: 100%;       /* the alt face parks/travels in the CELL's own metrics — one roll distance for every face kind */
  text-align: center; /* text faces centred in the in-flow face's width */
  transform: translateY(105%);
  transition: transform 260ms var(--ease);
  will-change: transform;
}
.m-roll .roll-alt svg {
  display: block;
  height: var(--cap-h);
  width: auto;
  margin: var(--baseline-gap) auto 0; /* ink centred; cap-top aligned to the letter's cap-top */
  fill: currentColor;
}
.m-roll.roll-on .roll-face:first-child {
  transition: transform 260ms var(--ease);
  will-change: transform;
}
.giant-word.is-rolled .m-roll .roll-face:first-child { transform: translateY(-105%); }
.giant-word.is-rolled .m-roll .roll-alt { transform: translateY(0); }

/* --------------------------------------------------------------------------
   §32.1 NUUS GLYPH PROVENANCE + KERNING — the UU faces are the EXACT N∩
   glyph path (index-next.html), rotated 180° about its ink centre:
   identical curves and weight, flipped — NOT a separate brand-U rendering.
   (The §31.3 master-U faces also CLIPPED against the E cell: Inter 800's
   E advance 0.60986em is narrower than the glyph's 0.61715em box, so the
   old faces were cut by the cell's overflow clip.) Two mechanics let the
   rolled composition kern cleanly without moving any rest-state cell:
   · the NEWS roll cells clip via clip-path with HORIZONTAL slack
     (inset(0 -0.5em 0 -0.5em)) instead of overflow:hidden — the vertical
     roll travel stays clipped exactly as before, while the alt faces may
     overhang the E/W cell boxes;
   · the alt faces position from the WORD box (.m-en is their containing
     block once roll-inner goes static) at measured em offsets, so the pair
     composes with even gaps across N | U | U | S.
   Offsets derived from the shipped Inter woff2 (wght 800, fontTools — same
   method as base.css): ∩/∪ box 0.61715em, ink 0.61559em, bearings
   0.00077/0.00079em; N ink right 0.61636em; S ink left = cell4 left +
   0.03512em with the §31.3 cell chain.

   §33.1 W-GLYPH REST FACE: cell 3's EN face is no longer the text W — it
   is the brand-m path rotated 180° (the EXACT symbol WORK's W uses,
   viewBox 1902.49 1.12 575.2 388.88, rotate about 2190.0862 195.5596),
   seated on the text baseline exactly like a .m-glyph cell (cap-height
   ink + --baseline-gap lift). The E|W-glyph boundary becomes a
   symbol boundary → cell 3 carries kern-g (the composed 0.045em gap,
   §31.3 law); NEWS has NO kern-p consumer any more (the tight intra-pair
   gap now lives only in the ROLLED composition, owned by the --uu-x
   offsets below). Rendered chain (Chromium, measured @F 252):
   ∩ 0.61706 + kern-g 0.045 + E-cell 0.59099 + kern-g 0.045 + W-glyph
   cell 1.07607 + kern-g 0.045 + S — the even distribution of the
   EW→UU surplus against THAT chain gives G ≈ 0.2016em → --uu-x1
   0.8172em, --uu-x2 1.6352em, re-measured and pinned by probe-s33
   (rolled gaps even to ±0.01em; a browser that drops trailing spacing
   widens the outer gaps — no overlap, still clean). The REST state
   (NEWS) renders the composed marks exactly as §31.3 — the alt faces are
   display:none until .roll-on arms them.
   -------------------------------------------------------------------------- */
.giant-news .m-roll .roll-face:first-child svg {
  display: block;
  height: var(--cap-h); /* the §33.1 W glyph: ink height = Inter cap-height exactly */
  width: auto;
  margin: 0 0 var(--baseline-gap); /* ink seats on the text baseline like .m-glyph */
  fill: currentColor;
}
.giant-news .m-cell.m-roll {
  overflow: visible;
  clip-path: inset(0 -0.5em 0 -0.5em);
}
.giant-news .m-roll .roll-inner { position: static; }
.giant-news .m-roll.roll-on .roll-alt {
  right: auto;
  width: max-content;
  left: var(--uu-x);
}
.giant-news .m-roll .roll-alt svg { margin: var(--baseline-gap) 0 0; }
.giant-news .m-roll.kern-g .roll-alt { --uu-x: 0.8172em; } /* cell 2: E→U */
.giant-news .m-roll.kern-g + .m-roll .roll-alt { --uu-x: 1.6352em; } /* cell 3: §33.1 W-glyph→U */

/* --------------------------------------------------------------------------
   §32.2 KONTAK K SEAT — the trailing CT→K roll: the K face sits at the
   CELL's left edge — the C's pre-roll x-position — instead of centred in
   the merged CT cell; the freed T-width becomes trailing air (§34.14: the
   nu companion is REMOVED — the word's metrics are unchanged, no other
   reflow). The leading C→K cell keeps the centred grammar (a single-letter
   cell has no seat to change).
   -------------------------------------------------------------------------- */
.giant-contact .m-cell.m-roll:last-child .roll-alt { text-align: left; }

/* --------------------------------------------------------------------------
   §35.3 MENU MORPH — the trigger's three stripes MORPH INTO THE NU MARK on
   open. PROVENANCE (§31–§33): the m-nu letterforms are the MASTER paths —
   the h1 wordmark's n, u + junction sliver, copied VERBATIM into the svg by
   Phase A (this file animates them, never re-draws them; the paths ARE the
   master letterforms). Rest: g.m-stripes (three rect.m-stripe). §37.2
   (layout authority nu-menu-diagram.jpg, MEASURED): the rest seats ARE the
   nu stem seats — stripes born at stem width (83.7/83.5/84.2 of the 390
   glyph height, the letterform's own stem:height ratio) on the stem
   columns (x 0.83/246.75/491.8). Open: there is NO glide left to do — the
   old translateX/scaleX onto the seats is RETIRED (identity transforms;
   no new glyph paths, no new geometry — width/alignment parity only), the
   stems are already settled, and the letterforms take over: m-stripes
   fades OUT while m-nu rises with
   the §35.3 slight bounce. The glyph box itself grows 27px → 30px
   (the nu letter scale at the 180px wordmark is ≈28.3px; 30px chosen —
   verify visually, flag if it reads wrong). The svg's arches ARE the
   "rounded joins" (top join of stripes 1+2 = the n arch, bottom join of
   stripes 2+3 = the u bowl).

   §36.7 REFINEMENT PASS (client loves the morph — polish, NOT a redesign;
   NO new glyph geometry: the master paths and the at-rest stripe seats are
   byte-identical, all transforms unchanged). What changed and why:
   · ONE CHOREOGRAPHY: the stem glide (340ms) and the letterform rise no
     longer run back-to-back — the nu now starts rising at 100ms (was a
     240ms delay) and the stripes dissolve at 140ms (was 260ms), so the
     letterforms EMERGE from the still-gliding stems instead of waiting for
     them; the whole open reads as one continuous gesture.
   · SNAPPY TOTAL: open settles at 100 + 560 = 660ms — inside the ~560ms
     ±20% band (was 240 + 560 = 800ms); the menu stays snappy, nothing
     slowed into lag.
   · SOFTER LANDING: the overshoot eases 1.06 → 1.045 and the settle dip
     0.985 → 0.99, with PER-SEGMENT easing (strong decel into the overshoot,
     ease-out through the dip) — a considered landing, not duration
     inflation.
   · SYMMETRIC CLOSE BY CONSTRUCTION: the base .m-nu transform is now the
     animation's END state (scale(1), was scale(0.92)) — closing removes the
     animation and changes NOTHING (the old base scale(0.92) forced a
     transform change on close that could snap when the filled animation
     released). The close is now: the nu dissolves in place (opacity 300ms,
     immediate, no delay) while the stripes fade back in (140ms delay +
     220ms) — §37.2: there is no glide home any more (the stripes never
     left their seats) — the mirror of the open crossfade.
   JS-off: html.menu-open never happens — the stripes render statically (nu
   sits at opacity 0 inside the same svg; rest state is the JS-off state).
   Reduced-motion: see the reduce block — explicit state rules make the
   open state INSTANTLY correct (the generic kill-switch zeroes durations
   but not delays; the explicit `transition: none` there removes the delays
   entirely).
   -------------------------------------------------------------------------- */
.menu-glyph {
  height: 27px;
  width: auto;
  fill: currentColor;
  overflow: visible; /* the bounce overshoot may exceed the viewBox ink box */
  transition: height 340ms var(--ease); /* §36.7: synced to the glyph height step (was 320ms) */
}
html.menu-open .menu-glyph { height: 30px; }
.m-stripe {
  transform-box: fill-box;
  transform-origin: left center;
  /* §37.2: the stripes SIT on the stem seats at rest — there is no
     transform state left to transition; the crossfade is the whole
     motion. The opacity delay (140ms) + 220ms ease stay so the swap
     reads as one continuous gesture. */
  transition: transform 340ms var(--ease), opacity 220ms ease 140ms;
}
/* §37.2: the open transforms are IDENTITY (seats == stems). Declared
   explicitly so the state stays addressable and close/open remain exact
   mirrors by construction (nothing moves; only opacity swaps). */
html.menu-open .m-stripe:nth-child(1),
html.menu-open .m-stripe:nth-child(2),
html.menu-open .m-stripe:nth-child(3) { transform: none; }
/* the bars fade as the letterforms take over — the opacity swap is applied
   to the GROUP, so the delay lives on the group too (a per-stripe opacity
   transition alone would make the group swap instantly); the per-stripe
   opacity transition above stays for any per-stripe state use. */
.m-stripes { transition: opacity 220ms ease 140ms; } /* §36.7: 200ms ease 260ms */
html.menu-open .m-stripes { opacity: 0; }
.m-nu {
  opacity: 0;
  transform-box: view-box;
  transform-origin: center;
  /* §36.7: the base transform IS the open animation's end state — closing
     can never snap (the animation's removal changes nothing; the old
     scale(0.92) base forced a transform change on close). The hidden
     closed state is carried by opacity: 0 alone. */
  transform: scale(1);
  /* close: the nu dissolves immediately while the stripes fade back in
     (open side: opacity fades in over the stationary stems; the transform
     half is owned by the bounce animation) */
  transition: opacity 300ms ease;
}
html.menu-open .m-nu {
  opacity: 1;
  animation: menu-nu-bounce 560ms var(--ease) 100ms both; /* §36.7: delay 240→100ms — total open 660ms */
}
@keyframes menu-nu-bounce {
  /* §36.7 softer landing: overshoot 1.045 (was 1.06), settle dip 0.99 (was
     0.985); per-segment easing — a strong decel rises into the overshoot,
     ease-out falls and settles */
  0% { transform: scale(0.92); animation-timing-function: cubic-bezier(0.3, 0.9, 0.35, 1); }
  58% { transform: scale(1.045); animation-timing-function: ease-out; }
  82% { transform: scale(0.99); animation-timing-function: ease-out; }
  100% { transform: scale(1); }
}

/* --------------------------------------------------------------------------
   §30.8 WORDMARK DRAW-ON — per-letter clip wipes in the §24-loader grammar
   family. app.js builds one clipPath rect per .wm-l group (letter's own
   ink box, from getBBox) and adds .wm-armed (rects at width 0); the trigger
   ('nuvorm:loader-done' when the block is at/near the viewport, else the
   ~25% construct intersection) adds .wm-drawn and JS transitions each rect
   to its full width — staggered left-to-right in reading order, 430ms per
   letter at 150ms steps (total ≈1.18s), decisive ease, clip-only (zero CLS).
   JS-off / reduced-motion: no clip markup exists — the completed wordmark
   renders instantly. After the wipe app.js strips the clip markup so the
   revealed state is pixel-identical to the static one.
   §35.5 SLOWER OPENING — the whole draw scales ~×1.6: 430 → 600ms per
   letter, stagger 150 → 210ms (nth 2–6: 210/420/630/840/1050ms; total
   ≈1.65s). (The JS-side constants + the inline loader script own their
   own durations — CSS owns the transition durations/delays only.)
   -------------------------------------------------------------------------- */
.wm-armed clipPath rect { transition: width 600ms var(--ease); }
.wm-armed clipPath:nth-of-type(1) rect { transition-delay: 0ms; }
.wm-armed clipPath:nth-of-type(2) rect { transition-delay: 210ms; }
.wm-armed clipPath:nth-of-type(3) rect { transition-delay: 420ms; }
.wm-armed clipPath:nth-of-type(4) rect { transition-delay: 630ms; }
.wm-armed clipPath:nth-of-type(5) rect { transition-delay: 840ms; }
.wm-armed clipPath:nth-of-type(6) rect { transition-delay: 1050ms; }
/* (the §29.7 .giant-word.is-af layer-swap state is retired with the layer
   pairs — rolls are in-cell; see §31.3 above) */

/* --------------------------------------------------------------------------
   §29.5 + §30.2 LIGHTBOX — full-screen horizontal panel view over a
   PER-PROJECT image array + the write-up panel. Ported here from app.js
   inline styles (v1 debt cleared §30.2): app.js toggles state only
   (.lightbox[hidden] → .is-open → .is-closing) and applies the runtime
   §2.4 1.5×-native cap (dynamic, per-image values CSS cannot compute).
   -------------------------------------------------------------------------- */
.lightbox { position: fixed; inset: 0; z-index: 200; }
.lightbox[hidden] { display: none; }
.lightbox-backdrop { position: absolute; inset: 0; background: rgba(255, 255, 255, 0.92); }
.lightbox-dialog {
  position: absolute;
  inset: 24px;
  z-index: 1;
  display: flex;
  flex-direction: column;
  background: var(--white);
  border: 1px solid var(--black);
  border-radius: var(--radius-lg);
  overflow: hidden;
}
.lightbox.is-open .lightbox-backdrop { animation: lb-fade 240ms ease both; }
.lightbox.is-open .lightbox-dialog { animation: lb-dialog-in 260ms var(--ease) both; }
.lightbox.is-closing { opacity: 0; transition: opacity 170ms ease; }
@keyframes lb-fade { from { opacity: 0; } to { opacity: 1; } }
@keyframes lb-dialog-in { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
/* §35.12 CLOSE = THE X (the §34.13 o-ring glyph + .exit-disc are RETIRED):
   a plain two-stroke X (JS-built 24×24 path, stroke currentColor — CSS
   sizes it to 20×20). Same seat (top-right of the dialog); 44×44 hit area;
   radius 0 again (the circle exemption no longer applies to this control).
   Rest = 55% ink over the white dialog; hover/focus lifts to full ink + a
   slight scale, 160ms.
   §53 (client: "move the X close button on the modals so that it does not
   encroach on the images — further into the corner"): the seat moves 14px
   → 6px from the dialog's top/right edges — the 20×20 glyph now sits
   ~17px into the corner (was ~25px), clearing the full-height image
   frames further; the 44×44 hit area and all §36.3 exclusions are
   UNTOUCHED. */
.lightbox-close {
  position: absolute;
  top: 6px;
  right: 6px;
  z-index: 6;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 44px;
  height: 44px;
  border: 0;
  border-radius: 0;
  background: transparent;
  color: var(--black);
  opacity: 0.55;
  cursor: pointer;
  transition: opacity 160ms ease, transform 160ms var(--ease);
}
.lightbox-close svg { display: block; width: 20px; height: 20px; }
.lightbox-close:hover,
.lightbox-close:focus-visible { opacity: 1; transform: scale(1.08); }
.lightbox-close:focus-visible { outline: 3px solid var(--black); outline-offset: 2px; }
.lightbox-track {
  position: relative;
  flex: 1;
  min-height: 0;
  display: flex;
  align-items: center;
  gap: var(--band-gap, 12px);
  /* §35.12 control seats: the right padding grows so a parked
     end frame clears the X's seat (top 14–58px) at desktop widths.
     §48: the bottom band's dot-row clearance is RETIRED with the dots —
     the padding value is kept as visual air. Left/top
     register unchanged (the §34.23.3 parked air at scroll 0). */
  padding: clamp(20px, 4svh, 48px) clamp(20px, 4.5vw, 72px) clamp(24px, 7svh, 64px)
    clamp(20px, 3vw, 56px);
  overflow-x: auto;
  overflow-y: hidden;
  overscroll-behavior-y: auto;   /* §31.9: vertical scroll is never captured */
  touch-action: pan-x pan-y;
  /* §34.23.3 FREE STOP (2026-10-16, Dillon — pre-deploy, BINDING): the
     track carries NO scroll-snap at all — snap-type, snap-align and the
     snap-only scroll-padding-inline are RETIRED. The client: "when removing
     the mouse … it jumps to a predetermined spot, not where it actually
     stopped — very jarring"; that jump was the §34.20.1 release-re-snap.
     Free scroll everywhere: the glide stops EXACTLY where the cursor leaves
     it; wheel/trackpad stop in place; arrows/keyboard still step panels via
     panelStep (its scrollTo targets the DESIGNED panel rest — offsetLeft −
     the track's left padding, the landing the snapport used to correct to —
     the one-panel glide is the DESIGNED motion). With no snap there is
     nothing to suspend/recapture, so the §34.20.1 snap-suspension machinery
     in app.js simplifies away and its G-bug (a mandatory snap re-capturing
     every per-frame scrollLeft step) dies with the snap. The track's own
     CSS padding keeps the parked air at both ends (scroll position 0 IS the
     write-up's rest with its padding air — the rev18 open-parked state
     holds without a snapport). */
  -webkit-overflow-scrolling: touch;
}
/* §34.13 LIGHTBOX — NO masonry (client: "modals should not be mason grid for
   images, they should be full height side scrolling. bold images"). Track
   order (art-director call): the WRITE-UP is the LEADING panel (first track
   position — §32.5's open-on-write-up order law holds), then each project
   image is its own FULL-HEIGHT frame: viewport-height bold image frames in
   a horizontal FREE-SCROLL track (§34.23.3: no snap — manual scroll/glide
   stops where the user leaves it; swipe native; wheel = nudge or
   nothing — the §31.2 no-page-capture law carries). The §2.4 1.5× native
   cap per image stays (applied by app.js). Same edge-pan gesture + .lb-arrow
   arrows (renamed from the retired band affordance — app.js creates them
   on open); no wheel capture, no click-to-advance. The visible counter
   counts the track positions (write-up + N image frames). */
.lightbox-panel {
  flex: none;
  margin: 0;
  background: var(--white);
}
.lightbox-frame {
  height: 100%;
  display: flex;
  align-items: center;
}
.lightbox-frame img { display: block; width: auto; height: auto; max-height: 100%; }
.lightbox-writeup {
  /* §34.24.3 WRITE-UP NARROWER — IMAGES TAKE PRECEDENCE (client amendment
     2026-10-16): the panel was 44vw (measured 633.6px = 44% @1440×900 —
     WIDER than the first visible image frame, 617px). New law:
     clamp(300px, 30vw, 640px) — 30% @1440/1920 (432px/576px), 300px
     readability floor below ~1000px viewports, 640px cap on ultrawide
     (panel content ≈512px ≈64ch max). The image area dominates the modal.
     §62 RE-STANDARDISED (client: the write-up area slightly wider + the
     text slightly smaller so no scroller is needed; all modals standard):
     width clamp(340px, 39vw, 720px), padding clamp(20px, 3vw, 48px), body
     13.5px/1.5, title clamp(20px, 2vw, 30px) — measured: the longest
     write-up (Riverside Business Park, 5 paragraphs) fits the track
     content box with NO vertical scroll at 1440×900 and every larger
     pinned size. Mobile (≤700px) keeps its stacked register (touch
     scroll is the natural grammar there). */
  width: clamp(340px, 39vw, 720px);
  align-self: stretch;
  display: flex;
  flex-direction: column;
  padding: clamp(20px, 3vw, 48px);
  overflow-y: auto;
  /* §32.5: the write-up is the FIRST panel — the hairline moves to its
     right edge, separating it from the image grid. */
  border-right: 1px solid var(--black);
}
/* §34.24.2 (client amendment 2026-10-16) — the discipline chip
   (.lightbox-chip) is REMOVED from the write-up panel: title + place +
   note + placeholder line remain; the title OPENS the panel, so its
   former 22px under-chip offset retires with the chip — the panel's own
   padding carries the top air (no orphaned gap). */
.lightbox-project {
  margin: 0;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: clamp(20px, 2vw, 30px);
  letter-spacing: -0.005em;
  line-height: 1.1;
}
.lightbox-place {
  margin: 10px 0 0;
  font-family: var(--font-display);
  font-weight: 600;
  font-size: 10.5px;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  line-height: 1.3;
}
.lightbox-note {
  margin: 16px 0 0;
  max-width: 60ch;
  font-family: var(--font-body);
  font-size: 13.5px;
  line-height: 1.5;
}
.lightbox-note + .lightbox-note { margin-top: 10px; } /* §62: 12→10px — the longest write-up (Moreleta) must clear the panel with no scroller */
/* §60 award/credit emphasis (client: bold the credit + the award labels) */
.lightbox-note strong { font-weight: 700; }
.lightbox-ph {
  margin: 28px 0 0;
  margin-top: auto;
  padding-top: 28px;
  font-family: var(--font-display);
  font-weight: 600;
  font-size: 10.5px;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  line-height: 1.4;
}
/* §30.2 the visible position counter — "n / m" over the panel array
   (radius 0 per §34.8 — the pill form is retired) */
.lightbox-counter {
  position: absolute;
  bottom: 14px;
  left: 16px;
  z-index: 3;
  margin: 0;
  padding: 7px 13px;
  background: var(--white);
  border: 1px solid var(--black);
  border-radius: 0;
  font-family: var(--font-display);
  font-size: 12.5px;
  font-weight: 600;
  letter-spacing: 0.06em;
  line-height: 1;
}
@media (max-width: 700px) {
  .lightbox-dialog { inset: 12px; }
  /* §34.24.3 mobile: proportionate narrowing 86vw → 72vw (measured
     335.4px = 86% @390 with a 0.6px image sliver → 280.8px = 72% with a
     ~35px first-image sliver — the image reads beside the panel);
     260px readability floor below ~361px viewports (no readability
     collapse), 480px cap on large tablets. */
  .lightbox-writeup { width: clamp(260px, 72vw, 480px); }
}
/* §48 DOT NAVIGATION — REMOVED (client request: "remove the dots underneath
   the scrolling images on all modals"). The entire .lightbox-dots/
   .lightbox-dot build (the §35.11 inverted ring grammar) is retired —
   app.js no longer builds the nav, no dead selectors kept. The §34.8
   circle-exemption record loses its lightbox entry (hero dots + favicon
   remain). */

/* --------------------------------------------------------------------------
   §29.6 NEWS watermark chip — RETIRED (§34.12 cycle-1 fixNow 3): the
   persistent white nu plate on the tiles violated the photo-only rest state
   (critic geometry: [90,118,35,25] at opacity 1 on tile 1). No watermark
   element exists in HTML, JS or CSS; identity lives in the NUUS heading +
   the §34.12 hover/focus register. Never re-add without a directive.
   -------------------------------------------------------------------------- */

/* --------------------------------------------------------------------------
   §34.12 TILES — PHOTO ONLY at rest (client: "tiles should be Photo only,
   rollover with information + darker transparency"; register cited from the
   live nuvorm.com wall). At rest NOTHING is visible but the photograph —
   the persistent identity chip and the always-visible plate are RETIRED;
   the info (kind small + place + project + note) lives in
   figcaption.tile-info:
   · JS-on, hover-capable pointers: a uniform black scrim (darker
     transparency, rgba(0,0,0,.55)) + the white info overlay on
     hover/focus-within — nothing else.
   · Touch (hover:none): tap opens the modal directly — no hover gate.
   · JS-off: the tile-info renders as a plain black caption BELOW the tile —
     nothing unreachable (CSS-gated: the static default is the caption;
     html.js repositions the same element into the overlay).
   · Entrance: per-tile construct beats (li[data-construct] →
     .is-constructed), column-staggered — this SUPERSEDES §30.3's
     no-tile-entry law (§34.12: "some animation to them when coming in").
   Anatomy: figure.tile (no fixed height) > span.tile-media (the 4:5 clipped
   media box — aspect-ratio lives HERE so the JS-off caption can flow below
   the figure without overlap) > img; the scrim is .tile-media::after.
   -------------------------------------------------------------------------- */
.tile {
  position: relative;
  display: block;
  margin: 0;
  /* §35 critic cycle-2 fixNow 5 — JS-OFF CAPTION LEGIBILITY: the tile bg
     was var(--black), and the JS-off static caption (block flow BELOW the
     media) painted BLACK TEXT on that black field — invisible. White bg:
     JS-on it stays hidden (the media covers the figure completely — photo-
     only-at-rest unchanged); JS-off the caption area renders as a white
     band with plain black text (the §8-A7 nothing-unreachable law, now
     actually legible). .tile-media keeps its own black field for the
     lazy-load pre-paint. */
  background: var(--white);
  border-radius: 0; /* §34.8 */
  isolation: isolate;
}
.tile-media {
  position: relative; /* seats the ::after scrim + the js-off caption flow */
  display: block;
  aspect-ratio: 4 / 5;
  overflow: hidden;
  background: var(--black);
}
.tile-media img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center;
}
/* §35.9 ZOOM REGISTER — the IMAGE moves, the frame never does (overflow
   hidden on .tile-media clips the scale). JS-gated (html.js — without JS
   the tile is a static photo + caption, no hover device). Keyboard parity:
   :focus-within gets the same register as hover (no information
   hover-only, §5 AC8). Reduced-motion: see the reduce block — the zoom is
   disabled entirely (static-safe), not just instant. */
html.js .tile-media img { transition: transform 480ms var(--ease); }
/* the §34.12 darker-transparency scrim — uniform black veil (replaces the
   old bottom gradient; the photo carries all colour, the scrim only darkens).
   At rest 0; hover/focus-within 1 (JS-on only — the veil is never visible
   without JS, matching the JS-off caption state). */
.tile-media::after {
  position: absolute;
  inset: 0;
  z-index: 1;
  content: "";
  background: rgba(0, 0, 0, 0.55);
  opacity: 0;
  transition: opacity 200ms ease;
  pointer-events: none;
}
/* JS-off caption — below the photo, plain black text (§8-A7: nothing
   hover-gated; html.js repositions the SAME element into the overlay) */
.tile-info {
  display: block;
  margin: 10px 2px 0;
  color: var(--black);
  font-family: var(--font-body);
  font-size: 13px;
  line-height: 1.45;
}
/* §34.24.1 (client amendment 2026-10-16) — the discipline denotation
   (.tile-kind) is REMOVED from the tile register: place leads now, so its
   former 4px under-kind offset is retired with the kind rule. */
.tile-info .tile-place {
  display: block;
  font-family: var(--font-display);
  font-size: 10px;
  font-weight: 600;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  line-height: 1.3;
}
.tile-info .tile-project {
  display: block;
  margin-top: 2px;
  font-family: var(--font-display);
  font-size: 15px;
  font-weight: 700;
  letter-spacing: -0.005em;
  line-height: 1.1;
}
.tile-info .tile-note { display: block; margin: 2px 0 0; }
/* JS-on overlay grammar — photo only at rest; scrim + white info on
   hover/focus-within (keyboard gets the same reveal via :focus-within;
   touch taps through to the modal — no hover gate, §34.12) */
html.js .tile-info {
  position: absolute;
  inset: 0;
  z-index: 3;
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  gap: 0;
  margin: 0;
  padding: 16px;
  color: var(--white);
  opacity: 0;
  transition: opacity 200ms ease;
}
html.js .tile:hover .tile-media::after,
html.js .tile:focus-within .tile-media::after { opacity: 1; }
html.js .tile:hover .tile-info,
html.js .tile:focus-within .tile-info { opacity: 1; }
html.js .tile-info .tile-project { font-size: clamp(15px, 1.5vw, 21px); }
/* §30.2 trigger — the whole tile is the lightbox affordance (styling ported
   from app.js inline styles; app.js builds the element, this file styles it) */
.tile-btn {
  position: absolute;
  inset: 0;
  z-index: 4;
  width: 100%;
  padding: 0;
  border: 0;
  background: transparent;
  cursor: pointer;
}
.tile-btn:focus-visible {
  outline: 3px solid var(--white);
  outline-offset: -6px;
  box-shadow: inset 0 0 0 3px var(--black);
}

/* --------------------------------------------------------------------------
   §35.9 TILE HOVER ZOOM — the §30.3 TILE-BOUNCE BLOCK IS RETIRED (client:
   "no jump on hover — a slight zoom of the image instead"); the @keyframes
   tile-bounce + the hover animation above are gone. The tile FRAME never
   moves; the IMAGE zooms 1.045 inside the fixed overflow-hidden frame.
   Pointer-gated (hover + fine pointer only — touch taps straight through
   to the modal, §34.12 touch law). Focus-within joins the register
   (keyboard parity of the zoom). JS-off: no zoom (the html.js gate) —
   static photo, correct. Reduced-motion: no zoom (see reduce block).
   -------------------------------------------------------------------------- */
.beat { min-width: 0; margin: 0; padding: 0; }
@media (hover: hover) and (pointer: fine) {
  html.js .tile:hover .tile-media img,
  html.js .tile:focus-within .tile-media img { transform: scale(1.045); }
}

/* --------------------------------------------------------------------------
   §34.12/§34.17 NEWS TILES — the SAME §34.12 grammar as the work tiles,
   including the FULL hover/focus register — discipline + place + project
   + note (cycle-1 fixNow 4; the note-only register was §34.12-incomplete).
   JS-off: the info renders as the plain black caption below the photo.
   The §29.6 watermark is RETIRED — photo only at rest (fixNow 3).
   Placeholder mode = the static 6-tile set; the §34.17 feed replaces the
   grid content wholesale when live (any failure → placeholders stay).
   -------------------------------------------------------------------------- */
.news-tile { position: relative; margin: 0; padding: 0; min-width: 0; }
/* fixNow 4 keyboard parity — the §34.17 permalink grammar in placeholder
   mode: each placeholder tile is wrapped by the studio-profile link (live
   = the post's own permalink; same structure the mapper renders). The
   register reveal keys off the LINK's :focus-visible — the link is the
   figure's PARENT, so :focus-within on .tile cannot see it. Hover keeps
   the shared .tile:hover grammar (the link covers the tile). */
.news-tile-link { display: block; color: inherit; text-decoration: none; }
html.js .news-tile-link:focus-visible .tile-media::after,
html.js .news-tile-link:focus-visible .tile-info { opacity: 1; }
.news-tile-link:focus-visible {
  outline: 3px solid var(--black);
  outline-offset: 2px;
}

/* §42 BEHOLD LIVE — CAPTION CLAMP: the live feed's captions run 600–1400
   chars, and .tile-info is an absolute inset:0 flex-end hover overlay — an
   unclamped note would overflow the tile. Minimal register-preserving
   clamp, scoped to the news grid only (the work grid's write-up notes are
   one-line by law and must not gain an ellipsis register). Placeholder
   notes ("Project write-up to follow.") are one line — visually
   unaffected; JS-off the feed never renders (fetch needs JS), so the
   static caption below the tile carries placeholder text only. The
   §34.24.1 register is untouched: place/project/note spans keep their
   per-field fallbacks; the clamp only bounds the note's height. */
#news-grid .tile-note {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 4;
  line-clamp: 4;
  overflow: hidden;
}

/* --------------------------------------------------------------------------
   §24.5 CONSTRUCT REVEAL GRAMMAR (ported) — [data-construct] groups; JS adds
   .is-constructed once (~25% visibility). .construct-target children rise+fades
   in; .sketch-real headings draw as outlines first, then fill (paint-only:
   text-stroke colour crossfade — zero layout shift, §18 never-clip holds).
   --sketch-ink is context-overridden to white on the dark contact field.
   -------------------------------------------------------------------------- */
.construct-target { opacity: 1; transform: none; }
.js [data-construct]:not(.is-constructed) .construct-target { opacity: 0; transform: translateY(16px); }
.js [data-construct].is-constructed .construct-target {
  opacity: 1;
  transform: translateY(0);
  transition: opacity 430ms ease 130ms, transform 430ms var(--ease) 130ms;
}
.js [data-construct].is-constructed .construct-target + .construct-target { transition-delay: 300ms, 300ms; }
.js [data-construct].is-constructed .construct-target + .construct-target + .construct-target { transition-delay: 440ms, 440ms; }

/* §34.10 SEQUENTIAL DISCIPLINES — the positioning li reveal in sequence
   after the heading (client answer 2: "each discipline should appear
   separately"); ~210ms steps = meaningful offsets. Specificity sits above
   the generic sibling chains so the beats hold regardless of adjacency. */
/* §34.10 AS EXTENDED §35.7 — FIVE disciplines now: the ladder grows a fifth
   beat; the kicker (the group's first construct-target) and the h2 are
   pinned EXPLICITLY to the generic chain's first/second beats (130/300ms)
   so the intro reads: kicker → heading → 01…05. */
/* §38.5: the kicker's 130ms pre-beat delay hook is retired with the kicker
   (the .positioning-kicker element no longer exists); the title keeps its
   own beat. */
.js [data-construct].is-constructed .positioning-title { transition-delay: 300ms, 300ms; }
.js [data-construct].is-constructed .capability-line li.construct-target:nth-child(1) { transition-delay: 350ms, 350ms; }
.js [data-construct].is-constructed .capability-line li.construct-target:nth-child(2) { transition-delay: 560ms, 560ms; }
.js [data-construct].is-constructed .capability-line li.construct-target:nth-child(3) { transition-delay: 770ms, 770ms; }
.js [data-construct].is-constructed .capability-line li.construct-target:nth-child(4) { transition-delay: 980ms, 980ms; }
.js [data-construct].is-constructed .capability-line li.construct-target:nth-child(5) { transition-delay: 1190ms, 1190ms; }
/* §36.4: the shared panel completes the sequence — it joins at the fifth
   li's beat + one step (the ladder rhythm's 210ms step). */
.js [data-construct].is-constructed .discipline-panel.construct-target { transition-delay: 1400ms, 1400ms; }

/* §34.20.2 TILE ENTRANCE = SIMPLE ZOOM (client: "the tile should be visible,
   just jump forward when scrolled to" — the §34.12 opacity fade + translateY
   retire). The tiles are VISIBLE at rest and throughout: no opacity
   animation, no translate. On construct the tile pops FORWARD with ONE
   scale beat: scale(0.94) → scale(1), ~430ms var(--ease). The per-seat
   column-stagger beats are KEPT (0/150/0/150/300/450ms). The work/news
   targets are the figure.tile.construct-target inside each li's own
   data-construct group; the generic §24.5 rise+fade grammar above still
   serves every non-tile group (disciplines et al). Reduced motion: no
   zoom, instant (reduce block at EOF); JS-off: static visible (html.js
   gate never applies). */
.js #work-grid li[data-construct]:not(.is-constructed) .construct-target,
.js #news-grid li[data-construct]:not(.is-constructed) .construct-target {
  opacity: 1;
  transform: scale(0.94);
}
.js #work-grid li.is-constructed .construct-target,
.js #news-grid li.is-constructed .construct-target {
  opacity: 1;
  transform: scale(1);
  transition: transform 430ms var(--ease) 0ms;
}
.js #work-grid li:nth-child(2).is-constructed .construct-target,
.js #news-grid li:nth-child(2).is-constructed .construct-target { transition-delay: 150ms; }
.js #work-grid li:nth-child(4).is-constructed .construct-target,
.js #news-grid li:nth-child(4).is-constructed .construct-target { transition-delay: 150ms; }
.js #work-grid li:nth-child(5).is-constructed .construct-target,
.js #news-grid li:nth-child(5).is-constructed .construct-target { transition-delay: 300ms; }
.js #work-grid li:nth-child(6).is-constructed .construct-target,
.js #news-grid li:nth-child(6).is-constructed .construct-target { transition-delay: 450ms; }

/* §49.3 CONTACT — construct grammar retargeted to the §34.10 tile jump
   (client: "the contact section elements appear when scrolling to the
   section, I would prefer them to be there already and just animate with
   a little jump like the way the tiles load"): the contact construct
   groups (.contact-head, .contact-cols) keep the §12 observer timing, but
   their construct-targets are NEVER invisible — opacity 1 throughout, and
   the entrance is the tiles' scale(0.94) → scale(1) pop (430ms var(--ease)).
   The generic rise-from-invisible and its 130–440ms staggered delays are
   overridden away inside the contact section. The CONTACT/KONTAK roll law
   and the §20.9 reveal-pill mechanism are UNTOUCHED. */
.js .contact-section [data-construct]:not(.is-constructed) .construct-target {
  opacity: 1;
  transform: scale(0.94);
}
.js .contact-section [data-construct].is-constructed .construct-target {
  opacity: 1;
  transform: scale(1);
  transition: transform 430ms var(--ease) 0ms;
}
.js .contact-section [data-construct].is-constructed .construct-target + .construct-target,
.js .contact-section [data-construct].is-constructed .construct-target + .construct-target + .construct-target {
  transition-delay: 0ms, 0ms;
}

.construct--dark { --sketch-ink: var(--white); }
.sketch-real { --sketch-ink: var(--black); }
.js .construct.is-constructed .sketch-real,
.js [data-construct].is-constructed .sketch-real {
  -webkit-text-stroke-width: 1.5px;
  animation: sketch-to-real 950ms linear both;
}
@keyframes sketch-to-real {
  0%, 52.6% { color: transparent; -webkit-text-stroke-color: var(--sketch-ink); animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1); }
  100% { color: var(--sketch-ink); -webkit-text-stroke-color: transparent; }
}

/* --------------------------------------------------------------------------
   §20 LOADER (§29.8 fixes applied) — JS-injected overlay; CSS pre-styled.
   FIXES: (a) guides/circles animate to opacity 1 — NO transparency on the
   loading state; (b) no bounce/pulse keyframes anywhere — dots ride the
   reveal edge ONCE (their one-shot driver is the ported inline script).
   Overlay exit = single opacity fade 380ms. Reduced motion: the script bails
   (no overlay at all); belt-and-braces CSS below.
   -------------------------------------------------------------------------- */
.loader {
  position: fixed;
  inset: 0;
  z-index: 100;
  display: grid;
  place-items: center;
  background: var(--white);
  pointer-events: none;
  opacity: 1;
}
/* §35.5 SLOWER OPENING: overlay exit 380 → 600ms; the guide/circle fades
   300/320 → 480/500ms; their delays scale ×1.6 too (0.05→0.08s,
   0.18→0.29s, circle+circle 0.26→0.42s). (§37.1: the old 450ms@1.5s
   loader-word rule is retired with the removed text — the overlay is the
   mark alone; the draw itself is the inline script's — START 300/DRAW
   270/STEP 240/HOLD 340.) */
.loader.is-done { opacity: 0; transition: opacity 600ms ease; }
.loader-inner { display: flex; flex-direction: column; align-items: center; gap: 26px; }
.loader-mark { width: min(88vw, 620px); height: auto; overflow: visible; }
.loader-guides line { stroke: var(--black); stroke-width: 2.5; stroke-dasharray: 8 30; opacity: 0; }
.loader-circles circle { fill: none; stroke: var(--black); stroke-width: 3; stroke-dasharray: 13 24; opacity: 0; }
.loader.is-on .loader-guides line { animation: loader-fade 480ms ease 0.08s both; }
.loader.is-on .loader-circles circle { animation: loader-fade 500ms ease 0.29s both; }
.loader-circles circle + circle { animation-delay: 0.42s; }
@keyframes loader-fade { from { opacity: 0; } to { opacity: 1; } } /* §29.8: was .42 */
.loader-letters path { fill: var(--black); stroke: none; }
.loader-dots .dot { opacity: 0; transition: opacity 200ms ease; }
.loader-dots .dot-solid circle { fill: var(--black); }
.loader-dots .dot-counter circle { fill: var(--white); stroke: var(--black); stroke-width: 5; }
/* §37.1: .loader-word + loader-fade-full RETIRED (the text line was removed
   from the overlay string; nothing else animates to full opacity here). */

/* --------------------------------------------------------------------------
   §56 CONTACT LINES — PLAIN ANCHORS (Dillon 2026-10-24: the §20.9 obfuscation
   mechanism is REMOVED — "it seems like overkill now"). The char-code arrays,
   reveal pills, contact-in animation and <noscript> fallbacks are retired;
   the details render as ordinary anchors — visible, clickable, JS-off-safe.
   The discovered §20.9 defect (both phone slots decoded from ONE hardcoded
   CODES.pHref — the JHB pill revealed the STELLENBOSCH number) dies with it.
   -------------------------------------------------------------------------- */
.contact-slot { display: inline-block; }
.contact-slot a {
  display: inline-flex;
  align-items: center;
  min-height: 44px; /* the anchor IS the touch target (the retired pill's law) */
  font-weight: 600;
  text-underline-offset: 4px;
}

/* --------------------------------------------------------------------------
   Dark-field focus + selection overrides (contact section is black)
   -------------------------------------------------------------------------- */
.contact-section :focus-visible { outline-color: var(--white); }
.contact-section ::selection { background: var(--white); color: var(--black); }

/* --------------------------------------------------------------------------
   §35.4 (cycle 2) KEN-BURNS PAN — a continuous, subtle architectural pan
   inside the ACTIVE hero slide (the §35.4 continuity law after the cycle-1
   drift-strip adjudication; layout.css carries the retirement note). The
   pan runs on the IMG ONLY — .hero-tag (figcaption) is a SIBLING inside the
   figure, so the caption stays pinned and fully visible while the photo pans
   beneath it (the cycle-1 clipped-caption failure cannot recur).
   §35.4 never-pause law: there is NO :hover/:focus hook on this animation —
   its only trigger is the JS-owned .is-active, so mouse-active and keyboard
   focus can never pause it (§35.4 honored by construction).
   Pacing: LINEAR timing — an even architectural pan; var(--ease) would
   ease-in/out and read as a lurch at every slide handover. Duration 6800ms
   (≡ 6.8s) > CONFIG.heroIntervalMs 6000ms + the 600ms crossfade, so the pan
   is still moving when the slide hands over — motion never visibly
   completes and rests. §12-restraint territory: max travel is 1.03→1.085
   scale (+1.1% shift) over ~7s — felt, not watched.
   Edge law: the scale FLOOR is 1.03 at every keyframe; object-fit: cover has
   zero overscan at scale(1), so the floor guarantees the img box always
   exceeds the slide box, and the ±1.1% translateX stays inside the overscan
   (worst state: rev from — 1.5% overscan − ≈1.13% shifted ≈ 0.37% cover
   margin; no-exposure verified in capture at 1440/390). overflow: hidden
   stays on .hero; slides are inset 0 (layout.css).
   Variety: even slides (2nd/4th) pan the reversed direction — same scale
   floor, same pace, mirrored travel. JS-off: .is-active never applies
   (slide 1 shows via :first-child display only) — no pan, static.
   Crossfade fit: app.js holds .is-active on the outgoing slide until the
   fade completes (the pre-§35 crossfade contract), so both slides pan
   concurrently during the overlap and the outgoing's animation ends only
   once it is fully covered — no transform snap is ever visible.
   -------------------------------------------------------------------------- */
.hero-slide.is-active img {
  animation: hero-pan 6800ms linear both; /* ≡ 6.8s — still moving at the 6s handover */
}
.hero-slide:nth-child(even).is-active img {
  animation-name: hero-pan-rev; /* only the name flips; pace/duration/fill carry */
}
@keyframes hero-pan {
  from { transform: scale(1.03); }
  to { transform: scale(1.085) translateX(-1.1%); }
}
@keyframes hero-pan-rev {
  from { transform: scale(1.03) translateX(1.1%); }
  to { transform: scale(1.085); }
}

/* --------------------------------------------------------------------------
   Reduced motion: full parity, nothing gated. The global kill-switch lives in
   base.css; these are the component-specific final-state collapses.
   -------------------------------------------------------------------------- */
@media (prefers-reduced-motion: reduce) {
  /* hero: static — the base rules already hold slide 1 only; kill all motion.
     §35.4 cycle-2: this line also zeroes the ken-burns pan on .is-active img
     (the !important here beats the non-important hero-pan shorthand), so the
     reduce state renders the slide static AND complete: no animation, no
     fill-mode transform — the img computes transform: none at scale(1) cover. */
  .hero-track,
  .hero-slide,
  .hero-tag,
  .hero-slide img { transition: none !important; animation: none !important; }

  /* menu: instant, no stagger (display flip still works for the JS toggle) */
  .menu-panel,
  .menu-overlay::before,
  .menu-link { animation: none !important; opacity: 1 !important; transform: none !important; }

  /* §35.3 morph (as refined §36.7): the open state must render INSTANTLY
     correct — stripes hidden, nu visible at full scale. The global
     kill-switch zeroes durations but NOT the 140ms crossfade delay, so the
     delays are removed here and the state swaps are re-declared delay-free
     (explicit state rules — the scoped-state guarantee the §35.3
     verification called for). */
  .m-stripe,
  .m-stripes,
  .m-nu { transition: none !important; animation: none !important; }
  html.menu-open .m-stripes { opacity: 0; }
  html.menu-open .m-nu { opacity: 1; transform: none; }

  /* §31.3 rolls: static EN — the alt faces never engage (WORK/NEWS/CONTACT) */
  .m-roll .roll-alt { display: none !important; }
  .m-roll .roll-face,
  .m-roll .roll-alt { transition: none !important; }

  /* §30.8 draw-on: app.js never arms under reduce — belt and braces */
  .wm-armed clipPath rect { transition: none !important; }

  /* construct: final state immediately (§30.3: tiles have no entry animation;
     §34.20.2: no zoom under reduce either — the tile-specific pre-construct
     scale rule outranks the generic line below, so re-pin it here) */
  .js [data-construct]:not(.is-constructed) .construct-target { opacity: 1; transform: none; }
  .js #work-grid li[data-construct]:not(.is-constructed) .construct-target,
  .js #news-grid li[data-construct]:not(.is-constructed) .construct-target { opacity: 1; transform: none; }
  .js [data-construct].is-constructed .sketch-real { animation: none !important; }

  /* loader: the script bails under reduce — belt and braces */
  .loader { display: none !important; }

  /* §34.13 lightbox as amended §35.11/§35.12: the arrows no longer exist
     (nothing to hide); the dot nav + X carry no entrance animation — they
     render instantly (their hover transitions are zeroed by the global
     kill-switch); the track keeps its native free scroll. */

  /* §30.3 bounce RETIRED (§35.9): the tile frame never moves; the IMAGE
     zoom stays OFF under reduce — static = the rest photo, not an instant
     zoom. */
  .tile { animation: none !important; }
  html.js .tile-media img { transition: none !important; }
  html.js .tile:hover .tile-media img,
  html.js .tile:focus-within .tile-media img { transform: none !important; }

  /* lightbox: entrance/exit are instant state flips */
  .lightbox.is-open .lightbox-backdrop,
  .lightbox.is-open .lightbox-dialog { animation: none !important; }
  .lightbox.is-closing { transition: none !important; }

  /* tiles: instant reveal state (scrim/info appear without transition;
     the at-rest photo-only law holds — hover still reveals, instantly) */
  .tile-media::after,
  .tile-info { transition: none !important; }
}