/* ═══════════════════════════════════════════════════════════
   APM PORTFOLIO — brutalist.css
   Restyle after "Trend Forecasting Web" (Emote, Dribbble 27425155).
   Stack: vanilla CSS + GSAP/ScrollTrigger/Lenis. No Bootstrap.

   Three type voices, and the tension between them is the identity:
     1. DISPLAY — Anton, uppercase, absurd scale. Headlines, wordmarks.
     2. MICRO   — Space Mono, uppercase, ( BRACKET ) + ( GRAMMAR ). Meta.
     3. BODY    — Inter. Case-study prose only. Stays quiet, stays readable.
   ═══════════════════════════════════════════════════════════ */

/* ── Fonts (self-hosted, preloaded in <head>) ──
   Same discipline as the outgoing sheet: same-origin and preloaded so nothing
   arrives after first paint. Latin subsets only — the display face is used for
   ASCII headlines, so latin-ext would be dead weight.

   URLs are ROOT-ABSOLUTE (/assets/...) rather than relative to this file, so
   the sheet resolves its fonts from wherever it is served — which is what let
   it move from /v2/ to / at cutover. A relative ../ would have broken. */
@font-face {
  font-family: 'Anton';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('/assets/fonts/Anton-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2122, U+2212, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'Space Mono';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('/assets/fonts/SpaceMono-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+20AC, U+2122, U+2212, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'Space Mono';
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url('/assets/fonts/SpaceMono-700-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+20AC, U+2122, U+2212, U+FEFF, U+FFFD;
}
/* Body voice carried over unchanged from the outgoing sheet. */
@font-face {
  font-family: 'Inter';
  font-style: normal;
  font-weight: 400 700;
  font-display: swap;
  src: url('/assets/fonts/Inter-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  /* Inter's latin-ext subset is 83 KB, and this page uses EXACTLY ONE character
     from it: the rupee sign, 14 times in the CLM case study. 83 KB of font for
     one glyph — and woff2 is already compressed, so the CDN's brotli does
     nothing for it. Subset to U+20B9 alone: 83 KB -> 1.2 KB.

     The narrow unicode-range is the load-bearing part. If any other latin-ext
     character is ever added to the page — an accented letter, a dagger, another
     currency sign — it will silently fall back to the system face rather than
     Inter. Re-cut the subset with:
       pyftsubset Inter-latin-ext.woff2 --unicodes=U+20B9,<new> \
         --flavor=woff2 --layout-features='' --output-file=Inter-rupee.woff2 */
  font-family: 'Inter';
  font-style: normal;
  font-weight: 400 700;
  font-display: swap;
  src: url('/assets/fonts/Inter-rupee.woff2') format('woff2');
  unicode-range: U+20B9;
}

/* ── Metrics-matched fallbacks ──
   Carried forward from the outgoing sheet, which fixed a 0.14 CLS. The Inter
   numbers below were measured in-browser and are unchanged.

   Anton and Space Mono are NEW and their size-adjust values are STARTING
   POINTS, not measurements. Anton is matched to Impact (present on macOS and
   Windows, and the closest ultra-condensed grotesque in the system stack);
   Space Mono to Courier New, which is metrically monospaced already so the
   advance width is stable by construction. Both need the same in-browser
   sweep the Inter values got — block the web fonts in devtools and step
   size-adjust until the display line-break positions stop moving. Display type
   at 18vw amplifies any mismatch, so this is worth doing properly.
   TODO(verify): sweep Anton size-adjust against real rendered geometry. */
@font-face {
  font-family: 'Anton Fallback';
  font-weight: 400;
  src: local('Impact'), local('Haettenschweiler'), local('Arial Narrow Bold');
  size-adjust: 92%;
  ascent-override: 100%;
  descent-override: 22%;
  line-gap-override: 0%;
}
@font-face {
  font-family: 'Mono Fallback';
  font-weight: 400 700;
  src: local('Courier New'), local('Menlo'), local('DejaVu Sans Mono');
  size-adjust: 96%;
  line-gap-override: 0%;
}
@font-face {
  font-family: 'Inter Fallback';
  font-weight: 400 500;
  src: local('Arial'), local('Helvetica'), local('Liberation Sans');
  size-adjust: 108.0%;
  line-gap-override: 0%;
}
@font-face {
  font-family: 'Inter Fallback';
  font-weight: 600 700;
  src: local('Arial'), local('Helvetica'), local('Liberation Sans');
  size-adjust: 104.7%;
  line-gap-override: 0%;
}

/* ═══════════════════════════════════════════════════════════
   TOKENS
   ═══════════════════════════════════════════════════════════ */
/* Values below were sampled from the reference frames themselves, NOT from the
   palette Dribbble lists on the shot page. That list is auto-extracted and is
   materially wrong: it reports the yellow as #EBA800 (a muddy amber) when the
   actual rendered panel is #FACC03, and the red as #E21806 when the actual
   display type is #FF4427. Building from the listed palette produced a
   noticeably duller page than the reference. */
:root {
  --ink:    #030303;  /* canvas type, black blocks, pills */
  --paper:  #FFFFFF;  /* the canvas — dominant surface */
  --signal: #FACC03;  /* yellow blocks and panels — BACKGROUND ONLY, see below */
  --alarm:  #FF4427;  /* display accent — LARGE TEXT AND FILLS ONLY */
  --alarm-ink: #E21806; /* the same hue, darkened, for text below display scale */
  --panel:  #EBEBEB;  /* grey showcase panels, card fills */
  --mute:   #8A8580;  /* mono micro-type at rest */
  --rule:   rgba(3, 3, 3, 0.14);

  --font-display: 'Anton', 'Anton Fallback', Impact, sans-serif;
  --font-mono:    'Space Mono', 'Mono Fallback', ui-monospace, monospace;
  --font-body:    'Inter', 'Inter Fallback', system-ui, sans-serif;

  --radius: 28px;
  --gutter: clamp(1rem, 4vw, 4rem);
  --ease-out: cubic-bezier(0.16, 1, 0.3, 1);

  /* Hero display size. Tuned for a SIX-letter word — a longer one needs a
     smaller value, so this is per-word by design, not a type-scale step. */
  --hero-fs: clamp(3.5rem, 27vw, 26rem);

  /* Marquee durations, centralised so the reduced-motion block and the JS
     guard can both reason about one source of truth. */
  --marquee-h: 38s;
  --marquee-v: 46s;
}

/* ── Dark ──
   Brutalism inverts cleanly, but this is a second authored palette, not a
   filter. The one simplification: on black the bright red already clears AA at
   every size, so dark mode does not need the darkened text variant that light
   mode does. See the contrast contract below. */
[data-theme="dark"] {
  --ink:    #FFFFFF;
  --paper:  #030303;
  --signal: #FACC03;
  --alarm:  #FF4427;
  /* On black the bright red is already 6.00:1, so dark mode needs no darkened
     variant — the two red tokens collapse into one. */
  --alarm-ink: #FF4427;
  --panel:  #141414;
  --mute:   #8A8580;
  --rule:   rgba(255, 255, 255, 0.16);
}

/*  ⚠ CONTRAST CONTRACT — measured, not guessed. Do not change a colour here
    without re-running the numbers.

      black on --signal ............ 13.46:1   ✓ any size
      --signal on white ............  1.53:1   ✗ FAILS AT EVERY SIZE
      --alarm on white .............  3.44:1   ✓ large text only (>=24px)
      white on --alarm .............  3.44:1   ✗ fails for UI-sized text
      black on --alarm .............  6.11:1   ✓ any size
      --alarm-ink on white .........  4.81:1   ✓ any size
      --alarm on black .............  6.00:1   ✓ any size

    Consequences, which the rest of this file obeys:
      1. --signal is a BACKGROUND with --ink on it, or a decorative fill.
         It is never a text colour, at any size, in either theme.
      2. --alarm is for DISPLAY-SCALE type and fills. This is exactly how the
         reference uses it — every red word in the shot is enormous.
      3. Anything red below display scale uses --alarm-ink.
      4. Text on a red fill is --ink, never white.
      5. --alarm is NEVER placed on --signal. That pairing is 2.24:1 — worse
         than either colour on white. The reference does exactly this on its
         phone screen; the restyle declines to copy it. Red and yellow only
         ever meet across a black or white boundary.
    The verification step greps for violations of 1, 3 and 5. */

/* ═══════════════════════════════════════════════════════════
   RESET + BASE
   ═══════════════════════════════════════════════════════════ */
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }

/* NOTE: the outgoing sheet set `html { scroll-behavior: smooth }`. That fights
   Lenis — native smooth scrolling and Lenis's rAF loop both try to own the
   scroll position, which produces a visible tug-of-war on anchor jumps. Lenis
   owns scrolling here, so this is deliberately absent. The reduced-motion
   block at the end of this file hands it back when Lenis is disabled. */
html {
  -webkit-text-size-adjust: 100%;
  /* `clip`, not `hidden`. Both stop the marquees widening the page, but
     `overflow-x: hidden` turns the element into a SCROLL CONTAINER, which:
       - constrains every `position: sticky` descendant to that container
         (the case-study numerals depend on sticky), and
       - moves the scrollport off the viewport, which upsets scroll anchoring
         and Lenis's position bookkeeping.
     `overflow-x: clip` clips without creating a scroll container, which is
     what is actually wanted here. Note it must sit on html rather than body:
     on body, overflow propagates to the viewport and the distinction is lost. */
  overflow-x: clip;
}

body {
  background: var(--paper);
  color: var(--ink);
  font-family: var(--font-body);
  font-size: 1rem;
  line-height: 1.6;
}

/* Lenis owns the scroll; these are its required hooks. */
html.lenis, html.lenis body { height: auto; }
.lenis.lenis-smooth { scroll-behavior: auto !important; }
.lenis.lenis-stopped { overflow: hidden; }

a { color: inherit; text-decoration: none; }
img, svg { max-width: 100%; display: block; }
button { font: inherit; color: inherit; background: none; border: 0; cursor: pointer; }

/* Focus must survive on top of black blocks — --alarm reads against both
   --ink and --paper, so one ring colour works on every surface. */
:focus-visible {
  outline: 3px solid var(--alarm-ink);
  outline-offset: 3px;
}

::selection { background: var(--signal); color: #030303; }

/* ═══════════════════════════════════════════════════════════
   TYPE VOICES
   ═══════════════════════════════════════════════════════════ */

/* Voice 1 — DISPLAY */
.display {
  font-family: var(--font-display);
  font-weight: 400;
  text-transform: uppercase;
  line-height: 0.82;
  letter-spacing: -0.015em;
  overflow-wrap: anywhere;
}
.display--hero { font-size: var(--hero-fs, clamp(4rem, 18vw, 20rem)); }
.display--xl   { font-size: clamp(3rem, 11vw, 11rem); }
.display--lg   { font-size: clamp(2.25rem, 6vw, 5.5rem); }
/* 0.82 is right for a single-line display word, but these are the sizes used
   for real titles, which wrap — and at 0.82 the second line's caps collide with
   the first line's baseline. Anything that can wrap gets 0.95. */
.display--md   { font-size: clamp(1.75rem, 3.4vw, 3rem); line-height: 0.95; }
.display--alarm { color: var(--alarm); }

/* Voice 2 — MICRO. The ( BRACKET ) grammar is content, not decoration, so it
   lives in the markup rather than in ::before/::after — screen readers should
   read the label, and the brackets are part of how the label reads. */
.mono {
  font-family: var(--font-mono);
  font-size: 0.7rem;
  line-height: 1.5;
  letter-spacing: 0.08em;
  text-transform: uppercase;
}
.mono--sm { font-size: 0.62rem; letter-spacing: 0.1em; }
.mono--lg { font-size: 0.82rem; }
.mono--mute { color: var(--mute); }

/* Voice 3 — BODY. Deliberately unremarkable. The case studies are the
   substance and must survive the styling. */
.prose {
  font-family: var(--font-body);
  font-size: 0.97rem;
  line-height: 1.7;
  max-width: 68ch;
}
.prose strong { font-weight: 600; }

/* ═══════════════════════════════════════════════════════════
   LAYOUT PRIMITIVES
   ═══════════════════════════════════════════════════════════ */
.shell { padding-inline: var(--gutter); }
.bleed { width: 100%; }

.section {
  position: relative;
  padding-block: clamp(4rem, 10vw, 9rem);
}

.section__eyebrow {
  display: flex;
  gap: 0.75rem;
  align-items: center;
  margin-bottom: 1.25rem;
  color: var(--mute);
}

.rule { height: 1px; background: var(--rule); border: 0; }

/* Big rounded panels that break the grid. */
.panel {
  border-radius: var(--radius);
  position: relative;
  overflow: hidden;
}
.panel--signal { background: var(--signal); color: #030303; }
.panel--ink    { background: var(--ink);    color: var(--paper); }
.panel--grey   { background: var(--panel); }

/* Pill buttons — fully round, high contrast, magnetic on pointer-fine (A12). */
.pill {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.9rem 1.75rem;
  border-radius: 999px;
  background: var(--ink);
  color: var(--paper);
  font-family: var(--font-mono);
  font-size: 0.72rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  white-space: nowrap;
  transition: background 0.3s var(--ease-out), color 0.3s var(--ease-out);
}
/* --alarm-ink, not --alarm: pill labels are 0.72rem, and white on the bright
   red is only 3.44:1. The darkened hue is 4.81:1 and reads identically.
   will-change is scoped to hover: declaring it permanently on every pill keeps
   a compositor layer alive for each one for the whole session, which is exactly
   the cost will-change is supposed to avoid. */
.pill:hover { background: var(--alarm-ink); color: #FFFFFF; will-change: transform; }
.pill--ghost {
  background: transparent;
  color: var(--ink);
  box-shadow: inset 0 0 0 1.5px currentColor;
}
.pill--ghost:hover { background: var(--ink); color: var(--paper); box-shadow: none; }
/* ── Theme-invariant surfaces ──
   --signal is the one token that does NOT invert between themes, so anything
   sitting on a yellow panel must not invert either. Left to the normal tokens,
   a solid pill in dark mode becomes white-on-yellow, which is 1.53:1 as a shape
   — the pill all but disappears into the panel. Everything on yellow is
   therefore hard-coded to the light-mode relationship in both themes.
   That covers all three always-yellow surfaces: the hero panel, the phone
   screen, and the project cards. */
.panel--signal .pill,
.phone__screen .pill,
.project-card .pill {
  background: #030303;
  color: #FFFFFF;
}
.panel--signal .pill:hover,
.phone__screen .pill:hover,
.project-card .pill:hover { background: var(--alarm-ink); color: #FFFFFF; }
.panel--signal .pill--ghost {
  background: transparent;
  color: #030303;
  box-shadow: inset 0 0 0 1.5px #030303;
}
.panel--signal .pill--ghost:hover { background: #030303; color: #FFFFFF; box-shadow: none; }

/* ═══════════════════════════════════════════════════════════
   MARQUEE (A4)
   Duplicated track + pure CSS translate. Cheaper than JS, and one media query
   at the end of this file stops every instance. The duplicate is aria-hidden
   in the markup; the track itself is decorative repetition, so the whole
   component is hidden from the accessibility tree.
   ═══════════════════════════════════════════════════════════ */
.marquee {
  --marquee-dur: var(--marquee-h);
  position: relative;
  display: flex;
  overflow: hidden;
  user-select: none;
  -webkit-mask-image: linear-gradient(90deg, transparent, #000 6%, #000 94%, transparent);
          mask-image: linear-gradient(90deg, transparent, #000 6%, #000 94%, transparent);
}
.marquee__track {
  display: flex;
  flex: 0 0 auto;
  gap: 1.5rem;
  padding-right: 1.5rem;
  white-space: nowrap;
  animation: marquee-x var(--marquee-dur) linear infinite;
  animation-play-state: var(--marquee-state, running);
}
.marquee--reverse .marquee__track { animation-direction: reverse; }
.marquee--nomask { -webkit-mask-image: none; mask-image: none; }

@keyframes marquee-x {
  from { transform: translate3d(0, 0, 0); }
  to   { transform: translate3d(-50%, 0, 0); }
}

/* Vertical pair running up both edges of the hero panel, mirrored on the
   right exactly as the reference does it. */
.marquee--v {
  --marquee-dur: var(--marquee-v);
  position: absolute;
  top: 0;
  bottom: 0;
  width: 2.25rem;
  flex-direction: column;
  -webkit-mask-image: linear-gradient(180deg, transparent, #000 8%, #000 92%, transparent);
          mask-image: linear-gradient(180deg, transparent, #000 8%, #000 92%, transparent);
}
.marquee--v .marquee__track {
  flex-direction: column;
  padding-right: 0;
  padding-bottom: 1.5rem;
  animation-name: marquee-y;
  writing-mode: vertical-rl;
}
.marquee--v-left  { left: 0;  }
.marquee--v-right { right: 0; transform: rotate(180deg); }
.marquee--v-right .marquee__track { animation-direction: reverse; }

@keyframes marquee-y {
  from { transform: translate3d(0, 0, 0); }
  to   { transform: translate3d(0, -50%, 0); }
}

/* Infinite animations pause when their element is off-screen. Marquees and the
   spinning bloom otherwise composite forever, burning battery animating content
   nobody is looking at. The class is toggled by an IntersectionObserver. */
.is-paused .marquee__track,
.is-paused .chiptrack,
.is-paused > g { animation-play-state: paused; }

/* Full-width band used as a section divider. */
.marquee-band {
  border-block: 1px solid var(--rule);
  padding-block: 0.7rem;
}

/* ═══════════════════════════════════════════════════════════
   NAV
   ═══════════════════════════════════════════════════════════ */
.nav {
  position: fixed;
  inset: 0 0 auto 0;
  z-index: 100;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  padding: 1.1rem var(--gutter);
  background: var(--paper);
  border-bottom: 1px solid var(--rule);
}
.nav__mark {
  font-family: var(--font-mono);
  font-weight: 700;
  font-size: 0.78rem;
  letter-spacing: 0.14em;
  text-transform: uppercase;
}
.nav__links { display: flex; gap: 1.1rem; }
.nav__link { color: var(--mute); transition: color 0.25s var(--ease-out); }
.nav__link:hover, .nav__link[aria-current="true"] { color: var(--ink); }
.nav__right { display: flex; align-items: center; gap: 1rem; }

/* Progress bar — carried over from the outgoing site, restyled. Sits on the
   nav's bottom rule rather than above it. */
.progress {
  position: fixed;
  top: 0; left: 0;
  height: 3px;
  width: 100%;
  background: var(--alarm);
  z-index: 101;
  /* scaleX rather than width. Animating `width` on every scroll event dirties
     layout each frame; a transform is composited and touches neither layout nor
     paint. */
  transform: scaleX(0);
  transform-origin: 0 50%;
  will-change: transform;
}

@media (max-width: 860px) {
  .nav__links { display: none; }
}

/* ═══════════════════════════════════════════════════════════
   FOOTER WORDMARK (A11)
   Bleeds off both edges. clip-path wipe on enter.
   ═══════════════════════════════════════════════════════════ */
.wordmark {
  font-family: var(--font-display);
  text-transform: uppercase;
  line-height: 0.78;
  letter-spacing: -0.02em;
  font-size: clamp(3.5rem, 15.5vw, 17rem);
  white-space: nowrap;
  display: block;
  margin-inline: calc(var(--gutter) * -1);
  text-align: center;
}
/* Gated CSS, like the display letters, and for the same reason. This used to be
   a GSAP fromTo, which applies `clip-path: inset(0 0 100% 0)` the instant it is
   created — so a trigger that never fired left the wordmark clipped to nothing,
   permanently invisible, on the very last element of the page. The resting state
   is now unclipped; the class only ever adds motion. */
.is-mark-in {
  animation: mark-wipe 1.2s cubic-bezier(0.16, 1, 0.3, 1) both;
}
@keyframes mark-wipe {
  from { clip-path: inset(0 0 100% 0); transform: scale(0.94); }
  to   { clip-path: inset(0 0 0% 0);   transform: scale(1); }
}

/* ═══════════════════════════════════════════════════════════
   REVEAL BASELINE
   Elements start hidden ONLY when JS is driving them. If GSAP fails to load or
   is blocked, .js-anim is never set and everything renders in final state —
   the site must never be able to strand content at opacity 0.
   ═══════════════════════════════════════════════════════════ */
/* Reveals are CSS transitions gated on .is-in, added by an IntersectionObserver.
   GSAP is deliberately not involved. Three problems went away with it:

     1. Stranded content. A GSAP tween only renders while its ticker is running;
        parked mid-flight it leaves elements at partial or zero opacity while
        reporting itself as fired. Observed repeatedly. A CSS transition either
        runs or the element is simply already visible — it has no failure state
        that hides content.
     2. Dead hover effects. GSAP animates INLINE styles, so a revealed element
        kept `transform: translate(0px, 0px)`, and an inline transform outranks
        any `:hover { transform }`. The blob lift and the case-row nudge were
        both silently dead. Nothing here writes inline styles.
     3. Cost. This was ~18 tweens plus 18 ScrollTriggers doing work that two
        CSS properties and one observer do for free.

   GSAP now only drives scroll parallax, where a dropped frame costs an offset
   and can never hide anything. */
.js-anim [data-reveal] {
  transition:
    opacity 0.9s var(--ease-out),
    transform 0.9s var(--ease-out);
  transition-delay: calc(var(--g, 0) * 0.07s);
}
.js-anim [data-reveal]:not(.is-in) { opacity: 0; }
.js-anim [data-reveal="up"]:not(.is-in) { transform: translateY(2rem); }
/* Explicit end state so the transition has somewhere to go, and so no inline
   style is ever needed to hold it there. */
.js-anim [data-reveal].is-in { opacity: 1; transform: none; }

/* Per-letter mask-up for the display headings (A2). Letters are wrapped in the
   HTML, not injected on load — injecting them reflows the hero and re-breaks
   the CLS fix in commit edc58ae.

   ── Why this is CSS and not GSAP ──
   The hero's load-in used to be a GSAP timeline. GSAP tweens only render while
   its ticker is running, and a stalled ticker leaves every tween parked on its
   FROM state — which for a mask-up means the headline is pushed out of its own
   clipping box and the hero renders completely blank. That was observed here,
   and it is an unacceptable failure mode for the first thing on the page.

   A CSS animation cannot fail that way. It is declarative, it runs off the
   compositor rather than a JS loop, and `animation-fill-mode: both` guarantees
   the end state even if the page is backgrounded, throttled, or JS never runs
   at all. The stagger comes from a per-letter --i set in the markup.

   Scroll-DRIVEN motion stays in GSAP, where a missed frame only costs a
   parallax offset and never hides content. */
/* The clip box for the mask-up reveal has to be TALLER than the line box, or it
   shaves the tops off the glyphs.

   Measured: at font-size 388.8px with line-height 0.82 the .split box is 318.8px
   while the letter's own content is 451px — so ~70px of ascent was being clipped,
   which ate roughly 15% of Anton's cap height. Visible as flattened tops on the
   round letters (the G and O of GROWTH) at every viewport.

   padding-top grows the padding box that overflow:hidden clips against; the
   equal negative margin cancels its effect on layout, so nothing moves. Stated
   in em so it tracks the font size instead of needing a value per breakpoint. */
.split {
  display: inline-block;
  overflow: hidden;
  vertical-align: bottom;
  padding-top: 0.22em;
  margin-top: -0.22em;
}
.split__i { display: inline-block; }

/* The animation is GATED ON A CLASS, and the resting state carries no transform.
   Three properties fall out of that, all of which we want:

     1. No JS, blocked CDN, or a thrown error -> the class is never added, no
        animation ever runs, and the letters are simply visible. There is no
        state in which a heading can be stranded outside its clipping box.
     2. The animation starts when the heading is SCROLLED TO rather than on
        page load. Putting the animation on .split__i unconditionally meant
        every heading on the page played its stagger immediately at load, so
        by the time you reached IMPACT or CASES it had long since finished.
     3. It stays off the JS ticker, which is the reason the hero moved to CSS
        in the first place.

   The class is added by an IntersectionObserver, deliberately not by GSAP. */
.is-split-in .split__i {
  will-change: transform;
  animation: letter-up 1.05s cubic-bezier(0.16, 1, 0.3, 1) both;
  animation-delay: calc(var(--i, 0) * 0.04s);
}
@keyframes letter-up {
  from { transform: translateY(110%); }
  to   { transform: translateY(0); }
}

/* Hero panel and phone rise in, panel first. Same reasoning as above: these
   carry the whole hero composition, so their entrance is CSS. */
.hero__panel {
  animation: hero-rise 1.15s cubic-bezier(0.16, 1, 0.3, 1) 0.1s both;
}
/* fill-mode `backwards`, NOT `both`. A CSS animation outranks inline styles in
   the cascade, so a `both` fill would keep applying its final keyframe forever
   and permanently beat the inline transform GSAP writes for the scroll
   parallax — the phone would simply never move. `backwards` holds the FROM
   state during the delay and then stops applying entirely once the animation
   finishes, handing the transform back to the base rule and to GSAP. */
.phone {
  animation: hero-rise-phone 1.25s cubic-bezier(0.16, 1, 0.3, 1) 0.25s backwards;
}
@keyframes hero-rise {
  from { opacity: 0; transform: translateY(3.5rem); }
  to   { opacity: 1; transform: translateY(0); }
}
/* The phone's resting transform is translate(-50%, -8%), so its keyframes must
   restate the x centring or it snaps to the left on the final frame. */
@keyframes hero-rise-phone {
  from { opacity: 0; transform: translate(-50%, 6%); }
  to   { opacity: 1; transform: translate(-50%, -8%); }
}

/* ═══════════════════════════════════════════════════════════
   REDUCED MOTION (A13)
   The CSS half of the kill switch. The JS half declines to construct Lenis or
   any ScrollTrigger at all. Between them: no marquees, no stagger, no pins,
   no parallax, and native smooth scroll handed back to the browser.
   ═══════════════════════════════════════════════════════════ */
@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: smooth; scroll-padding-top: 6rem; }

  *, *::before, *::after {
    animation-duration: 0.001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.001ms !important;
    scroll-behavior: auto !important;
  }

  /* Park every animated element in its resting state. The :not(.is-in) variants
     are matched here too, since with motion off nothing ever adds .is-in. */
  .js-anim [data-reveal],
  .js-anim [data-reveal]:not(.is-in),
  .js-anim .split__i {
    opacity: 1 !important;
    transform: none !important;
  }
  .marquee__track { animation: none !important; }
  /* The hero load-in is CSS now, so reduced motion parks it here rather than
     relying on the JS guard. */
  .split__i, .hero__panel, .phone, .is-mark-in { animation: none !important; }
  .wordmark { clip-path: none !important; transform: none !important; }
  /* A single chip, parked, instead of a carousel. */
  .chiptrack { animation: none !important; transform: none !important; }
}

/* ═══════════════════════════════════════════════════════════
   HERO — A3 / A5

   The occlusion is pure CSS and needs no JS: the display word sits at z-index 1
   and the opaque yellow panel at z-index 2 with a negative top margin, so the
   panel eats the bottom third of the letters. The phone then sits above both at
   z-index 3, pushed up far enough to break the panel's top edge.
   ═══════════════════════════════════════════════════════════ */
.hero {
  position: relative;
  padding-top: clamp(5.5rem, 11vh, 8rem);
  padding-inline: var(--gutter);
  /* .hero__display is `white-space: nowrap` at 18vw with a negative inline
     margin, so the letters bleed past the viewport at small widths and must be
     clipped — but ONLY horizontally. Clipping vertically here would fight the
     fold, which owns the bottom edge via clip-path and needs the box itself
     left alone.

     `visible` + `clip` is the single legal mixed pair (with `hidden` the
     visible axis would compute to `auto` and grow a scrollbar). Browsers
     without `clip` keep the `hidden` fallback above: they lose the bleed, not
     the layout. */
  overflow: hidden;
  overflow: clip visible;
}

.hero__display {
  position: relative;
  z-index: 1;
  text-align: center;
  white-space: nowrap;
  /* Optical centring: the display face has more visual weight on the left, and
     nowrap at 18vw means the glyphs can exceed the shell — that is intended. */
  margin-inline: calc(var(--gutter) * -1);
}

.hero__stage {
  position: relative;
  z-index: 2;
  /* The overlap. Scales with viewport so the occlusion depth stays proportional
     to the type size rather than drifting at wide widths. */
  margin-top: clamp(-9rem, -7vw, -2.5rem);
}

.hero__panel {
  position: relative;
  display: grid;
  grid-template-columns: 1fr minmax(0, 15rem) 1fr;
  gap: clamp(1rem, 3vw, 3rem);
  align-items: start;
  min-height: clamp(20rem, 42vw, 30rem);
  padding: clamp(2rem, 4vw, 3.5rem) clamp(3rem, 5vw, 4.5rem);
}

.hero__col { display: flex; flex-direction: column; gap: 1.75rem; }
.hero__col--right { align-self: end; text-align: right; }
.hero__col--left  { grid-column: 1; }
.hero__col--right { grid-column: 3; }

.hero__lede { max-width: 32ch; line-height: 1.7; }
.hero__col--right .hero__lede { margin-left: auto; }

.hero__ctas { display: flex; flex-wrap: wrap; gap: 0.75rem; }

@media (max-width: 860px) {
  .hero__panel {
    grid-template-columns: 1fr;
    padding-inline: clamp(1.25rem, 6vw, 2rem);
  }
  .hero__col--left  { grid-column: 1; }
  .hero__col--right { grid-column: 1; text-align: left; }
  .hero__col--right .hero__lede { margin-left: 0; }
  .marquee--v { display: none; } /* no room for edge marquees at this width */
}

/* ═══════════════════════════════════════════════════════════
   PHONE (A3b) — pure CSS device. No image asset.
   ═══════════════════════════════════════════════════════════ */
/* Two elements, one transform each — see the phone__rig comment in index.html.
   .phone: positioning + the CSS entrance animation.
   .phone__rig: nothing, until GSAP puts the scroll parallax on it. */
.phone {
  position: absolute;
  left: 50%;
  bottom: 0;
  z-index: 3;
  width: clamp(13rem, 20vw, 17rem);
  /* Pushed up so the frame breaks the panel's top edge, matching the reference. */
  transform: translate(-50%, -8%);
}
.phone__rig { will-change: transform; }

.phone__frame {
  position: relative;
  aspect-ratio: 41 / 84;
  border-radius: clamp(1.75rem, 3vw, 2.5rem);
  padding: 0.42rem;
  background: linear-gradient(150deg, #4a4a4f, #17171a 32%, #37373c 66%, #101013);
  box-shadow:
    0 0 0 1px rgba(255, 255, 255, 0.14) inset,
    0 2rem 4rem rgba(0, 0, 0, 0.28);
}

.phone__island {
  position: absolute;
  top: 1.05rem;
  left: 50%;
  transform: translateX(-50%);
  width: 32%;
  height: 1.1rem;
  border-radius: 999px;
  background: #08080a;
  z-index: 3;
}

.phone__screen {
  position: relative;
  height: 100%;
  border-radius: clamp(1.4rem, 2.5vw, 2.1rem);
  background: var(--signal);
  color: #030303;
  overflow: hidden;
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: 0.85rem 0.7rem 1rem;
}

.phone__status {
  display: flex;
  justify-content: space-between;
  width: 100%;
  font-size: 0.5rem;
  letter-spacing: 0.06em;
}

.phone__word {
  margin-top: 1.15rem;
  font-size: clamp(1.9rem, 3.4vw, 2.9rem);
  line-height: 0.9;
  letter-spacing: -0.02em;
}

/* Mono labels running down both screen edges, as in the reference's app frame. */
.phone__edge {
  position: absolute;
  top: 26%;
  font-size: 0.42rem;
  letter-spacing: 0.14em;
  writing-mode: vertical-rl;
  color: rgba(3, 3, 3, 0.62);
}
.phone__edge--l { left: 0.45rem; transform: rotate(180deg); }
.phone__edge--r { right: 0.45rem; }

/* The living element — the reference's chip carousel. Same duplicated-track
   technique as the page marquees, so one reduced-motion rule stops both. The
   track holds two copies of six chips, hence -50%. */
.phone__chips {
  position: absolute;
  bottom: 3.6rem;
  left: 0;
  right: 0;
  overflow: hidden;
}
/* The spacing here is load-bearing for the loop, not styling preference.

   `translate3d(-50%)` only wraps invisibly if the first half is EXACTLY half
   the track's border box. Two things used to break that, and together they
   produced a visible hitch every cycle, right as the first chip came round:

     - `gap` puts space only BETWEEN items, so twelve chips have eleven gaps
       and 50% of the track is 6 chips + 5.5 gaps — while wrapping needs
       6 chips + 6 gaps. Half a gap short, every cycle. Now each chip carries
       its own trailing margin, so one half is exactly 6 x (chip + margin).
     - The old `padding-left: 0.6rem` inset counted toward the 50% while
       appearing only once. Moving it to `margin-left` fixed the arithmetic but
       not the seam: an inset makes translate-0 a SPECIAL FRAME, showing an
       empty gutter where every other frame of the loop shows the tail of the
       previous chip. That is a flicker once per cycle. The inset is gone
       entirely rather than compensated for — it was only ever visible in that
       one frame, so it was buying nothing. */
.chiptrack {
  display: flex;
  width: max-content;
  /* 28s for twelve chips is the same linear speed the six ran at in 14s. */
  animation: chip-x 28s linear infinite;
}
@keyframes chip-x {
  from { transform: translate3d(0, 0, 0); }
  to   { transform: translate3d(-50%, 0, 0); }
}

.chip {
  flex: 0 0 auto;
  /* Not `gap` on the track — see the loop maths there. */
  margin-right: 0.4rem;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  width: 3.9rem;
  height: 3.9rem;
  border-radius: 50%;
  font-family: var(--font-mono);
  font-size: 0.4rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  line-height: 1.3;
}
.chip b { font-family: var(--font-display); font-size: 0.86rem; letter-spacing: 0; }
.chip--alarm { background: var(--alarm); color: #030303; }
.chip--ink   { background: #030303;     color: #FFFFFF; }
.chip--paper { background: #FFFFFF;     color: #030303; }

.phone__cta {
  margin-top: auto;
  width: calc(100% - 1rem);
  justify-content: center;
  padding: 0.55rem 1rem;
  font-size: 0.5rem;
}

/* Below 860px a phone-inside-a-phone reads badly, so the device frame is
   dropped and the hero panel carries its own content directly. */
@media (max-width: 860px) {
  .phone { display: none; }
  .hero__panel { min-height: 0; }
}

/* ═══════════════════════════════════════════════════════════
   THEME SWITCH + BACK TO TOP
   ═══════════════════════════════════════════════════════════ */
.theme-switch { display: inline-flex; cursor: pointer; }
.theme-switch input {
  position: absolute;
  width: 1px; height: 1px;
  opacity: 0;
  /* Kept in the layer so :focus-visible still lands on the visible track. */
}
.theme-switch__track {
  width: 2.4rem; height: 1.2rem;
  border-radius: 999px;
  box-shadow: inset 0 0 0 1.5px var(--ink);
  position: relative;
  transition: background 0.3s var(--ease-out);
}
.theme-switch__track::after {
  content: '';
  position: absolute;
  top: 0.2rem; left: 0.2rem;
  width: 0.8rem; height: 0.8rem;
  border-radius: 50%;
  background: var(--ink);
  transition: transform 0.3s var(--ease-out);
}
.theme-switch input:checked + .theme-switch__track { background: var(--signal); }
.theme-switch input:checked + .theme-switch__track::after { transform: translateX(1.2rem); }
.theme-switch input:focus-visible + .theme-switch__track {
  outline: 3px solid var(--alarm-ink);
  outline-offset: 3px;
}

.pill--sm { padding: 0.55rem 1.1rem; font-size: 0.64rem; }

.to-top {
  position: fixed;
  right: var(--gutter);
  bottom: 1.5rem;
  z-index: 99;
  width: 2.75rem; height: 2.75rem;
  border-radius: 50%;
  background: var(--ink);
  color: var(--paper);
  font-size: 1rem;
  opacity: 0;
  pointer-events: none;
  transform: translateY(0.5rem);
  transition: opacity 0.3s var(--ease-out), transform 0.3s var(--ease-out);
}
.to-top.is-visible { opacity: 1; pointer-events: auto; transform: translateY(0); }

/* ═══════════════════════════════════════════════════════════
   FOOTER
   ═══════════════════════════════════════════════════════════ */
.footer {
  border-top: 1px solid var(--rule);
  padding-top: clamp(2.5rem, 6vw, 5rem);
  overflow: hidden;
}
.footer__cols {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
  gap: 2rem;
  padding-bottom: clamp(2rem, 5vw, 4rem);
}
.footer__col { display: flex; flex-direction: column; gap: 0.45rem; }
.footer__col--wide { grid-column: span 2; max-width: 46ch; }
.footer__col a { color: var(--mute); transition: color 0.25s var(--ease-out); }
.footer__col a:hover { color: var(--ink); }
.footer__mail { color: var(--ink) !important; text-decoration: underline; text-underline-offset: 3px; }
.footer__mail.copied::after { content: ' — copied'; color: var(--alarm-ink); }

.footer__base {
  display: flex;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: 1rem;
  padding-block: 1.25rem;
  border-top: 1px solid var(--rule);
}

@media (max-width: 620px) {
  .footer__col--wide { grid-column: span 1; }
}

/* ── Phone bloom (the scalloped flower that replaces the mascot) ──
   Slow counter-rotation so the screen has a living element even when the chip
   carousel is off-screen. The <text> stays upright by counter-rotating with it. */
.phone__bloom {
  position: absolute;
  top: 30%;
  left: 50%;
  width: 62%;
  transform: translateX(-50%);
  overflow: visible;
}
.phone__bloom > g {
  transform-origin: 50% 50%;
  animation: bloom-spin 34s linear infinite;
}
@keyframes bloom-spin {
  from { transform: rotate(0deg); }
  to   { transform: rotate(360deg); }
}
.phone__bloom-num,
.phone__bloom-lbl {
  text-anchor: middle;
  fill: var(--signal); /* yellow ON black is 13.46:1 — the inverse of the ban */
  font-family: var(--font-display);
}
.phone__bloom-num { font-size: 19px; }
.phone__bloom-lbl { font-size: 7px; font-family: var(--font-mono); letter-spacing: 0.14em; }

@media (prefers-reduced-motion: reduce) {
  .phone__bloom > g { animation: none !important; }
}

/* ═══════════════════════════════════════════════════════════
   IMPACT (A6)

   Recruiter-legibility drove this layout. The first version was eight
   equally-weighted blobs scattered on a grid, labelled with internal metric
   names — "Lands-to-Apply", "Cost per DAU", "Clicks-to-Leads". Someone who does
   not work on this product cannot decode any of that, and a scatter has no
   reading order, so there was no way in.

   So: four labelled rows that read as a funnel — get in, act, come back, do it
   for less. Each row states in one plain sentence why that stage matters, and
   each metric leads with plain language ("Phone verification success") while the
   internal term drops to the context line for the people who know it. The blob
   shapes stay, because the anti-chart is the design's signature; what changed is
   that they now sit in a structure that tells you what you are looking at.
   ═══════════════════════════════════════════════════════════ */
.sprite { position: absolute; width: 0; height: 0; overflow: hidden; }

.impact__lede {
  margin-top: 1.5rem;
  max-width: 62ch;
  font-family: var(--font-body);
  font-size: 1.02rem;
  line-height: 1.65;
}

.impactfield { margin-top: clamp(2.5rem, 5vw, 4rem); }

.impactrow {
  display: grid;
  grid-template-columns: minmax(0, 16rem) minmax(0, 1fr);
  gap: clamp(1.5rem, 4vw, 4rem);
  align-items: center;
  padding-block: clamp(2rem, 4vw, 3rem);
  border-top: 1px solid var(--rule);
}
.impactrow:last-child { border-bottom: 1px solid var(--rule); }

.impactrow__name {
  font-weight: 700;
  color: var(--alarm-ink);
  margin-bottom: 0.5rem;
}
.impactrow__frame {
  font-family: var(--font-body);
  font-size: 0.95rem;
  line-height: 1.55;
  color: var(--mute);
  max-width: 26ch;
}

.impactrow__blobs {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 17rem), 1fr));
  gap: clamp(1.25rem, 3vw, 2.5rem);
}

/* Shape + number on the left, wording on the right. Pairing them side by side
   rather than stacking a caption under a circle is what makes the row scannable
   at a glance: eye lands on the number, then reads across. */
.blob {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  align-items: center;
  gap: 1rem;
}

.blob__inner {
  position: relative;
  display: grid;
  place-items: center;
  width: clamp(5.5rem, 9vw, 7.5rem);
  aspect-ratio: 1;
  color: var(--blob-ink);
  text-align: center;
  transition: transform 0.4s var(--ease-out);
}
.blob__shape {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  fill: var(--blob-fill);
}
.blob__val {
  position: relative;
  font-family: var(--font-display);
  font-size: clamp(1.1rem, 1.9vw, 1.6rem);
  line-height: 1;
  letter-spacing: -0.01em;
  font-variant-numeric: tabular-nums; /* stops the counter jittering as it ticks */
  padding: 0 0.3rem;
}

.blob__text { display: flex; flex-direction: column; gap: 0.3rem; min-width: 0; }
.blob__label {
  font-family: var(--font-display);
  text-transform: uppercase;
  font-size: clamp(0.85rem, 1.3vw, 1.05rem);
  line-height: 1.05;
  letter-spacing: 0.005em;
}
.blob__ctx { color: var(--mute); line-height: 1.45; }

.blob:hover .blob__inner { transform: rotate(-6deg) scale(1.06); }
.blob:hover .blob__label { color: var(--alarm-ink); }

/* Fills. --blob-ink is the text colour that belongs with each fill, resolved
   once here so nothing downstream has to think about contrast. --signal and
   --alarm do not invert between themes, so their text is hard-coded black:
   13.46:1 on yellow, 6.11:1 on red. */
.blob--ink    { --blob-fill: var(--ink);    --blob-ink: var(--paper); }
.blob--signal { --blob-fill: var(--signal); --blob-ink: #030303; }
.blob--alarm  { --blob-fill: var(--alarm);  --blob-ink: #030303; }

@media (max-width: 860px) {
  .impactrow { grid-template-columns: 1fr; gap: 1.25rem; align-items: start; }
  .impactrow__frame { max-width: none; }
}
@media (max-width: 420px) {
  .blob { grid-template-columns: 1fr; gap: 0.6rem; }
}

/* ═══════════════════════════════════════════════════════════
   CASE STUDIES (A7 / A10)

   Numbered editorial rows. The numeral lives in the left gutter at display
   scale; on narrow widths it moves inline above the title rather than eating
   horizontal room.
   ═══════════════════════════════════════════════════════════ */
.caselist { margin-top: clamp(3rem, 6vw, 5rem); }

.caserow {
  display: grid;
  grid-template-columns: minmax(0, 8rem) minmax(0, 1fr);
  gap: clamp(1rem, 3vw, 3rem);
  padding-block: clamp(2rem, 4vw, 3.5rem);
  border-top: 1px solid var(--rule);
}
.caserow:last-child { border-bottom: 1px solid var(--rule); }

.caserow__num {
  font-size: clamp(2.5rem, 6vw, 5.5rem);
  line-height: 0.8;
  color: var(--rule);
  /* Sticks alongside its row while the body scrolls past — the reference's
     numeral column, without needing a ScrollTrigger pin. */
  position: sticky;
  top: 7rem;
  align-self: start;
}
.caserow:hover .caserow__num { color: var(--alarm); }

.case-card { min-width: 0; }

.case-titlebar {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  gap: 1rem;
  margin-bottom: 0.75rem;
}
.case-title {
  font-family: var(--font-display);
  text-transform: uppercase;
  font-size: clamp(1.35rem, 2.8vw, 2.4rem);
  line-height: 0.95;
  letter-spacing: -0.01em;
  font-weight: 400;
}
.read-metadata {
  flex: 0 0 auto;
  font-family: var(--font-mono);
  font-size: 0.6rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--mute);
  white-space: nowrap;
  padding-top: 0.4rem;
}

.case-tags { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-bottom: 1.25rem; }
.tag {
  font-family: var(--font-mono);
  font-size: 0.56rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  padding: 0.3rem 0.6rem;
  border-radius: 999px;
  box-shadow: inset 0 0 0 1px var(--rule);
  color: var(--mute);
}

.case-tldr {
  font-family: var(--font-body);
  font-size: 1.02rem;
  line-height: 1.6;
  max-width: 62ch;
  margin-bottom: 1.25rem;
}

.case-metrics {
  display: flex;
  flex-wrap: wrap;
  gap: 0 clamp(1.25rem, 3vw, 2.5rem);
  margin-bottom: 1.25rem;
}
.case-metric { display: flex; flex-direction: column; }
.case-metric strong {
  font-family: var(--font-display);
  font-size: clamp(1.3rem, 2.4vw, 2rem);
  line-height: 1;
  font-weight: 400;
}
.case-metric span {
  font-family: var(--font-mono);
  font-size: 0.56rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--mute);
  margin-top: 0.25rem;
}

.case-flow { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-bottom: 1.5rem; }
.case-flow-step {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  font-family: var(--font-mono);
  font-size: 0.58rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  padding: 0.3rem 0.7rem 0.3rem 0.3rem;
  border-radius: 999px;
  background: var(--panel);
}
.case-flow-step i {
  display: grid;
  place-items: center;
  width: 1.15rem; height: 1.15rem;
  border-radius: 50%;
  background: var(--ink);
  color: var(--paper);
  font-style: normal;
  font-size: 0.5rem;
}

/* ── Toggles ── */
.case-toggle-btn,
.project-toggle-btn,
.exp-toggle-btn {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.75rem 1.4rem;
  border-radius: 999px;
  background: var(--ink);
  color: var(--paper);
  font-family: var(--font-mono);
  font-size: 0.64rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  transition: background 0.3s var(--ease-out);
}
.case-toggle-btn:hover,
.project-toggle-btn:hover,
.exp-toggle-btn:hover { background: var(--alarm-ink); color: #FFFFFF; }

.chev { width: 0.8rem; height: 0.8rem; transition: transform 0.3s var(--ease-out); }
.open .chev { transform: rotate(180deg); }

.nested-toggle-btn {
  display: flex;
  width: 100%;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  text-align: left;
  padding: 0.7rem 0;
  border-bottom: 1px solid var(--rule);
  font-family: var(--font-mono);
  font-size: 0.62rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
}
.nested-toggle-btn:hover { color: var(--alarm-ink); }

/* ── Case body ── */
.case-body {
  margin-top: 1.75rem;
  padding-top: 1.75rem;
  border-top: 1px solid var(--rule);
  animation: caseopen 0.34s var(--ease-out);
}
.case-body.closing { animation: caseshut 0.34s var(--ease-out) forwards; }
@keyframes caseopen { from { opacity: 0; transform: translateY(-0.5rem); } }
@keyframes caseshut { to   { opacity: 0; transform: translateY(-0.5rem); } }

.case-section { margin-bottom: 1.75rem; }
.case-section h4 {
  font-family: var(--font-mono);
  font-size: 0.62rem;
  font-weight: 700;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--alarm-ink);
  margin-bottom: 0.6rem;
}
/* Voice 3. The case bodies are the substance of the page and are the one place
   the brutalism steps back — normal measure, normal line-height, Inter. */
.case-section p,
.case-section li,
.case-note {
  font-family: var(--font-body);
  font-size: 0.95rem;
  line-height: 1.7;
  max-width: 72ch;
}
.case-section p + p, .case-note { margin-top: 0.75rem; }
.case-section ul { list-style: none; margin-top: 0.5rem; }
.case-section li { position: relative; padding-left: 1.1rem; margin-bottom: 0.5rem; }
.case-section li::before {
  content: '+';
  position: absolute;
  left: 0;
  color: var(--alarm-ink);
  font-family: var(--font-mono);
}
.case-section strong { font-weight: 600; }
.case-section em { font-style: italic; }

/* ── Tables ── */
.seg-table-wrap {
  overflow-x: auto; /* wide tables scroll inside themselves, never the page */
  margin-block: 1rem;
  border: 1px solid var(--rule);
  border-radius: 6px;
}
.seg-table { width: 100%; border-collapse: collapse; font-family: var(--font-mono); font-size: 0.66rem; }
.seg-table th {
  text-align: left;
  text-transform: uppercase;
  letter-spacing: 0.1em;
  font-size: 0.56rem;
  font-weight: 700;
  padding: 0.7rem 0.9rem;
  background: var(--panel);
  white-space: nowrap;
}
.seg-table td { padding: 0.6rem 0.9rem; border-top: 1px solid var(--rule); white-space: nowrap; }
.seg-name { font-family: var(--font-body); font-size: 0.85rem; white-space: normal; }
.seg-delta { color: var(--alarm-ink); font-weight: 700; }
.seg-total td { font-weight: 700; background: var(--panel); }

/* ── Impact grid + nested projects ── */
.impact-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
  gap: 1rem;
  margin-top: 1rem;
}
.impact-item {
  padding: 1rem;
  border-radius: 14px;
  background: var(--panel);
  font-family: var(--font-mono);
  font-size: 0.62rem;
  letter-spacing: 0.06em;
  line-height: 1.5;
}
.impact-item strong {
  display: block;
  font-family: var(--font-display);
  font-size: 1.5rem;
  letter-spacing: 0;
  font-weight: 400;
  margin-bottom: 0.3rem;
}

.nested-project-list { margin-top: 1rem; }
.nested-project { margin-bottom: 0.25rem; }
.nested-detail-box {
  padding: 1rem 0 1.25rem;
  font-family: var(--font-body);
  font-size: 0.9rem;
  line-height: 1.65;
}
.nested-detail-box ul { list-style: none; }
.nested-detail-box li { position: relative; padding-left: 1.1rem; margin-bottom: 0.4rem; }
.nested-detail-box li::before {
  content: '+'; position: absolute; left: 0; color: var(--alarm-ink); font-family: var(--font-mono);
}
.project-detail { animation: caseopen 0.34s var(--ease-out); }
.project-detail.closing { animation: caseshut 0.34s var(--ease-out) forwards; }
.project-link {
  display: inline-block;
  margin-top: 0.5rem;
  font-family: var(--font-mono);
  font-size: 0.6rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--alarm-ink);
  text-decoration: underline;
  text-underline-offset: 3px;
}

@media (max-width: 720px) {
  .caserow { grid-template-columns: 1fr; gap: 0.5rem; }
  .caserow__num { position: static; font-size: 2.5rem; }
  .case-titlebar { flex-direction: column; gap: 0.35rem; }
  .read-metadata { padding-top: 0; }
}

/* ═══════════════════════════════════════════════════════════
   EXPERIENCE — role cards
   The plan called for a horizontal pin-scrub here. Dropped deliberately: these
   roles carry three dense columns of quantified bullets, and pinning them
   sideways would make the substance of the page harder to read to win a scroll
   trick. The brutalism is in the type and the rules instead.
   ═══════════════════════════════════════════════════════════ */
.explist { margin-top: clamp(3rem, 6vw, 5rem); }

.exp-role-card {
  padding-block: clamp(2rem, 4vw, 3.25rem);
  border-top: 1px solid var(--rule);
}
.exp-role-card:last-child { border-bottom: 1px solid var(--rule); }

.exp-role-header {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  gap: 1.5rem;
  flex-wrap: wrap;
  margin-bottom: 1.5rem;
}
.exp-role-left { display: flex; align-items: center; gap: 1rem; }
.exp-role-logo {
  width: 3.25rem;
  height: auto;
  object-fit: contain;
  /* Logos are supplied on white. On the dark canvas they need their own plate
     rather than being inverted, which would wreck brand colours. */
  background: #FFFFFF;
  border-radius: 8px;
  padding: 0.35rem;
}
.exp-role-title {
  font-family: var(--font-display);
  text-transform: uppercase;
  font-size: clamp(1.2rem, 2.4vw, 2rem);
  line-height: 1;
  letter-spacing: -0.01em;
}
.exp-role-company {
  font-family: var(--font-mono);
  font-size: 0.66rem;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--alarm-ink);
  margin-top: 0.3rem;
}
.exp-role-period {
  font-family: var(--font-mono);
  font-size: 0.62rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--mute);
  white-space: nowrap;
}
.exp-role-summary {
  font-family: var(--font-body);
  font-size: 1rem;
  line-height: 1.65;
  max-width: 74ch;
  margin-bottom: 1.75rem;
}
.exp-role-cols {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
  gap: clamp(1.25rem, 3vw, 2.5rem);
}
.exp-role-col-title {
  font-family: var(--font-mono);
  font-size: 0.6rem;
  font-weight: 700;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  padding-bottom: 0.5rem;
  margin-bottom: 0.75rem;
  border-bottom: 1px solid var(--rule);
}
.exp-role-col ul { list-style: none; }
.exp-role-col li {
  position: relative;
  padding-left: 1.05rem;
  margin-bottom: 0.6rem;
  font-family: var(--font-body);
  font-size: 0.9rem;
  line-height: 1.6;
}
.exp-role-col li::before {
  content: '+';
  position: absolute; left: 0;
  color: var(--alarm-ink);
  font-family: var(--font-mono);
}
.exp-role-stack { display: flex; flex-wrap: wrap; gap: 0.35rem; margin-top: 1.5rem; }
.exp-stack-tag {
  font-family: var(--font-mono);
  font-size: 0.54rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  padding: 0.28rem 0.55rem;
  border-radius: 999px;
  background: var(--panel);
  color: var(--ink);
}
.exp-role-impact { margin-top: 1.25rem; display: flex; flex-wrap: wrap; gap: 0.4rem; }
.exp-impact-chip {
  font-family: var(--font-mono);
  font-size: 0.58rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  padding: 0.35rem 0.7rem;
  border-radius: 999px;
  box-shadow: inset 0 0 0 1.5px var(--ink);
  transition: background 0.25s var(--ease-out), color 0.25s var(--ease-out);
}
.exp-impact-chip:hover { background: var(--ink); color: var(--paper); }

@media (max-width: 620px) {
  .exp-role-header { gap: 0.75rem; }
  .exp-role-period { width: 100%; }
}

/* ═══════════════════════════════════════════════════════════
   SIDE PROJECTS — yellow-panel grid
   ═══════════════════════════════════════════════════════════ */
.projgrid {
  margin-top: clamp(3rem, 6vw, 5rem);
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 22rem), 1fr));
  gap: clamp(1rem, 2vw, 1.5rem);
}
.project-card {
  background: var(--signal);
  color: #030303;              /* theme-invariant: --signal never inverts */
  border-radius: var(--radius);
  padding: clamp(1.5rem, 3vw, 2.25rem);
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
}
.project-metric-badge {
  align-self: flex-start;
  background: #030303;
  color: #FFFFFF;
  padding: 0.3rem 0.7rem;
  border-radius: 999px;
}
.project-title { letter-spacing: -0.01em; }
.project-problem {
  font-family: var(--font-body);
  font-size: 0.95rem;
  line-height: 1.6;
}
/* Buttons and links inside the yellow panel stay black-on-yellow in both
   themes, for the same reason .panel--signal does. */
.project-card .project-toggle-btn { align-self: flex-start; background: #030303; color: #FFFFFF; }
.project-card .project-toggle-btn:hover { background: var(--alarm-ink); }
.project-card .project-detail p { font-family: var(--font-body); font-size: 0.9rem; line-height: 1.65; margin-top: 0.6rem; }
.project-card .project-link { color: #030303; }

/* ═══════════════════════════════════════════════════════════
   EDUCATION / PUBLICATIONS / SKILLS / WRITING
   The long tail. Dense typographic registers rather than four more hero
   moments — seven full-dress sections in a row would exhaust the reader.
   ═══════════════════════════════════════════════════════════ */
.edugrid {
  margin-top: clamp(2.5rem, 5vw, 4rem);
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr));
  gap: clamp(1rem, 2vw, 1.5rem);
}
.edu {
  background: var(--panel);
  border-radius: var(--radius);
  padding: clamp(1.25rem, 2.5vw, 2rem);
  display: flex;
  flex-direction: column;
  gap: 0.85rem;
}
.edu__logo {
  width: 3.5rem; height: 3.5rem;
  object-fit: contain;
  background: #FFFFFF;
  border-radius: 10px;
  padding: 0.3rem;
}
.edu__name { font-size: clamp(1.05rem, 1.8vw, 1.4rem); }
.edu__body { line-height: 1.7; color: var(--mute); }
.edu__body em { font-style: normal; color: var(--ink); }

.publist { margin-top: clamp(2.5rem, 5vw, 4rem); }
.pub {
  padding-block: clamp(1.5rem, 3vw, 2.25rem);
  border-top: 1px solid var(--rule);
}
.pub:last-child { border-bottom: 1px solid var(--rule); }
.pub__meta { margin-bottom: 0.6rem; }
.pub__title { max-width: 40ch; }
.pub__authors {
  margin-top: 0.75rem;
  font-family: var(--font-body);
  font-size: 0.9rem;
  line-height: 1.7;
}
.pub__authors a { color: var(--alarm-ink); text-decoration: underline; text-underline-offset: 3px; }
.pub__authors em { font-style: normal; }

.skilllist { margin-top: clamp(2.5rem, 5vw, 4rem); }
.skill {
  display: grid;
  grid-template-columns: minmax(0, 15rem) minmax(0, 1fr);
  gap: clamp(1rem, 3vw, 2.5rem);
  padding-block: clamp(1.25rem, 2.5vw, 1.75rem);
  border-top: 1px solid var(--rule);
  align-items: start;
}
.skill:last-child { border-bottom: 1px solid var(--rule); }
.skill__head { font-size: clamp(1rem, 1.7vw, 1.35rem); }
.skill__tags { display: flex; flex-wrap: wrap; gap: 0.35rem; }
.skill__tag {
  padding: 0.3rem 0.6rem;
  border-radius: 999px;
  box-shadow: inset 0 0 0 1px var(--rule);
  color: var(--ink);
}
@media (max-width: 720px) {
  .skill { grid-template-columns: 1fr; gap: 0.75rem; }
}

.postlist {
  margin-top: clamp(2.5rem, 5vw, 4rem);
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr));
  gap: clamp(1rem, 2vw, 1.5rem);
}
.post {
  background: var(--panel);
  border-radius: var(--radius);
  padding: clamp(1.5rem, 3vw, 2.25rem);
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
  transition: background 0.3s var(--ease-out), transform 0.3s var(--ease-out);
}
.post:hover { background: var(--signal); color: #030303; transform: translateY(-0.35rem); }
.post__title { font-size: clamp(1.05rem, 1.9vw, 1.5rem); }
.post__desc {
  font-family: var(--font-body);
  font-size: 0.92rem;
  line-height: 1.65;
  flex-grow: 1;
}
.post__cta { color: var(--alarm-ink); }
.post:hover .post__cta { color: #030303; }
.postlist__all { margin-top: 1.5rem; }

/* ═══════════════════════════════════════════════════════════
   PRINCIPLES — radial mind map

   Two renderings, ONE dom. Everything below the "GRAPH MODE" banner is scoped
   to [data-mm-mode="graph"]; everything above it is the base, which is
   simultaneously the ≤720px rendering AND the scripting-off rendering. That is
   the whole reason the nodes are HTML rather than SVG: the fallback is not a
   second implementation, it is this stylesheet with position:static.

   Nothing in this component ever puts a glyph inside the <svg> layer. SVG has
   no text wrapping, and the moment a label goes in there the responsive story
   collapses. The svg draws connector paths and nothing else.
   ═══════════════════════════════════════════════════════════ */
.mindmap { margin-top: clamp(2.5rem, 5vw, 4rem); }

/* The six details ship VISIBLE so the section is complete with scripting off.
   `.js` is set in <head>, before first paint, so with JS they are never painted
   open — collapsing them here rather than after DOMContentLoaded is what keeps
   this at zero CLS. */
.js .mm__detail:not(.is-open),
.js .mm__children:not(.is-open) { display: none; }
.js .mm__hint { display: block; }
.mm__hint { display: none; }

/* Hub is meaningful only when there is a ring to collapse. */
.mm__hub { display: none; }

.mm__nodes {
  list-style: none;
  display: grid;
  gap: clamp(0.75rem, 1.5vw, 1.1rem);
}

.mm__node { position: relative; }

.mm__btn {
  width: 100%;
  min-height: 44px;
  display: grid;
  grid-template-columns: 1fr auto;
  grid-template-areas: "num plus" "label label";
  align-items: center;
  gap: 0.35rem 1rem;
  text-align: left;
  background: var(--panel);
  color: var(--ink);
  border: 0;
  border-radius: var(--radius);
  padding: clamp(1.25rem, 3vw, 2rem);
  cursor: pointer;
  font: inherit;
  transition: background 0.3s var(--ease-out), transform 0.3s var(--ease-out);
}
.mm__num {
  grid-area: num;
  font-family: var(--font-display);
  font-size: clamp(2.25rem, 6vw, 3.5rem);
  line-height: 0.8;
  letter-spacing: 0;
  color: var(--rule);
  transition: color 0.3s var(--ease-out);
}
.mm__label {
  grid-area: label;
  font-family: var(--font-display);
  text-transform: uppercase;
  line-height: 0.95;
  font-size: clamp(1.05rem, 1.9vw, 1.45rem);
  text-wrap: balance;
}
/* Plus / cross, drawn rather than typed so it can rotate into an ×. */
.mm__plus {
  grid-area: plus;
  position: relative;
  width: 1.15rem;
  height: 1.15rem;
  flex: none;
  align-self: start;
}
.mm__plus::before,
.mm__plus::after {
  content: "";
  position: absolute;
  inset: 50% 0 auto 0;
  height: 2px;
  background: currentColor;
  transition: transform 0.35s var(--ease-out), opacity 0.35s var(--ease-out);
}
.mm__plus::after { transform: rotate(90deg); }
.mm__node.is-active .mm__plus::before { transform: rotate(135deg); }
.mm__node.is-active .mm__plus::after  { transform: rotate(45deg); }

.mm__btn:hover { background: var(--signal); color: #030303; }
.mm__btn:hover .mm__num { color: rgba(3, 3, 3, 0.3); }
.mm__node.is-active .mm__btn { background: var(--signal); color: #030303; }
.mm__node.is-active .mm__num { color: rgba(3, 3, 3, 0.3); }

/* Sub-steps: the reasoning the branch blooms into. */
.mm__children {
  list-style: none;
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem;
  margin: 0.6rem 0 0;
}
.mm__child {
  background: var(--paper);
  border: 1px solid var(--rule);
  border-radius: 999px;
  padding: 0.45rem 0.85rem;
  color: var(--ink);
  white-space: nowrap;
}

/* Detail — the four layers. */
.mm__detail {
  background: var(--panel);
  border-radius: var(--radius);
  padding: clamp(1.25rem, 3vw, 2rem);
  margin-top: 0.6rem;
}
/* In graph mode the panel sits below a tall stage, so opening a branch scrolls
   the active card out of view and the detail arrives with nothing naming it.
   This restates the branch inside the panel. aria-hidden because the region is
   already labelled by that principle's <h3> — this is a visual echo, and
   without it a screen reader would hear the title twice. Hidden in list mode,
   where the heading is right there above it. */
.mm__detail-head { display: none; }
[data-mm-mode="graph"] .mm__detail-head {
  display: flex;
  align-items: baseline;
  gap: 0.75rem;
  margin-bottom: 1.4rem;
  padding-bottom: 1.1rem;
  border-bottom: 1px solid var(--rule);
}
.mm__detail-num { color: var(--mute); }
.mm__detail-title { font-size: clamp(1.15rem, 2vw, 1.6rem); line-height: 1; }

.mm__parts { display: grid; gap: 1.15rem; }
.mm__part dt { color: var(--mute); margin-bottom: 0.3rem; }
.mm__part dd {
  margin: 0;
  font-family: var(--font-body);
  font-size: 0.93rem;
  line-height: 1.65;
  max-width: 60ch;
}
.mm__part--proof { border-top: 1px solid var(--rule); padding-top: 1.15rem; }
.mm__stat {
  display: block;
  font-size: clamp(1.55rem, 3vw, 2.1rem);
  line-height: 0.9;
  color: var(--alarm);
  margin-bottom: 0.5rem;
}
.mm__proof {
  display: inline-block;
  margin-top: 0.6rem;
  font-family: var(--font-mono);
  font-size: 0.7rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--alarm-ink);
  text-decoration: underline;
  text-underline-offset: 4px;
}

/* ── FOLD: Hero -> Impact ──────────────────────────────────────────────────
   The hero's bottom edge becomes a clipping mask and its stage compresses
   toward the top, like a sheet folding away. The hero's BOX is left alone
   on purpose: collapsing it would drag every section below up the page, and
   doing that per scroll frame is a reflow per frame. */
#hero { will-change: clip-path; }
/* Without this the stage scales about its centre and the panel drifts upward
   as it compresses, which reads as a squash rather than a fold. */
.hero__stage { transform-origin: top center; }
/* Opaque and above the pinned hero, so the folding sheet never shows
   through Impact as it rises in underneath it. */
#metrics { position: relative; z-index: 1; background: var(--paper); }

/* ── GRAPH MODE ────────────────────────────────────────────────────────────
   Applied by mindmap.js at ≥1100px only. Below that the stacked rendering
   above is what ships — not a compromise but a measurement: a six-node ring of
   readable cards, each with a sub-branch beside it, needs roughly 1000px of
   stage before the ring has to be shrunk into the hub to make room.

   Single column, stage above and detail below. A side-by-side panel was tried
   and abandoned: it halves the stage width, and the ring is then inscribed so
   tightly that there is no room OUTSIDE it for the sub-branches to bloom into.

   Node positions are written as left/top CORNER coordinates, never as a
   centring transform: [data-reveal].is-in resolves to `transform: none` and
   the 4s fail-safe sweep in brutalist.js guarantees it fires, which would fling
   every node by half its own size. `transform` on .mm__node is reserved for
   scale() in the dim state. */
[data-mm-mode="graph"] { display: grid; gap: clamp(1.5rem, 3vw, 2.5rem); }

[data-mm-mode="graph"] .mindmap__stage {
  position: relative;
  /* Height is viewport-driven, width fills the column, and the ring is simply
     the ellipse that fits — rx and ry are computed independently, so it does
     not care about the proportion.

     Explicitly NOT `aspect-ratio` + `max-height`: with both applied the browser
     holds the ratio by shrinking the WIDTH as well, which pulled the ring in
     until the bottom and lower-left cards overlapped.

     The 34rem floor is not arbitrary. Two cards on the same side sit `ry`
     apart, so the ring needs ry > card height, which works out at ~489px of
     stage. Below that the ring self-collides. On a viewport short enough for
     the floor to beat 72vh, the stage wins and revealPanel() does the rest. */
  height: clamp(34rem, 72vh, 54rem);
  /* Deliberately NOT overflow:hidden and NOT contain:paint — both clip the 3px
     focus ring at outline-offset:3px. Blooms are clamped inside the stage in
     JS instead, which is the only reason nothing needs clipping. */
  contain: layout;
}

/* The hint only makes sense next to a ring of branches. */
[data-mm-mode="list"] .mindmap__panel { display: none; }

[data-mm-mode="graph"] .mm__links {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  overflow: visible;
  pointer-events: none;
  z-index: 0;
}
/* No viewBox on the svg, so path coordinates are the same CSS pixels used for
   left/top. Stroke reads a custom property, so the theme swap needs no JS. */
.mm__link {
  fill: none;
  stroke: var(--rule);
  stroke-width: 1.5;
  stroke-linecap: round;
  transition: stroke 0.3s var(--ease-out), stroke-width 0.3s var(--ease-out),
              opacity 0.3s var(--ease-out);
}
.mm__link.is-active { stroke: var(--alarm); stroke-width: 3; }

/* Draw-on. A fixed dasharray rather than getTotalLength(): every path here is
   well under 1200px, and a constant survives the `d` being rewritten on resize
   without needing to re-measure. JS sets data-draw only when motion is allowed,
   so under Reduce there is no dasharray at all and no way to strand a connector
   mid-transition — an invisible connector is the one failure state that matters. */
.mm__links[data-draw] .mm__link {
  stroke-dasharray: 1200;
  animation: mm-draw 1.1s var(--ease-out) both;
  animation-delay: calc(var(--i, 0) * 0.08s);
}
@keyframes mm-draw {
  from { stroke-dashoffset: 1200; }
  to   { stroke-dashoffset: 0; }
}
[data-mm-open] .mm__link:not(.is-active) { opacity: 0.4; }
/* --rule is only 16% white in dark; 1.5px of it disappears against #141414. */
[data-theme="dark"] .mm__link { stroke-width: 2; }
[data-theme="dark"] .mm__link.is-active { stroke-width: 3.5; }

[data-mm-mode="graph"] .mm__hub {
  display: block;
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  z-index: 2;
}
.mm__hub-btn {
  width: 8.5rem;
  height: 8.5rem;
  border: 0;
  border-radius: 50%;
  background: var(--ink);
  color: var(--paper);
  font-size: 1.05rem;
  line-height: 0.95;
  cursor: pointer;
  transition: background 0.3s var(--ease-out), color 0.3s var(--ease-out);
}
.mm__hub-btn:hover,
[data-mm-open] .mm__hub-btn { background: var(--alarm); color: #030303; }

[data-mm-mode="graph"] .mm__nodes { display: block; }

[data-mm-mode="graph"] .mm__node {
  position: absolute;
  left: 0;
  top: 0;
  width: 12rem;
  z-index: 1;
  transition: opacity 0.35s var(--ease-out), transform 0.35s var(--ease-out);
}
[data-mm-mode="graph"] .mm__node.is-active { z-index: 3; }
/* Dimming is an affordance, not a lockout — siblings stay clickable. */
[data-mm-open] .mm__node:not(.is-active) { opacity: 0.3; transform: scale(0.94); }

[data-mm-mode="graph"] .mm__btn {
  min-height: 6.5rem;
  grid-template-columns: auto 1fr auto;
  grid-template-areas: "num label plus";
  gap: 0.8rem;
  padding: 1.1rem 1.25rem;
  align-items: center;
}
/* The numeral carries no .mono utility class on purpose: `.mono--sm` is
   redefined inside a mobile media query near the end of this file, which would
   win on source order and shrink the list-mode numeral to micro-type. */
[data-mm-mode="graph"] .mm__num {
  font-family: var(--font-mono);
  font-size: 0.7rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--mute);
  align-self: start;
}
[data-mm-mode="graph"] .mm__label { font-size: 1.05rem; }
[data-mm-mode="graph"] .mm__plus { width: 0.9rem; height: 0.9rem; align-self: center; }

/* Bloom. Positioned by JS relative to the node's own box (the node is the
   nearest positioned ancestor), so these contribute no layout height.

   Closed chips are visibility:hidden rather than display:none SO THEY CAN STILL
   BE MEASURED. The ring is inset by however much room the chips need, and that
   allowance has to be known before any branch is opened. visibility:hidden also
   keeps them out of the a11y tree and the tab order, which display:none would
   have done anyway. */
[data-mm-mode="graph"] .mm__children { display: block; margin: 0; }
[data-mm-mode="graph"] .mm__children:not(.is-open) { display: block; visibility: hidden; }
[data-mm-mode="graph"] .mm__child {
  position: absolute;
  left: 0;
  top: 0;
  width: 9rem;
  white-space: normal;
  text-align: center;
  line-height: 1.35;
  background: var(--paper);
  box-shadow: 0 2px 12px rgba(3, 3, 3, 0.09);
  animation: mm-bloom 0.45s var(--ease-out) both;
  animation-delay: calc(var(--g, 0) * 0.06s);
}
@keyframes mm-bloom {
  from { opacity: 0; transform: scale(0.8); }
  to   { opacity: 1; transform: none; }
}

/* Reserved so opening the first branch cannot shove the footer down. */
[data-mm-mode="graph"] .mindmap__panel { min-height: 16rem; }
[data-mm-mode="graph"] .mm__detail { margin-top: 0; }
[data-mm-mode="graph"] .mm__parts { max-width: 78ch; }

/* At rest the panel is empty. A dashed slot reads as "content lands here",
   where a lone line of micro-type just reads as a gap. */
[data-mm-mode="graph"] .mm__hint {
  display: grid;
  place-items: center;
  text-align: center;
  min-height: 16rem;
  margin: 0;
  padding: 2rem;
  border: 1px dashed var(--rule);
  border-radius: var(--radius);
}

/* Hover-only affordances must stay visible where there is no hover. */
@media (hover: none) {
  .mm__btn:hover { background: var(--panel); color: var(--ink); }
  .mm__btn:hover .mm__num { color: var(--rule); }
  [data-mm-mode="graph"] .mm__btn:hover .mm__num { color: var(--mute); }
  .mm__node.is-active .mm__btn { background: var(--signal); color: #030303; }
}

@media (prefers-reduced-motion: reduce) {
  .mm__btn, .mm__num, .mm__plus::before, .mm__plus::after,
  .mm__link, .mm__hub-btn,
  [data-mm-mode="graph"] .mm__node { transition: none; }
  /* JS never sets a dasharray under reduce, but belt and braces: a connector
     stranded mid-transition would simply be an invisible line. */
  .mm__links[data-draw] .mm__link {
    stroke-dasharray: none;
    stroke-dashoffset: 0;
    animation: none;
  }
  [data-mm-mode="graph"] .mm__child { animation: none; }
}

/* ═══════════════════════════════════════════════════════════
   CONTACT + DIALOG
   ═══════════════════════════════════════════════════════════ */
.contact__lede {
  margin-top: 1.5rem;
  max-width: 54ch;
  font-family: var(--font-body);
  font-size: 1.05rem;
  line-height: 1.65;
}
.contact__ctas { display: flex; flex-wrap: wrap; gap: 0.75rem; margin-top: 2rem; }
.pill--lg { padding: 1.05rem 2rem; font-size: 0.78rem; }
.contact__direct { margin-top: 1.5rem; }
.contact__mail { color: var(--ink); text-decoration: underline; text-underline-offset: 3px; }
.contact__mail.copied::after { content: ' — copied'; color: var(--alarm-ink); }

/* Native <dialog>: the backdrop, top-layer stacking, focus trapping and
   Esc-to-close all come from the platform. Only the look is ours. */
.cd {
  border: 0;
  padding: 0;
  background: transparent;
  color: var(--ink);
  /* Centring is declared explicitly rather than inherited from the UA's
     `dialog:modal { margin: auto }`. Relying on the UA rule put the panel in the
     top-left corner here, and an element in the top layer is awkward to debug —
     being explicit costs three lines and cannot be overridden by surprise. */
  position: fixed;
  inset: 0;
  margin: auto;
  width: min(46rem, calc(100vw - 2rem));
  max-height: calc(100dvh - 2rem);
}
.cd::backdrop { background: rgba(3, 3, 3, 0.62); backdrop-filter: blur(3px); }

.cd__panel {
  background: var(--paper);
  border-radius: var(--radius);
  box-shadow: 0 2rem 5rem rgba(0, 0, 0, 0.35);
  overflow: hidden;
  display: flex;
  flex-direction: column;
  max-height: calc(100vh - 2rem);
}
.cd__bar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  padding: 1rem clamp(1.25rem, 3vw, 2rem);
  background: var(--signal);
  color: #030303;                 /* always-yellow surface, so never inverted */
}
.cd__title { font-weight: 700; }
.cd__x {
  font-size: 1.5rem;
  line-height: 1;
  width: 2rem; height: 2rem;
  border-radius: 50%;
  color: #030303;
}
.cd__x:hover { background: rgba(3, 3, 3, 0.12); }

.cd__pane {
  padding: clamp(1.25rem, 3vw, 2rem);
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: 1rem;
}
.cd__pane[hidden] { display: none; }

.cd__lede { font-family: var(--font-body); font-size: 0.97rem; line-height: 1.6; max-width: 56ch; }

.cd__choices { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr)); gap: 0.75rem; }
.cd__choice {
  text-align: left;
  padding: 1.25rem;
  border-radius: 18px;
  background: var(--panel);
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
  transition: background 0.25s var(--ease-out), transform 0.25s var(--ease-out);
}
.cd__choice:hover { background: var(--signal); color: #030303; transform: translateY(-2px); }
.cd__choice-d { font-family: var(--font-body); font-size: 0.87rem; line-height: 1.5; }

.cd__back { align-self: flex-start; color: var(--mute); }
.cd__back:hover { color: var(--ink); }

.cd__row { display: grid; grid-template-columns: 5rem minmax(0, 1fr); align-items: center; gap: 0.75rem; }
.cd__k { color: var(--mute); }
.cd__to { font-size: 0.8rem; }
.cd__input, .cd__body {
  width: 100%;
  font-family: var(--font-body);
  font-size: 0.95rem;
  color: var(--ink);
  background: transparent;
  border: 0;
  border-bottom: 1px solid var(--rule);
  padding: 0.5rem 0;
}
.cd__input:focus, .cd__body:focus { outline: 0; border-bottom-color: var(--alarm-ink); }
.cd__input::placeholder, .cd__body::placeholder { color: var(--mute); }

.cd__toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 0.75rem; padding-top: 0.5rem; }
.cd__tool {
  padding: 0.35rem 0.7rem;
  border-radius: 999px;
  box-shadow: inset 0 0 0 1px var(--rule);
  cursor: pointer;
  color: var(--ink);
}
.cd__tool:hover { background: var(--panel); }
.cd__tool.is-disabled { opacity: 0.45; cursor: not-allowed; }
.cd__hint { color: var(--mute); }

.cd__body {
  border: 1px solid var(--rule);
  border-radius: 12px;
  padding: 0.75rem;
  resize: vertical;
  line-height: 1.6;
}
.cd__body--note { min-height: 7rem; }

.cd__files { list-style: none; display: flex; flex-direction: column; gap: 0.35rem; }
.cd__fileitem {
  display: flex; align-items: center; justify-content: space-between; gap: 0.75rem;
  background: var(--panel);
  border-radius: 999px;
  padding: 0.4rem 0.5rem 0.4rem 0.85rem;
  color: var(--ink);
}
.cd__filex { width: 1.3rem; height: 1.3rem; border-radius: 50%; line-height: 1; }
.cd__filex:hover { background: var(--alarm-ink); color: #FFFFFF; }

.cd__actions { display: flex; flex-wrap: wrap; align-items: center; gap: 0.75rem; padding-top: 0.5rem; }
.cd__status { color: var(--mute); }
.cd__status.is-bad { color: var(--alarm-ink); }

@media (max-width: 520px) {
  .cd__row { grid-template-columns: 1fr; gap: 0.2rem; }
}

/* Replaces a sentence that used to tell the reader "every number links to its
   case study". Saying it was a symptom: the affordance was not visible enough.
   Showing it costs one line per blob and cannot go stale. */
.blob__go {
  color: var(--alarm-ink);
  opacity: 0;
  transform: translateY(-0.2rem);
  transition: opacity 0.25s var(--ease-out), transform 0.25s var(--ease-out);
}
.blob:hover .blob__go,
.blob:focus-visible .blob__go { opacity: 1; transform: translateY(0); }
/* Touch and keyboard-only users get no hover, so it is simply always shown
   where hover is not available. */
@media (hover: none) {
  .blob__go { opacity: 1; transform: none; }
}

/* ── End-of-panel close control ──
   For a reader who has reached the bottom of a long case study. Separated by a
   rule so it reads as the end of the panel rather than another action inside it. */
.panel-end {
  margin-top: 1.75rem;
  padding-top: 1.5rem;
  border-top: 1px solid var(--rule);
}
.case-toggle-btn--end { background: transparent; color: var(--ink); box-shadow: inset 0 0 0 1.5px currentColor; }
.case-toggle-btn--end:hover { background: var(--ink); color: var(--paper); box-shadow: none; }
.chev--up { transition: none; }          /* points up already; must not flip with .open */
.open .chev--up { transform: none; }
/* On a yellow project card the outline pill stays black, like every other
   control on an always-yellow surface. */
.project-card .case-toggle-btn--end { color: #030303; box-shadow: inset 0 0 0 1.5px #030303; background: transparent; }
.project-card .case-toggle-btn--end:hover { background: #030303; color: #FFFFFF; box-shadow: none; }

/* ── Copy-address button in the dialog ── */
.cd__to { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; font-size: 0.8rem; }
.cd__copy {
  padding: 0.2rem 0.55rem;
  border-radius: 999px;
  box-shadow: inset 0 0 0 1px var(--rule);
  color: var(--mute);
}
.cd__copy:hover { color: var(--ink); box-shadow: inset 0 0 0 1px currentColor; }
.cd__copy.is-done { color: var(--alarm-ink); box-shadow: inset 0 0 0 1px currentColor; }

/* ═══════════════════════════════════════════════════════════
   MOBILE CORRECTIONS

   Everything below fixes something that was measurably wrong at 390px, not
   guessed at. Headless Chrome enforces a 500px minimum viewport, so these were
   found by rendering the page inside a fixed 390px iframe.
   ═══════════════════════════════════════════════════════════ */

/* The nav had mark + theme toggle + Resume + Hire me on one row. At 390px
   "Sayan Banerjee" wrapped onto two lines and the bar grew to fit it. Resume is
   also in the hero and the footer, so it is the one to drop; Hire me is the
   only action that has nowhere else to live. */
@media (max-width: 620px) {
  .nav { padding-block: 0.8rem; }
  .nav__mark { font-size: 0.7rem; letter-spacing: 0.1em; white-space: nowrap; }
  .nav__right { gap: 0.6rem; }
  .nav__right .pill--ghost { display: none; }
  .pill--sm { padding: 0.5rem 0.9rem; font-size: 0.6rem; }
}

@media (max-width: 640px) {
  /* Two CTAs at their natural widths stacked into a ragged left edge. Full
     width makes them read as a deliberate pair and gives a bigger tap target. */
  .hero__ctas { flex-direction: column; align-items: stretch; }
  .hero__ctas .pill { justify-content: center; }

  /* The panel carried two paragraphs of similar weight with a 1.75rem gap
     between every child, which left it tall and mostly empty. The second is
     supporting detail, so it steps down rather than competing. */
  .hero__col { gap: 1.15rem; }
  .hero__panel { gap: 1.15rem; }
  .hero__col--right .hero__lede { font-size: 0.62rem; color: rgba(3, 3, 3, 0.72); }
  .hero__lede { max-width: none; }
}

/* Stacking the shape above its label made every metric ~180px tall and turned
   the section into a long ragged list. Side by side fits comfortably at 390px —
   an 88px shape leaves ~250px for the wording — and halves the row height. */
@media (max-width: 420px) {
  .blob { grid-template-columns: auto minmax(0, 1fr); gap: 0.85rem; align-items: center; }
  .blob__inner { width: 4.75rem; }
  .blob__val { font-size: 1rem; }
}

@media (max-width: 620px) {
  .impactrow { padding-block: 1.5rem; gap: 1rem; }
  .impactrow__blobs { gap: 1.25rem; }
  /* 0.62rem mono is legible on a desktop at arm's length and not on a phone. */
  .mono--sm { font-size: 0.66rem; }
  .blob__ctx { font-size: 0.64rem; }
  .impact__lede, .contact__lede { font-size: 0.97rem; }

  /* Section rhythm: 4rem top and bottom on every one of eleven sections is a
     lot of scrolling between content on a phone. */
  .section { padding-block: 3rem; }

  /* Full-bleed panels keep their radius but stop wasting side padding. */
  .project-card, .post, .edu, .mm__btn, .mm__detail { padding: 1.25rem; }
  .cd__pane { padding: 1.1rem; }

  /* Stacked, full-width actions read better than a wrapped row of pills. */
  .contact__ctas { flex-direction: column; align-items: stretch; }
  .contact__ctas .pill { justify-content: center; }
  .panel-end .case-toggle-btn--end { width: 100%; justify-content: center; }
}
