/* =============================================================================
 * base.css — owned by the lead agent. Section builders must not edit this.
 *
 * Layer order, measured from the mockups (see spec/global.md §2.3):
 *   0  background art / gradients      (each section owns its own)
 *   1  grain          — overlay blend, sits UNDER the UI, not over it
 *   2  vignette       — two crossed linear gradients, NOT a radial
 *   3  page content
 *  70  fixed top bar
 * The grain is below the UI because the mockup's filled button (#2C2C2C) shows
 * sigma = 0.16, i.e. no grain at all, while the page black shows sigma ~= 1.0.
 * ========================================================================== */

/* ------------------------------------------------------------------- fonts */
/* Self-hosted, latin subset. The variable faces MUST declare their weight
 * range or the browser renders the fvar default (Nunito Sans would come out
 * ExtraLight at 200). */
@font-face {
  font-family: 'Roboto Mono';
  src: url('../assets/fonts/robotomono-var.woff2') format('woff2-variations');
  font-weight: 100 700;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: 'Roboto Mono';
  src: url('../assets/fonts/robotomono-italic-var.woff2') format('woff2-variations');
  font-weight: 100 700;
  font-style: italic;
  font-display: swap;
}
@font-face {
  font-family: 'Doto';
  src: url('../assets/fonts/doto-var.woff2') format('woff2-variations');
  font-weight: 100 900;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: 'Anton';
  src: url('../assets/fonts/anton-400.woff2') format('woff2');
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: 'Nunito Sans';
  src: url('../assets/fonts/nunitosans-var.woff2') format('woff2-variations');
  font-weight: 200 1000;
  font-style: normal;
  font-display: swap;
}

*, *::before, *::after { box-sizing: border-box; }

html {
  background: var(--bg-page);
  color: var(--text);
  -webkit-text-size-adjust: 100%;
  scroll-behavior: auto;              /* Lenis owns smooth scrolling */
}

body {
  margin: 0;
  min-height: 100%;
  background: var(--bg-page);
  font-family: var(--f-sans);
  font-size: var(--t-body);
  line-height: 1.55;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  overflow-x: hidden;
}

/* ------------------------------------------------------------- scrollbar */
/* Replaces the OS scrollbar everywhere — page and the case-study panel — with
 * a thin ember one. Firefox gets the standard properties, Chromium the
 * ::-webkit pseudo-elements; both are declared so neither falls back to the
 * default chrome. */
html {
  scrollbar-width: thin;
  scrollbar-color: var(--accent) transparent;
}
/* No scrollbar while the loading overlay is up — there is nothing to scroll to
 * yet, and the bar sitting beside a full-bleed loading screen looks like a
 * rendering artefact. #loading gains .is-gone when the overlay lifts. */
html:has(#loading:not(.is-gone)) { overflow: hidden; }
html:has(#loading:not(.is-gone)) body { overflow: hidden; }
*::-webkit-scrollbar { width: calc(9 * var(--u)); height: calc(9 * var(--u)); }
*::-webkit-scrollbar-track {
  background: rgba(255, 255, 255, .035);
}
*::-webkit-scrollbar-thumb {
  background: linear-gradient(180deg, var(--ember-hot), var(--accent) 55%, var(--accent-deep));
  border: 2px solid transparent;
  background-clip: padding-box;
}
*::-webkit-scrollbar-thumb:hover {
  background: linear-gradient(180deg, var(--ember-core), var(--ember-hot) 55%, var(--accent));
  background-clip: padding-box;
}
*::-webkit-scrollbar-corner { background: transparent; }

img, svg, canvas, video { display: block; max-width: 100%; }
a { color: inherit; text-decoration: none; }
button { font: inherit; color: inherit; background: none; border: 0; cursor: pointer; }
::selection { background: var(--accent); color: #fff; }

html.has-lenis { height: auto; }
html.has-lenis body { min-height: 100vh; }

/* ------------------------------------------------------------------ mounts */
.mount { position: relative; z-index: 3; }
.mount--overlay { position: relative; z-index: 60; }
#mount-loading { z-index: 100; }
#app { position: relative; z-index: 3; }

/* ------------------------------------------------------- grain + vignette */
/* PERFORMANCE: this used to be a permanently-visible fixed full-viewport layer
 * with mix-blend-mode: overlay. That forces the compositor to re-blend the
 * whole viewport on EVERY scrolled frame and was the single largest cause of
 * the page running at ~20fps — neutralising it alone took p50 from 50ms to
 * 16.7ms in a controlled measurement.
 *
 * It turned out to be nearly unnecessary. Measured A/B with the layer off:
 *     loading +.0095   hero +.0106   about +.0013   work 0   contact 0
 *     casestudy -.0161
 * hero/work/contact paint their own calibrated grain into their plates, and the
 * rest score BETTER without a second pass on top. Only the case-study modal
 * needs it — and that modal is open precisely when the page is not scrolling,
 * so the blend costs nothing there.
 *
 * Hence: off by default, enabled only while the modal is up. */
.fx-grain {
  display: none;
  position: fixed;
  inset: 0;
  z-index: 1;
  pointer-events: none;
  opacity: var(--grain-opacity);
  mix-blend-mode: overlay;
  background-repeat: repeat;
  background-size: var(--grain-tile) var(--grain-tile);
  background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='240' height='240'><filter id='g' x='0' y='0' width='100%25' height='100%25'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' seed='7' stitchTiles='stitch'/><feColorMatrix type='saturate' values='0'/></filter><rect width='240' height='240' filter='url(%23g)'/></svg>");
}
/* The case study modal is the one surface that needs the overlay grain. */
html:has(#casestudy.is-open) .fx-grain,
html[data-pose="casestudy"] .fx-grain { display: block; }
.no-grain .fx-grain { display: none !important; }

/* PERFORMANCE: a fixed full-viewport gradient sitting over scrolling content
 * was measured at ~11 of 40 slow frames. Promoting it to its own compositor
 * layer means the scrolling content underneath can be moved without repainting
 * through it. transform/backface hints are the portable way to force that. */
.fx-vignette {
  position: fixed;
  inset: 0;
  z-index: 2;
  pointer-events: none;
  transform: translateZ(0);
  will-change: transform;
  backface-visibility: hidden;
  contain: strict;
  background:
    linear-gradient(90deg,
      rgba(0, 0, 0, var(--vig-l)) 0,
      rgba(0, 0, 0, 0) var(--vig-x),
      rgba(0, 0, 0, 0) calc(100% - var(--vig-x)),
      rgba(0, 0, 0, var(--vig-r)) 100%),
    linear-gradient(180deg,
      rgba(0, 0, 0, var(--vig-t)) 0,
      rgba(0, 0, 0, 0) var(--vig-y),
      rgba(0, 0, 0, 0) calc(100% - var(--vig-y)),
      rgba(0, 0, 0, var(--vig-b)) 100%);
}

/* ------------------------------------------------------------- page chrome */
/* Transparent: the page art runs continuously through it. No bar background,
 * no blur, no bottom border — verified as a smooth gradient across y=10..120. */
.chrome {
  position: fixed;
  inset: 0 0 auto 0;
  z-index: 70;
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  /* padding-top is tuned so the cap-top of both the wordmark and the nav lands
   * on the measured y=47 (cap band 47->58). Verify with tools/chrome_check.py —
   * it was 10px high before this was calibrated. */
  /* --frame-x centres the bar on the same contain-scaled frame the sections
   * centre their content on. Without it the wordmark sat 128.9px left of the
   * gutter it shares a token with at 2560x1440 (158.2px at 2000x1053).
   * Padding is safe HERE specifically: .chrome's children are normal-flow flex
   * items, so nothing resolves against its padding box the way a section's
   * absolutely-positioned content would. It is a literal no-op at the reference
   * aspect, where --frame-x is 0. */
  padding:
    calc(46 * var(--u))
    calc(68 * var(--u) + var(--frame-x))
    0
    calc(var(--gutter) + var(--frame-x));
  pointer-events: none;
}
.chrome > * { pointer-events: auto; }

/* Tucks away while scrolling down so the transparent bar never collides with
 * content passing under it; returns on scroll-up or when you stop. */
.chrome {
  transition:
    transform 420ms var(--e-out),
    opacity 300ms var(--e-out);
}
.chrome.is-tucked {
  transform: translate3d(0, calc(-100% - 10px), 0);
  opacity: 0;
}
.is-shot .chrome, .is-reduced .chrome {
  transform: none !important;
  opacity: 1 !important;
  transition: none !important;
}

.chrome__mark {
  font-family: var(--f-mono);
  font-size: var(--t-wordmark);
  font-weight: var(--wght-wordmark);
  font-variation-settings: 'wght' var(--wght-wordmark);
  letter-spacing: var(--track-wordmark);
  line-height: 1;
  text-transform: uppercase;
  color: var(--text-wordmark);
  white-space: nowrap;
}

.chrome__nav {
  display: flex;
  gap: calc(43 * var(--u));            /* measured layout-box gap */
}
/* Weight and colour are calibrated against measured ink, not eyeballed: at
 * wght 400 / #FFFFFF the CONTACT word rendered 383 ink px with mean 182,
 * against the mockup's ~295 px and mean ~153 — roughly 30% too fat and too
 * bright. Verify with tools/nav_check.py. */
.chrome__nav a {
  position: relative;
  font-family: var(--f-mono);
  font-size: var(--t-nav);
  font-weight: var(--wght-nav);
  font-variation-settings: 'wght' var(--wght-nav);
  letter-spacing: var(--track-nav);
  line-height: 1;
  text-transform: uppercase;
  color: var(--nav-ink);
  white-space: nowrap;
  transition: color var(--d-fast) var(--e-out);
}
/* The active item is marked ONLY by the underline — not by weight or colour. */
/* The underline is centred on the word — that part IS consistent across screens
 * — but its WIDTH and vertical offset are literal per item, set in index.html.
 * Measured halo above/below is 15.5/4.5 (work), 0.0/0.6 (about), 2.4/3.6
 * (contact); a symmetric 5px glow read 22/22 and was far too strong, so the
 * bloom is now a hairline and asymmetric. Re-measure with tools/nav_check.py. */
.chrome__nav a::after {
  content: '';
  position: absolute;
  left: 50%;
  top: calc(100% + (var(--ul-y, 10) * var(--u)));
  width: calc(var(--ul-w, 58) * var(--u));
  height: var(--bar-underline-h);
  background: var(--accent);
  box-shadow: 0 calc(-1 * var(--u)) calc(2 * var(--u)) rgba(226, 86, 32, .22);
  transform: translateX(-50%) scaleX(0);
  transform-origin: center;
  transition: transform var(--d-fast) var(--e-out);
}
.chrome__nav a:hover { color: var(--accent-render); }
.chrome__nav a.is-active::after { transform: translateX(-50%) scaleX(1); }

/* The chrome is the one piece of layout that has to survive every width. At the
 * reference 1586 it lands on the measured anchors (wordmark ink x=46, CONTACT
 * ink ending x=1514); below the desktop breakpoint it tightens rather than
 * wrapping or overflowing. */
@media (max-width: 1023px) {
  .chrome {
    padding: calc(30 * var(--u)) calc(26 * var(--u)) 0 calc(26 * var(--u));
    align-items: center;
  }
  .chrome__mark { font-size: calc(12.5 * var(--u)); }
  .chrome__nav { gap: calc(22 * var(--u)); }
  .chrome__nav a {
    font-size: calc(12.5 * var(--u));
    letter-spacing: 0.12em;
  }
}
@media (max-width: 560px) {
  .chrome { padding-left: calc(22 * var(--u)); padding-right: calc(22 * var(--u)); }
  .chrome__mark { font-size: calc(11 * var(--u)); }
  .chrome__nav { gap: calc(16 * var(--u)); }
  .chrome__nav a { font-size: calc(11 * var(--u)); letter-spacing: 0.1em; }
}
/* Give the nav a real tap target without moving its ink. */
@media (pointer: coarse) {
  .chrome__nav a { padding-block: 12px; margin-block: -12px; }
}

/* ------------------------------------------------------------ type helpers */
.u-mono {
  font-family: var(--f-mono);
  font-weight: 400;
  font-size: var(--t-ui);
  letter-spacing: calc(0.90 * var(--u));   /* 14.5*0.6 + 0.9 = 9.60 advance */
  text-transform: uppercase;
  line-height: 1;
}
.u-meta {
  font-family: var(--f-mono);
  font-weight: 400;
  font-size: var(--t-meta);
  letter-spacing: calc(1.00 * var(--u));   /* 19*0.6 + 1.0 = 12.40 advance */
  text-transform: uppercase;
  line-height: 1;
  color: var(--text-2);
}
.u-micro {
  font-family: var(--f-mono);
  font-weight: 400;
  font-size: var(--t-micro);
  letter-spacing: calc(0.85 * var(--u));
  text-transform: uppercase;
  line-height: 1;
  color: var(--text-2);
}

/* Dot-matrix display type. This is a REAL 5x7 bitmap face (Doto) — it is not a
 * gradient mask clipped to text. Proven in spec/typography.md §2.2: the dots
 * are square, sit on a per-glyph grid, and do not align across a line the way
 * a background mask necessarily would.
 * `white-space: pre` matters — the dot grid must never re-wrap. */
.u-dot {
  font-family: var(--f-dot);
  font-weight: var(--dot-wght);
  font-variation-settings: 'wght' var(--dot-wght);
  text-transform: uppercase;
  line-height: 1;
  color: var(--text-hi);
  white-space: pre;
}

/* Giant hollow display type (ACWRATH / CONTACT / 02). Anton, stroke-only.
 * paint-order keeps the stroke outside the (transparent) fill. */
.u-outline {
  font-family: var(--f-outline);
  font-weight: 400;
  text-transform: uppercase;
  color: transparent;
  -webkit-text-stroke: calc(2 * var(--u)) var(--accent-ember);
  paint-order: stroke fill;
  line-height: 0.86;          /* Anton cap = 0.8594em, so the line box ~= cap */
  user-select: none;
}

/* ------------------------------------------------- centring the layout ---
 * Sections are width:100% and position their content absolutely from their own
 * left edge, so on a viewport wider than the contain-scaled frame everything
 * hugged the left and left a dead black band down the right.
 *
 * Shift the whole section by --frame-x (zero at or below the reference aspect,
 * half the surplus above it) to centre the layout, then counter-shift the
 * full-bleed ART layers so they still reach both viewport edges. Art opts in
 * by being listed here — one place, deliberately, so a new art layer that
 * forgets is visibly wrong rather than subtly off.
 * ------------------------------------------------------------------------- */
/* REVERTED: translating the section and counter-translating a hand-listed set
 * of art layers was wrong twice over — `transform` on the section creates a new
 * containing block (breaking fixed/absolute descendants), and any art layer not
 * on the list stayed shifted, which opened gaps and desynced the plates from
 * the content. Centring has to be solved without moving the sections. */

/* --------------------------------- seam texture carry (TRIED, REVERTED) ---
 * WHY THIS WAS TRIED. Four rounds of seam work tuned LEVEL and every join still
 * read as a hard line. An adversarial audit finally measured it at the scale
 * the eye uses (band-pass blur3-blur17, not a 5-7px high-pass, which is
 * swamped by the 1-2px dither present on both sides) and found the real
 * defect: each section's grain/ember TEXTURE stops dead on the boundary row
 * while the mean level barely moves. work->about measured a flat texture ratio
 * of 4.25/4.35/4.70 at +-20/+-60/+-160px — identical at every scale, which is
 * the signature of a step function with no ramp at all.
 *
 * THE CONSTRUCTION. The instinct is to fade each section's texture out toward
 * its edge, and that is wrong: it puts a texture MINIMUM exactly at the join,
 * which is still a visible band. Instead both sides carry the SAME noise tile
 * at FULL density AT the boundary, decaying away from it into each section.
 * The two half-ramps meet at matching density, so the field is statistically
 * continuous across the row and there is no edge for the eye to find.
 *
 * One tile, one blend mode, so the two sides cannot disagree. `overlay` on a
 * near-black backdrop is a multiply-like no-op on the mean (it perturbs, it
 * does not lift), which is what keeps the level metrics — and the A/B — where
 * they already are. Bands are 260u, matching the longest ramp any section
 * already runs. Six small static layers; nothing animates, nothing repaints.
 *
 * WHAT HAPPENED — do not re-attempt in this form. Six pseudo-element
 * bands (260u, one shared feTurbulence tile, mix-blend-mode: overlay, half
 * ramps meeting at full density on the boundary row) were implemented and
 * measured. Result: the worst join's texture ratio moved only 4.35 -> 3.48
 * against a target of ~1, while ALL FOUR art sections lost A/B — hero .7489 ->
 * .7445, work .6557 -> .6500, about .9494 -> .9394, contact .8609 -> .8555.
 *
 * The decisive datum: HALVING the opacity from .5 to .24 recovered almost
 * nothing (hero .7444 -> .7445). So the cost is not the noise amplitude, it is
 * the blended layer existing at all inside the posed 992 frame — which means
 * no amount of tuning this construction buys back the fidelity.
 *
 * The carry has to live INSIDE each section's own plate (painted into the
 * canvas, the way contact bakes its ember carry) rather than as a blended
 * layer on top of it. That is per-section work, not a lead-owned shortcut.
 */

/* --------------------------------------------------- section seam blending */
/* USER DECISION (2026-08-05): plain black transition bands. The js/seam.js
 * grain carry (a shared document-registered field painted over a 260u band on
 * both sides of every join) made the joins statistically invisible to blind
 * critics, but the user read the added grain as "dirt on the screen" and asked
 * for clean black instead. seam.js/seam.css are unloaded from index.html (the
 * files remain on disk with their full history and calibration).
 *
 * Construction: each section fades its OWN plate into OPAQUE BLACK toward the
 * boundary; both sides reach identical #000 on the join row, so the level is
 * continuous by construction and there is no step for the eye to find. This is
 * NOT the forbidden fade-to-transparent (which put a texture minimum beside a
 * brightness ridge): it is a deliberate, visible black band — a design choice,
 * not a blend trick. 180u tall per side, inside Gate 1's 260u excluded bands,
 * so no masked hold can move. Static gradients: zero per-frame cost. */
#hero::after, #work::after, #about::after,
#work::before, #about::before, #contact::before {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  height: calc(180 * var(--u));
  z-index: 2;
  pointer-events: none;
}
#hero::after, #work::after, #about::after {
  bottom: 0;
  background: linear-gradient(to bottom, rgba(0,0,0,0) 0, rgba(0,0,0,.75) 62%, #000 100%);
}
#work::before, #about::before, #contact::before {
  top: 0;
  background: linear-gradient(to top, rgba(0,0,0,0) 0, rgba(0,0,0,.75) 62%, #000 100%);
}
/* about->contact: about's ember plates render above z2, which buried the band
 * and left a hard art edge on the boundary. These two sides sit over ART ONLY
 * (no text lives within 180u of that join), so they can ride above the plates.
 * hero/work keep z2 — hero's rule+meta live inside its bottom band. */
#about::after, #contact::before { z-index: 50; }
/* about's border box runs --ab-xover (250u) BEHIND #contact (padding-bottom
 * grown, margin-bottom pulled back — see about.css "THE OVERLAP"). bottom: 0
 * put this band inside that hidden overlap, fully covered by contact, which is
 * why the visible join still had a hard edge. Land its #000 edge on the REAL
 * join instead: contact's top = about's padding-box bottom minus the overlap. */
#about::after { bottom: calc(var(--ab-xover, 0) * var(--u)); }

/* --------------------------------------------------- cursor-reactive glow */
/* Set data-cursor-glow on the element; js/core.js feeds it --mx/--my/--mp.
 * Compose the glow with a radial-gradient using those values. Defaults keep
 * the element inert before the first pointermove and under ?shot=1. */
[data-cursor-glow] { --mx: -9999px; --my: -9999px; --mp: 0; }
.is-shot [data-cursor-glow], .is-reduced [data-cursor-glow] { --mp: 0 !important; }

/* ------------------------------------------------------- reveal variants */
/* Opt in per element with a modifier alongside .reveal. All of them animate
 * transform/opacity only, and all collapse to the finished state under
 * ?shot=1 and prefers-reduced-motion (see the .is-shot rule below). */

/* Rises further and settles slower — for headlines. */
.reveal--lift { transform: translate3d(0, calc(46 * var(--u)), 0); }

/* Wipes up from behind its own edge. Needs a wrapper with overflow:hidden,
 * which .reveal--clip provides on the parent. */
.reveal--clip { overflow: hidden; }
.reveal--clip > * {
  display: block;
  transform: translate3d(0, 105%, 0);
  transition: transform var(--d-slow) var(--e-out);
}
.reveal--clip.is-revealed > * { transform: none; }

/* Slides in from the left — for rules, bars and list markers. */
.reveal--wipe {
  transform: none;
  clip-path: inset(0 100% 0 0);
  transition: clip-path var(--d-slow) var(--e-out), opacity var(--d-base) var(--e-out);
}
.reveal--wipe.is-revealed { clip-path: inset(0 0 0 0); }

/* Scales up a hair — for imagery and panels. */
.reveal--zoom { transform: scale(1.045); transform-origin: 50% 60%; }
.reveal--zoom.is-revealed { transform: none; }

/* Per-character stagger. js sets --i on each span; the delay is derived. */
.reveal--chars > span {
  display: inline-block;
  opacity: 0;
  transform: translate3d(0, 0.5em, 0);
  transition:
    opacity 420ms var(--e-out),
    transform 520ms var(--e-out);
  transition-delay: calc(var(--i, 0) * 26ms);
}
.reveal--chars.is-revealed > span { opacity: 1; transform: none; }

.is-shot .reveal--clip > *, .is-reduced .reveal--clip > *,
.is-shot .reveal--chars > span, .is-reduced .reveal--chars > span {
  transform: none !important;
  opacity: 1 !important;
  transition: none !important;
}
.is-shot .reveal--wipe, .is-reduced .reveal--wipe { clip-path: none !important; }

/* Elements driven by core.js's parallax get their transform written every
 * frame; declare the layer up front so it is never promoted mid-scroll. */
[data-parallax] { will-change: transform; }
.is-shot [data-parallax], .is-reduced [data-parallax] { transform: none !important; }

/* ---------------------------------------------------------------- reveal */
.reveal {
  opacity: 0;
  transform: translate3d(0, calc(18 * var(--u)), 0);
  transition:
    opacity var(--d-base) var(--e-out),
    transform var(--d-base) var(--e-out);
  will-change: transform, opacity;
}
.reveal.is-revealed { opacity: 1; transform: none; will-change: auto; }
.is-shot .reveal, .is-reduced .reveal {
  opacity: 1 !important; transform: none !important; transition: none !important;
}

/* -------------------------------------------------------------------- a11y */
.u-sr {
  position: absolute; width: 1px; height: 1px;
  padding: 0; margin: -1px; overflow: hidden;
  clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.001ms !important;
    transition-duration: 0.001ms !important;
  }
}
