/* =========================================================
   Jaydon — Portfolio
   Restrained glassmorphism, matched to the reference layout
   ========================================================= */

/* Sinteca — no longer referenced by --font-hero (see below, now Medival
   Pixel), but the files/@font-face stay: nothing currently uses them,
   removing a working @font-face isn't part of this task, and it's a
   one-line revert if a future design ever wants this face back. */
@font-face {
  font-family: "Sinteca";
  src: url("../assets/fonts/Sinteca-Regular.otf") format("opentype");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: "Sinteca";
  src: url("../assets/fonts/Sinteca-Bold.otf") format("opentype");
  font-weight: 700;
  font-style: normal;
  font-display: swap;
}

/* FS Mondwest — the pixel/glitch display face (see --font-hero below).
   A from-scratch recreation of PP Mondwest's look by NZWStudios2024,
   released under the SIL Open Font License 1.1
   (assets/fonts/fs-mondwest-license.txt), which explicitly permits
   embedding/redistributing it on a site like this — that's why this
   file ships with its license + readme alongside it in assets/fonts/,
   per the OFL's own requirement to keep those attached to any copy.
   Regular only — ships in a single weight. */
@font-face {
  font-family: "FS Mondwest";
  src: url("../assets/fonts/fs-mondwest-regular.otf") format("opentype");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

:root {
  /* base */
  --bg-top: #121327;
  --bg-bottom: #000000;
  --text-primary: #f4f5fb;
  --text-secondary: #a9aec7;
  --text-muted: #6d7291;

  /* nav / surfaces */
  --nav-bg: #262739;
  --surface-bg: rgba(255, 255, 255, 0.035);
  --surface-border: rgba(255, 255, 255, 0.09);
  --surface-blur: blur(14px) saturate(140%);

  /* accent — used sparingly: primary CTA + small hover/active touches */
  --accent: #a855f7;
  --accent-strong: #9333ea;

  /* light pill (Contact button, active filter) */
  /* ---- theme-swappable semantics (see html[data-theme="light"]) ----
     Everything below used to be a hard-coded literal repeated through
     the file. They are tokens now because the light theme is a real
     palette swap: one value changes here and every rule that names it
     follows, rather than each component carrying its own light-mode
     override.

     --overlay-rgb is the load-bearing one. Roughly fifty rules paint a
     translucent white over the dark page — hairline grids, glass fills,
     inner glows, borders — as rgba(255, 255, 255, a). Written as
     rgba(var(--overlay-rgb), a) they all invert together, so a light
     theme gets dark hairlines on light ground without touching a single
     one of them individually. Shadows deliberately do NOT participate:
     rgba(0, 0, 0, a) stays black in both themes, because a shadow is
     cast light, not a surface tint, and an inverted shadow reads as a
     glow. */
  --overlay-rgb: 255, 255, 255;

  --ink: #ffffff;                 /* text/marks that sit on the page ground */
  /* Running prose, one step down from --ink.

     --ink is right for anything the eye should land on: a heading, the
     current entry in a contents list, a word set in <strong>. It is too
     much for a paragraph. At full white every line of body copy competes
     with the heading above it, and a case study this long reads as one
     undifferentiated block of maximum contrast.

     Deliberately not --text-secondary. That token belongs to the nav and
     the small labels around a component, and it is a light lavender
     picked to sit against those surfaces; on paragraphs it tints the
     whole page. This is a cool blue-grey instead, and it holds 5.7:1
     against the page ground (6.5:1 against the gradient's black end) -
     past AA for body text, which --text-muted (3.2:1) would not be.

     See html[data-theme="light"] for the counterpart, and why it is not
     this value inverted. */
  --copy-ink: #81929d;
  --chip-bg: #171717;             /* .tag / .cs-tag / .cs-callout fill */
  --chip-border: #727272;         /* the shared button + chip stroke */
  --media-backdrop: #0d1024;      /* letterbox behind project thumbnails */
  --hero-glow: rgba(255, 255, 255, 0.62);  /* halo on the FS Mondwest titles */

  /* selected controls (nav Work/Play, filter pills). Split out of
     --light/--light-ink so the footer slab can keep its own light
     surface while these invert with the theme. */
  --sel-bg: #eef0fb;
  --sel-ink: #0d0f1e;

  /* custom cursor (see .cursor-ring). White, not the Figma spec's
     #727272: that grey is the site's own chip/button stroke, which is
     exactly why it disappeared — the ring read as one more piece of
     furniture instead of the pointer.

     White stays white in BOTH themes rather than flipping to dark on the
     light one. That works only because of --cursor-halo, a contrasting
     1px outline hugging the ring — the same trick the OS pointer itself
     uses to stay visible over arbitrary content. The halo is dark in both
     themes for exactly that reason: it is what carries a white ring over
     a light page, a photo, a video tile or the 3D model. */
  --cursor-stroke: #ffffff;
  --cursor-stroke-hot: #ffffff;
  --cursor-glow: rgba(255, 255, 255, 0.32);
  --cursor-halo: rgba(0, 0, 0, 0.5);

  /* the footer's slab — a deliberate surface change against the page,
     so it flips to the opposite of whatever the page ground is */
  --slab-bg: #ffffff;
  --slab-ink: #0d0f1e;
  --slab-ink-soft: rgba(13, 15, 30, 0.55);
  --slab-btn-bg: #ffffff;
  --slab-btn-hover: #eef0fb;

  --font-display: "Inter", sans-serif;
  --font-body: "Inter", sans-serif;
  /* the big hero-style titles (homepage name, case-study hero titles,
     footer heading) plus the FS Mondwest section headings — was Jersey
     15, then Sinteca, then PP Mondwest, then FS Mondwest, then Medival
     Pixel, now back to FS Mondwest */
  --font-hero: "FS Mondwest", serif;
  /* FS Mondwest's own glyphs sit noticeably smaller in their em-box
     than PP Mondwest's did — measured the actual rendered ink (cap
     height, word height, word width) of both fonts at an identical
     font-size via canvas pixel bounds: FS came out ~1.635x smaller
     across all three measurements, not just a rough guess. Multiplies
     every --font-hero font-size below so FS Mondwest reads at the
     same visual size PP Mondwest did — applied at the point of use,
     not baked into --text-title/--text-h1 themselves, since --text-h1
     is also used by .cs-heading (Inter, --font-display) which must
     stay its own size. */
  --font-hero-scale: 1.635;

  --radius-lg: 28px;
  --radius-md: 20px;
  --radius-sm: 14px;
  --radius-pill: 999px;

  --container: 1320px;

  /* 0–1 "shrink everything in this hero by this much so it fits below the
     nav" multiplier — see .hero's own comment for the mechanism. main.js
     overrides it per hero element (.hero, .cs-hero); 1 here is both the
     pre-JS fallback and the real value for everything that ISN'T a hero,
     since .hero__title and .hero__desc are shared classes (About's
     .page-hero, the case-study body copy) and multiplying by an undefined
     custom property would make those font-size declarations invalid. */
  --hero-fit: 1;

  /* Height arms for the two things that sit ABOVE the hero content and
     push it down: the nav bar itself, and the Play canvas's fixed top
     padding. Both were width-only, and every clamp driving nav height
     reaches its ceiling at 900px wide (see .nav__switch-opt) — so between
     a 2560×1440 monitor and a 1920×1080 laptop at 125% the nav shrank by
     4.6px while the available height fell by 593px, taking the nav from
     8.8% of the viewport to 16% of it and pushing everything below it
     down with it.

     Plain CSS rather than JS, unlike --hero-fit: a hero's natural height
     depends on the fit being solved for, which calc() can't express, but
     the nav's height has no effect on the viewport height it's reading,
     so there's no cycle here.

     Both reach their 1 ceiling at 1230px of viewport height, deliberately
     a little BELOW the 1271px reference monitor rather than exactly at
     it: calibrated to land on 1271 they evaluated to 0.99999 there and
     took 0.08px off the nav, so the reference wasn't quite pinned. The
     headroom means every window at or above 1230px tall is untouched, and
     the floors stop them running away on a very short one.

     Every value they multiply is also max()'d against its own existing
     floor, so the documented 900px→375px phone schedule is byte-identical
     — verified at 375×812 and 375×667, where these resolve to ~0.79 and
     every floor wins. */
  --nav-fit: clamp(0.6, calc(0.3839 + 100svh / 1996px), 1);
  --stage-lead: clamp(0.6, calc(0.4499 + 100svh / 2236px), 1);

  /* ---------- type scale — 7 styles, nothing else ----------
     FS Mondwest (--font-hero) + Inter (--font-body/--font-display),
     base 16 / scale 1.392, sized for a 1440px-wide screen. Every size
     reaches its ceiling exactly at 1440px viewport width and holds
     flat beyond it — a bigger monitor sits further away, so type
     shouldn't keep growing past this reference; --container already
     caps line length instead. The remaining two display/heading tiers
     (--text-h1, --text-h2) fall back fluidly to a floor at 375px;
     --text-body/--text-caption stay flat at every width since 16px/11px
     are already close to the smallest comfortable reading size.

     --text-title is the one exception to all of the above — see its own
     definition just below for why it's pure vw with no 1440px ceiling
     at all now. */
  /* restored to the original brand-spec size (120px @1440) — it had
     been dialed to 2/3 (80px) earlier in the project, but that read too
     small across the board, so it's back to spec. Applies to EVERY
     member of this style everywhere it's used (JAYDON WANG, Jaydon's
     Digital Museum, About me, the footer, and the three case-study page
     titles) via this one token, not a per-element override, so the
     class stays one consistent size.

     Pure vw (no rem term, no separate ≥1440px tier below anymore) —
     aileens-beautiful-site.webflow.io was pulled up specifically to see
     how a reference site keeps large display text at a genuinely
     CONSTANT size relative to the viewport at every width, with no
     "stops scaling past this breakpoint" seam: their whole type scale
     is `body { font-size: 1vw }` + headings sized in em off that, so
     every heading is a fixed % of viewport width forever. Reproduced
     here directly on the token instead (this codebase sizes type via
     px/rem custom properties, not an em cascade off body, so there's
     no root font-size to repurpose the same way) — 8.333vw reproduces
     the exact 120px-@-1440px brand anchor (120 / 1440 × 100 = 8.3̄vw)
     while now scaling continuously past it instead of flattening at
     7.5rem/120px the way the old two-tier clamp() did. 48px/300px are
     safety rails only, not a second tier: 48px keeps it legible below
     ~576px (phones), 300px is a backstop against runaway growth on an
     extreme ultra-wide monitor (doesn't engage until ~3600px wide) —
     neither changes the ratio anywhere in the range real viewports
     actually use, unlike the old clamp()'s floor/ceiling. */
  --text-title: clamp(48px, 8.333vw, 300px);   /* 120px @1440 — titles (FS Mondwest, scramble) */
  --text-h1: clamp(2rem, calc(1.296rem + 3.005vw), 4rem);          /* 64px @1440 — section headings (both fonts) */
  --text-h2: clamp(1.75rem, calc(1.42rem + 1.408vw), 2.688rem);    /* 43px @1440 — card titles */
  /* 43px @1440 — case-study meta labels (Role/Timeline/Skills) and
     subheadings. Identical to --text-h2 at 1440 and close to it through
     the desktop range, which is the point: these three styles are meant
     to read as one voice (see .cs-hero__meta-item span), and on a desktop
     they still do.

     It exists because --text-h2's floor is too high for the phone range.
     That ramp only reaches 28px before flattening (below ~375px), while
     .cs-heading — the SECTION title these subheads are supposed to sit
     under — floors at 29px. Measured at 360/390/430px: subhead 28/28.2/
     28.8 against a section title of 29.3 at all three. A half-pixel step
     is no step at all, so a subheading and the heading above it rendered
     as the same size, and the Role/Timeline/Skills labels sat at 28px
     over their own 12px values.

     Same 2.688rem ceiling, but pinned through (400px, 20px) instead, so
     it stays a true ramp across phone widths: ~20.7px at 430, ~28px at
     768 against a 48px section title, 43px at 1440. */
  --text-subhead: clamp(1.125rem, calc(0.697rem + 2.212vw), 2.688rem);
  /* Was a flat 1.375rem (22px) at every width — fine at 1440, far too
     heavy on a phone, where it landed at 22px under a 78.5px title and
     ran the hero paragraph to four lines. Fluid now, pinned through
     (430px, 15px) and (1440px, 22px): unchanged on desktop, ~32% smaller
     on a phone. The 0.9375rem floor holds it at 15px below 430 rather
     than letting it keep shrinking into unreadability. */
  --text-h3: clamp(0.9375rem, calc(0.751rem + 0.693vw), 1.375rem);  /* 22px @1440 — homepage hero paragraph ("-Uses his love of games...") */
  --text-body: 1rem;                                                /* 16px — all paragraph + button text */
  --text-caption: 0.688rem;                                         /* 11px — captions, tags, metadata */
  /* dedicated to the nav's Work/Play switch (.nav__switch-opt) — not a
     reuse of --text-body, which is intentionally flat/fixed below
     1440px (see the media query below) since paragraph text shouldn't
     shrink on a narrower screen. The switch is different: its padding
     already shrinks fluidly at these widths (see .nav__switch-opt's
     own comment), and leaving its font-size flat against that shrinking
     padding was exactly the "doesn't shrink like the title does"
     mismatch this token fixes. Same 900px→375px breakpoint pair as
     that padding clamp, so text and padding shrink on the same
     schedule instead of drifting apart. */
  --text-nav-switch: clamp(11px, calc(9.571px + 0.381vw), 13px);     /* 13px @900px+, 11px @375px */
}

/* ---------- light mode ----------
   A real palette, not a filter. The previous implementation was
   `html[data-theme="light"] { filter: invert(1) hue-rotate(180deg) }`
   with a second, cancelling invert on every img/video/canvas — a trick
   that dates from an older version of this site and had stopped being
   survivable: it inverted the brand purple into a green, it could not
   tell a surface tint from a cast shadow (so shadows became glows), the
   double-invert on media broke the moment an element was both a surface
   and a picture, and `filter` on <html> creates a containing block that
   quietly breaks position: fixed inside it.

   This block instead redefines the tokens the rest of the file already
   speaks in. Nothing below it is theme-aware; every rule just names a
   token and follows. Adding a component needs no light-mode rule of its
   own as long as it uses these.

   Deliberately NOT overridden: rgba(0, 0, 0, a) shadows, which stay dark
   in both themes, and the #000 letterbox on .cs-video / .cs-square--contain,
   which matches the video sources' own black slate rather than the page. */
html[data-theme="light"] {
  --bg-top: #e9ebf7;
  --bg-bottom: #ffffff;

  --text-primary: #14162b;
  --text-secondary: #4e5372;
  --text-muted: #767b96;

  --nav-bg: #dcdff0;
  --surface-bg: rgba(20, 22, 43, 0.04);
  --surface-border: rgba(20, 22, 43, 0.12);

  /* #a855f7 only reaches ~3.5:1 on white — fine for a 120px title, not
     for the inline links and small marks that also use it. Deepened. */
  --accent: #7e22ce;
  --accent-strong: #6b21a8;

  /* the one line that flips ~44 translucent-white rules to dark */
  --overlay-rgb: 20, 22, 43;

  --ink: #14162b;
  /* --copy-ink's counterpart, matched by CONTRAST rather than by hue.
     Inverting the dark value lands in the warm greys, which measure the
     same on a dark ground but only ~4.9:1 on this one - the same nominal
     step away from --ink reads noticeably fainter on a light page. This
     is picked to
     hold ~5.9:1 against the lighter end of the --bg-top/--bg-bottom
     gradient, so body copy sits the same distance below its headings in
     both themes, and it keeps the palette's blue lean rather than the
     warm grey a straight inversion produces. */
  --copy-ink: #5f6377;
  --chip-bg: #e7eaf7;
  --chip-border: #a6abc6;
  --media-backdrop: #dce0f0;
  /* a white halo is invisible on white — a soft dark one keeps the
     Mondwest titles feeling lit rather than flat */
  --hero-glow: rgba(20, 22, 43, 0.22);

  --sel-bg: #14162b;
  --sel-ink: #f4f5fb;

  /* the ring stays white here — only its supporting colours change. A
     white glow is invisible on a light page, so it becomes a soft dark
     one reading as a shadow under the ring, and the halo is pushed from
     0.5 to 0.72: on a dark page the white stroke carries itself and the
     halo only needs to define an edge, but on a light one the halo is
     doing all the work of separating ring from background. */
  --cursor-glow: rgba(20, 22, 43, 0.22);
  --cursor-halo: rgba(20, 22, 43, 0.72);

  /* the footer slab is a deliberate surface CHANGE, so it inverts with
     the page rather than staying white-on-white */
  --slab-bg: #14162b;
  --slab-ink: #f4f5fb;
  --slab-ink-soft: rgba(244, 245, 251, 0.62);
  --slab-btn-bg: #22254a;
  --slab-btn-hover: #2d3160;
}


/* the 1440px sizes above are the brand spec's ceiling, not the whole
   site's — on an actual large monitor they were reading a little small.
   Past 1440px, every token keeps growing (each formula picks up exactly
   where its 1440px value left off, so there's no visible jump at the
   breakpoint) up to a second, higher ceiling reached at 1920px, then
   holds flat again beyond that — display/heading tiers grow ~15%
   further, body/caption a gentler ~10% since reading text shouldn't
   scale as aggressively as headlines. */
@media (min-width: 1440px) {
  :root {
    /* headings (title/h1/h2/h3) now scale ×2 by 1920px — same multiplier
       across the whole heading family, so their relative proportions to
       each other stay exactly what they are at 1440px, just uniformly
       bigger. Body/caption scale ×1.375 (the title/body split from
       before — display text can grow more aggressively than reading
       text without hurting layout/legibility). */
    /* --text-title no longer has a tier here — it's pure vw now (see
       its :root definition above), so it already keeps scaling smoothly
       past 1440px on its own, with no seam at this breakpoint to patch.
       --text-h1, --text-h2, and --text-h3 are also intentionally NOT
       overridden here — all three stay at their original, flat ceilings
       (the base :root values above, unedited since first defined) for
       any viewport ≥1440px: 4rem for --text-h1 (section headings —
       "Projects & Case Studies", "Introduction", etc.), 2.688rem for
       --text-h2 (the "subheading" style), and 1.375rem/22px for
       --text-h3 (the homepage hero paragraph) — confirmed against the
       actual 1.375rem/22px spec, no big-screen growth for any of the
       three. */
    --text-body: clamp(1rem, calc(-0.125rem + 1.25vw), 1.375rem);     /* 22px @1920 (×1.375) */
    --text-caption: clamp(0.688rem, calc(-0.086rem + 0.86vw), 0.946rem); /* ~15.1px @1920 (×1.375) */
  }
}

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

button,
input,
select,
textarea {
  font-family: inherit;
  color: inherit;
}

html {
  scroll-behavior: smooth;
}

/* ---------- custom cursor ----------
   A 38px ring that replaces the system pointer. Shape follows the Figma
   panel — a circle with no fill — but the stroke deliberately does not:
   the spec's 1px #727272 is the site's own chip/button stroke colour, and
   at 38px across it was a grey hairline that disappeared against photos,
   video tiles and the 3D model. It is 2px of white with a
   contrasting dark halo instead (see --cursor-stroke in :root). Restore the
   original by setting --cursor-stroke: #727272 and the border to 1px. Drawn in CSS rather
   than shipped as the supplied Ellipse PNG on purpose — "stroke weight 1,
   position inside" is a vector instruction, and a 38px raster would soften
   on a HiDPI screen while a border stays exactly 1 device-independent
   pixel at any zoom. It also costs no request and can react to hover.

   Gated behind pointer: fine. A touch device has no persistent pointer to
   follow, and hiding the system cursor there would take away the caret on
   a laptop's trackpad-less tablet mode for nothing. Everything in here —
   including cursor: none — lives inside that query, so a touch user keeps
   native behaviour untouched. */
@media (pointer: fine) {
  /* !important, and on the universal selector, on purpose. Six rules in
     this file set cursor: pointer and every one of them is class-based
     (.btn, .filter-pill, .nav__theme-toggle, ...). A type or universal
     selector loses to a class on specificity, so `button { cursor: none }`
     was silently beaten and the system arrow still appeared over every
     button — measured, not assumed. Raising specificity selector-by-
     selector would mean naming all six and re-naming them whenever one is
     added; the intent here is absolute ("no native cursor anywhere while
     the ring is active"), and !important is the honest expression of it.
     Scoped to pointer: fine, so it cannot reach a touch device. */
  html,
  body,
  *,
  *::before,
  *::after {
    cursor: none !important;
  }

  /* Two elements, deliberately. This one is a fixed 38px POSITIONING box
     that never changes size; ::after draws the visible circle inside it
     and is the only thing that resizes.

     The split exists because main.js places the ring with
     translate3d(x - RADIUS, y - RADIUS) using a constant 19px — half of
     38. Resize this box and that constant is wrong: the circle would
     drift ~10px down-right of the true pointer the moment it changed
     size, and the smear's rotate/scale (applied about the box's own
     centre) would pivot around the wrong point. Keeping the box rigid
     means the hover size change costs the follow loop nothing. */
  .cursor-ring {
    position: fixed;
    top: 0;
    left: 0;
    width: 38px;
    height: 38px;
    /* never let the ring eat a click, and keep it above everything
       including .nav-wrap's z-index: 999 */
    pointer-events: none;
    z-index: 2147483647;
    /* the element is moved by transform alone (never top/left), so the
       whole follow loop stays on the compositor. `filter` used to be
       listed here too, back when the loop wrote a blur radius every
       frame — promising the compositor a filter it no longer animates
       just costs it preparation for nothing. */
    will-change: transform;
    /* opacity only — position must not be transitioned or it would fight
       the rAF loop that already smooths it */
    transition: opacity 0.25s ease;
    opacity: 0;
  }

  .cursor-ring::after {
    content: "";
    position: absolute;
    /* inset is what animates on hover — insetting equally on all four
       sides shrinks the circle about the box's centre, so it stays
       exactly on the pointer at every size */
    inset: 0;
    border-radius: 50%;
    /* Figma: Fill (none) + Stroke #727272, weight 1, inside. --cursor-fill
       is a hook rather than a value in use: set it to a colour if the ring
       should read as the solid ball the PNG export shows. */
    background: var(--cursor-fill, transparent);
    /* 1px -> 2px. At 38px across, a single pixel of stroke is a hairline
       and reads as an artefact rather than a pointer; 2px is still a thin
       ring but holds its shape while it is being stretched and blurred by
       the motion smear, which thins the stroke further along one axis. */
    border: 2px solid var(--cursor-stroke, #ffffff);
    /* first shadow is the halo — a contrasting hairline hugging the ring
       so it stays readable over photos, video tiles and the 3D model,
       where a single flat colour would sink into the background. Second
       is a soft glow that makes it feel lit rather than drawn. */
    box-shadow:
      0 0 0 1px var(--cursor-halo, rgba(0, 0, 0, 0.45)),
      0 0 14px 2px var(--cursor-glow, transparent);
    transition: inset 0.2s ease, border-color 0.2s ease;
  }

  /* revealed on the first real pointer move, so the ring never sits parked
     in the top-left corner before the visitor has touched the mouse */
  .cursor-ring.is-live {
    opacity: 1;
  }

  /* stands in for the system pointer over interactive elements — the
     ring contracts to a small dot-like circle rather than expanding.
     Shrinking reads as the cursor "focusing" on the thing under it, and
     it keeps the target visible: a 58px ring grown over a filter pill
     covered the pill it was pointing at, which is the opposite of what a
     pointer should do. */
  .cursor-ring.is-hot::after {
    /* 38 - (10 * 2) = an 18px circle, still centred on the pointer */
    inset: 10px;
    border-color: var(--cursor-stroke-hot, #ffffff);
  }

  /* A ring is a 1px line; a plain filter: blur() at any strength that
     reads as "fast" would dissolve it entirely. The motion smear is
     therefore the directional stretch applied in main.js — the ring
     elongates along its own travel and thins across it, which is what a
     smeared circle actually looks like.

     It used to be that stretch PLUS a light per-frame filter: blur().
     The blur is gone, and the reason is cost rather than taste. This
     element replaces the system cursor, and a system cursor is drawn by
     the OS compositor at no cost to the page at all. Everything the ring
     does per frame is now a single `transform` write — position, rotate
     and scale are all compositor properties, so a moving ring produces
     no layout, no paint and no raster. An animated filter radius was the
     one thing that broke that: it forced the element to be re-rasterised
     and run through a GPU blur every frame it moved, which on a laptop
     already short of frames is precisely where it hurt most. The stretch
     was always doing most of the work of reading as speed anyway. */
}

/* prefers-reduced-motion needs no rule of its own here any more. It used
   to exist to out-specify the inline filter the loop wrote every frame;
   with no inline filter left, main.js's own `reduced` branch simply skips
   the smear transform and the ring follows the pointer rigidly. */

/* ---------- scrollbar ---------- */

/* hidden entirely, on request — was previously a thin, rounded,
   translucent bar (styled, not removed); this replaces that whole
   ::-webkit-scrollbar-* rule set (track/thumb/hover) since none of it
   paints once the bar itself is gone. Scrolling itself is untouched —
   this only hides the visual track/thumb, not overflow/scroll
   behavior — and -ms-overflow-style covers old (EdgeHTML) Edge, which
   ignores both scrollbar-width and ::-webkit-scrollbar. */
html {
  scrollbar-width: none;
  -ms-overflow-style: none;
}

::-webkit-scrollbar {
  display: none;
  width: 0;
  height: 0;
}

body {
  position: relative;
  font-family: var(--font-body);
  font-size: var(--text-body);
  /* #121327 fades to black right around where the circle buttons begin.
     NOT `fixed` attachment — that pins the gradient to the viewport, so
     it recomputes for whatever's currently on screen and effectively
     "follows" you as you scroll. Default (scroll) attachment anchors it
     to the page instead: purple only at the very top, solid black for
     the rest of the page as you scroll down. */
  background: linear-gradient(180deg, var(--bg-top) 0px, var(--bg-bottom) 1396px);
  color: var(--text-primary);
  min-height: 100vh;
  overflow-x: hidden;
}

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

a {
  color: inherit;
  text-decoration: none;
}

ul {
  list-style: none;
}

::selection {
  background: var(--accent);
  color: var(--ink);
}

/* ---------- restrained "glass" surface (secondary panels only) ---------- */

.glass {
  background: var(--surface-bg);
  border: 1px solid var(--surface-border);
  backdrop-filter: var(--surface-blur);
  -webkit-backdrop-filter: var(--surface-blur);
  box-shadow: 0 8px 28px rgba(0, 0, 0, 0.3);
}

/* ---------- layout helpers ---------- */

.container {
  width: min(100% - 48px, var(--container));
  margin-inline: auto;
}

.container--wide {
  /* the 110px side gutter was a flat subtraction regardless of viewport —
     fine at desktop widths, but on a small phone (~320px) it eats a
     disproportionate chunk of the screen (110/320 = ~34% just for
     padding) and was tight enough to wrap the hero CTA button onto two
     lines. Fluid between the same floor .container itself uses (48px)
     and the original 110px ceiling, so it shrinks in step with the
     viewport instead of staying desktop-sized until it doesn't fit. */
  width: min(100% - clamp(48px, 6vw, 110px), 2600px);
}

section {
  padding-block: 64px;
}

/* separation BETWEEN sections, on top of the padding each already
   carries. Written as section + section rather than a margin on every
   section for two reasons: it can't add a gap above the first one (the
   heroes are viewport-fitted — .hero's height is measured as viewport
   minus nav, so a margin above it would push it down and overflow the
   very fit that JS just solved), and adjacent margins can't double up.

   Same construction as .cs-container section + section, which has done
   this inside the case-study pages all along; that rule is more specific,
   so those pages keep their own tighter rhythm rather than inheriting
   this one. */
section + section {
  margin-top: clamp(3rem, calc(1.944rem + 4.507vw), 6rem);
}

.eyebrow {
  display: inline-block;
  font-family: var(--font-display);
  font-size: var(--text-caption);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--text-muted);
  margin-bottom: 14px;
}

/* ---------- nav ---------- */

.nav-wrap {
  /* not sticky by design — the bar scrolls away with the rest of the
     page instead of pinning to the viewport */
  /* --nav-fit is the height arm (see :root); max() against the clamp's
     own floor stops the two compounding into something tiny on a short
     phone. Range dropped 14–20px → 10–14px in the same pass that took the
     whole bar down a size — see .nav__switch-opt for the reasoning. */
  padding-top: max(10px, calc(clamp(10px, calc(9.030px + 0.259vw), 14px) * var(--nav-fit)));
  /* position: relative carries no visual/layout change on its own (no
     offsets set) — it's here purely so z-index actually applies: a
     static element ignores z-index entirely. Wrapping the whole nav in
     one stacking context like this, rather than relying on the mobile
     dropdown's own z-index: 60 below, guarantees nothing on the page
     — now or added later, anywhere else in this file — can ever paint
     over any part of the nav, not just the dropdown. 999 is well above
     every other z-index in this file (60 is the next-highest, on
     .nav__links below, which this now sits above too). */
  /* absolute, not relative: the bar is a LAYER over the page now rather
     than a block that occupies flow and pushes every hero down by its own
     height. z-index: 999 already put it on top in paint order; what it
     did not do was stop it consuming ~130px of vertical space that the
     Play hero wanted back.

     Still not fixed — it scrolls away with the page exactly as before,
     which absolute preserves and fixed would not. `main` takes an equal
     padding-top so every other page renders byte-identically; only
     .dm-hero opts out of that padding to reclaim the band. */
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  z-index: 999;
}

/* Pays back the flow space .nav-wrap stopped occupying. Set from the
   measured bar height by main.js (--nav-h); the 130px fallback is the
   value at a typical desktop window, used only for the instant before
   the script runs and whenever JS is unavailable. */
main {
  padding-top: var(--nav-h, 130px);
}

.nav {
  /* three independent groups (logo / Work·Play switch / links), not one
     floating pill — a grid keeps the switch mathematically centered
     (equal 1fr side columns) no matter how wide the logo or the link
     list ends up being, which justify-content:space-between can't
     guarantee once the two sides are different widths */
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: center;
  gap: clamp(14px, calc(11.087px + 0.777vw), 26px);
  /* height arm as on .nav-wrap above — gap is horizontal so it's left
     alone; only the block padding contributes to how tall the bar is */
  padding-block: max(8px, calc(clamp(8px, calc(7.273px + 0.194vw), 11px) * var(--nav-fit)));
}

/* shared "liquid glass" surface, now only for .btn-glass (currently
   unused in markup, kept as a reusable component) — the nav bar itself
   sits directly on the page with no pill/blur of its own; only the
   Work/Play switch below carries its own glass surface */
.btn-glass {
  position: relative;
  isolation: isolate;
  background: var(--nav-bg);
  /* drop shadow: 50% #000, blur 17, y 4 + inner shadow: 25% #fff, blur 10, y 4 */
  box-shadow:
    0 4px 17px rgba(0, 0, 0, 0.5),
    inset 0 4px 10px rgba(var(--overlay-rgb), 0.25);
  /* glass effect approximation (refraction 58 / depth 20 / dispersion 50) */
  backdrop-filter: blur(20px) saturate(180%);
  -webkit-backdrop-filter: blur(20px) saturate(180%);
}

.btn-glass::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  padding: 1px;
  background: linear-gradient(
    115deg,
    rgba(var(--overlay-rgb), 0.35),
    rgba(168, 85, 247, 0.18) 35%,
    rgba(103, 232, 249, 0.14) 65%,
    rgba(var(--overlay-rgb), 0.05)
  );
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
  mask-composite: exclude;
  pointer-events: none;
}

/* The grid's first column, holding the logo and the theme switch side by
   side. Without this wrapper the switch had no grid-column of its own, so
   auto-placement dropped it into row 2 — the bar became two rows tall,
   which is what pushed --nav-h (and with it main's padding-top and every
   hero below) down the page. One row again. */
.nav__brand {
  grid-column: 1;
  justify-self: start;
  display: flex;
  align-items: center;
  gap: clamp(8px, calc(6.545px + 0.388vw), 14px);
}

.nav__logo {
  display: flex;
  align-items: center;
  flex-shrink: 0;
}

.nav__logo-badge {
  /* full image, uncropped — aspect-ratio matches the source asset
     (124×59) exactly, so object-fit: cover below never has to crop
     anything off the sides to fill the box */
  width: clamp(48px, calc(44.683px + 0.884vw), 66px);
  aspect-ratio: 124 / 59;
  border-radius: 14px;
  overflow: hidden;
  display: flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  background: var(--nav-bg);
  box-shadow:
    0 3px 12px rgba(0, 0, 0, 0.45),
    inset 0 3px 6px rgba(var(--overlay-rgb), 0.2);
}

.nav__logo-badge img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center;
}

.nav__logo-text {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 16px;
  letter-spacing: 0.01em;
  display: none;
}

/* Work / Play — the site's two halves (case studies vs. Digital Media)
   as a segmented control. The track shares the sitewide glass-outline
   formula (see .btn-outline, e.g. the homepage's "Building experiences"
   button) so it reads as the same surface; .is-active reuses the
   sitewide "active pill" look (see .filter-pill.is-active) so the
   interaction pattern matches too. */
.nav__switch {
  display: flex;
  align-items: center;
  flex-shrink: 0;
  border-radius: var(--radius-pill);
  grid-column: 2;
  justify-self: center;
  /* the track (this element) shrinking alongside the buttons it holds —
     same 900px→375px schedule as .nav__switch-opt's own padding/font
     clamps below, so the whole switch (track + both buttons) reads as
     one thing shrinking together, not just the buttons shrinking
     inside a fixed-size holder. */
  gap: clamp(1px, calc(0.286px + 0.190vw), 2px);
  /* --nav-fit applied to all four sides, not just the block axis: this is
     a pill, so letting the track thin vertically while holding its full
     horizontal inset would visibly change its shape rather than its size */
  padding: max(2px, calc(clamp(2px, calc(1.286px + 0.190vw), 3px) * var(--nav-fit)));
}

.nav__switch-opt {
  /* NOT the flat sitewide .btn spec (0.938rem/1.875rem) anymore — that
     read fine at desktop widths but stayed completely fixed all the way
     down to 420px (where a separate, abrupt media-query override used
     to cut it down to 8px/12px in one jump), making the nav bar's own
     Work/Play switch eat a disproportionate, non-shrinking chunk of a
     shrinking nav on tablet/small-laptop widths. Fluid instead, so
     there's no breakpoint jump and no "stuck at full size" range in
     between. The old 420px-only override is gone; this clamp covers it.

     The whole bar was then taken down a size on request — it read as too
     heavy for what it holds. Every nav clamp keeps the same 375px→cap
     schedule shape it always had, with both endpoints scaled down and the
     preferred term recomputed so the line still meets them exactly: this
     padding 8→15.008px became 6→9px, the inline pair 12→30px became
     10→20px, --text-nav-switch 12→16px became 11→13px, .nav-wrap's
     padding-top 14→20px became 10→14px, .nav's padding-block 10→16px
     became 8→11px, .nav__switch's track 2→4px became 2→3px, and
     .nav__logo-badge 64→90px became 48→66px.

     Unlike the earlier height-only pass, the INLINE axis shrinks too —
     the request was for width as well as height, so the switch gets
     genuinely smaller rather than just flatter. Only the block axis takes
     --nav-fit on top of that, since only it feeds the bar's height. */
  padding-block: max(6px, calc(clamp(6px, calc(3.857px + 0.571vw), 9px) * var(--nav-fit)));
  padding-inline: clamp(10px, calc(2.857px + 1.905vw), 20px);
  border-radius: var(--radius-pill);
  /* was var(--text-body), which is intentionally flat/fixed below
     1440px — left the text at a constant 16px while the padding above
     kept shrinking around it, so the button never actually looked like
     it was shrinking, just getting tighter-fitting. --text-nav-switch
     is a dedicated token shrinking on this exact same 900px→375px
     schedule, so text and padding scale down together — see its own
     :root comment for the full reasoning. */
  font-size: var(--text-nav-switch);
  font-weight: 500;
  color: var(--text-secondary);
  white-space: nowrap;
  /* same bouncy back-out curve as .btn — see its own comment for why */
  transition: color 0.2s ease, background 0.2s ease, transform 0.22s cubic-bezier(0.34, 1.56, 0.64, 1);
}

.nav__switch-opt:hover {
  color: var(--text-primary);
  transform: scale(1.14);
}

.nav__switch-opt.is-active {
  background: var(--sel-bg);
  color: var(--sel-ink);
}

.nav__links {
  display: flex;
  align-items: center;
  /* tightened 20–36px → 14–24px alongside the smaller link text above;
     36px of air between three short words was spacing built for the
     old, larger type */
  gap: clamp(14px, calc(8.140px + 1.563vw), 24px);
  flex-wrap: nowrap;
  grid-column: 3;
  justify-self: end;
}

.nav__links a {
  /* --text-nav-switch, not --text-body. --text-body grows above 1440px
     (see its media query) and reached 22px on a 2560-wide monitor, which
     was already the largest thing in the bar and became conspicuous once
     the switch came down to 13px — a 22px link next to a 13px button read
     as two different components rather than one bar. Sharing the switch's
     token keeps every piece of nav text on one size and one schedule. */
  font-size: var(--text-nav-switch);
  font-weight: 400;
  color: var(--ink);
  white-space: nowrap;
  transition: color 0.2s ease;
}

.nav__links a:hover,
.nav__links a:focus-visible {
  color: var(--text-secondary);
}

/* light/dark mode switch — always visible (unlike .nav__toggle, which
   only shows up once the mobile hamburger breakpoint kicks in) */
.nav__theme-toggle {
  display: flex;
  align-items: center;
  justify-content: center;
  /* takes --nav-fit like the rest of the bar, so it shrinks with the logo
     on a short window instead of being the one fixed-size thing in the
     row and quietly setting the bar's minimum height */
  width: max(30px, calc(38px * var(--nav-fit)));
  height: max(30px, calc(38px * var(--nav-fit)));
  flex-shrink: 0;
  border-radius: 50%;
  background: rgba(var(--overlay-rgb), 0.05);
  border: 1px solid rgba(var(--overlay-rgb), 0.14);
  color: var(--text-primary);
  cursor: pointer;
  /* same bouncy back-out curve as .btn — see its own comment for why */
  transition: background 0.2s ease, transform 0.22s cubic-bezier(0.34, 1.56, 0.64, 1);
}

.nav__theme-toggle:hover {
  background: rgba(var(--overlay-rgb), 0.1);
}

.nav__theme-toggle:active {
  transform: scale(0.92);
}

/* dark mode (default): sun shown, click to switch TO light.
   light mode: moon shown, click to switch back to dark. */
.nav__theme-toggle .nav__theme-icon--moon {
  display: none;
}

html[data-theme="light"] .nav__theme-toggle .nav__theme-icon--sun {
  display: none;
}

html[data-theme="light"] .nav__theme-toggle .nav__theme-icon--moon {
  display: block;
}

.nav__toggle {
  display: none;
  grid-column: 3;
  justify-self: end;
  /* same bouncy back-out curve as .btn — see its own comment for why */
  transition: transform 0.22s cubic-bezier(0.34, 1.56, 0.64, 1);
}

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

/* every button on the site (regardless of variant class) shares one
   exact spec now, not just .btn-outline: #fff text, no fill, 1px
   #727272 stroke, an inner shadow (x:0 y:1 blur:10 #fff 25%), and the
   sitewide glass effect (light -45°/80%, refraction 58, depth 18,
   dispersion 50, frost 15 — approximated the same way every other glass
   surface on the site is, via backdrop-filter blur+saturate plus the
   inner-shadow glow standing in for refraction/depth/dispersion/frost;
   there's no direct CSS equivalent for those Figma-specific params).
   .btn-glass/.btn-light/.btn-gradient used to each carry their own
   fill/text-color/shadow on top of this — those are gone now so every
   variant actually renders identically; the variant classes are kept
   only where a hover still needs its own distinct feedback (see below).
   .footer-links .btn is the one deliberate exception (see its own
   comment) — white text + no fill would be invisible on that section's
   white background. */
.btn {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  font-family: var(--font-body);
  font-weight: 600;
  font-size: var(--text-body);
  /* fixed, not fluid — a flat 0.938rem/1.875rem spec across every
     button on the site, not a clamp() range like before */
  padding: 0.938rem 1.875rem;
  border-radius: var(--radius-pill);
  border: 1px solid var(--chip-border);
  background: transparent;
  color: var(--ink);
  box-shadow: inset 0 1px 10px rgba(var(--overlay-rgb), 0.25);
  backdrop-filter: blur(10px) saturate(180%);
  -webkit-backdrop-filter: blur(10px) saturate(180%);
  cursor: pointer;
  /* back-out easing (overshoots past the target size, then settles) is
     what actually gives this the bouncy, "pops up" feel on hover instead
     of a flat linear grow — same idea as the chunky, springy button
     scale-up in Nintendo's Mii Creator. Faster/bigger than the first
     pass (0.35s/1.08 -> 0.22s/1.14) per follow-up feedback. */
  transition: transform 0.22s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.25s ease, border-color 0.2s ease, background 0.2s ease;
}

.btn:hover {
  transform: translateY(-2px) scale(1.14);
  background: rgba(var(--overlay-rgb), 0.08);
}

/* glass pill — "Case studies", mobile nav toggle. No properties of its
   own left — kept only as a marker class (see the shared .btn rule) in
   case a future variant-specific tweak needs a hook here again. */
.btn-glass {
}

/* solid light pill — "Contact" — no longer solid; same shared glass
   look as every other button now, kept only for its own hover tweak */
.btn-light:hover {
  background: rgba(var(--overlay-rgb), 0.08);
}

/* the one accent-colored button on the site — no longer accent-colored;
   same shared glass look as every other button now, kept only for its
   own hover tweak (a bit brighter than the default, since this one used
   to carry the site's one spot of accent color) */
.btn-gradient:hover {
  background: rgba(168, 85, 247, 0.18);
}

/* no-fill glass — the About hero's discs, and (below) the callout / CTA pills and
   takeaway cards: every glass-surface component on the site that ISN'T
   a button or a tag (those each moved to their own distinct formula —
   buttons up in .btn, tags down by .tag) shares this one formula:
   low 10px blur (kept low on purpose — see .cs-callout, "The creative
   content..." — rather than the heavier 20px blur this group used to
   carry), drop shadow, and a soft-blurred inner glow standing in for
   the Figma refraction/depth/dispersion glass effect. Original
   ~90%-opacity white stroke — these are decorative/content surfaces,
   not buttons, so they keep the old look rather than .btn's #727272 +
   single inner-shadow spec.

   .cs-callout and .cs-takeaway-card (the "My takeaways"/"The next
   steps"/"What I learned" cards — same component, different heading
   copy per case study) still share this base (position/isolation/color)
   but get their own flat fill + no stroke + no glass right below, and
   .cs-quote now joins them there. .about-hero__disc is what still keeps
   the original glass look. .cs-cta
   (the "Visit the website here."-style callouts that are actually
   links) is deliberately excluded from that flat treatment too: it
   already carries .btn/.btn-outline and gets the sitewide button
   treatment from there instead, which per this request is exactly what
   every callout WITH a link keeps. */
.about-hero__disc,
.cs-callout,
.cs-takeaway-card,
.cs-quote {
  position: relative;
  isolation: isolate;
  background: transparent;
  border: 0.5px solid rgba(var(--overlay-rgb), 0.9);
  backdrop-filter: blur(10px) saturate(180%);
  -webkit-backdrop-filter: blur(10px) saturate(180%);
  box-shadow:
    0 0 23px rgba(0, 0, 0, 0.25),
    inset 0 4px 16px rgba(var(--overlay-rgb), 0.25);
  color: var(--ink);
}

/* flat #171717, no stroke, no glass at all — overrides the shared rule
   above entirely rather than leaving blur/box-shadow declared but
   invisible under an opaque fill (the inner glow specifically would
   still show through regardless of the fill's opacity, so it has to be
   switched off outright, not just covered up). */
.cs-callout,
.cs-takeaway-card,
.cs-quote {
  background: var(--chip-bg);
  border: none;
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
  box-shadow: none;
}

/* .filter-pill/.nav__switch don't carry the .btn class, so they need
   this repeated explicitly — otherwise identical to .btn's own glass
   spec (transparent fill, #fff text, 1px #727272 stroke, the same
   single inner-shadow). .btn-outline/.cs-cta dropped from this whole
   block — both always carry .btn too (see their own markup), so they
   already get every one of these properties from there now; repeating
   them here would just be redundant, not different. */
.filter-pill,
.nav__switch {
  position: relative;
  isolation: isolate;
  background: transparent;
  border: 1px solid var(--chip-border);
  color: var(--ink);
  box-shadow: inset 0 1px 10px rgba(var(--overlay-rgb), 0.25);
  backdrop-filter: blur(10px) saturate(180%);
  -webkit-backdrop-filter: blur(10px) saturate(180%);
}

.about-hero__disc:hover,
.filter-pill:hover,
.nav__switch:hover {
  background: rgba(var(--overlay-rgb), 0.08);
}

/* Every button on the site grows on hover, to the same 1.14 the Work
   section's filter pills use. The theme toggle and the mobile menu
   button were changing colour and sitting still.

   Scale alone, no translateY: .btn adds a 2px lift on top because it is
   a free-standing pill with room above it, but these two are seated in
   the nav bar, where lifting reads as drift rather than as a
   press-ready button. */
.nav__theme-toggle:hover,
.nav__toggle:hover {
  transform: scale(1.14);
}

/* The five buttons in the About hero (three discs, Biography, My design
   ethos) hover on `scale` rather than `transform`, unlike every other
   button on the site. All five run the wiggle keyframes, which animate
   `transform` — and an animation's value outranks a normal declaration
   for the same property, so a hover written as `transform` is liable to
   be swallowed for as long as the wiggle is running. That also applies
   to .btn's own hover transform, which is what Biography and My design
   ethos would otherwise be relying on.

   `scale` is an independent transform property, so it composes with
   whatever `transform` the animation sets on that frame instead of
   competing for the same slot. Same 1.14 as everything else, and the
   wiggle keeps drifting underneath it either way. */
.about-hero__disc:hover,
.about-hero__action:hover {
  scale: 1.14;
}

/* font-size intentionally not overridden here — every button shares
   --text-body now (see .btn); this variant only changes padding */
.btn-sm {
  padding: 12px 22px;
}

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

/* Fit-the-viewport hero — nothing in here ever needs a scrollbar to see,
   at any window size, including a wide-but-short desktop window (the old
   version only shrank with WIDTH via vw units, so a short window just
   cropped the bottom of the hero instead of shrinking to fit it).

   Every fluid property below is `calc(<its own vw-based clamp> *
   var(--hero-fit))`. The vw-clamp arm is completely unchanged, so
   width-driven behavior (phones, tablets) is exactly what it always was;
   --hero-fit is a 0–1 multiplier that only ever pulls things smaller,
   and only once the window gets short enough for it to matter.

   This used to be `min(<clamp>, calc(<max> * var(--hero-fit)))`, which
   was NOT proportional: near fit = 1 the clamp arm won instead, so the
   rendered height wasn't a straight multiple of --hero-fit and the
   shrink landed unevenly across the stack. A plain multiply is linear,
   which is what lets main.js solve for the right fit in a single
   measuring pass — and it means every part of the hero shrinks by the
   SAME factor, so a short window reproduces the tall-window composition
   at a smaller size instead of rearranging it into a different one.

   Unlike the old version the title and description shrink too (see their
   own comments). They were deliberately left out before because
   shrinking JAYDON WANG on its own made it render smaller than every
   other page's title on an ordinary laptop; that objection is gone now
   that .cs-hero__title takes the same multiplier, so the titles shrink
   together and stay in proportion with each other rather than drifting
   apart.

   --hero-fit itself is set by main.js, not computed here: it depends on
   the hero's natural (unshrunk) content height, which depends in turn on
   --hero-fit, and neither calc() nor a container query can resolve that
   cycle. That also retires the old hard-coded 1070px "natural height"
   constant and the 375–3840px width sweep that was needed to keep it
   honest — main.js measures the real number off the real page on every
   resize instead. :root's --hero-fit: 1 is the fallback, so anything
   that isn't a hero still renders full size. Unlike the old clamp()s
   this has no floor: a short enough window keeps shrinking for as long
   as it takes to fit, by design. */
.hero {
  position: relative;
  overflow: hidden;
  /* container-type: size used to be here purely so --hero-fit could read
     100cqh; main.js computes the fit now, and nothing else in the file
     queries this container, so the containment it forced is gone too */
  /* JS (main.js) overwrites this with the exact space left below the
     nav; 100vh is just the pre-JS fallback so nothing flashes at 0
     height for the instant before that runs */
  height: 100vh;
  display: flex;
  flex-direction: column;
  align-items: center;
  /* anchored to the top (padding-top is what controls the gap below the
     nav) rather than vertically centered — centering left a big empty
     gap above the laptop/title/paragraph/button on anything taller than
     the hero's natural content height, which read as the whole block
     sitting too low. overflow: hidden above is still the safety net if
     --hero-fit's estimate ever runs a few px short on a very tight
     window, same as before. */
  justify-content: flex-start;
  text-align: center;
  /* the fixed gap between title/laptop/desc in .hero__intro's stacked
     grid (see its own comment) — replaces what used to be a 1fr row's
     worth of leftover hero height sitting between them. Multiplied by
     --hero-fit like the paddings below, so it shrinks along with
     everything else on a short window instead of holding a flat gap
     the shrunk content no longer has room for. Tightened further on
     request (was 20-48px) — the laptop sits noticeably closer under
     the title now, and the paragraph (same gap token, so it tracks the
     laptop's new position rather than getting stranded below it) moves
     up right along with it. */
  /* nudged back up slightly (28px cap -> 36px) after .hero__intro
     .hero__title picked up its own negative margin-bottom below —
     trimming that title's real invisible font-metric padding closed
     part of the title-to-laptop gap for free, so this can afford to be
     a bit more generous again without the group reading as cramped */
  --hero-stack-gap: calc(clamp(14px, 1.8vw, 36px) * var(--hero-fit));
  /* these override the generic section{padding-block} rule (higher
     specificity), so without their own fluid range the hero would
     hold onto a fixed padding even on a phone — clamp() gives it the
     same continuous shrink as everything else, and the --hero-fit
     multiplier gives it a further shrink once height is tight */
  /* down again (24-39px -> 14-23px) — with the title now a plain grid
     item (see .hero__intro), this padding-top IS the whole gap to the
     nav, no separate margin nudge layered on top of it anymore */
  padding-top: calc(clamp(14px, calc(12.2px + 0.583vw), 23px) * var(--hero-fit));
  /* was a flat 150px taller than padding-top at every viewport width
     (214–330px) — that was headroom for a decorative background that no
     longer needs it, and was the single biggest reason the hero didn't
     fit on an ordinary laptop window in the first place, so it's been
     brought back down near padding-top's own range instead */
  padding-bottom: calc(clamp(24px, calc(14.29px + 2.589vw), 64px) * var(--hero-fit));
}

.hero__grid {
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  background-image:
    repeating-linear-gradient(
      0deg,
      rgba(var(--overlay-rgb), 0.4) 0px,
      rgba(var(--overlay-rgb), 0.4) 1px,
      transparent 1px,
      transparent 220px
    ),
    repeating-linear-gradient(
      90deg,
      rgba(var(--overlay-rgb), 0.4) 0px,
      rgba(var(--overlay-rgb), 0.4) 1px,
      transparent 1px,
      transparent 220px
    );
  /* fades out toward the bottom of the hero as it hands off to the section below */
  mask-image: linear-gradient(to bottom, #000 0%, #000 55%, transparent 95%);
  -webkit-mask-image: linear-gradient(to bottom, #000 0%, #000 55%, transparent 95%);
}

/* homepage-only variant — lives at the body level instead of inside
   .hero (see the HTML comment above its markup for why), so it needs
   its own positioning against body's box instead of .hero's inset: 0.
   height is set inline by main.js, since it has to reach from the true
   page top through the bottom of .hero — a distance CSS alone can't
   express once the element isn't nested inside .hero anymore. Negative
   z-index keeps it behind body's normal-flow content (nav, hero text)
   while still painting above body's own background. */
.hero__grid.hero__grid--homepage {
  top: 0;
  right: 0;
  bottom: auto;
  left: 0;
  z-index: -1;
  /* fainter, bigger squares than the shared .hero__grid default — full
     background-image override since its two comma-separated gradient
     layers (horizontal lines, vertical lines) can't be partially
     overridden. background-position shifts just the vertical-line layer
     half a square over, so the line that would otherwise sit flush
     against the page's left edge (reading like an unwanted border)
     lands in the middle of a gap instead. */
  background-image:
    repeating-linear-gradient(
      0deg,
      rgba(var(--overlay-rgb), 0.18) 0px,
      rgba(var(--overlay-rgb), 0.18) 1px,
      transparent 1px,
      transparent 320px
    ),
    repeating-linear-gradient(
      90deg,
      rgba(var(--overlay-rgb), 0.18) 0px,
      rgba(var(--overlay-rgb), 0.18) 1px,
      transparent 1px,
      transparent 320px
    );
  background-position: 0 0, 160px 0;
}

/* fills the full height .hero hands it (flex: 1 — .hero__intro is
   .hero's only child, so this just becomes 100% of .hero's own,
   JS-managed height) so the title/laptop/desc below have a real box to
   lay out against instead of stacking by natural content height.
   Four grid rows — title (auto), laptop (auto), desc (auto), then a
   trailing 1fr SPACER row with nothing in it. That 1fr row is what
   soaks up whatever height .hero__intro has left over beyond its own
   three lines of actual content, and — because it comes AFTER all
   three, not between them — that leftover space collects below the
   paragraph instead of being stretched out as gaps between the title/
   laptop/desc themselves. (Used to be auto 1fr auto, with the laptop
   sitting in that middle 1fr row — centered there by default, nudged
   up toward the title with a large margin-bottom. That kept the title
   flush against the top, same as now, but put the entire hero's worth
   of leftover space between the three elements, reading as "too far
   apart" — moved that leftover space out to its own trailing row
   instead, and gave title/laptop/desc a small fixed --hero-stack-gap
   between them, so request.) */
.hero__intro {
  position: relative;
  z-index: 1;
  flex: 1;
  /* NOT width: 100% — that would override the width .container/
     .container--wide already give this element (same specificity,
     later in cascade) and erase their side inset entirely, pinning the
     title flush against the true viewport edge */
  display: grid;
  grid-template-rows: auto auto auto 1fr;
  row-gap: var(--hero-stack-gap);
}

.hero__title {
  font-family: var(--font-hero);
  /* FS Mondwest's own space glyph is essentially broken — measured it
     directly off the font file: 0.024em advance width, against
     0.12–0.54em for actual letters (roughly a lowercase "i" would be
     the narrowest normal glyph). Words run together with no visible
     gap without this. word-spacing ADDS to whatever the glyph's own
     advance already is. Was 0.28em (landing the total around 0.3em,
     matched to this font's own letterforms) — tightened to 0.18em on
     request, still comfortably clear of the words running together.
     Repeated on every other rule that sets font-family:
     var(--font-hero) (this is a font-level bug, not specific to this
     one title). */
  word-spacing: 0.18em;
  /* 400, not 700 — FS Mondwest only ships one weight; a "bold" request
     against it would just be browser-synthesized faux bold, which
     distorts a pixel-grid face like this one */
  font-weight: 400;
  /* --text-title, same as every other member of this style (Digital
     Media's title, the case-study page titles, About me, JAYDON WANG
     included), times --hero-fit.

     The --hero-fit arm was previously removed from here: shrinking this
     title on a short window made JAYDON WANG render visibly smaller than
     every other title on ordinary laptop screens, because it was the
     ONLY title taking the multiplier. That's no longer true —
     .cs-hero__title now takes the same one and the Digital Museum title
     rides its stage's transform, so every title on the site shrinks by
     the same proportion on the same window and they stay matched to each
     other. Off the homepage --hero-fit resolves to :root's 1, so About's
     .page-hero title is untouched.

     JAYDON WANG briefly had its own width-driven override here (see
     .hero__intro .hero__title below) to keep it under half the
     viewport's width at any screen size — that constraint was
     explicitly dropped in favor of matching every other title's size
     exactly, and this still does that. */
  font-size: calc(var(--text-title) * var(--font-hero-scale) * var(--hero-fit));
  /* FS Mondwest also bakes in far more ascent/descent padding around
     its own glyphs than PP Mondwest did — measured both fonts' line
     metrics directly (canvas ascent/descent vs actual glyph ink):
     FS carries roughly 0.29em more built-in leading than PP did at
     any given line-height ratio. This was invisible on single-line
     titles, but on the multi-line ones ("Projects & Case Studies",
     "Jaydon's Digital Museum") it showed up as noticeably looser
     line spacing once --font-hero-scale enlarged the font to match
     PP's visual size. Was 1.2 under PP Mondwest — 0.91 here is that
     same PP-era looseness with FS's extra leading subtracted back
     out, not an arbitrary tighten. Repeated (with each rule's own
     original ratio minus ~0.29) on every other --font-hero rule. */
  line-height: 0.91;
  color: var(--ink);
}

/* homepage only (scoped through .hero__intro, not the bare .hero__title
   the About page also uses) — pins the name to the top row, centered,
   matching the centered stack every other page keeps (was left-aligned;
   changed on request to match the reference layout). Just a normal
   grid item now (row 1, auto-sized to its own height) — no more
   position: absolute + a manually-tuned negative margin to fake "flush
   with the top": row 1 already IS exactly the title's height, so it
   sits flush against .hero's padding-top with nothing extra needed. To
   move the title closer to the nav from here, shrink .hero's own
   padding-top (see .hero) — that's the actual gap being closed. */
.hero__intro .hero__title {
  grid-row: 1;
  justify-self: center;
  /* no text-align override — inherits center from .hero */
  /* no font-size override anymore — this used to have its own pure-vw
     clamp() specifically so JAYDON WANG always stayed under half the
     viewport's width at any screen size (measured off the font's actual
     glyph widths, with its own legibility floor for narrow phones).
     That constraint was explicitly dropped in favor of matching every
     other title's size exactly (see .hero__title's own comment on
     font-size) — so this now just inherits --text-title like the rest,
     and will run wider than half the viewport on most screens as a
     result. See git history for the old formula if that constraint
     ever needs to come back. */
  /* white glow behind the name, per design request: FFFFFF at 62%
     opacity, 17px blur, no offset (a pure glow, not a cast shadow) */
  filter: drop-shadow(0 0 17px var(--hero-glow));
  /* FS Mondwest's line-box carries real empty space below the visible
     letters that --hero-stack-gap can't see or account for — it's
     baked inside this element's own box, not the space between this
     and .hero__faces below. Measured directly (rendered the same text
     to an offscreen canvas at this element's computed font-size and
     scanned for ink): about 0.1em of dead air sits between the bottom
     of the glyphs and the bottom of this box, on top of whatever
     --hero-stack-gap adds after it. This negative margin cancels that
     out so the laptop sits right under the actual letters instead of
     under invisible font padding — em-based so it scales with
     --text-title/--font-hero-scale at every viewport, same as the
     glow/word-spacing above. */
  margin-bottom: -0.1em;
}

.hero__desc {
  /* the reserved --text-h3 (1.375rem) style — this is the placement for
     it. The About page used to carry a second, more specific
     .page-hero .hero__desc override; that page is a single-screen hero
     now (see .about-hero) and this class no longer appears on it, so the
     rule reaches the homepage paragraph and nothing else.

     The --hero-fit multiplier is back (it was dropped when the title's
     was — see .hero__title for why that no longer applies). This class
     is also shared with the case-study body copy, which sits outside any
     hero and so resolves --hero-fit to :root's 1; .cs-para overrides
     font-size there anyway. */
  font-size: calc(var(--text-h3) * var(--hero-fit));
  line-height: 1.5em;
  /* --copy-ink, the running-prose grey (see its own :root comment) —
     the same colour .cs-para already used, so every paragraph on the
     site that is not inside a button or a callout now reads as one
     voice. */
  color: var(--copy-ink);
}

/* homepage only (see .hero__intro .hero__title above) — bottom row,
   centered, mirroring the title's placement across the grid (was right
   edge; changed on request to match the reference layout). Row 3 is
   auto-sized to this paragraph's own height, so it sits flush against
   .hero's padding-bottom the same way the title sits flush against
   padding-top — no position: absolute needed here either. */
.hero__intro .hero__desc {
  grid-row: 3;
  justify-self: center;
  margin: 0;
  /* scaled by --hero-fit like the font-size it measures, so the line
     breaks stay put. Left flat, a shrunk paragraph kept its full-width
     measure and re-wrapped from three lines to two, which changed the
     shape of the block on exactly the screens this is meant to leave
     looking identical. (.cs-hero__title's 14ch and .cs-hero__meta-item
     p's 32ch don't need this — ch already scales with font-size.) */
  max-width: calc(560px * var(--hero-fit));
  /* no text-align override — inherits center from .hero */
}

.hero__desc strong {
  color: var(--ink);
  font-weight: 700;
}

/* ---------- About sub-pages: biography, film, music, trash ---------- */

/* Each view replaces the composition inside the SAME box the composition
   occupies: one screen below the nav, and no scrollbar. That is the whole
   point of the About page, and a view that pushed the window taller than
   the thing it replaced would break the illusion that you are still on
   the same screen.

   height, not min-height, and overflow: hidden with it — the contents are
   fitted to this box below rather than allowed to set it, so a clip is
   the failure mode instead of a scrollbar. Same bargain .about-hero makes
   for the composition, and the same 100vh - --nav-h arithmetic: --nav-h
   is measured by main.js and falls back to the 130px every other rule in
   this file uses. svh rather than vh so a mobile browser's collapsing
   address bar does not leave the box taller than the window. */
.about-view {
  /* a sheet laid on the board. Absolute inside .about-hero so it covers
     exactly the composition's box and nothing else: the grid underneath
     (.about-hero__grid, z-index 0) keeps drawing through every state,
     which is the one thing that makes this read as a swap rather than as
     a page change. Same z-index as .about-hero__inner, later in source,
     so a visible view sits over it. */
  position: absolute;
  inset: 0;
  z-index: 1;

  /* laid out at all times, shown only when targeted. display: none would
     be simpler and cannot be animated — an element has to be in the
     layout to fade. visibility rides along with opacity so a hidden
     sheet is out of the accessibility tree and off the tab order rather
     than merely transparent, and it is in the transition list so it
     flips at the END of the fade out, not the start of it. */
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
  transition: opacity 0.45s ease, visibility 0.45s;

  display: flex;
  flex-direction: column;
  overflow: hidden;

  /* Wider side gutters than .container--wide's own, which tops out at
     110px and read as flush against the edge once the window is 2000px+.
     7vw holds the proportion the design was drawn at instead of freezing
     at a pixel value. */
  padding-inline: clamp(1.25rem, 7vw, 11rem);
  padding-block: clamp(0.75rem, 2.5vh, 2.25rem) clamp(1.25rem, 4vh, 3.5rem);

  /* Following a fragment scrolls the target to the top of the WINDOW,
     which here means straight past the nav. Reserving the nav's own
     height means the ideal scroll position is the one the page is
     already at, so nothing moves at all.

     That only holds while the sheet really does start at the top of the
     hero, which is what the margin reset below is protecting. */
  scroll-margin-top: var(--nav-h, 130px);

  /* These are <section>s, so the sitewide `section + section` rhythm
     reached them: every view after the first (the three discs — only
     Biography is preceded by a div rather than a section) picked up a
     ~96px top margin. On an absolutely positioned box with inset: 0 that
     margin is not dead weight — it offsets the top edge and takes the
     same amount off the height, so the sheet sat one margin lower than
     the composition it replaces AND its scroll-margin no longer described
     where it starts. Following the fragment then scrolled the window down
     by exactly that margin, dragging the nav half off the screen.

     The rhythm between stacked sections means nothing for a sheet laid
     over another one, so it is switched off here rather than compensated
     for. margin-block only: these are .container elements, and the
     margin-inline: auto they get from it is what centres the sheet
     between left: 0 and right: 0 — a blanket margin: 0 here parks every
     view against the left edge and shifts its contents half a gutter off
     centre. */
  margin-block: 0;
}

.about-view:target {
  opacity: 1;
  visibility: visible;
  pointer-events: auto;
}

/* the composition is the sheet that leaves. Not display: none, for the
   same reason as above: it has to stay in the layout to fade, and
   visibility keeps it out of the tab order while it is gone. */
.about-hero__inner {
  transition: opacity 0.45s ease, visibility 0.45s;
}

.about-hero:has(.about-view:target) .about-hero__inner {
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
}

/* the crossfade is the interaction here, not decoration, so reduced
   motion gets the swap without the fade rather than losing the swap. */
@media (prefers-reduced-motion: reduce) {
  .about-view,
  .about-hero__inner {
    transition: none;
  }
}

/* everything in a view that is not the Back link is the content block.
   min-height: 0 because a flex item defaults to min-height: auto, which
   refuses to shrink below its content and is exactly how a "fitted" box
   ends up overflowing anyway.

   .subpage__shot is excluded rather than overridden: it is an <img> that
   IS the content block, and flex: 1 in a column container sets an item's
   height, which stretched a 1431x1029 capture to fill the box and
   upscaled it into softness. Excluding it here is cheaper than winning a
   specificity fight with this selector later. */
.about-view > :not(.subpage__back):not(.subpage__shot) {
  flex: 1;
  min-height: 0;
  margin-top: clamp(0.75rem, 3vh, 2.25rem);
}

/* the way back up to the composition. Sized and coloured like the
   contents-list links in the case studies rather than like a .btn — it is
   a step in the page's hierarchy, not an action, and a full glass pill
   here would outweigh what it sits above. align-self keeps it from
   stretching the full width of the flex column. */
.subpage__back {
  align-self: flex-start;
  display: inline-flex;
  align-items: center;
  gap: 0.5em;
  font-size: var(--text-body);
  color: var(--copy-ink);
  transition: color 0.2s ease;
}

.subpage__back:hover {
  color: var(--ink);
}

/* the arrow steps back on hover, toward where the link goes */
.subpage__back-arrow {
  display: inline-block;
  transition: transform 0.22s cubic-bezier(0.34, 1.56, 0.64, 1);
}

.subpage__back:hover .subpage__back-arrow {
  transform: translateX(-0.25em);
}

/* ---- Music and Trash: one screenshot of a desktop app ----
   contain rather than a max-width, so the window is fitted to whichever
   of the two axes runs out first and is never cropped or upscaled. It
   keeps its own corners: these are captures of real windows that are
   already rounded, and a radius from us would sit inside theirs and read
   as a double edge. */
.subpage__shot {
  display: block;
  width: auto;
  height: auto;

  /* Held well inside the sheet rather than fitted to it. At 100%/100%
     these two captures are under their own 1431px wide at most desktop
     sizes, so they rendered at full size and filled the board edge to
     edge — a window pasted over the page rather than a picture of one
     sitting on it. Both arms are caps, not sizes: whichever runs out
     first still decides, the aspect is still the image's own, and it is
     still never upscaled past its own pixels. */
  max-width: min(100%, 70rem);
  max-height: 76vh;

  /* shrink to fit, never grow past its own pixels; align-self stops the
     cross-axis stretch doing the same to its width. */
  flex: 0 1 auto;
  align-self: center;
  min-height: 0;

  /* auto block margins split the leftover column space above and below
     instead of banking it all under the image, which is what a fixed
     margin-top did — the capture hung off the Back link with the gap
     pooling at the bottom. This is the whole content of these two views,
     so it belongs in the middle of the board, not at the top of it. */
  margin-block: auto;
}

/* ---- Film: three posters across ----
   A list, because that is what it is: an unordered set of three things,
   not a layout of unrelated boxes. Sized off the row's HEIGHT — the posters
   are ~0.68:1 portraits, so height is what decides whether three of them
   fit a short window, and driving them from the width meant each one came
   out taller than the screen. */
.film-grid {
  list-style: none;
  padding: 0;
  display: flex;
  justify-content: center;
  align-items: flex-start;
  gap: clamp(0.75rem, 2.2vw, 2.5rem);
}

.film-grid li {
  min-height: 0;
  height: 100%;
  display: flex;
}

/* each poster keeps its own proportions: the three sources are 623, 608
   and 602 wide against an identical 902 tall, so a shared width would
   stretch two of the three by a couple of percent. Equal heights, natural
   widths. */
.film-grid__poster {
  display: block;
  /* short of the full row on purpose: filling the box edge to edge made
     three posters read as a wall rather than as three objects laid out,
     and left no air under them. 62vh is roughly the proportion the
     design is drawn at. */
  height: min(100%, 62vh);
  width: auto;
  max-width: 100%;
  object-fit: contain;
  border-radius: var(--radius-md);
}

/* ---- Film, Music and Trash: the project cards' flip and tilt ----
   Same motion as .project-card__media: a rotateY(-15deg) -> 0 swing in,
   then the pointer-tracked tilt from js/main.js. The swing is a keyframe
   on :target rather than the cards' class swap, because these sheets are
   opened by a link, not scrolled into, and :target re-matching restarts
   the animation every time a view is opened. A running animation also
   outranks the tilt's inline transform, so the swing can't be hijacked
   mid-flight and needs none of the cards' isFlipping() guard.

   The vanishing point lives on the parent, as with .project-card: each
   poster gets its own <li> so the outer two don't skew toward the middle
   of the row, and the lone screenshot uses the sheet itself. */
.film-grid li,
.about-view:has(> .subpage__shot) {
  perspective: 900px;
}

.about-tilt {
  transition: transform 0.25s ease-out;
  will-change: transform;
  backface-visibility: hidden;
  -webkit-backface-visibility: hidden;
}

.about-view:target .about-tilt {
  animation: about-tilt-flip-in 1.1s ease-in-out;
}

@keyframes about-tilt-flip-in {
  from {
    transform: rotateY(-15deg);
  }
  to {
    transform: rotateY(0deg);
  }
}

@media (prefers-reduced-motion: reduce) {
  .about-view:target .about-tilt {
    animation: none;
  }
}

/* ---- Biography: copy left, photograph right ---- */

/* The pair is pushed down off the top of the view rather than starting
   flush under the Back link. Two reasons, both about how the eye reads a
   fixed-height box:

   1. Optical balance. Content jammed against the top of a full-screen
      container with a large void beneath it reads as unfinished, like
      the page failed to load the rest. Sharing the slack between top and
      bottom makes the block look placed rather than dropped.
   2. Slightly ABOVE centre, not on it. A block sitting on the exact
      geometric middle reads as low, because the eye takes the optical
      centre to be a little higher than the real one. The top gap here
      lands a bit under the bottom one on purpose.

   vh rather than a fixed pixel offset so the proportion survives a
   window of any height, and padding on the layout rather than a margin
   on either column, so the title and the photo still start on exactly
   the same line (see .bio__title's own margin-top). */
.bio__layout {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  gap: clamp(1.5rem, 4vw, 5rem);
  align-items: stretch;
  padding-top: clamp(1rem, 10vh, 9rem);
}

.bio__copy {
  display: flex;
  flex-direction: column;
  /* top-aligned, not centred: the title's first letter is meant to line
     up with the top edge of the photograph beside it. */
  justify-content: flex-start;
  gap: clamp(0.75rem, 1.6vh, 1.5rem);
  min-height: 0;
}

/* Full title size, the same --text-title * --font-hero-scale every other
   H1 in this face uses (JAYDON WANG, the case-study titles, the footer).
   The 26vh arm is a guard, not a design choice: this title shares a fixed
   box with two paragraphs, so on a short window it has to give way rather
   than push the copy out of the bottom of the view.

   line-height 0.76 for the same reason the case-study titles carry it:
   FS Mondwest's default line box is far taller than its ink. */
.bio__title {
  font-size: min(calc(var(--text-title) * var(--font-hero-scale)), 26vh);
  line-height: 0.76;
  /* the white halo every FS Mondwest title on the site carries: FFFFFF at
     62%, 17px blur, no offset. See .hero__intro .hero__title. */
  filter: drop-shadow(0 0 17px var(--hero-glow));

  /* FS Mondwest's line box opens with a slab of empty space above the
     letters, so top-aligning the two columns lined up the title's BOX
     with the photo, leaving its first letter sitting well below the
     photo's top edge. Measured for this rule the way .about-hero__title
     measures its own (glyph metrics off a canvas at the computed size,
     against this title's own 0.76 line-height): the ink starts 0.195em
     below the box. Cancelling exactly that puts the top of the B on the
     top of the photograph. em-based, so it holds at every size the
     clamp resolves to. */
  margin-top: -0.195em;
  margin-bottom: clamp(0.5rem, 2vh, 1.75rem);
}

.bio__para {
  margin: 0;
  font-size: var(--text-body);
  line-height: 1.5em;
  /* the running-prose grey every other paragraph on the site uses, not
     full --ink: see .cs-para, which this matches exactly. */
  color: var(--copy-ink);
  max-width: 62ch;
}

.bio__media {
  min-height: 0;
  display: flex;
}

.bio__photo {
  display: block;
  width: 100%;
  height: auto;
  max-height: 100%;
  object-fit: contain;
  align-self: flex-start;
  border-radius: var(--radius-md);
}

/* ---- My design ethos: heading, rule, three takeaway cards ----
   The case studies' "My takeaways" block lifted whole. The block is
   centred in the sheet the way the Music and Trash captures are, rather
   than hanging off the Back link with the slack pooled underneath. */
.ethos {
  display: flex;
  flex-direction: column;
  justify-content: center;
}

.ethos__grid {
  margin-top: 0;
}

/* Below the site's shared breakpoint the one-screen bargain is off: a
   phone cannot hold three posters or a two-column biography at a legible
   size, so the views go back to laying out in flow and scrolling. */
@media (max-width: 56.25em) {
  /* ---- the views stop being sheets pinned to a one-screen board ----

     .about-hero is height: var(--about-h) — the viewport minus the nav —
     with overflow: hidden, and each .about-view is absolutely positioned
     inset: 0 inside it. That is the whole composition's premise on a
     desktop: the discs, the character and every view occupy exactly one
     screen and swapping between them never moves the page.

     It is also a hard cap, and on a phone the biography and the ethos
     cards simply contain more than one phone screen of content. Letting
     the sheet scroll inside itself was not enough: the content was still
     squeezed into a box the size of the viewport, with its own scrollbar
     nested inside the page's.

     So below this breakpoint an OPEN view drops the cap entirely — the
     section grows to whatever the content needs and the page scrolls,
     like every other section on the site. The board keeps its exact
     desktop behaviour whenever no view is open, which is the state the
     composition was actually designed for.

     display, not opacity, is what picks the visible view here: all five
     are laid out at once (see .about-view — they have to be, to fade),
     and in normal flow that would stack five full-height views down the
     page. The crossfade is the cost, and it is the right one to pay:
     display: none cannot transition, so the swap is instant on a phone
     and animated everywhere else. */
  .about-hero:has(.about-view:target) {
    height: auto;
    /* still at least a full screen, so a short view (Music, Trash) does
       not leave the section shorter than the board it replaced */
    min-height: var(--about-h);
    /* the desktop clip is a safety net for a composition built to fit;
       here the content is meant to exceed the old box */
    overflow: visible;
  }

  /* opacity alone leaves it holding its full height in the flow, which
     would push every open view a screen down the page */
  .about-hero:has(.about-view:target) .about-hero__inner {
    display: none;
  }

  .about-view {
    position: static;
    display: none;
    height: auto;
    /* overrides the base sheet's clip — the page scrolls now, not this */
    overflow: visible;
  }

  .about-view:target {
    /* the base rule's column flex, restored: :target beats .about-view on
       specificity and comes later here, so this is what actually renders */
    display: flex;
  }

  /* .about-view > :not(...) above gives every content block flex: 1 with
     min-height: 0, to divide up a sheet of known height. With the section
     auto-height there is no free space to divide, and a flex-basis of 0
     with min-height: 0 collapses the block to nothing instead. Content
     sizing is what the flow layout needs. */
  .about-view > :not(.subpage__back):not(.subpage__shot) {
    flex: 0 0 auto;
  }

  .bio__layout,
  .film-grid {
    grid-template-columns: 1fr;
    flex-direction: column;
    align-items: center;
  }

  .bio__layout {
    /* start, not the inherited center: with natural row heights there is
       nothing to centre, and centring is what turned an overflow into an
       overlap in both directions when there was. */
    align-items: start;
    /* 10vh of optical centring belongs to a sheet holding one screen of
       two-column content — stacked and scrolling, it is 93px of dead
       space above the title on a 932px phone. */
    padding-top: clamp(0.5rem, 2vh, 1.5rem);
  }

  .bio__title {
    /* cancels the -0.195em that lifts the title's ink onto the top edge
       of the photograph beside it. Stacked, the photograph is underneath
       and there is nothing to align to — the lift only pulls the title up
       into the Back link. */
    margin-top: 0;
  }

  .bio__photo {
    /* max-height: 100% was a share of the squashed box; the column is
       content-sized now, so the image just takes its own aspect */
    max-height: none;
  }

  .film-grid__poster {
    height: auto;
    width: 100%;
    max-width: 22rem;
  }

  .bio__photo {
    height: auto;
    aspect-ratio: 1448 / 1086;
  }

  .subpage__shot {
    height: auto;
  }
}

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

/* sits in .hero__intro's 2nd grid row, auto-sized to its own content —
   spacing to the title above and the paragraph below now comes from
   .hero__intro's own row-gap (--hero-stack-gap), not a margin nudge
   here. Used to sit in a 1fr row (the leftover space between title and
   paragraph) with a large margin-bottom pulling it off-center back
   toward the title — see .hero__intro's own comment for why that grew
   the title/laptop/desc gaps far apart and was replaced. */
.hero__faces {
  grid-row: 2;
  justify-self: center;
  position: relative;
  display: flex;
  justify-content: center;
  /* pulls the laptop up toward the title on request. Because this is a
     real margin on a grid row (not a transform), the row below follows:
     the paragraph rides up by the same amount and the pair keeps its
     own spacing, with the slack handed to .hero__intro's trailing 1fr
     row. Expressed against --hero-stack-gap rather than as a fixed px
     so it stays proportional as that gap scales with the viewport and
     --hero-fit — tightened on request from -0.7 to -1.5, so it now eats
     the whole row-gap and half as much again on top of it. That sounds
     like an overlap and isn't: the multiplier runs against the LAYOUT
     gap, while what reads as the gap on screen is the distance from the
     title's glyphs down to the laptop's raised right corner, and the
     title's line box leaves a deep band of empty space under its
     baseline that no letter occupies. Measured in the browser, this
     still leaves clear air at that tightest point. Push much past -1.5
     and it closes — that corner is what runs out of room first, not
     anything the box model can see (see .hero__computer for the tilt). */
  margin-top: calc(var(--hero-stack-gap) * -1.5);
}

/* replaces the old two-headshot pairing (a photo + a cartoon face) with
   a single looping clip — a laptop screen glitching between video static
   and the same cartoon avatar, rendered with real alpha transparency
   (WebM/VP9) so only the laptop shape shows, no background box. Sized up
   from the old two-headshot footprint — as a single, more detailed
   focal element it needs more visual weight to hold its own against the
   headline below; width follows the laptop's own ~1.8:1 aspect ratio. */
.hero__computer {
  /* multiplied by --hero-fit like everything else in the hero (see
     .hero), so it shrinks along with the rest on a short window instead
     of forcing the hero taller than the space actually available.
     Enlarged ~1.4x (142.5-270px -> 200-380px) on request — now that the
     hero is a tight, centered stack (title/laptop/desc all close
     together, see --hero-stack-gap) rather than spread across the full
     hero height, the laptop needed more visual weight to still read as
     the stack's secondary focal point instead of shrinking into it.
     Bumped up another ~10% (200-380px -> 220-420px, same vw slope
     methodology, just rescaled) — the three-line stack (title/laptop/
     desc) sits well short of .hero's full height on tall/wide
     viewports, and the leftover space all collects below the
     paragraph (see .hero__intro's own comment on the trailing 1fr
     row); giving the laptop more presence is a safer way to fill that
     imbalance than stretching gaps or overriding the title's
     match-every-page size. */
  /* Same size as the height-driven version this replaces (the laptop is
     exactly 1080x600, so height x 1.8 IS the width it was resolving to),
     but expressed as a width so it can be capped.

     Why it had to be: .hero__intro is a single-column grid, and a column
     with no explicit track size is as wide as its widest item. On a phone
     that item was this laptop — 220px tall, so 396px wide against a 342px
     container at 390px viewport — and a grid column wider than its
     container overflows to ONE side, the right. The title and paragraph
     are centred in that column, so the whole stack sat ~28px right of
     centre while the container around it was centred correctly.

     The cap is .container--wide's own width formula (its 2600px arm left
     out: it only binds past ~2710px, where this is nowhere near the
     limit), minus 1.5rem of air for the corners the -6deg tilt below
     swings past the layout box. */
  width: calc(clamp(220px, calc(181px + 10.31vw), 420px) * var(--hero-fit) * 1.8);
  max-width: calc(100vw - clamp(48px, 6vw, 110px) - 1.5rem);
  height: auto;
  /* tilted counter-clockwise so the laptop leans toward the left side
     of the stack instead of sitting perfectly square between the title
     and the paragraph. Rotation doesn't change the layout box, so the
     corners swing past it by roughly (width / 2) * sin(angle) — at this
     size that's ~35px top and bottom against a --hero-stack-gap of only
     14-36px, which is why the angle stays small: any steeper and the
     top-right corner starts biting into JAYDON WANG's baseline. The
     drop-shadow rotates with the element (filter is resolved in the
     element's own space, before transform); at 6deg the offset lands
     ~1.3px off true vertical, which is not perceptible. */
  transform: rotate(-6deg);
  filter: drop-shadow(0 12px 24px rgba(0, 0, 0, 0.45));
}

/* irregular After-Effects-style wiggle — x/y drift only, no rotation */
@keyframes wiggle {
  0% { transform: translate(0, 0); }
  8% { transform: translate(4px, -4px); }
  16% { transform: translate(-4px, 4px); }
  25% { transform: translate(6px, 4px); }
  35% { transform: translate(-4px, -6px); }
  45% { transform: translate(4px, 2px); }
  55% { transform: translate(-6px, -2px); }
  65% { transform: translate(2px, 6px); }
  75% { transform: translate(-3px, -4px); }
  85% { transform: translate(5px, 3px); }
  95% { transform: translate(-2px, -1px); }
  100% { transform: translate(0, 0); }
}

@media (prefers-reduced-motion: reduce) {
  .project-card__media,
  .project-card__media.is-flipping-in,
  .project-card__media.is-flipped-in {
    transition: none;
  }
}

/* ---------- section heading ---------- */

.section-head {
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: 24px;
  /* 36px, not 24px, so the head itself guarantees the gap to whatever
     follows it. It used to depend on the body element instead:
     .dm-media-grid carries margin-top: 36px and collapsed against this to
     36, while .cs-cta carries none and fell back to the bare 24 — so
     Motion design sat 36px under its heading and Jaydon MP4 only 24px,
     on two sections that are otherwise identical. Matching 36 here makes
     Motion design collapse to the same 36 it already had (no visual
     change there) and brings the others up to it. */
  margin-bottom: 36px;
  flex-wrap: wrap;
}

/* the heading/copy column fills the row instead of shrinking to its own
   content. As a default flex item (flex: 0 1 auto) it sized to its
   widest child, so the Jaydon MP4 paragraph sat in an 860px column
   inside a 2450px section while .cs-cta and the video grid below it ran
   the full width — the section read as two different widths stacked.
   min-width: 0 lets it actually shrink when it needs to, since a flex
   item's default min-width: auto refuses to go below its content. */
.section-head > div {
  flex: 1;
  min-width: 0;
}

/* Projects & Case Studies only — centered above the filters instead of
   the left-aligned heading+subtitle bar .section-head is elsewhere (e.g.
   Digital Media's "Motion design"). Scoped through .case-studies__side so
   that base .section-head layout stays untouched for other pages. Was
   right-aligned flush to the column's far edge; centered to read closer
   to the kynejang.com reference's centered sidebar treatment. */
.case-studies__side .section-head {
  justify-content: center;
  margin-bottom: 32px;
}

.case-studies__side .section-head h2 {
  text-align: center;
  /* "Projects" / "& Case Studies" is a hard <br> in the markup, not
     natural wrapping — line-height is what actually controls the gap
     between those two lines. Deliberately NOT part of the site-wide
     1.5em paragraph line-height below — this is a heading, not a
     paragraph. Was 1em under PP Mondwest — see .hero__title for why
     FS Mondwest needs its own ratio (0.71) to read the same. */
  line-height: 0.71;
}

.section-head h2 {
  font-family: var(--font-hero);
  /* see .hero__title for why — FS Mondwest's own space glyph is
     essentially zero-width */
  word-spacing: 0.18em;
  font-weight: 400;
  font-size: calc(var(--text-h1) * var(--font-hero-scale));
  /* was unset ("normal", ≈1em for this font) — see .hero__title for
     why FS Mondwest needs an explicit, tighter ratio */
  line-height: 0.71;
  letter-spacing: -0.01em;
}

/* spacing only. This paragraph now carries "hero__desc cs-para" in the
   markup, exactly like every case-study body paragraph, so its size,
   line-height and color come from .cs-para rather than being restated
   here — same reasoning as the comment on .cs-para itself: literally the
   same class, not a numeric match that can drift apart later. It used to
   set --text-secondary, which is what made it read grey next to the white
   body copy on the case studies.

   Scoped through .section-head so this margin still beats .cs-para's
   margin: 0 — .cs-para is later in the file, so a bare .section-head__sub
   would lose to it and the paragraph would sit flush against the h2 (the
   heading and this share a plain div with no gap of its own). */
.section-head .section-head__sub {
  /* the same clamp .dm-filter-row uses, not a fixed 28px. That row is
     what occupies this slot under Motion design's heading, and its gap is
     fluid (16px narrow → 28px at 1920px+), so a flat 28 would have
     matched only above 1920 and drifted apart everywhere below — at
     1600px wide the pills sit 25.5px under their heading, not 28. Copying
     the expression keeps the two sections identical at every width. Was
     8px, which made this paragraph read as tucked under the title rather
     than as its own line of the composition. */
  margin-top: clamp(16px, calc(13.087px + 0.7767vw), 28px);
  /* runs the section's full width rather than .cs-para's 62ch measure.
     62ch is a reading-length cap that suits a case study's long body
     copy; here it left the paragraph in a narrow column while everything
     else in the section spanned the full width. Type, line-height and
     color still come from .cs-para — this overrides the measure only. */
  max-width: none;
}

/* ---------- case studies layout: narrow card list + right-hand
   heading/filters column ---------- */

.case-studies__layout {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  /* was 64px — with .project-grid widened (see below), the sticky
     column no longer needs as much of a moat between it and the cards;
     a screenshot pass at 1440px showed the old 32%-wide card column
     leaving roughly two-thirds of the section as empty black space,
     with the heading/filters floating disconnected from the content
     they act on across that gap */
  gap: 48px;
  margin-top: 40px;
}

/* heading + filters, right of the card list — sticks in view while the
   (now taller, 3-card) list scrolls past on the left, and is forced to
   exactly one viewport tall (top: 0 + height: 100vh) with its content
   flex-centered inside that box, so once pinned the heading/filters land
   near the vertical middle of the screen rather than hugging the top
   edge. height is min()'d against --side-max-h (set by main.js from
   .project-grid's real rendered height) purely as a safety cap — on a
   short card list / very tall viewport, an unconstrained 100vh box here
   would be taller than the list itself and stretch the whole row past
   what the list actually needs.

   padding-bottom is what pushes the pinned content up toward the top
   third of the screen instead of dead center: box-sizing: border-box
   (sitewide reset) keeps the box itself pinned at exactly 100vh, but
   shrinks the content area justify-content centers within, from the
   bottom only — so the centered group settles higher, above the box's
   true middle. vh-based so it lands at roughly the same ~33%-from-top
   position on any screen size. Was pushed as far as ~22vh at one point
   (a series of "move it up more" requests), which in an actual
   screenshot read as too close to the top edge — cramped, not the
   "visually appealing" middle-upper placement that was the actual goal
   the whole time. Backed off toward the original ~36% reference point,
   not all the way back to it — the earlier nudges up weren't wrong,
   just overshot.

   margin-top is kept small and fixed on purpose: it needs to already be
   visible the moment you scroll into the section (not pushed down out
   of view while waiting to pin), so it only nudges the heading off the
   very top edge rather than delaying the pin by any real amount. */
.case-studies__side {
  flex: 1;
  min-width: 240px;
  position: sticky;
  top: 0;
  height: min(100vh, var(--side-max-h, 100vh));
  display: flex;
  flex-direction: column;
  justify-content: center;
  padding-bottom: 34vh;
  margin-top: 8px;
}

/* ---------- filters ---------- */

/* was a sticky, vertically-centered sidebar; now a plain wrapping row
   under the heading, centered so it lines up with the heading's own
   centered treatment (see .case-studies__side .section-head) — matches
   the kynejang.com reference's centered sidebar, and drops the
   position: sticky + translateY(-50%) reveal-animation math that only
   made sense for the old sidebar treatment */
.filters {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 12px;
  /* the pills wrap against THIS, not against however wide the sticky
     column happens to be. .case-studies__side is flex: 1, so it ran to
     1177px on a 2560-wide monitor and held all four pills in one long
     763px row, only breaking below ~1500px. Capping the row makes it
     break at every width instead.

     32rem specifically: swept 24–32rem against the real pill widths at
     1000–2560px. 32rem is the widest value that breaks on a big monitor,
     and it lands on 3+1 at every width — the same split the layout
     already fell into below 1440px, so nothing new appears. Narrower caps
     (26rem and below) split it 2+1+1 on wide screens, which reads as
     ragged rather than deliberate.

     margin-inline keeps the capped row centred under the heading; without
     it the column's align-items would leave it stuck to one side. */
  max-width: 32rem;
  margin-inline: auto;
}

.filter-pill {
  /* same flat spec as .btn (was 12px 24px) — filter pills are a button
     family too now, not just tags/callouts' glass cousin */
  padding: 0.938rem 1.875rem;
  border-radius: var(--radius-pill);
  font-size: var(--text-body);
  font-weight: 400;
  cursor: pointer;
  white-space: nowrap;
  /* same bouncy back-out curve as .btn — see its own comment for why */
  transition: color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease, transform 0.22s cubic-bezier(0.34, 1.56, 0.64, 1);
}

.filter-pill:hover {
  transform: scale(1.14);
}

.filter-pill.is-active {
  color: var(--sel-ink);
  background: var(--sel-bg);
  border: none;
  box-shadow: none;
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
}

/* ---------- project list ---------- */

/* a substantial fraction of the row (was a flat 480px cap, then 50%,
   then dialed back to 32% — 50% read as too dominant against the
   sparse right column once the media boxes were fixed to the images'
   real 16:9 ratio, see .project-card__media). Back up to 50% now that
   the right column carries the sticky heading/filters at full-viewport
   height (see .case-studies__side) instead of being a plain in-flow
   block — a screenshot pass showed 32% leaving roughly two-thirds of
   the section as dead space at ordinary desktop widths, with the
   filters floating disconnected from the cards across that gap; wider
   cards also mean less total scroll depth for the same 3 projects.
   flex-grow: 0 keeps it from ballooning past this just because
   .case-studies__side doesn't need the rest of the space; flex-shrink: 1
   is a safety net for the narrow band between this and the 900px
   mobile breakpoint below, where the layout is still in row mode.
   flex-basis sizes this along .case-studies__layout's main axis, which
   is row on desktop — on mobile that layout switches to column (see
   @media below), where flex-basis would size HEIGHT instead, so that
   breakpoint sets an explicit width there. */
.project-grid {
  flex: 0 1 50%;
  min-width: 0;
  /* filtering hides cards in here; without this the browser's scroll
     anchoring nudges the page to follow whichever card it was tracking,
     which reads as the screen jumping (see project filtering in main.js) */
  overflow-anchor: none;
  display: flex;
  flex-direction: column;
  gap: 72px;
}

/* image on top, tag/title/description stacked below — was a horizontal
   media-left/copy-right row; the reference reads as a vertical card.
   The hover effect (pointer-tracked 3D tilt, see js/main.js) is scoped
   to .project-card__media only — .project-card itself carries no
   hover transform, so the tag row/title/paragraph never move. */
.project-card {
  display: flex;
  flex-direction: column;
  gap: 20px;
  /* gives .project-card__media's tilt below a vanishing point — lives
     on the parent (not .project-card__media itself) because an element
     can't be its own 3D perspective root */
  perspective: 900px;
}

.project-card__media {
  width: 100%;
  /* matches the source thumbnails' real aspect ratio (all three are
     natively 16:9) — a mismatched box forces object-fit: cover to crop
     regardless of how big or small the container is; this is what
     actually eliminates the crop, not the container's width */
  aspect-ratio: 16 / 9;
  overflow: hidden;
  border-radius: 16px;
  background: var(--media-backdrop);
  box-shadow: 0 14px 34px rgba(0, 0, 0, 0.4);
  /* the rotate below is applied here, to the rounded/clipped box itself
     rather than to the img inside it — so the corners tilt as part of
     the same rigid object as the picture instead of staying a flat,
     static window with the image swimming around behind it (which is
     also what let the placeholder background peek through before) */
  transition: transform 0.25s ease-out, box-shadow 0.3s ease;
  will-change: transform;
  /* the hover tilt only ever reaches ±10°, and the entrance flip below
     stays well under 90° too — this is now a defensive margin rather
     than something actually triggered, kept in case the flip angle
     ever gets dialed back up past 90°, where (without this) the
     browser would render the element's own mirrored front face
     instead of a blank back, i.e. the photo and its logo/text would
     visibly reverse partway through. */
  backface-visibility: hidden;
  -webkit-backface-visibility: hidden;
}

.project-card__media:hover {
  box-shadow: 0 22px 48px rgba(0, 0, 0, 0.55);
}

/* one-time flip-in the first time the card scrolls into view (see
   js/main.js), paired with .project-card.reveal's opacity fade below.
   Driven by its own pair of classes rather than through
   .reveal/.is-visible directly: .project-card__media's transform is
   also what the pointer-tilt hover above sets inline, and .is-visible
   stays on the card forever once added — if this slow 1.1s entrance
   transition lived there, every hover tilt afterward would inherit
   that same sluggish duration instead of the snappy 0.25s above. JS
   swaps is-flipping-in -> is-flipped-in to run the animation, then
   drops both classes once it finishes, handing the element back to
   the plain rule above for all subsequent hover tilts. ease-in-out
   (rather than the hover tilt's linear-ish ease-out) is what makes
   the swing read as smooth momentum building up and settling back
   down, instead of snapping off the start at full speed. */
.project-card__media.is-flipping-in,
.project-card__media.is-flipped-in {
  transition: transform 1.1s ease-in-out, box-shadow 0.3s ease;
}

.project-card__media.is-flipping-in {
  transform: rotateY(-15deg);
}

.project-card__media.is-flipped-in {
  transform: rotateY(0deg);
}

.project-card__media img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.project-card__body {
  display: flex;
  flex-direction: column;
  gap: 14px;
}

.tag-row {
  display: flex;
  gap: 8px;
  flex-wrap: wrap;
}

/* flat, not glass — deliberately distinct from .btn/.btn-outline's
   blurred surface (see the shared glass-formula group above) so tags
   read as a different kind of element from buttons at a glance, not
   just a smaller button.

   These no longer share .btn's 0.938rem/1.875rem padding spec, which
   used to be the thing tying the two families together. That pairing
   was costing more than it bought: at 1920px the chips rendered ~50px
   tall and ~155px wide for "After effects" — button-sized, for a
   non-interactive metadata label. A .tag is a <span>, not a control, so
   it carries none of a button's hit-target obligation (the 44px minimum
   applies to .filter-pill, which IS a <button>, and is deliberately
   left alone). Standard chip sizing is 24–32px tall, and reading these
   as clearly lighter-weight than the buttons beside them is the point.

   Padding is in em rather than rem/px so it stays locked to the type
   size at every width — one ratio instead of a second schedule that can
   drift out of step with the font's own clamp, which is the failure
   mode the stage comment at .dm-hero__stage describes. */
.tag {
  /* own size rather than --text-caption: that token ramps ×1.375 above
     1440px and hit ~15.1px at 1920, oversized for a metadata chip while
     several other captions legitimately want that growth. This tops out
     at 13px instead, on a 1000px→1920px schedule, and still floors at
     --text-caption's own 11px so nothing changes on phones.
       11.0px @ ≤1000px    11.96px @ 1440px
       12.17px @ 1536px (a 1920×1080 laptop at 125% Windows scaling)
       13.0px @ 1920px  (the same laptop at 100%) */
  font-size: clamp(0.688rem, calc(0.552rem + 0.217vw), 0.813rem);
  font-weight: 600;
  /* ≈6px/12px at 1920, ≈5px/10px at the 11px floor — chip height lands
     at ~31px and ~27px respectively, both inside the 24–32px range. */
  padding: 0.46em 0.92em;
  /* 12px → 8px: the radius has to come down with the height or the
     proportion changes. 12px on a 50px chip reads as a rounded
     rectangle; the same 12px on a 31px chip reads nearly as a pill,
     which would collide with .filter-pill's actual pill shape. 8px
     holds the original's rounded-rectangle character. */
  border-radius: 8px;
  background: var(--chip-bg);
  border: 1px solid var(--chip-border);
  color: var(--ink);
}

.project-card__body h3 {
  font-family: var(--font-display);
  font-size: var(--text-h2);
  font-weight: 400;
}

.project-card__body p {
  /* --copy-ink, the running-prose grey (see its own :root comment) —
     the same colour .cs-para already used, so every paragraph on the
     site that is not inside a button or a callout now reads as one
     voice. */
  color: var(--copy-ink);
  font-size: var(--text-body);
  line-height: 1.5em;
  /* was 60ch, which capped this narrower than the card itself (and so
     narrower than the image above it) at ordinary widths — 100% lets it
     run the full width of .project-card__media instead, wrapping onto
     fewer lines for the same copy */
  max-width: 100%;
}

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

/* ---------- site footer ("Let's c*nnect!") ----------
   Full-bleed light section, deliberately breaking from the dark theme
   the rest of the site uses — the one bright, high-contrast close on
   every page. A plain <footer> with no ancestor width constraint
   already spans the full viewport on its own, so no breakout trick
   is needed the way .cs-hero's grid needs one. */
footer {
  /* dark breathing room before the white slab starts. The generous
     padding-block below is INSIDE that white, so it separates the footer
     text from the footer's own edges — it does nothing to stop the page's
     last section running straight into the colour change. This margin is
     what actually holds the two apart, and it's bigger than the gap
     between sections because this is a change of surface, not just the
     next block of the same page. */
  margin-top: clamp(4rem, calc(1.888rem + 9.014vw), 10rem);
  background: var(--slab-bg);
  /* text-to-edge distance top and bottom — 240px (15rem) floor,
     never less regardless of viewport, growing a bit further on very
     large screens. Left/right keep the previous, smaller clamp.
     Every arm is the old value x 0.75, so the band is shorter but
     ramps across exactly the same 375px-1920px window it always did
     rather than changing shape partway. */
  padding-block: clamp(15rem, calc(13.9078rem + 4.66vw), 19.5rem);
  padding-inline: clamp(3rem, calc(2.3932rem + 2.589vw), 5.5rem);
}

.footer-inner {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: clamp(1.5rem, calc(1.318rem + 0.777vw), 2.25rem);
  text-align: center;
}

/* same hero font + size as the case-study hero titles (e.g. "Faculty
   of environment") */
.footer-inner h2 {
  font-family: var(--font-hero);
  /* see .hero__title for why — FS Mondwest's own space glyph is
     essentially zero-width */
  word-spacing: 0.18em;
  font-weight: 400;
  font-size: calc(var(--text-title) * var(--font-hero-scale));
  /* was 1.05 under PP Mondwest — see .hero__title for why FS Mondwest
     needs its own ratio (0.76) to read the same */
  line-height: 0.76;
  color: var(--slab-ink);
  /* same white glow as JAYDON WANG (see .hero__intro .hero__title) —
     reads more subtly here since the footer sits on a light background,
     but keeps the treatment consistent across every title */
  filter: drop-shadow(0 0 17px var(--hero-glow));
}

.footer-links {
  display: flex;
  gap: clamp(0.75rem, calc(0.6286rem + 0.518vw), 1.25rem);
  flex-wrap: wrap;
  justify-content: center;
}

/* same pill shape/padding/weight/stroke as every other .btn on the site
   — just the fill recolored for a light section, since the sitewide
   glass formula (blur, inner glow) is tuned for a dark background and
   would be nearly invisible here. Soft shadow stands in for that same
   "defined pill on its surface" read; #727272 (see .btn) reads fine on
   white too, so the border no longer needs its own light-theme value. */
.footer-links .btn {
  background: var(--slab-btn-bg);
  color: var(--slab-ink);
  box-shadow: 0 4px 14px rgba(13, 15, 30, 0.08);
}

.footer-links .btn:hover {
  background: var(--slab-btn-hover);
}

.footer-inner p {
  color: var(--slab-ink-soft);
  font-size: var(--text-caption);
  line-height: 1.5em;
  margin: 0;
}

/* ---------- digital media hero ---------- */

.dm-hero {
  /* the one hero that reclaims the nav band instead of clearing it — see
     main's padding-top above. Pulling the section up by exactly that
     padding puts .dm-hero's top edge at the very top of the page, which
     is what gives the title room to rise; .dm-hero__stage then takes an
     equal `top` offset so the character and the grid stay exactly where
     they were and only the text moves. */
  margin-top: calc(var(--nav-h, 130px) * -1);
  position: relative;
  overflow: hidden;
  /* the section's own box is just a proportional frame — every actual
     pixel value lives on .dm-hero__stage below, fixed at the original
     design's real size (2540×1266 — this was a live 1:1 measurement of
     the actual browser window the original min-height: 100vh design
     rendered well in) and scaled down to fit here. container-type turns
     "cqw" inside the stage's scale() into "1% of THIS element's width"
     specifically, rather than the viewport's, so the math is correct
     even if something upstream (a scrollbar, a future wrapper) ever
     makes this narrower than the viewport. */
  container-type: inline-size;
  /* pre-JS fallback only — main.js overwrites height with
     1266px * the real --stage-scale it computes (see .dm-hero__stage).
     The scale used to be width-driven alone, which meant this section's
     height was always viewport-width × 0.4984 no matter how short the
     window was: on a 2560×1440 monitor that lands at almost exactly the
     viewport height, which is why it looked right there and nowhere
     else. On a wide-but-short laptop it ran a couple of hundred pixels
     past the fold and overflow: hidden clipped the buttons off. */
  aspect-ratio: 2540 / 1266;
  /* both of these exist to stop aspect-ratio working BACKWARDS. Once
     main.js sets a height, an auto width lets the ratio derive the width
     FROM that height rather than the other way round. It silently agreed
     with the right answer at ordinary sizes (1266·s × 2.006 = 2540·s), so
     it looked correct — until the generic section{padding-block: 64px}
     floored this box at 128px on a very short window, at which point the
     ratio handed the section a 257px width, the stage scale spiralled
     down to match, and the composition collapsed. An explicit width makes
     width and height both definite so the ratio is ignored; zeroing the
     inherited padding removes the 128px floor that triggered it. The
     stage is absolutely positioned and carries its own 170/80 padding, so
     neither changes what actually renders. */
  width: 100%;
  padding-block: 0;
}

.dm-hero__stage {
  position: absolute;
  /* cancels .dm-hero's negative margin above, so the canvas — and with it
     the 3D character and the background grid — lands in exactly the same
     place on screen as before. The extra room opened up above the stage
     is unclipped hero box, which is what lets the text overflow upward
     into the nav band instead of being cut off by overflow: hidden. */
  top: var(--nav-h, 130px);
  /* centers the canvas once --stage-scale is height-bound rather than
     width-bound: at the width-bound scale 2540px * scale IS this
     element's width, so this resolves to 0 and nothing moves (which is
     every window at or above the design's 2.006 aspect ratio). On a
     shorter window the canvas is scaled down past the point of filling
     the width, and this splits the leftover across both sides instead of
     leaving it all on the right. The 200px page-edge indent inside the
     canvas (see .dm-hero__content) is preserved either way — it's part
     of the composition being scaled, not measured from the viewport. */
  left: calc((100% - 2540px * var(--stage-scale) * var(--stage-fit)) / 2);
  width: 2540px;
  height: 1266px;
  /* was .dm-hero's own padding before the stage existed — kept here (not
     dropped) since it's what pushes the title below the nav and gives the
     character's canvas the same vertical room the original design had.

     The top value takes --stage-lead (see :root). In canvas units this is
     a rigid gap, so it rendered as a fixed distance under a nav that was
     itself a much larger share of a short viewport — the two compounded
     and pushed the text block down until the paragraph ended ~106px from
     the bottom of a 671px window, against 421px of a 1271px one.

     Only the character is absolutely positioned in here, and an abspos
     child resolves against this box's PADDING box — which spans the whole
     2540×1266 canvas whatever the padding is — so changing this moves the
     text and leaves the character exactly where and how big it was. (The
     previous wording of this comment implied the opposite; verified in
     the browser that the head does not move.) */
  /* CAUTION: the title's position on this page is coupled to the nav's
     height, and this constant is the only thing balancing it.

     .dm-hero is the one section that reclaims the nav band rather than
     clearing it (margin-top: -var(--nav-h)), and .dm-hero__stage pushes
     back down by top: var(--nav-h). The title therefore lands at:

         title_y = --nav-h + (padding-top - 110) * stage-scale

     — the -110 being .dm-hero__content's negative margin. So --nav-h maps
     onto the title's y position one-for-one: shrink the bar and the title
     rises by exactly as much.

     That is what happened here. This was 90px while the nav was 130px
     tall (a wrapped two-row bar). Fixing the wrap took the nav to ~70px
     and the title rose 60px with it, up into the bar. 180px rebalances
     it against the corrected nav height and puts the title back where it
     was: measured title_y of 111px at 2000x990, against 47px before.

     If the nav's height is ever changed again, re-check this number. */
  padding: calc(180px * var(--stage-lead)) 0 80px;
  box-sizing: border-box;
  /* named so text sizes inside the stage (see .dm-hero__content
     .hero__title etc.) can divide it back out and land on the exact
     same rendered size as the rest of the site's type scale at ANY
     viewport width, not just the one width this was hand-calibrated
     against.

     Unchanged, and deliberately still width-only — the type inside the
     stage divides by THIS, so it has to keep meaning "1 canvas px is this
     many real px at this viewport width" and nothing else. */
  --stage-scale: calc(100cqw / 2540px);
  /* The height answer is a SECOND factor rather than a smaller
     --stage-scale, and the difference matters. Type in here is sized
     `/ var(--stage-scale)` so it lands on the site's type scale after the
     transform — which means shrinking --stage-scale itself doesn't shrink
     the text at all, it just makes the text bigger in canvas units until
     it rewraps inside the fixed 1500px content column and the canvas gets
     TALLER the more you try to shrink it. (Measured: solving that way
     collapsed the scale to 0.11 and drove the buttons a full viewport
     below the fold.)

     --stage-fit multiplies the transform without being divided back out,
     so it scales the text along with everything else and the canvas's own
     geometry stays fixed. That makes the rendered height exactly linear
     in it, which is what lets main.js solve for it in one measurement. 1
     is both the pre-JS fallback and the value on any window at or taller
     than the canvas's 2.006 aspect ratio, so the reference display and
     every phone width are untouched. Still exactly one scale() for the
     whole stage — the no-@media-inside-the-stage rule below is intact. */
  --stage-fit: 1;
  transform: scale(calc(var(--stage-scale) * var(--stage-fit)));
  transform-origin: top left;
}

.dm-hero__grid {
  position: absolute;
  inset: 0;
  /* runs well past the canvas on both sides so the pattern still reaches
     the section's edges once --stage-fit is height-bound and the stage no
     longer fills the width. Without this it stopped exactly at the canvas
     and left the gutters bare — 79px a side on a 1524×671 laptop, and
     ~390px on a very short window, where the seam is obvious. vw (not a
     fixed canvas value) because the overhang has to out-reach the gutter
     at every scale, and .dm-hero's own overflow: hidden crops whatever
     isn't needed. Tiling is seamless: the repeating gradients below are
     in canvas units, so extending the box just draws more of the same
     pattern rather than rescaling it. */
  left: -100vw;
  right: -100vw;
  z-index: 0;
  pointer-events: none;
  background-image:
    repeating-linear-gradient(
      20deg,
      rgba(var(--overlay-rgb), 0.07) 0px,
      rgba(var(--overlay-rgb), 0.07) 1px,
      transparent 1px,
      transparent 200px
    ),
    repeating-linear-gradient(
      -20deg,
      rgba(var(--overlay-rgb), 0.07) 0px,
      rgba(var(--overlay-rgb), 0.07) 1px,
      transparent 1px,
      transparent 200px
    );
}

.dm-hero__row {
  position: relative;
  z-index: 2;
}

.dm-hero__content {
  max-width: 1500px;
  margin-top: -110px;
  /* reverted back to the original 200px on request — briefly tried 0
     (nav-logo-aligned, no indent) but the 200px indent from the page
     edge is the intended horizontal position, not a bug; margin-top
     above is untouched either way, this is X-axis only. */
  margin-left: 200px;
  text-align: left;
}

/* fixed px, overriding .hero__title's own shared clamp() (used as-is on
   the homepage/about hero, where IT is the thing scaling the section) —
   in here .dm-hero__stage's transform: scale() is the only thing that
   should ever shrink this, so its own font-size stays pinned at the
   original design's value. Same reason line-height is a fixed 1.05, not
   a separate clamp()/vw value of its own: this whole title is already
   "responsive" via .dm-hero__stage's uniform scale(), which keeps it
   looking identical — this line-height included — at every viewport
   width; giving it its own independently-fluid line-height would just
   fight that and drift out of sync with the rest of the stage instead.
   Was 0.85 — tight enough that "Jaydon's / Digital / Museum" (this
   wraps to one word per line at the stage's fixed width) collided
   between lines, the descender on "Jaydon's" cutting into "Digital"
   and "Digital"'s into "Museum". 1.05 clears both. */
/* dividing by --stage-scale (see .dm-hero__stage) instead of multiplying
   by a hardcoded 2540/1440 is what makes this land on --text-title's
   EXACT rendered size at any viewport width, not just 1440px — the old
   fixed ratio only cancelled the stage's scale-down correctly at the
   one width it was calibrated against, and silently drifted apart from
   the homepage title (same class, meant to always match) at every
   other width. References the token itself (not a hardcoded 7.5rem) so
   this stays in sync if that token's value ever changes again. */
.dm-hero__content .hero__title {
  font-size: calc(var(--text-title) * var(--font-hero-scale) / var(--stage-scale));
  /* was 1.05 under PP Mondwest — see .hero__title for why FS Mondwest
     needs its own ratio (0.76) to read the same. Matters here more
     than most: "Jaydon's Digital Museum" wraps to two lines, so this
     is the other multi-line title (with "Projects & Case Studies")
     that visibly showed the extra leading. */
  line-height: 0.76;
  /* same white glow as JAYDON WANG (see .hero__intro .hero__title) —
     the 17px blur is divided by --stage-scale for the same reason
     font-size above is: this element sits inside .dm-hero__stage's own
     transform: scale(), so an uncompensated 17px would render smaller
     than 17px once that scale shrinks it. Dividing first means it comes
     out the ACTUAL 17px on screen, matching JAYDON WANG's glow exactly
     rather than a scaled-down version of it. */
  filter: drop-shadow(0 0 calc(17px / var(--stage-scale)) var(--hero-glow));
}

/* same reasoning as .dm-hero__content .hero__title above — was a
   hardcoded 1rem, tracking neither --text-body's growth on a big screen
   nor the stage's actual scale at widths other than 1440px */
.dm-hero__desc {
  margin: 24px 0 0;
  font-size: calc(var(--text-body) / var(--stage-scale));
  text-align: left;
  /* was unset, so this stretched to fill .dm-hero__content's full
     1500px — noticeably wider than the title above it, which only
     actually renders about 1253px wide at its widest wrapped line
     (measured directly off .hero__title's rendered text, not its
     1500px-wide box — the title's own box is the same 1500px, its
     GLYPHS just don't reach that far). Was 1250px (lined the paragraph's
     wrap width up with the title's rendered width instead) — shortened
     further to 900px on request, wrapping onto more, shorter lines for
     easier reading rather than matching the title's own width. Same
     plain-px stage-unit system as .dm-hero__content either way (not
     divided by --stage-scale — this is a layout dimension inside the
     fixed 2540px canvas, not a type size). */
  max-width: 900px;
}

/* Full-bleed layer, same trick as .dm-hero__grid — the model sits underneath
   the title/paragraph instead of being boxed into a flex column, so it's
   free to occupy the whole section width instead of getting cropped by a
   narrow column edge. */
.dm-hero__media {
  position: absolute;
  inset: 0;
  z-index: 1;
  pointer-events: none;
}

/* Invisible reference box matching where the character actually sits on
   screen — used only so head-track.js can normalize cursor position against
   his visual footprint instead of the full-bleed canvas behind him. */
.dm-hero__track-zone {
  position: absolute;
  inset: 0;
  left: 58%;
  pointer-events: none;
}

.dm-hero__model {
  position: absolute;
  /* top/left rather than inset: 0 — the box below is explicitly sized, so
     a bottom/right constraint would just be over-constrained and ignored. */
  top: 0;
  left: 0;

  /* ---- render resolution, decoupled from canvas coordinates ----
     Everything else in this stage is laid out in 2540x1266 canvas units
     and shrunk to fit by ONE scale() on .dm-hero__stage. For plain DOM
     content that's free: transform is a paint-time operation, so the
     layout box stays 2540x1266 and only the painted result shrinks.

     <model-viewer> is the exception, because it doesn't paint — it
     rasterises. It sizes its WebGL drawing buffer off its own LAYOUT box
     (x devicePixelRatio) and has no idea an ancestor is scaling it down,
     so it was rendering a full 2540x1266 buffer no matter how small the
     window was. Measured: at a 1512px-wide container it rendered 3.2M
     pixels to display 0.71M of them — 4.5x over-render, paid every frame
     the head-tracking loop redraws, and paid twice over since
     model-viewer keeps a second presentation canvas at the same size.

     The perverse part is that it got WORSE the smaller the window: at
     2560px wide the scale lands near 1.0 and the over-render is 0.98x,
     i.e. free. That's why this never showed up on the big display and
     dragged badly on a laptop.

     Fix: size the box in REAL screen pixels, then scale it back up by the
     same factor so it still visually covers the canvas. Geometry is
     unchanged (same origin, same aspect ratio, same translateX in canvas
     units, since that's applied in the parent's coordinate space before
     the counter-scale) — only the pixel count changes.

     --model-quality is the knob, and it's worth understanding what it
     trades. The OLD behaviour was accidental supersampling: a fixed
     2540px-wide buffer shown at the container's width, i.e. a factor of
     2540/container. On a 1512px laptop that's 1.68x; on a 1280px one,
     1.98x. Free edge smoothing on hair and the shoulder silhouette — and
     the exact thing that was costing the frame rate.

     So --model-quality is how much of that smoothing to keep:
       1    = native display resolution (no supersampling)
       1.5  = keeps most of what a ~1500px laptop used to get
     Measured on a fresh load at 1512x860 (buffer tracks the box exactly):
       old            2540x1266 = 3.22M px
       quality 1.5    2268x1131 = 2.57M px   (1.25x fewer)
       quality 1      1512x754  = 1.14M px   (2.82x fewer)
     1 is the default because 1.5 barely moves the needle, and
     model-viewer already runs MSAA of its own, so native resolution
     still gets antialiased edges — it just isn't supersampled on top.
     If the hair or collar edges read as too crisp/jaggy on a real
     laptop, raise this to 1.25 or 1.5; that's the only value to touch.

     Capped at 1 via min() so a wide display never renders a buffer
     LARGER than the 2540px canvas it used to — this can only ever do
     less work than before, never more. (At 2560px wide the cap binds and
     the geometry is byte-for-byte what it was: scale 1, no change.) */
  --model-quality: 1;
  --model-render-scale: min(
    1,
    calc(var(--stage-scale) * var(--stage-fit) * var(--model-quality))
  );
  width: calc(2540px * var(--model-render-scale));
  height: calc(1266px * var(--model-render-scale));
  /* translateX first (canvas units, unchanged), counter-scale second.
     transform-origin has to be top left so the box grows back out from
     the stage's own corner rather than from its centre — it was
     irrelevant while this transform was translate-only. */
  transform: translateX(560px) scale(calc(1 / var(--model-render-scale)));
  transform-origin: top left;

  /* ---- poster stand-in, painted by CSS rather than by <model-viewer> ----
     The <model-viewer> script is no longer loaded during page load (see
     digital-media.html), which means this element spends the first couple
     of seconds as an un-upgraded unknown element — and an un-upgraded
     element cannot honour its own `poster` attribute, so the hero would
     open with a hole where the character belongs.

     Painting the same poster as this element's background covers that
     window with no JavaScript at all. It costs one image decode that was
     already happening (the file is preloaded in <head>), and because the
     poster is 1920x957 — aspect 2.006, the canvas's own ratio to three
     decimal places — `100% 100%` lands it on exactly the framing the
     model renders at, with no letterboxing and no distortion. The
     mask-image below applies to it too, so the stand-in fades out at the
     bottom the same way the real model does.

     It is dropped again on the model's `load` event, because the WebGL
     canvas is transparent everywhere the character isn't: left in place,
     the poster would keep showing through around the real model. */
  background-image: url("../assets/images/me-head-poster.webp");
  background-size: 100% 100%;
  background-repeat: no-repeat;

  --poster-color: transparent;
  /* <model-viewer> draws its own loading bar across the TOP of the element
     while the .glb downloads — a 5px rgba(0, 0, 0, 0.4) strip that fades
     out a second after the model lands. On this page that read as a stray
     black line cutting across the hero on a cold load (and never on a
     warm one, since a cached model finishes before the bar ever paints),
     with nothing to say it belonged to the loading state. The poster
     already covers the wait, so the bar is redundant: zero out both the
     colour and the height so nothing is left to paint. */
  --progress-bar-color: transparent;
  --progress-bar-height: 0px;
  background-color: transparent;
  /* fades the shoulders/torso out into the page background below */
  mask-image: linear-gradient(to bottom, #000 55%, transparent 88%);
  -webkit-mask-image: linear-gradient(to bottom, #000 55%, transparent 88%);
}

/* see .dm-hero__model's background-image above for why this exists */
.dm-hero__model.is-model-ready {
  background-image: none;
}

/* ---------- Play hero on a phone: normal flow, not a scaled canvas ----------
   Everything above lays this hero out on a fixed 2540x1266 stage and
   shrinks it with ONE transform: scale(), which is what keeps the
   composition identical at every width instead of reflowing. That holds
   until the scale gets small enough to stop being a zoom and start being
   a thumbnail. On a 430px phone it lands at 430/2540 = 0.169, and two
   things break at once:

     - the section renders 1266 * 0.169 = 214px of canvas, so the whole
       hero occupied 280px of a 932px screen with the page's next section
       starting right under it;
     - .dm-hero__desc's 900px canvas column becomes 152px of real width,
       which wraps the paragraph to ~18 lines running 288px down from a
       stage only 214px tall. .dm-hero's overflow: hidden then cut it off
       entirely — the paragraph was not shortened or faded, it was never
       on screen.

   Neither is fixable by choosing a better scale: the canvas's 2.006
   aspect ratio is landscape and a phone is not, so any scale that fits
   the width leaves the height nearly empty. Below 769px the stage is
   dismantled instead — static, unscaled, --stage-scale pinned to 1 so
   every `/ var(--stage-scale)` type size resolves to the plain value the
   rest of the site uses — and the pieces stack: title, paragraph, then
   the character filling the bottom. main.js bails out at the same width
   (see PLAY_HERO_STAGE_MIN) so no inline height pins the section back to
   the canvas's aspect.

   This is the documented exception to the no-@media-inside-the-stage
   rule. It does not tune the composition at a breakpoint — it replaces
   it wholesale, once, below the width where a uniform scale can work. */
@media (max-width: 768px) {
  .dm-hero {
    /* the canvas aspect is what forced the 280px box; in flow the section
       sizes to its own content, with a floor so it still owns the screen */
    aspect-ratio: auto;
    min-height: 88svh;
  }

  /* svh is recent enough to want a fallback — vh is the old behaviour
     (it over-measures by the mobile browser's collapsing toolbar, which
     is survivable for a MIN height) */
  @supports not (height: 1svh) {
    .dm-hero {
      min-height: 88vh;
    }
  }

  .dm-hero__stage {
    position: static;
    width: 100%;
    height: auto;
    transform: none;
    /* 1 canvas px = 1 real px now, so the type inside dividing by this
       lands on the site's ordinary scale with nothing to cancel out */
    --stage-scale: 1;
    --stage-fit: 1;
    /* position: static drops the `top` offset that used to clear the nav,
       and .dm-hero still reclaims the nav band with its negative margin,
       so the padding has to do that job instead */
    padding: calc(var(--nav-h, 70px) + 1.5rem) 0 0;
    display: flex;
    flex-direction: column;
  }

  .dm-hero__content {
    /* the 200px page-edge indent and the -110px lift are canvas-unit
       placements; .container--wide on the row already gives the real
       gutter here */
    margin: 0;
    max-width: none;
  }

  .dm-hero__content .hero__title {
    /* no stage scale left to divide back out */
    font-size: calc(var(--text-title) * var(--font-hero-scale));
    filter: drop-shadow(0 0 17px var(--hero-glow));
  }

  .dm-hero__desc {
    font-size: var(--text-body);
    /* was a 900px canvas column — the gutter sets the measure now */
    max-width: none;
    margin-top: 1rem;
  }

  /* The character takes the bottom band rather than the full box, so the
     text above it keeps a clean background and he still reads as standing
     in the section rather than behind the type. */
  .dm-hero__media {
    top: auto;
    height: 58%;
  }

  .dm-hero__model {
    /* drop the canvas-unit geometry wholesale: the 2540x1266 box, the
       560px shove to the right that balanced him against left-aligned
       text, and the counter-scale that undid the stage's own. He fills
       this band and centres in it. */
    inset: 0;
    width: 100%;
    height: 100%;
    transform: none;
    --model-render-scale: 1;
    /* background-size: 100% 100% assumed the poster's 2.006 aspect
       matched its box; this band is nearer square, so stretching it would
       distort the stand-in for the second before the model lands */
    background-size: contain;
    background-position: center bottom;
  }

  .dm-hero__track-zone {
    /* head-track.js normalises the cursor against this box — it mirrored
       the character's old right-hand placement, and he is centred now */
    left: 0;
  }
}

/* no longer position: absolute inside .dm-hero__stage's transformed
   canvas — see the HTML comment where this now sits, right after
   .dm-hero, for why. Plain flex row in normal document flow instead:
   .btn-outline's own sitewide sizing/padding applies as-is (dropped the
   old .dm-btn class, which only made sense scaled down from a huge
   canvas-calibrated size — at this element's real, unscaled size that
   would have rendered enormous), wraps at narrow widths since there's
   no scale() shrinking it to fit anymore.

   UPDATE: moved back inside .dm-hero__content, directly under the
   paragraph — a truly responsive version of the original bottom-right
   overlay (same spot, but never cut off on a short window) wasn't
   possible without either scaling the title/character too, or escaping
   the canvas entirely the way this used to. Under the paragraph was the
   explicit fallback for when that's the case: still part of the same
   scaled canvas as everything else (so it reflows with the title/
   paragraph exactly in step, not independently), just laid out in
   normal flow — column stacked, like the reference — instead of
   position: absolute against a bottom offset that could run past a
   short window. */
.dm-hero__actions {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  /* 20px -> 24px, and the margin-top below 40px -> 56px: a bit more air
     both before the CTA group and between the two buttons in it, so the
     group reads as its own distinct step after the reading content
     (title + paragraph) instead of trailing directly off the last
     line — same "give the next group room to breathe" move as the
     homepage hero's --hero-stack-gap increase. */
  gap: 24px;
  margin-top: 56px;
}

/* sized with the same canvas-scale math as the paragraph right above it
   (.dm-hero__desc) — font-size divided by --stage-scale so it lands on
   --text-body's real rendered size at any viewport width, not a
   shrunk-down fraction of it. Padding/gap stay plain canvas-coordinate
   pixels (not divided) on purpose, same as most other spacing in this
   stage — these should scale WITH the composition as it zooms, not stay
   pinned to one fixed on-screen size while everything around them
   resizes. */
.dm-btn {
  justify-content: space-between;
  /* 40px -> 44px, and the padding below 22px/32px -> 28px/40px: these
     read as noticeably smaller/lighter than everything else on a stage
     built around a ~350px-tall title — sized up a bit for more presence
     and a more comfortable click target, still well inside the same
     canvas-unit scaling (not divided by --stage-scale) every other
     spacing value here uses, so it zooms with the rest of the
     composition instead of holding a fixed on-screen size. */
  gap: 44px;
  font-size: calc(var(--text-body) / var(--stage-scale));
  padding: 28px 40px;
}

/* No @media breakpoints for anything inside .dm-hero__stage — that's the
   point of it. Every element in there keeps one fixed pixel value (the
   1920px-wide look) and .dm-hero__stage's transform: scale() is the only
   thing that ever changes, so the whole composition — text, character,
   buttons — stays proportionally identical at every viewport width
   instead of each piece reflowing on its own and drifting apart. */

/* ---------- digital media: content sections (Motion design, Jaydon MP4,
   Inventory) ----------
   Reuses the case-study system's spacing scale — same gap clamp(), same
   .cs-square media-tile treatment (radius, overflow, 1:1 default) — so
   this page reads as the same design language as the rest of the site,
   rather than a one-off layout. */
/* gap between the "Motion design" heading and its filter pills — was
   flush (0, via the sitewide * { margin: 0 } reset) before this */
.dm-filter-row {
  margin-top: clamp(16px, calc(13.087px + 0.7767vw), 28px);
}

.dm-media-grid {
  display: grid;
  gap: clamp(1rem, calc(0.818rem + 0.777vw), 1.75rem);
  margin-top: clamp(1.5rem, 3vw, 2.25rem);
}

/* title + tag row under each video tile in the Motion design grid — same
   tag-chip language as the homepage's project cards (.tag-row .tag),
   just scaled down to fit under a grid tile instead of a full card. */
.dm-video-meta {
  margin-top: 1rem;
}

.dm-video-meta .tag-row {
  margin-bottom: 0.75rem;
}

.dm-video-meta__title {
  font-family: var(--font-display);
  font-weight: 400;
  /* was var(--text-h2) (a fluid clamp reaching 43px+) — fixed 1.375rem
     (22px) instead, matching the smaller, flat caption-style size used
     elsewhere on the site (e.g. the homepage hero paragraph) rather
     than scaling up like a section heading */
  font-size: 1.375rem;
  color: var(--ink);
}

/* the one grid column layout for both sections — every tile (wide or
   portrait) sits in the same 2-column grid, so a portrait tile's
   rendered width always matches a horizontal tile's width exactly,
   rather than being divided across a separate 3- or 4-column grid */
.dm-media-grid--two {
  grid-template-columns: 1fr 1fr;
}

/* Motion design's own layout — 3 columns, the vertical Ground FX recap
   in the middle, 2 horizontal videos stacked in each side column — per
   a provided reference layout. Explicit :nth-child placement, not
   grid-auto-flow, since the vertical tile specifically has to land in
   the MIDDLE column regardless of DOM order, not wherever
   auto-placement would happen to put it; DOM order itself (the 4
   horizontal videos, then Ground FX last) is still what makes the
   mobile fallback below work with zero extra rules.

   Middle column is auto-sized (was a fixed 1.5fr, then briefly wider
   still) rather than a wider fr share — Ground FX recap is capped by
   HEIGHT (see .cs-square--fit-viewport's max-height), not by its
   column's width, so a wider fr track didn't actually make the video
   any bigger; it just left dead space between the video's real
   (narrower) rendered width and the column's track width, which then
   stacked with the grid's own gap to read as one oversized gap between
   Ground FX and the right column. auto sizes the middle track to
   exactly the video's own rendered width instead, so there's no leftover
   space left to create that gap — Ground FX renders at whatever height
   the max-height cap gives it, unrelated to any of this. */
.dm-media-grid--motion {
  grid-template-columns: 1fr auto 1fr;
}

.dm-media-grid--motion .dm-media-item:nth-child(1) {
  grid-column: 1;
  grid-row: 1;
}

/* nth-child(2)/(4) (row 2's pair) bottom-anchor their own content —
   Terry Balloon's 1 tag vs. 10 years of design's 4 (which can wrap to
   an extra line) would otherwise leave one title sitting higher than
   the other purely because there's less above it. This only touches
   the two items that actually share a row and need to agree with each
   other — Ground FX recap below is a deliberate exception, see its own
   comment. */
.dm-media-grid--motion .dm-media-item:nth-child(2) {
  grid-column: 1;
  grid-row: 2;
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
}

.dm-media-grid--motion .dm-media-item:nth-child(3) {
  grid-column: 3;
  grid-row: 1;
}

.dm-media-grid--motion .dm-media-item:nth-child(4) {
  grid-column: 3;
  grid-row: 2;
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
}

/* align-self: start, not end: this used to bottom-anchor Ground FX
   recap so its title landed on the exact same line as "10 years of
   design" — but at this tile's actual size, its own content (video +
   tags + title) doesn't fill the full 2-row span it sits in, so
   bottom-anchoring it left an ~80px dead gap ABOVE the video instead,
   floating away from row 1 with nothing touching it. That reads as
   broken, not aligned — a video and its own caption disagreeing with
   every neighboring tile on where the row actually starts is a worse
   look than the caption landing a bit off from "10 years of design"'s.
   start keeps the video flush with row 1's top, matching every other
   tile here, with its title sitting directly under it same as the
   rest — it just runs long past row 1 into row 2's space, which is
   expected for the one tall item in the layout. */
.dm-media-grid--motion .dm-media-item:nth-child(5) {
  grid-column: 2;
  grid-row: 1 / span 2;
  align-self: start;
}

/* below this, 3 columns of video tiles — one a tall portrait clip —
   get too narrow to comfortably watch. Falls back to a single column
   and hands placement back to plain DOM order: the 4 horizontal videos
   first, the vertical Ground FX recap last (i.e. underneath all of
   them) — exactly the fallback stacking order asked for, with no extra
   reordering needed since that's already the source order. */
@media (max-width: 1000px) {
  .dm-media-grid--motion {
    grid-template-columns: 1fr;
  }

  .dm-media-grid--motion .dm-media-item:nth-child(1),
  .dm-media-grid--motion .dm-media-item:nth-child(2),
  .dm-media-grid--motion .dm-media-item:nth-child(3),
  .dm-media-grid--motion .dm-media-item:nth-child(4),
  .dm-media-grid--motion .dm-media-item:nth-child(5) {
    grid-column: auto;
    grid-row: auto;
    align-self: auto;
  }
}

/* horizontal/widescreen tile ratio — 1129:711. Compound selector (not
   just .cs-square--wide alone) so it reliably beats .cs-square's own
   1:1 aspect-ratio regardless of which rule is declared later in the
   file — same fix as .dm-media-tile--empty needed above. */
.cs-square.cs-square--wide {
  aspect-ratio: 1129 / 711;
}

/* true 16:9, for screen recordings (e.g. the eShop discovery demo) —
   --wide's 1129:711 is close but not equal, and cover would shave the
   sides off a UI capture */
.cs-square.cs-square--16x9 {
  aspect-ratio: 16 / 9;
}

/* portrait variant of .cs-square (matches .cs-video-tile's 711:1206
   ratio) for the Shorts/Reels-style clips in these two galleries */
.cs-square.cs-square--portrait {
  aspect-ratio: 711 / 1206;
}

/* opt-out of .cs-square img/video's default object-fit: cover — for a
   source whose own aspect ratio doesn't closely match the tile's (e.g.
   Motion design's Ground FX recap, 9:16 against this tile's 711:1206),
   cover crops a sliver off the edges to fill the box exactly; contain
   scales it down just enough that the whole frame stays visible, letter-
   boxed by a few px instead. Background goes black so that letterboxing
   reads as intentional, matching the source's own black slate instead of
   .cs-square's light placeholder-image gray showing through. */
.cs-square.cs-square--contain {
  background: #000;
}

.cs-square.cs-square--contain img,
.cs-square.cs-square--contain video {
  object-fit: contain;
}

/* Ground FX recap — at this grid's normal "fill the column" width, a
   711:1206 tile can end up taller than the viewport itself, so seeing
   the whole thing means scrolling past it. Cap by height instead of
   width: aspect-ratio then works out a narrower width to match, so the
   full video fits on screen at once. Centered since it no longer fills
   its column like every other tile here. */
.cs-square.cs-square--fit-viewport {
  width: auto;
  max-width: 100%;
  /* was 65vh — bumped up so Ground FX recap (the only tile using this
     class) renders bigger, per its own explicit "make it larger"
     request; still capped rather than unbounded so it can't grow taller
     than the viewport itself on a short window */
  max-height: 78vh;
  /* was margin-inline: auto, which centered this tile's video (it's
     narrower than its column — the only tile here that's ever narrower
     than 100%, since the max-height cap above wins over width) while
     .dm-video-meta right below it stays full-width/left-aligned same as
     every other tile. That's what the "still misaligned" screenshot was
     actually showing: the video floating inset from both edges while
     its own caption hugs the true left edge underneath it. 0 keeps the
     video flush left instead, matching the caption's left edge exactly
     — and matching where every other tile's video sits, since none of
     the others are ever narrower than their column to begin with. */
  margin-inline: 0;
}

/* honest "space reserved" tile — no fake play button or borrowed
   screenshot pretending to be finished work, just the same dashed-border
   treatment already used by .placeholder-note elsewhere on this page.
   Compound selector (not just .dm-media-tile--empty alone) so it beats
   .cs-square's own #d9d9d9 placeholder-image background regardless of
   which rule happens to be declared later in the file. */
.cs-square.dm-media-tile--empty {
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 1rem;
  text-align: center;
  border: 1px dashed rgba(var(--overlay-rgb), 0.16);
  background: rgba(var(--overlay-rgb), 0.02);
  color: var(--text-muted);
  font-size: var(--text-caption);
}

/* Inventory — empty item-slot grid standing in for a future real
   screenshot/demo of the system, rather than a fabricated one */
.dm-inventory-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(44px, 1fr));
  gap: 10px;
  margin-top: clamp(1.5rem, 3vw, 2.25rem);
}

.dm-inventory-slot {
  aspect-ratio: 1 / 1;
  border-radius: 10px;
  background: rgba(var(--overlay-rgb), 0.03);
  border: 1px solid rgba(var(--overlay-rgb), 0.08);
}

@media (max-width: 560px) {
  .dm-media-grid--two {
    grid-template-columns: 1fr;
  }

  .dm-inventory-grid {
    grid-template-columns: repeat(auto-fill, minmax(32px, 1fr));
    gap: 6px;
  }
}

/* ---------- inner pages (about / digital media / case studies) ---------- */

.page-hero {
  padding-top: 110px;
  padding-bottom: 48px;
}

.page-hero h1 {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: clamp(44px, calc(37.204px + 1.812vw), 72px);
  letter-spacing: -0.01em;
  margin-bottom: 20px;
}

.page-hero p {
  max-width: 680px;
  color: var(--text-secondary);
  font-size: var(--text-body);
  line-height: 1.5em;
}

/* ---------- About: the single-screen hero ----------

   The whole About page above the footer. Two rules carry the entire
   thing:

   1. The section is exactly as tall as the space below the nav — no
      taller, no shorter — so the page never scrolls.
   2. Every piece inside is absolutely placed as a PERCENTAGE of that
      box and sized off one shared unit (--u below). The percentages
      keep the arrangement; --u keeps the pieces in proportion to it.
      So a short window, a wide one and a tall one all render the same
      composition at a different size, rather than each reflowing into a
      different layout.

   This deliberately does NOT use --hero-fit. That machinery exists
   because the homepage/case-study heroes are sized BY their own content,
   which makes the fit a fixed point only JS can solve (see solveFit in
   main.js). Nothing here is content-sized: the box is the viewport and
   the pieces are placed in %, so the ratio is known up front and plain
   CSS can express it. main.js only hands over the measured height. */
.about-hero {
  position: relative;
  /* the safety net if a piece ever outgrows its slot — the composition
     is built not to need it, but a clip is a better failure here than a
     scrollbar on a page that is supposed to be one screen */
  overflow: hidden;

  /* main.js (sizeAboutHero) overwrites this with the real space below
     the measured nav. The calc() is the pre-JS/no-JS fallback and reuses
     --nav-h's own 130px default, the same way `main`'s padding-top does.
     Kept as a variable rather than set straight on `height` because --u
     below has to read the same number. */
  --about-h: calc(100vh - var(--nav-h, 130px));

  /* ---- the composition's one scale unit ----
     1u is 1% of the reference layout's width, and the min() gives it a
     second, height-driven arm. 53.5 is that reference window's own
     width-to-height ratio below the nav (1.869:1) expressed against the
     same 1%: on that window the two arms are equal and 1u is exactly
     1vw. Any SHORTER window makes the height arm win, and then the
     title, discs, character and buttons all shrink by one shared
     factor — which is what stops a wide-but-short laptop window from
     rendering the pieces at full size and colliding them. */
  --u: min(1vw, calc(var(--about-h) / 53.5));

  height: var(--about-h);
  /* overrides the generic section{padding-block: 64px} — the box IS the
     viewport here, so any padding would push the composition down out of
     the space it was just measured against */
  padding-block: 0;

  /* #about-top, the target every Back link points at. Same reservation
     the views make: this section starts one nav-height down the page, so
     without it a Back click from anywhere the page has been scrolled
     top-aligns the hero and leaves the nav scrolled off above it. */
  scroll-margin-top: var(--nav-h, 130px);
}

/* diagonal hairlines, the same pair of 20deg/-20deg repeating gradients
   the Play hero uses (.dm-hero__grid) rather than the orthogonal
   .hero__grid. No negative left/right overhang needed here: this section
   is already full page width, so inset: 0 reaches both edges on its own.

   The spacing is in --u rather than the Play hero flat 200px, so the
   pattern is part of the composition and not a fixed-size texture laid
   under it — a wide window would otherwise crowd many more crossings
   behind the same artwork than the reference has. */
.about-hero__grid {
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  background-image:
    repeating-linear-gradient(
      20deg,
      rgba(var(--overlay-rgb), 0.09) 0px,
      rgba(var(--overlay-rgb), 0.09) 1px,
      transparent 1px,
      transparent calc(17 * var(--u))
    ),
    repeating-linear-gradient(
      -20deg,
      rgba(var(--overlay-rgb), 0.09) 0px,
      rgba(var(--overlay-rgb), 0.09) 1px,
      transparent 1px,
      transparent calc(17 * var(--u))
    );
}

/* the box every percentage below resolves against: .container--wide's
   width (so the title lines up with the nav logo's own gutter) by the
   section's full height */
.about-hero__inner {
  position: relative;
  height: 100%;
  z-index: 1;
}

/* top is where the title's INK starts, not where its box does — the
   negative margin on .about-hero__title below cancels the difference
   (see there), so this number can be read straight off the reference
   composition.

   left lines this block up with the Play page's title and paragraph
   (.dm-hero__content), which are NOT on the same grid as anything else
   on the site: they sit at a flat 200px indent inside a .container--wide
   that lives inside .dm-hero__stage, a fixed 2540px canvas the page then
   scales down by 100cqw/2540px. So the museum's real X is
   (half its gutter + 200) canvas units at that scale — reproduced here
   exactly rather than approximated, then with this element's own
   container inset subtracted back off, since .about-hero__inner already
   starts one inset in. (Chrome resolves length / length in calc() to a
   plain number, which is what makes the canvas scale expressible here at
   all — the same division .dm-hero__stage's --stage-scale relies on.)

   That inset is a max() of two things, not just the gutter:
   .container--wide is min(100% - gutter, 2600px), so past ~2710px wide
   the 2600px cap wins and the real inset becomes (100vw - 2600px)/2
   instead. The museum never hits that cap — its .container--wide sits
   inside the 2540px canvas, where 2540 - gutter is always under 2600 —
   so on a very wide monitor its title is genuinely further left than
   this container starts, and this resolves to a negative left to reach
   it. Without the max() arm the title landed 300px right of the museum
   at 3440px wide.

   Deliberately NOT matched: the Play stage also carries --stage-fit and
   re-centres itself horizontally when a window is too short for its 3D
   model, which throws that title several hundred px right. That is a
   consequence of fitting the model, not the designed position, and
   copying it would make this title lurch sideways as the window height
   changes. This tracks the width-driven position, which is what the
   museum renders at every normal window size. */
.about-hero__intro {
  position: absolute;
  left: calc(
    (clamp(48px, 6vw, 110px) / 2 + 200px) * (100vw / 2540px)
    - max(clamp(48px, 6vw, 110px) / 2, (100vw - 2600px) / 2)
  );
  top: 7.9%;
  /* Under the discs and buttons rather than over them (was 3, above
     everything) — this is the layer separation the indent needs. The
     block does not actually collide with any of them at the sizes
     measured (1280x500 through 3840x2160, and the phone sizes), but it
     is shrink-to-fit around whatever the title says, so the margin is
     not guaranteed by anything; if it ever does reach one, the disc
     should be the thing on top. Hit-testing follows paint order, so
     this ordering also settles clicks — an interactive element can
     never end up behind this text. Deliberately no pointer-events:
     none on top of that: the z-index already does the job, and
     switching it off would cost the ability to select the text for
     nothing. */
  z-index: 1;
  text-align: left;
}

/* overrides .hero__title's own font-size (--text-title x --hero-fit,
   i.e. viewport-WIDTH-driven only) with the shared unit, so the title
   shrinks with the rest of the composition on a short window instead of
   holding desktop size over a scaled-down stage. Everything else about
   the face — FS Mondwest, the 0.18em word-spacing its zero-width space
   glyph needs, weight 400, the 0.91 line-height — is inherited from
   .hero__title unchanged. */
.about-hero__title {
  font-size: calc(12.3 * var(--u));
  /* FS Mondwest's line box opens with a big slab of empty space above
     the letters — measured on this exact rule (canvas font metrics vs
     the glyphs' actual ink) at 0.27em between the top of the box and the
     top of the H. Same fix, and the same reason, as the -0.1em on
     .hero__intro .hero__title, which cancels the equivalent dead air
     BELOW its own letters. Cancelling it here is what lets
     .about-hero__intro's own top be the ink position rather than a
     number tuned by eye at one font size — em-based, so it holds as --u scales
     the title. */
  margin: -0.27em 0 0;
  /* the same white glow the other FS Mondwest titles carry (see
     .hero__intro .hero__title) */
  filter: drop-shadow(0 0 17px var(--hero-glow));
}

.about-hero__lede {
  margin: calc(1.2 * var(--u)) 0 0;
  /* floored so the line stays legible once --u is height-bound on a
     short window — it is the only real sentence on the page */
  font-size: max(15px, calc(1.35 * var(--u)));
  line-height: 1.4;
  /* --copy-ink, the running-prose grey (see its own :root comment) —
     the same colour .cs-para already used, so every paragraph on the
     site that is not inside a button or a callout now reads as one
     voice. */
  color: var(--copy-ink);
}

/* One slot per stage piece. The slot owns the position and the -50%
   centering transform; its child owns the wiggle and the hover.
   Splitting them across two elements is what lets both run: a single
   element carrying the centering AND the animation would have the
   animation overwrite the centering outright (animations beat plain
   declarations), which is also why the old stage's buttons never
   actually grew on hover. */
.about-hero__slot {
  position: absolute;
  z-index: 2;
  transform: translate(-50%, -50%);
}

/* Horizontal placement is an offset in --u from the CENTRE line, not a
   percentage of the box. The two axes are not symmetrical here: --u is
   height-bound on any window wider than the reference 1.869:1 (which is
   most of them), so percentages across the width would keep pushing the
   pieces apart on a wide monitor while --u kept them the same size — the
   composition pulls itself open and the character is left stranded in
   the middle of it. Offsets in --u make the arrangement rigid: it scales
   as one thing and stays centred on the character, and extra width just
   becomes margin either side.

   Vertical placement stays in percentages, because that axis IS the
   thing being fitted — these are the proportions the hero is drawn at,
   measured off the reference composition. */
.about-hero__slot--disc-top     { left: 50%; margin-left: 0;                       top: 15.2%; }
.about-hero__slot--disc-left    { left: 50%; margin-left: calc(-19.8 * var(--u));  top: 43.2%; }
.about-hero__slot--disc-right   { left: 50%; margin-left: calc(19.8 * var(--u));   top: 43.2%; }
.about-hero__slot--action-left  { left: 50%; margin-left: calc(-26 * var(--u));    top: 68.4%; }
.about-hero__slot--action-right { left: 50%; margin-left: calc(26 * var(--u));     top: 68.4%; }

/* top edge, not centre — the character is the one piece whose height is
   read off --u rather than off the box, so anchoring its TOP is what
   keeps its head clear of the two side discs at every scale */
.about-hero__slot--character {
  left: 50%;
  top: 33.2%;
  transform: translate(-50%, 0);
  z-index: 1;
}

.about-hero__character {
  display: block;
  /* 23.25u — once --u is height-bound this resolves to 43.5% of the
     section's height, the proportion the composition is drawn at */
  height: calc(23.25 * var(--u));
  width: auto;
  filter: drop-shadow(0 30px 50px rgba(0, 0, 0, 0.5));
}

/* the glass surface comes from the shared no-fill rule further up (see
   ".about-hero__disc, .cs-callout, ..."); this only sets the geometry.

   max(44px, ...) is the touch-target floor, not a design choice: 7.25u
   drops under 44px once --u falls below ~6, which a short landscape
   window reaches (measured 46px at 900x400) while the desktop layout is
   still in force. Above that the floor is inert and the disc scales with
   the composition exactly as before. */
.about-hero__disc {
  /* flex, not block, purely to centre the artwork inside the circle: the
     disc is a fixed square and the art is a free-standing object with its
     own proportions, so there is nothing else holding it in the middle. */
  display: flex;
  /* same bouncy back-out curve as .btn and .filter-pill — see .btn's own
     comment for why. `scale`, not `transform`: the hover uses the
     independent property so it can coexist with the wiggle animation
     (see the hover rule further up). */
  transition: background 0.2s ease, scale 0.22s cubic-bezier(0.34, 1.56, 0.64, 1);
  align-items: center;
  justify-content: center;
  width: max(44px, calc(7.25 * var(--u)));
  height: max(44px, calc(7.25 * var(--u)));
  border-radius: 50%;
  padding: 0;
  cursor: pointer;
}

/* The art sits inside the glass rather than filling it. 68% leaves a ring
   of the disc's own surface visible all the way round, which is what keeps
   the object reading as sitting IN the circle instead of being cropped by
   it. contain, not cover: the bin is portrait (82x101) and the film reel
   and record are square, so a cover crop would shave the bin's handles off
   while leaving the other two untouched. */
.about-hero__disc img {
  display: block;
  width: 68%;
  height: 68%;
  object-fit: contain;
}

/* the one place on the site where a button is NOT the flat sitewide
   0.938rem/1.875rem spec (see .btn): these two are part of the
   composition rather than chrome around it, so they scale with
   everything else in it. Floored the same way .about-hero__lede is. */
.about-hero__action {
  /* .btn already transitions transform on the same curve, but the hover
     here rides on `scale` instead (see .about-hero__disc:hover), so the
     property has to be named explicitly or the grow would snap. */
  transition: scale 0.22s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.25s ease, border-color 0.2s ease, background 0.2s ease;
  font-size: max(13px, calc(1.35 * var(--u)));
  padding: calc(1.15 * var(--u)) calc(3.6 * var(--u));
  white-space: nowrap;
  /* same touch-target floor as the disc above. The padding is what goes
     first here: with the font already on its 13px floor, a short window
     rendered these 33px tall at 900x400 and 40px at 760x380. */
  min-height: 44px;
}

/* The site styles :focus-visible in two places (.nav__links a and the
   case-study contents list) and leaves everything else on the browser
   default. These five sit on a dark glass ground where that default is
   easy to lose, and they are the only way to reach this page by
   keyboard, so they take the same explicit ring the contents list uses
   (see .cs-toc__link:focus-visible). */
.about-hero__disc:focus-visible,
.about-hero__action:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
}

/* staggered so the five pieces never drift in step with one another */
.about-hero__slot--disc-top     .about-hero__disc   { animation: wiggle 8.2s ease-in-out 0.4s infinite; }
.about-hero__slot--disc-left    .about-hero__disc   { animation: wiggle 7.6s ease-in-out 1.1s infinite; }
.about-hero__slot--disc-right   .about-hero__disc   { animation: wiggle 9.8s ease-in-out 0.3s infinite; }
.about-hero__slot--action-left  .about-hero__action { animation: wiggle 9s ease-in-out 0s infinite; }
.about-hero__slot--action-right .about-hero__action { animation: wiggle 10.4s ease-in-out 0.6s infinite; }

/* Deliberately written with the same two-class shape as the five rules
   above, and placed after them. A bare .about-hero__disc here (which is
   where this rule used to live, up by the keyframes) is one class less
   specific than .about-hero__slot--disc-top .about-hero__disc and simply
   loses to it — the wiggle went on running for anyone who had asked for
   reduced motion. Equal specificity plus later source order is what
   actually switches it off. */
@media (prefers-reduced-motion: reduce) {
  .about-hero__slot .about-hero__disc,
  .about-hero__slot .about-hero__action {
    animation: none;
  }
}

/* Below this width the desktop arrangement runs out of room: the two
   buttons stop shrinking (their font-size hits its floor) while the
   +/-26u offsets holding them apart keep shrinking, so they close in on
   the character. Measured, that crosses over at roughly 400px wide — the
   breakpoint sits well clear of it.

   What changes here is the ARRANGEMENT, not the promise: this is still
   exactly one screenful. The pieces re-lay into a column — title, the
   three discs in a row, the character, the two buttons — and the
   character's row is the grid's only 1fr, so it absorbs whatever height
   is left over and the composition fits by construction rather than by
   tuning. */
@media (max-width: 768px) {
  .about-hero__inner {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    /* the 1fr is the character's row — see above */
    grid-template-rows: auto auto 1fr auto auto;
    row-gap: clamp(10px, 2.4vh, 26px);
    justify-items: center;
    align-items: center;
    padding-block: clamp(10px, 2.4vh, 26px);
  }

  .about-hero__intro {
    position: static;
    grid-column: 1 / -1;
    grid-row: 1;
    justify-self: start;
  }

  .about-hero__title {
    /* --u is a bare 1vw down here (the height arm can't win on a portrait
       phone), which would render this at ~47px on a 390px screen — under
       the site's own --text-title floor. Back to the sitewide title size,
       capped so a short phone in landscape doesn't hand the title more
       height than the character has left. */
    font-size: min(calc(var(--text-title) * var(--font-hero-scale)), 13vh);
    margin: 0;
  }

  .about-hero__lede {
    margin-top: 6px;
    font-size: var(--text-body);
  }

  .about-hero__slot {
    position: static;
    transform: none;
    margin-left: 0;
  }

  .about-hero__slot--disc-left  { grid-column: 1; grid-row: 2; }
  .about-hero__slot--disc-top   { grid-column: 2; grid-row: 2; }
  .about-hero__slot--disc-right { grid-column: 3; grid-row: 2; }

  /* the flex box is what lets the character centre itself inside
     whatever height the 1fr row ends up with; min-height: 0 is what
     lets that row actually shrink below the image's intrinsic size */
  .about-hero__slot--character {
    grid-column: 1 / -1;
    grid-row: 3;
    display: flex;
    align-items: center;
    justify-content: center;
    min-height: 0;
    align-self: stretch;
    width: 100%;
  }

  .about-hero__slot--action-left  { grid-column: 1 / -1; grid-row: 4; }
  .about-hero__slot--action-right { grid-column: 1 / -1; grid-row: 5; }

  /* height: 100% against the 1fr row, max-width so a wide-but-short
     phone in landscape can't let it grow past the screen instead */
  .about-hero__character {
    height: 100%;
    width: auto;
    max-width: 100%;
    object-fit: contain;
  }

  .about-hero__disc {
    width: min(20vw, 84px);
    height: min(20vw, 84px);
  }

  .about-hero__action {
    font-size: var(--text-body);
    padding: clamp(9px, 1.4vh, 15px) 1.875rem;
  }
}

.breadcrumb {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: var(--text-caption);
  color: var(--text-muted);
  margin-bottom: 28px;
}

.breadcrumb a {
  color: var(--accent);
}

.case-banner {
  border-radius: var(--radius-lg);
  overflow: hidden;
  aspect-ratio: 21 / 9;
  margin-bottom: 56px;
}

.case-banner img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* "Next project" banner — reuses .case-banner's shape/proportions, but
   it's a link (not a static image), so it gets its own hover feedback:
   a slight zoom on the thumbnail inside the already-clipped rounded
   corners. */
.cs-next-project {
  display: block;
}

.cs-next-project img {
  transition: transform 0.4s ease;
}

.cs-next-project:hover img {
  transform: scale(1.04);
}

.case-meta {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 24px;
  padding: 36px;
  border-radius: var(--radius-md);
  margin-bottom: 64px;
}

.case-meta div span {
  display: block;
  font-size: clamp(11px, calc(10.515px + 0.129vw), 13px);
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--text-muted);
  margin-bottom: 6px;
}

.case-meta div strong {
  font-family: var(--font-display);
  font-size: clamp(14px, calc(13.272px + 0.194vw), 17px);
  font-weight: 600;
}

.case-body {
  display: grid;
  gap: 52px;
  max-width: 820px;
  margin-bottom: 64px;
}

.case-body h2 {
  font-family: var(--font-display);
  font-size: clamp(20px, calc(18.544px + 0.388vw), 26px);
  margin-bottom: 14px;
}

.case-body p {
  color: var(--text-secondary);
  line-height: 1.5em;
  font-size: clamp(15px, calc(14.636px + 0.097vw), 16.5px);
}

.placeholder-note {
  border-radius: var(--radius-md);
  padding: 24px 28px;
  color: var(--text-muted);
  font-size: clamp(13px, calc(12.515px + 0.129vw), 15px);
  line-height: 1.6;
  border: 1px dashed rgba(var(--overlay-rgb), 0.16);
  background: rgba(var(--overlay-rgb), 0.02);
}

.tile-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 28px;
}

.tile {
  aspect-ratio: 4 / 3;
  border-radius: var(--radius-md);
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--text-muted);
  font-size: clamp(13px, calc(12.636px + 0.097vw), 14.5px);
  text-align: center;
  padding: 20px;
}

/* ---------- reveal-on-scroll ---------- */

/* Case-study pages opt out of the generic fade-and-rise entirely (body
   .case-study, set on all four) — their content is in place from the
   first paint. Section headings are the exception and keep animating:
   .cs-heading.reveal below overrides both of these anyway with a rise
   and no fade, which is a different effect and was asked to stay.

   The opt-out is written as :where(), whose argument contributes NOTHING
   to specificity, so both selectors still weigh exactly what they did as
   bare .reveal / .reveal.is-visible. That matters: the per-component
   overrides further down (.cs-heading.reveal, .project-card.reveal) are
   plain two-class selectors that only win by sitting later in the
   cascade, and a real descendant selector here would outweigh them and
   quietly put the rise back on the homepage's project cards.

   main.js is untouched — the observer still runs and still adds
   .is-visible on these pages. Nothing reads it there now, but the card
   flip-in it also drives is hung off the same callback. */
:where(body:not(.case-study)) .reveal {
  opacity: 0;
  transform: translateY(24px);
  transition: opacity 0.7s ease, transform 0.7s ease;
}

:where(body:not(.case-study)) .reveal.is-visible {
  opacity: 1;
  transform: translateY(0);
}

/* ---------- scramble-in title effect ---------- */

/* Plain inline, not inline-block: the animation only ever changes color and
   text-shadow (see .scramble-char--lock below), neither of which needs a
   box of its own. inline-block would let the browser treat the gap between
   any two letters as a valid line-break point, which is what was splitting
   words like "Museum" mid-word on narrower viewports. */
.scramble-char {
  display: inline;
}

.scramble-char--lock {
  animation: scramble-flash 380ms cubic-bezier(0.2, 0, 0, 1);
}

/* Every element this effect runs on also carries a filter: drop-shadow()
   glow, and a filter has to be re-rendered from scratch each time the
   element's content changes — which, for the 1500ms this animation runs,
   is nearly every frame. scramble-text.js adds this class for exactly
   that window (see the comment there). !important because the glow is
   declared at several different specificities across the heroes and this
   has to out-rank all of them; it is removed again the moment the last
   character locks, so the glow is only absent while the text is still
   unreadable glyphs anyway. */
[data-scramble].is-scrambling {
  filter: none !important;
}

@keyframes scramble-flash {
  0% {
    color: var(--accent, #a855f7);
    text-shadow: 0 0 18px rgba(168, 85, 247, 0.6);
  }
  100% {
    color: inherit;
    text-shadow: none;
  }
}

@media (prefers-reduced-motion: reduce) {
  .scramble-char--lock {
    animation: none;
  }
}

/* ---------- responsive ---------- */

@media (max-width: 900px) {
  /* stacks the side column (heading + filters) above the card list —
     .project-card and .project-card__media need no override here
     anymore, they're already full-width/column at every size now */
  .case-studies__layout {
    flex-direction: column-reverse;
    gap: 32px;
  }

  /* .case-studies__layout's own align-items: flex-start doesn't stretch
     children to full width in either row or column mode, and
     .project-grid's flex-basis sizes height (not width) once the
     layout above switches to column — set the width explicitly here */
  .project-grid {
    width: 100%;
  }

  /* sticky only makes sense in the side-by-side desktop layout — on
     mobile .case-studies__layout stacks into one column (column-reverse
     above), so this sits in normal flow above the cards instead */
  .case-studies__side {
    width: 100%;
    position: static;
    /* the forced 100vh height and the sticky-delay margin (see
       .case-studies__side) only make sense ahead of the desktop sticky
       behavior; mobile stacks this column above the cards via
       column-reverse, so it just needs its own natural content height
       and the layout's own 32px gap like everything else here */
    height: auto;
    padding-bottom: 0;
    margin-top: 0;
  }

  /* Hold the heading at the size it already has where this breakpoint
     starts, instead of letting it keep shrinking down the phone range.

     --text-h1's ramp is tuned for the DESKTOP layout, where this heading
     lives in a narrow sticky sidebar: at 901px that column is only 375px
     wide and the heading is 78px, filling about 73% of it. Stacked, the
     column is the full viewport instead, but the type kept scaling with
     the VIEWPORT rather than the column it now sits in — so it fell to
     55px at 430 and 52px at 360 while the filter pills below it stayed
     put at a fixed 16px in 52px-tall boxes. FS Mondwest sets small glyphs
     for its em size, so a 55px heading in it reads lighter than a 52px
     button: the buttons looked larger than the heading they belong to.

     max() against the existing ramp, so this only ever raises the floor
     and never lowers a size: 78.1px at 899 is what the ramp already gives
     there, which makes the breakpoint boundary continuous rather than a
     step. The 22vw arm is a fit guard, not a design choice — "& Case
     Studies" (the longer of the two hard-broken lines) renders ~3.5x the
     font size, so at 78px it needs 274px of the column. That clears
     360px phones with room, but would overflow a 320px one; 22vw eases
     it back to ~70px there and stays out of the way above ~355px. */
  .case-studies__side .section-head h2 {
    font-size: max(calc(var(--text-h1) * var(--font-hero-scale)), min(4.875rem, 22vw));
  }

  .case-studies__side .section-head,
  .filters {
    justify-content: flex-start;
  }

  /* justify-content above only left-aligns the pills INSIDE .filters'
     box; it does nothing about where that box sits. The base rule caps
     it at 32rem and centres it with margin-inline: auto, which is right
     under a centred heading in the desktop sidebar — but this breakpoint
     switches the heading to text-align: left, so the two disagreed: the
     heading sat flush at the column's edge while the pills started
     wherever the centred 512px box happened to begin. Measured at 899px
     that was a 166px indent (and it shrank as the column narrowed, which
     is why it only looked wrong across a band of widths rather than
     always). Zeroing the margin puts the box's left edge on the column's,
     so both start from the same line. */
  .filters {
    margin-inline: 0;
  }

  .case-studies__side .section-head h2 {
    text-align: left;
  }

  .case-meta {
    grid-template-columns: repeat(2, 1fr);
  }

  .tile-grid {
    grid-template-columns: repeat(2, 1fr);
  }
}

/* 680px, not 920px — the new bar is much lighter than the old one (icon-
   only logo, a two-word Work/Play switch, three plain-text links, no
   Contact button), so it keeps fitting in one row much further down
   before the hamburger has to take over */
@media (max-width: 680px) {
  .nav__links {
    /* absolute against .nav-wrap (position: relative, see its rule) with
       top: 100%, rather than fixed at a hard-coded 88px. That 88px was a
       measurement of the old, taller bar: once the nav shrank it stopped
       matching, leaving a ~31px gap between the bar and the menu hanging
       off it. Measuring from the wrap's own bottom edge tracks whatever
       height the bar actually is, at any width or --nav-fit.

       absolute, not fixed, is also the correct pairing for a nav that is
       deliberately not sticky (see .nav-wrap): fixed kept the menu pinned
       to the viewport while the bar it belongs to scrolled away from
       underneath it.

       NOTE the left/right pair below is currently inert, and was inert
       under the old fixed positioning too (measured both: same 131px box
       either way, so this rule change didn't cause it). .nav__links
       carries justify-self: end for the desktop grid row, and for an
       absolutely positioned box a non-normal justify-self makes it
       shrink-to-fit and align to one edge rather than stretch between two
       non-auto insets. Adding justify-self: stretch here would make the
       menu span 16px to 16px as these declarations imply — left alone
       deliberately, since that changes how the mobile menu looks and
       nobody has asked for it. */
    position: absolute;
    top: calc(100% + 8px);
    left: 16px;
    right: 16px;
    z-index: 60;
    flex-direction: column;
    align-items: stretch;
    gap: 4px;
    padding: 14px;
    border-radius: var(--radius-md);
    background: var(--nav-bg);
    border: 1px solid rgba(var(--overlay-rgb), 0.08);
    box-shadow: 0 14px 34px rgba(0, 0, 0, 0.4);
    transform: translateY(-12px);
    opacity: 0;
    pointer-events: none;
    transition: all 0.25s ease;
  }

  /* stacked in the dropdown, each link needs a real tap target and its
     own hover/active feedback — the plain inline-text treatment above
     is sized for a single horizontal row, not a column of rows */
  .nav__links a {
    display: block;
    padding: 12px 14px;
    border-radius: var(--radius-sm);
    color: var(--text-primary);
  }

  .nav__links a:hover,
  .nav__links a:focus-visible {
    background: rgba(var(--overlay-rgb), 0.08);
  }

  .nav__links.is-open {
    transform: translateY(0);
    opacity: 1;
    pointer-events: auto;
  }

  .nav__toggle {
    display: flex;
    align-items: center;
    justify-content: center;
    /* fluid across this media query's own range (375–680px) rather than
       a flat 46px all the way down to a phone — it's the one nav piece
       that was still a hard-coded size regardless of viewport */
    width: clamp(38px, calc(28.164px + 2.623vw), 46px);
    height: clamp(38px, calc(28.164px + 2.623vw), 46px);
    border-radius: 50%;
    background: rgba(var(--overlay-rgb), 0.05);
    border: 1px solid rgba(var(--overlay-rgb), 0.14);
    color: var(--text-primary);
    cursor: pointer;
  }
}

/* below ~420px even the compact switch starts crowding the logo and
   hamburger in the same row — shrink its padding/gap further than the
   fluid clamps above already take it, rather than letting it wrap */
@media (max-width: 420px) {
  .nav {
    gap: 8px;
  }

  /* .nav__switch-opt's own padding no longer needs an override here —
     its fluid clamp() already floors at this exact 8px/12px by the time
     the viewport is this narrow (see its own rule for why) */
}

@media (max-width: 560px) {
  section {
    padding-block: 48px;
  }

  .case-meta {
    grid-template-columns: 1fr 1fr;
  }

  .tile-grid {
    grid-template-columns: 1fr;
  }
}

/* =========================================================
   Case study v2 — richer editorial layout (Faculty of Environment)
   Namespaced under .cs- so the older placeholder case-study
   pages (aira, ground-fx) are untouched — this pass is scoped to
   this page only.

   Unit strategy (rem + fluid clamp()), inspired by how
   aileens-beautiful-site.webflow.io/roblox holds its proportions
   across viewport sizes:
     - All sizing here is in rem (1rem = 16px root), not px, so it
       respects the reader's browser text-size/zoom setting.
     - Anything that should visibly shrink on a narrow screen — outer
       padding, section gaps, hero/heading type — uses clamp(min,
       preferred-vw, max) instead of a fixed value, so it scales
       continuously between breakpoints rather than staying desktop-
       sized until a media query suddenly snaps it smaller.
     - Media query breakpoints are in em, the conventional unit for
       breakpoints specifically (avoids a legacy Safari zoom bug that
       px/rem breakpoints have).
     - Exceptions, left in px on purpose because they're rendering
       effects rather than layout/typography: hairline borders
       (0.5px/1px — sub-pixel values only rasterize predictably in
       px), and blur/shadow radii (backdrop-filter, box-shadow) —
       scaling a blur amount with viewport width has no visual
       benefit and just adds rounding noise.
   ========================================================= */

/* wider page container for this case study — fluid side margins
   (up to 9.6875rem / 155px) instead of the site-wide capped 1320px
   .container, so content runs edge-to-edge the way the reference
   does, shrinking smoothly down to a small fixed gutter on mobile. */
.cs-container {
  width: 100%;
  padding-inline: clamp(1.25rem, calc(-0.7979rem + 8.738vw), 9.6875rem);
  padding-bottom: clamp(3rem, calc(2.2718rem + 3.107vw), 6rem);
  margin-inline: auto;
  box-sizing: border-box;
}

.cs-hero {
  /* The meta band's column gutter, hoisted up to the hero so TWO things
     can share one definition: the band itself (see .cs-hero__meta) and
     the hero card, which centres itself over the band's third column and
     needs the gutter to work out where that column actually is. Left on
     .cs-hero__meta alone the card would have to hardcode a guess at the
     grid, and the two would drift apart the moment this value changed. */
  --cs-meta-gap: calc(clamp(2rem, calc(1.4544rem + 2.33vw), 4.5rem) * var(--hero-fit));

  position: relative;
  /* Column flex so the meta band can be pushed to the bottom edge with
     margin-top: auto (see .cs-hero__meta). main.js sets an inline
     min-height equal to the space below the nav, which is what makes all
     three case studies present the SAME first screen regardless of how
     many lines their title takes.

     Before this, --hero-fit could only ever shrink a hero (solveFit
     clamps at Math.min(1, fit)), never stretch one. Faculty of
     Environment's two-line title overflowed the screen, so it got scaled
     down to exactly fill it and looked right; Aira and Ground FX have
     one-line titles, came up naturally short, sat at fit: 1, and left a
     band of empty space at the bottom of the viewport that the jump
     links rode up into. Filling the box and bottom-aligning the meta
     fixes the short case without scaling anything UP, so type stays at
     its designed size on all three. */
  display: flex;
  flex-direction: column;
  /* pulled up close to the nav, matching the digital-media page's
     title position */
  /* every vertical value in this hero is multiplied by --hero-fit, the
     same 0–1 "shrink to fit below the nav" factor the homepage hero uses
     (see .hero for the full mechanism; main.js sets it per hero). This
     hero had NO height-awareness at all before: its own dominant driver
     is the 10–21rem gap above .cs-hero__meta (which .cs-hero__actions
     owned until the jump links moved out to their own section), a purely
     width-driven value, so on a wide-but-short laptop window the title
     wrapped to two lines AND that gap stayed at desktop size, pushing the
     hero's last row a couple of hundred pixels below the fold. Nothing here is min()'d — a
     straight multiply keeps the whole composition proportional, so a
     short window shows the same layout at a smaller size. */
  padding-top: calc(clamp(1rem, calc(0.7573rem + 1.036vw), 2rem) * var(--hero-fit));
  padding-bottom: calc(clamp(4rem, calc(3.2718rem + 3.107vw), 7rem) * var(--hero-fit));
  /* NOT overflow:hidden — the grid below breaks out to full viewport
     width via a negative-margin technique; overflow:hidden here would
     clip that breakout back down to the padded content width. Page-
     level horizontal scroll is still guarded by body{overflow-x:hidden}. */
}

/* every case-study section sits flush against its neighbour by default
   (sibling <section> boxes touch — each section's own padding-block
   only adds room *inside* itself). Add real breathing room at each
   section boundary, including hero → introduction, without touching
   any individual section's own padding. */
.cs-container section + section {
  margin-top: clamp(1.5rem, calc(1.1359rem + 1.553vw), 3rem);
}

/* subtle hairline grid, matching the reference exactly: much fainter
   than the shared .hero__grid's default (rgba(var(--overlay-rgb), 0.4) reads
   as a bold line; the reference is closer to 0.1), and stays visible
   almost all the way down instead of fading out at 55–95% — the
   shared version fades out quickly because the homepage hero is
   short; this one is tall enough that the grid needs to read as
   filling the whole section, right down to where the CTA buttons sit.

   It's also broken out to full VIEWPORT width (not just the padded
   .cs-container content width) using the standard left:50%/-50vw
   full-bleed trick — .cs-container's side padding would otherwise cut
   the grid off well before the actual edge of the page. */
.cs-hero .hero__grid {
  left: 50%;
  right: auto;
  width: 100vw;
  margin-left: -50vw;
  background-image:
    repeating-linear-gradient(
      0deg,
      rgba(var(--overlay-rgb), 0.1) 0px,
      rgba(var(--overlay-rgb), 0.1) 1px,
      transparent 1px,
      transparent 20vw
    ),
    repeating-linear-gradient(
      90deg,
      rgba(var(--overlay-rgb), 0.1) 0px,
      rgba(var(--overlay-rgb), 0.1) 1px,
      transparent 1px,
      transparent 20vw
    );
  mask-image: linear-gradient(to bottom, #000 0%, #000 88%, transparent 100%);
  -webkit-mask-image: linear-gradient(to bottom, #000 0%, #000 88%, transparent 100%);
}

/* =========================================================
   Case-study hero cards
   =========================================================
   Every case study gets one piece of its own work floating behind
   its hero, turning slowly in 3D. Aira's is the app's welcome
   screen; Faculty of Environment's is the ENVoices programs card.
   Everything they have in common lives here — placement, the
   turntable, the spin, the in-view pause — and a per-study modifier
   supplies only what actually differs between them:

     --cs-hero-card-w       how wide the card sits
     --cs-hero-card-radius  corner rounding, for art that isn't
                            already cut to shape

   Adding the third case study's card should be a modifier and two
   <img> tags, nothing more.

   Absolutely positioned, and that is the point rather than a
   convenience: the hero is a flex column whose spacing is tuned
   against --hero-fit, so a card in normal flow would push the title,
   tags and meta band around and change the first screen. Out of flow
   it costs the layout nothing — moving it, resizing it, or deleting
   it leaves every other element exactly where it was.

   z-index 0 puts it behind .cs-hero__top (z-index: 1), so a title
   long enough to reach it simply passes in front. It shares that 0
   with .hero__grid and wins on source order alone (it is written
   after it in the markup), which is what keeps it in front of the
   hairline grid without a z-index arms race.

   pointer-events: none is what makes it inert to the cursor — no
   hover, no tilt-to-pointer like .project-card__media on the
   homepage, and it can never intercept a click meant for the text
   sitting over it. */
.cs-hero__card {
  /* neutral defaults; each modifier overrides what its own art needs */
  --cs-hero-card-w: clamp(9rem, 20vw, 24rem);
  --cs-hero-card-radius: 0px;

  position: absolute;
  /* 40%, not 50%: the hero's bottom fifth belongs to .cs-hero__meta
     (the Role / Timeline / Skills band), and a card centred on the
     whole hero hangs into it. Sitting the card slightly high centres
     it on the title half instead and leaves the band clear. */
  top: 40%;
  /* Centred over the meta band's THIRD column — the one headed "Skills"
     — rather than sitting at some offset from the right edge.

     This is an alignment, not a number. .cs-hero__meta is a
     repeat(3, 1fr) grid across this same box, so its last track — the
     Skills one — is (100% - 2 gutters) / 3 wide, and the calc below
     places the card three quarters of a track in from the right edge.
     That is a quarter-track left of the column's exact centre: dead
     centre sat the art further right than the composition wanted, and
     this still lands the card well inside the column it belongs over.
     Written as (100% - 2 gutters) / 4 because 0.75 x track reduces to
     exactly that.

     Because it is derived from --cs-meta-gap (the band's own gutter,
     hoisted onto .cs-hero for exactly this), the card follows the band
     automatically if that spacing is ever retuned — including through
     --hero-fit, which scales the gutter on short windows.

     Anchoring the card's own CENTRE also fixes something the old
     right-edge offset could not: Aira's card is portrait and ENVoices'
     is square, so at a shared `right` they lined up by their edges and
     their centres landed in different places. Now both sit on the same
     line whatever their shape.

     right: auto is stated rather than left to the initial value on
     purpose: a box with BOTH left and right resolved is stretched
     between them instead of sized by its own width, so this is the
     guard against a future modifier reaching for the right edge. */
  left: calc(100% - (100% - 2 * var(--cs-meta-gap)) / 4);
  right: auto;
  width: var(--cs-hero-card-w);
  /* -50% on BOTH axes now: X centres the card on that column, Y keeps
     the vertical centring the top: 40% above depends on. */
  transform: translate(-50%, -50%);
  z-index: 0;
  pointer-events: none;
  /* the vanishing point for the spin below. It lives here, on the
     wrapper, because an element cannot be its own 3D perspective
     root — the same split .project-card / .project-card__media uses
     on the homepage. Keeping the wrapper's translateY(-50%) here
     also leaves the spinner's transform free to be nothing but
     rotation — the wrapper owns translate(-50%, -50%), so a keyframe
     can never clobber the centring. */
  perspective: 1200px;
}

/* Why each width is a min() of two arms.

   Width is the only size worth setting, because it is the only one
   that does anything: the <img> inside is width: 100% / height: auto,
   so the picture's height falls out of it and a height on the wrapper
   would be ignored.

   So the first arm is the width the composition wants (a share of the
   page, capped so the card can't outgrow the title on an ultrawide
   monitor) and the second is a HEIGHT budget converted into a width
   through that card's own aspect ratio. On a short window the second
   arm wins and the card shrinks to stay clear of the meta band, which
   a purely width-driven value could not do — vw knows nothing about
   how much vertical room is left.

   That conversion is why the two numbers look unrelated. Aira's art
   is 402:882, so a ~50vh budget is 0.4558 x 50vh ~= 23vh of width.
   ENVoices' is square, so the same budget converts 1:1 and its arm
   can be far larger before it binds — correct, because a square is
   much shorter than the portrait card at any given width. */
.cs-hero__card--aira {
  --cs-hero-card-w: min(clamp(7rem, 16vw, 22rem), 23vh);
}

/* square art with hard edges of its own, so unlike Aira's pre-cut PNG
   this one needs its corners put on in CSS */
.cs-hero__card--envoices {
  --cs-hero-card-w: min(clamp(9rem, 20vw, 27rem), 40vh);
  --cs-hero-card-radius: 27px;
}

/* the moving GFX logo: square, hard-edged video on its own navy ground,
   so it takes the ENVoices card's size and corner treatment */
.cs-hero__card--gfx {
  --cs-hero-card-w: min(clamp(9rem, 20vw, 27rem), 40vh);
  --cs-hero-card-radius: 27px;
}

/* the eShop Switch, 1400:598 (~2.34:1) — far wider than it is tall, so
   its height arm converts generously (~40vh of height ~= 94vh of width)
   and the width arm is what binds almost everywhere. Kept narrower than
   its share of the page would allow so the long two-line title doesn't
   run too far across it. Pre-cut PNG-style art: radius stays 0. */
.cs-hero__card--eshop {
  --cs-hero-card-w: min(clamp(12rem, 30vw, 42rem), 90vh);
}

/* The turntable. Its own element rather than the <img>, because a
   full 360 has to show something on the way round: half of every
   revolution has the card's BACK toward the viewer, and an image on
   its own has no back — it would either vanish (backface-visibility:
   hidden) or show itself mirrored, reading backwards.

   So this holds two copies of the picture, one facing each way, and
   spins the pair as one rigid object. preserve-3d is what keeps the
   two faces in the same 3D space as they turn; without it the
   browser flattens them into the parent's plane and the back face's
   rotateY(180deg) collapses to nothing. */
.cs-hero__card-spin {
  position: relative;
  transform-style: preserve-3d;
  animation: cs-hero-card-spin 7.5s linear infinite;
  animation-play-state: paused;
}

/* JS (see "in-view animations" in main.js) adds this only while the
   card is on screen AND the tab is visible; scrolled past the hero,
   the spin costs nothing. */
.cs-hero__card.is-running .cs-hero__card-spin {
  animation-play-state: running;
  will-change: transform;
}

.cs-hero__card-face {
  display: block;
  width: 100%;
  height: auto;
  /* 0 for art already cut to its own shape with transparent corners
     (Aira's PNG), a real radius for art that arrives as a hard-edged
     rectangle (the ENVoices GIF). border-radius clips a replaced
     element's own painting, so no overflow: hidden is needed here —
     and adding one would flatten the 3D. */
  border-radius: var(--cs-hero-card-radius);
  /* each face is drawn only while it actually points at the viewer,
     so the two never bleed through one another mid-turn */
  backface-visibility: hidden;
  -webkit-backface-visibility: hidden;
}

/* the second copy, turned to face the other way. Absolute so it
   stacks on the first instead of sitting under it — the front face
   is the one in flow, and it alone gives .cs-hero__card-spin its
   height. Same src, so this costs no extra download. */
.cs-hero__card-face--back {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: auto;
  transform: rotateY(180deg);
}

/* The turn itself: a fixed clockwise tilt that never changes, with a
   continuous rotateY carrying it all the way round. rotateY (not
   rotate/Z) is what makes the near edge travel toward the viewer and
   the far edge away — the card turns THROUGH the screen plane rather
   than spinning flat against it, which is what reads as 3D. It also
   means the card passes edge-on twice a revolution, thinning to a
   sliver at 90 and 270; that is the geometry being honest, not a
   glitch.

   linear, not ease-in-out: on a loop that ends where it began, any
   easing puts a visible hesitation at the seam. Constant speed is
   the only timing that hides the restart.

   The rotate(7deg) is repeated in both stops rather than being set
   once on the element: a transform is a single property, so a
   keyframe that named only rotateY would drop the tilt for the whole
   animation. */
@keyframes cs-hero-card-spin {
  from {
    transform: rotate(7deg) rotateY(0deg);
  }
  to {
    transform: rotate(7deg) rotateY(360deg);
  }
}

/* Reduced motion: keep the card and its tilt, drop the movement. */
@media (prefers-reduced-motion: reduce) {
  .cs-hero__card-spin,
  .cs-hero__card.is-running .cs-hero__card-spin {
    animation: none;
    transform: rotate(7deg);
    will-change: auto;
  }

  /* with nothing turning, the back copy is dead weight sitting
     behind the front one — and on a browser that ignores
     backface-visibility it could show through */
  .cs-hero__card-face--back {
    display: none;
  }
}

/* Was a wrapping ROW: title-col on the left, .cs-hero__meta as a narrow
   vertical stack pinned to the right, space-between pushing them apart.
   Now a column, so the meta reads as a full-width band of three across
   underneath the title rather than a sidebar beside it. The horizontal
   gap this used to need is gone with it — .cs-hero__meta owns its own
   margin-top now, so the space between the two blocks is one explicit
   value instead of a flex gap that also had to serve the row direction. */
.cs-hero__top {
  position: relative;
  z-index: 1;
  display: flex;
  flex-direction: column;
  align-items: stretch;
  /* takes whatever height .cs-hero's min-height leaves over, so the
     margin-top: auto below has room to work against */
  flex: 1;
  margin-top: calc(clamp(0.5rem, calc(0.3786rem + 0.518vw), 1rem) * var(--hero-fit));
}

/* wraps the title + tags together as .cs-hero__top's first flex item
   (.cs-hero__meta is the second) — no layout properties of its own
   needed beyond that; h1 and .cs-hero__tags already stack in normal
   block flow. This is what makes .cs-hero__tags' margin-top measure
   from the TITLE's own bottom edge specifically, instead of from
   whichever of title/meta happens to be taller (see .cs-hero__tags'
   own comment for why that was the actual bug). */
.cs-hero__title-col {
  min-width: 0;
  /* The FLOOR on the gap between the title block and the meta band. The
     band is bottom-aligned, so on a normal window the real gap is
     whatever the viewport leaves over — this only binds when the window
     is too short for that to be generous, and stops the band crowding
     the tags. */
  margin-bottom: calc(clamp(3rem, calc(2.2718rem + 3.107vw), 6rem) * var(--hero-fit));
}

/* same hero font as the "Jaydon Wang" homepage hero title */
.cs-hero__title {
  font-family: var(--font-hero);
  /* see .hero__title for why — FS Mondwest's own space glyph is
     essentially zero-width */
  word-spacing: 0.18em;
  font-weight: 400;
  /* --text-title, same as every other member of this style (JAYDON WANG,
     Digital Museum, About me, the footer) — the title class is one size
     everywhere, not scoped per element — times --hero-fit, exactly as
     .hero__title takes it, so the two pages' titles shrink by the same
     proportion on the same window instead of one holding full size while
     the other gives way */
  font-size: calc(var(--text-title) * var(--font-hero-scale) * var(--hero-fit));
  /* was 1.05 under PP Mondwest — see .hero__title for why FS Mondwest
     needs its own ratio (0.76) to read the same */
  line-height: 0.76;
  letter-spacing: normal;
  max-width: 14ch;
  /* same white glow as JAYDON WANG (see .hero__intro .hero__title) */
  filter: drop-shadow(0 0 17px var(--hero-glow));
}

.cs-hero__tags {
  position: relative;
  z-index: 1;
  display: flex;
  gap: clamp(0.5rem, calc(0.4393rem + 0.259vw), 0.75rem);
  flex-wrap: wrap;
  /* was clamp(1rem, ..., 1.75rem), and this element used to sit AFTER
     the whole .cs-hero__top row (title + meta) rather than nested under
     the title specifically (see .cs-hero__title-col) — on Aira and
     Ground FX, whose titles are one short line, .cs-hero__meta's 3
     stacked items ended up taller than the title, so the gap under the
     title read as huge even though the gap under the (taller) meta
     column was a normal, tight 28px. Now that this is nested inside
     .cs-hero__title-col right after the title, this margin-top measures
     from the title's own bottom edge specifically, unaffected by
     whatever height .cs-hero__meta happens to be. Bumped from the old
     28px cap to the site's established "small content gap" tier at the
     same time (same formula as .cs-validation-toggle's own
     default) for a clearly deliberate separation. */
  /* was clamp(1.5rem -> 2.25rem). Halved on request — the tags read as a
     caption belonging to the title, and the old value sat them far enough
     below it to look like a separate row. */
  margin-top: calc(clamp(0.75rem, calc(0.659rem + 0.388vw), 1.125rem) * var(--hero-fit));
}

/* same treatment as the homepage's .tag — flat fill/stroke, no glass
   (it was pulled out of the shared glass-formula group above), same
   0.938rem/1.875rem padding as .btn. color: #fff added explicitly since
   that used to come from the shared group too. */
/* Same treatment as .tag on the homepage/Play pages, and for the same
   reason — see that rule's comment for the full argument. These are
   <span>s carrying hero metadata ("Design jam", "2026", "UX/UI",
   "Healthcare"), not controls, so they carry none of a button's
   hit-target obligation, and they were running .btn's 0.938rem/1.875rem
   padding with --text-caption's ×1.375 ramp above 1440px: ~51px tall at
   1920, competing with the case-study title right above them. */
.cs-tag {
  /* identical schedule to .tag: 11px floor, 13px at 1920px */
  font-size: clamp(0.688rem, calc(0.552rem + 0.217vw), 0.813rem);
  font-weight: 500; /* unchanged — .cs-tag has always sat a step lighter than .tag's 600 */
  padding: 0.46em 0.92em;
  /* 0.625rem/10px → 6px, the same ×0.667 reduction .tag's radius took
     (12px → 8px). Keeps these a touch tighter-cornered than .tag, which
     is the relationship the two already had. */
  border-radius: 0.375rem;
  background: var(--chip-bg);
  border: 1px solid var(--chip-border);
  color: var(--ink);
}

/* Three equal columns spanning the hero's full width, not a fixed-width
   sidebar column. The old min-width clamp (11–13.75rem) existed to stop
   the sidebar collapsing next to the title; as a full-width grid there
   is nothing to reserve width against, so 1fr thirds replace it.

   This band is now the hero's last row: the jump links that used to sit
   beneath it have moved out to .cs-jump, so the 10–21rem gap they needed
   passes to this instead (see margin-top below). The hero ends on the
   Role/Timeline/Skills line rather than on a row of buttons. */
.cs-hero__meta {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  /* start, not the default stretch: "Skills" runs to three lines while
     "Timeline" runs to one, and stretching would leave each column's
     content vertically centred against a row sized by the tallest of
     them. Top alignment is what puts the three labels on one line. */
  align-items: start;
  /* Gutter widened from 1.5–3rem. With the columns at 1fr thirds and each
     value capped at 32ch, the old gutter was narrower than the ragged
     gap left inside a short column ("4 months"), so the eye couldn't
     tell a column break from a line break — three groups read as one
     drifting row. At 2–4.5rem the gutter is reliably the largest
     horizontal gap in the band, which is what makes proximity do the
     grouping work. */
  gap: var(--cs-meta-gap);
  min-width: 0;
  /* Bottom-aligned rather than a fixed 10–21rem push. A hard number
     could only ever be correct at one viewport height: it put the band
     in the right place on a two-line title and left it stranded
     mid-screen on a one-line one. auto absorbs the leftover height
     instead, so the band lands on the same line in every case study and
     the jump links below it stay off the first screen by construction
     rather than by luck. The minimum separation lives on
     .cs-hero__title-col's margin-bottom, since auto cannot carry one. */
  margin-top: auto;
}

/* Deliberately the exact same type as .project-card__body h3 (the "Aira"
   title on a homepage case-study card): --font-display at --text-h2,
   weight 400, no tracking adjustment. It always shared the family and the
   size — there was never a separate font for these labels — but it
   carried font-weight: 600 and letter-spacing: -0.01em of its own, which
   is what made it read as a heavier, tighter relative of the card title
   rather than the same style. Both dropped, so a project card and a case
   study now name their subject in one voice. Keep these two rules in
   step: if the card title's type changes, this follows. */
.cs-hero__meta-item span {
  display: block;
  font-family: var(--font-display);
  font-weight: 400;
  /* --text-subhead, not --text-h2: the same size as a card title on a
     desktop, but it keeps ramping down below ~375px instead of flattening
     at 28px, which is what left these labels nearly as large as the case
     study's own title on a phone. See the token for the measurements. */
  font-size: var(--text-subhead);
  /* was a flat 0.5rem/8px. A label of ~43px sitting 8px above its value
     binds them tightly, but the pair then sat closer to the NEXT
     column's text than its own parts were to each other in some
     wrappings. Scaled with the label instead of fixed, so the label and
     its value stay one visual unit at every size: 0.28em is ~12px at
     1440 and holds that ratio as --text-h2 moves. */
  margin-bottom: 0.28em;
}

.cs-hero__meta-item p {
  /* --copy-ink, the running-prose grey (see its own :root comment) —
     the same colour .cs-para already used, so every paragraph on the
     site that is not inside a button or a callout now reads as one
     voice. */
  color: var(--copy-ink);
  /* was --text-caption (11px at 1440). Against a --text-h2 label of
     ~43px that is a 3.9:1 step; the reference reads closer to 2.9:1, and
     as a full-width band rather than a cramped sidebar this text has the
     room for it. 12px floor / ~14px at 1440 / ~15.8px at 1920. Its own
     clamp rather than --text-body because --text-body reaches 16px at
     1440 already, which overshoots the reference. */
  font-size: clamp(0.75rem, calc(0.542rem + 0.37vw), 1rem);
  line-height: 1.5em;
  max-width: 32ch;
}

/* Was .cs-hero__actions, living inside .cs-hero. These are jump links
   into the page rather than part of the hero's statement, and holding
   them in the hero forced it to carry a 10–21rem block of empty space
   purely to seat them at the bottom — space the meta band now occupies.

   As its own section it needs no margin of its own: the global
   `section { padding-block: 64px }` plus `.cs-container section + section`
   already give it the same separation every other section boundary on
   the page gets, so the jump row is spaced by the page's own rhythm
   instead of a bespoke number. Dropping the --hero-fit multiplier with
   it is required, not incidental — that variable is set on .cs-hero, so
   out here the calc() would reference an undefined property and the
   whole declaration would be thrown out. */
.cs-jump__links {
  position: relative;
  z-index: 1;
  display: flex;
  justify-content: center;
  gap: clamp(1.5rem, calc(1.1359rem + 1.553vw), 3rem);
  flex-wrap: wrap;
}

.cs-heading {
  font-family: var(--font-display);
  /* 400, a real cut - the Google Fonts request in every page's head asks
     for Inter 400;500;600;700, so this is the shipped Regular. At the
     size below the heading no longer needs Bold to hold the top of the
     section; the scale does that work, and Regular keeps it from
     competing with the body copy's own weight. */
  font-weight: 400;
  /* The whole previous ramp divided by 1.5 - floor, slope arm and
     ceiling alike - so the curve keeps its shape and only its scale
     changes: 85px at the 1440px ceiling against the old 128px, 48px at
     768px, floored at 29px.

     Shrinking only relieves the constraint that set the old ceiling,
     which was the column and not the viewport. Measured all 22 headings
     on the site against .cs-layout__body at the width the contents rail
     leaves it at a 1440px window (883px): at 128px they broke 14 one-
     line / 7 two-line / 1 three-line, and at 85px they break 18 / 4 / 0.
     "Marketing: Social media campaigns" is the longest and the one that
     was three lines; it is two now. So four headings still wrap, but
     none worse than the old ceiling already wrapped them. */
  font-size: clamp(1.8333rem, calc(5.333px + 5.5553vw), 5.3333rem);
  letter-spacing: -0.01em;
  /* 12-24px. A divider reads as "attached" to its heading when the gap
     is a small fraction of the heading's size, and at 40px against a
     64px heading it was not: the rule floated between the title and the
     body rather than underlining the title. Halved, so the heading and
     its rule read as one unit - which is what lets the (larger) gap
     below the rule do the actual separating. See .cs-divider. */
  margin-bottom: clamp(0.75rem, calc(0.486rem + 1.1268vw), 1.5rem);
}

/* "rise" scroll-in, matching the section headings on
   aileens-beautiful-site.webflow.io/roblox — a pure upward slide with
   no fade (unlike the site's generic .reveal, which fades + rises
   1.5rem), using a bigger travel distance so it reads as the heading
   rising into place rather than a subtle nudge — clamped so that
   distance shrinks on short/narrow viewports instead of overshooting.
   Reuses the same .reveal/.is-visible IntersectionObserver hook
   already wired up in main.js — no JS changes needed, just an
   override for this class. */
.cs-heading.reveal {
  opacity: 1;
  transform: translateY(clamp(2.5rem, calc(1.5898rem + 3.883vw), 6.25rem));
  transition: transform 0.8s cubic-bezier(0.16, 1, 0.3, 1);
}

.cs-heading.reveal.is-visible {
  transform: translateY(0);
}

/* project cards fade in place — no rise — since the image's own
   flip-in (js/main.js, .project-card__media.is-flipping-in above)
   already carries the entrance motion; stacking the generic rise on
   top of that read as two competing animations on the same card. Same
   .reveal/.is-visible hook, same override pattern as .cs-heading.reveal
   just above. */
.project-card.reveal {
  transform: none;
}

/* The Play page's motion design tiles take the project cards' flip-in:
   rotateY(-15deg) -> 0 over 1.1s, started from the same reveal callback
   (startCardFlip in main.js), so each tile turns in as IT scrolls into
   view rather than the grid arriving as one block. The same fade-only
   override as .project-card.reveal applies, so the flip is the only
   motion. Entrance only: no hover tilt, since a tile becomes a YouTube
   player on click and a tilted iframe would freeze at whatever angle the
   cursor left it (mouse events stop reaching the page over an iframe).

   Cheap: one transform transition per tile, once, which the compositor
   runs without layout or paint. The class is dropped when it lands, so
   nothing is left promoted to its own layer afterwards. */
.dm-media-item {
  perspective: 900px;
}

.dm-media-item.reveal {
  transform: none;
}

.dm-flip {
  backface-visibility: hidden;
  -webkit-backface-visibility: hidden;
}

.dm-flip.is-flipping-in,
.dm-flip.is-flipped-in {
  transition: transform 1.1s ease-in-out;
  will-change: transform;
}

.dm-flip.is-flipping-in {
  transform: rotateY(-15deg);
}

.dm-flip.is-flipped-in {
  transform: rotateY(0deg);
}

@media (prefers-reduced-motion: reduce) {
  .dm-flip.is-flipping-in,
  .dm-flip.is-flipped-in {
    transition: none;
    transform: none;
  }
}

.project-card.reveal.is-visible {
  transform: none;
}

/* .filters no longer has a custom reveal override — it was built around
   the old position: sticky sidebar's translateY(-50%) centering, which
   no longer applies now that it's a plain in-flow wrapping row (see
   .filters). It uses the site's generic .reveal fade-and-rise instead. */

/* hairline divider under every section title */
/* 24-40px, down from 40-85px.

   This was the same 40-85px clamp .cs-split__body used before it was
   tightened, and it had the same problem: at 85px the heading block and
   the content it introduces read as two unrelated things stacked up,
   with the section's own 64px padding no longer dominating the spacing
   inside it. On a 2560px monitor the run from heading to first line of
   copy was 40 + 1 + 85 = 126px of mostly nothing.

   The two gaps are deliberately unequal - 24px above the rule, 40px
   below it. That ordering is the whole mechanism: the rule sits nearer
   the heading than the body, so it reads as the heading's underline and
   the larger gap beneath it becomes the real boundary. Equal gaps would
   leave the rule floating between two blocks, belonging to neither. */
.cs-divider {
  height: 1px;
  width: 100%;
  background: rgba(var(--overlay-rgb), 0.4);
  margin-bottom: clamp(1.5rem, calc(1.148rem + 1.5023vw), 2.5rem);
}

/* case-study body paragraphs now carry .hero__desc in the markup
   (class="hero__desc cs-para") so they're literally the same class as
   the homepage's "Enjoys working in product..." paragraph, not just a
   numeric match — this rule is that context's override, same pattern as
   .dm-hero__desc elsewhere in this file:
   - font-size/line-height are re-declared flat so this copy is never
     touched by --hero-fit, which .hero__desc's own font-size multiplies
     by. --hero-fit used to exist ONLY inside .hero, which made the var()
     invalid out here and silently dropped the whole declaration back to
     font-size:inherit — it now has a :root fallback of 1 specifically so
     that can't happen to any of .hero__desc's other contexts, but these
     paragraphs re-declare anyway: they sit outside any hero and should
     hold their own size regardless of what a hero elsewhere is doing.
   - margin:0 cancels .hero__desc's own margin-top and margin-inline:auto.
     The auto margins matter most: these paragraphs are flex items inside
     .cs-split__body (align-items:flex-start), and an auto margin on a
     flex item's cross axis wins over align-items, so left uncancelled it
     would re-center every paragraph instead of leaving it flush left.
   Other case-study elements sized "same as .cs-para" (callouts, captions,
   takeaway cards, etc.) keep their own original clamp() value rather than
   following this one, since those are buttons/captions/headings, not
   body paragraphs. */
.cs-para {
  margin: 0;
  font-size: var(--text-body);
  line-height: 1.5em;
  color: var(--copy-ink);
  max-width: 62ch;
}

/* callout / long-copy CTA pills — "The creative content...", "Visit the
   website here.", "Feel free to scroll through", "Visit the instagram
   page here" — glass formula lives in the shared rule above now; this
   is just this component's own bigger padding and ~70px radius. Text
   size matches .cs-para so callout copy reads the same as body copy. */
.cs-callout,
.cs-cta {
  font-size: var(--text-body);
  line-height: 1.55;
}

/* The two now split on radius AND padding. They only ever shared a rule
   because they are both rounded boxes of text; they are not the same kind
   of object, and one set of values could not be right for both.

   .cs-cta is a pill BUTTON ("Visit the website here") - it carries
   .btn/.btn-outline, its label is a few words, and wide end caps are what
   make it read as a button. It keeps exactly what it had. */
.cs-cta {
  border-radius: 3.125rem;
  padding: clamp(2rem, calc(1.4994rem + 2.136vw), 4.0625rem) clamp(1.5rem, calc(0.1044rem + 5.955vw), 7.25rem);
}

/* .cs-callout is a short block of COPY, and it was inheriting a button's
   padding: up to 65px top and bottom and 116px left and right, on a box
   holding one or two sentences. That is more frame than content - it
   pushed the text into a narrow ribbon down the middle, and made a note
   that belongs beside a paragraph outweigh the paragraph itself.

   24-44px here, every endpoint on the 4pt scale (24/32 block, 32/44
   inline), rising again above 1440 - see the media query below.

   This is the point where the block padding passes the 24-32px gap that
   separates the callout from the paragraph above it (.cs-split__body).
   Inner spacing larger than outer normally dissolves a box - the eye
   stops being able to tell where it ends - and that ordering is what
   held this to 28px through the previous two passes.

   It is safe to cross HERE, and only here, because the callout is not a
   borderless block: it has a solid --chip-bg fill against a near-black
   page, and a hard fill edge defines the boundary far more strongly than
   any spacing ratio can. The ratio only has to do that work when there
   is nothing else to do it. What stays true is the corner radius floor -
   the inline padding still has to clear the 16px radius so the curve
   never crowds the first character, and 32px clears it comfortably.

   Wider than tall throughout: line-height already supplies vertical air
   that the sides never get, so equal padding on four sides reads as
   horizontally pinched.

   text-align: center is about that padding LOOKING equal, which it did
   not, even though the shorthand below has always set both sides to the
   same value. The copy is left-aligned and it wraps, so the longest line
   stops wherever the break falls and the leftover rag sits on the right
   as apparent padding: measured at 2560, a 60px inline pad rendered as
   60px of space on the left against 91px on the right for "The circled
   area needed to change...", and 105px for "The creative content of my
   work...". Centring is what actually equalises the two, because it
   splits each line's slack instead of dumping all of it on one side.

   Not solvable by shrink-wrapping the box to the text instead: a text
   node's max-content width is the whole string on ONE line, so
   width: fit-content resolves to the full column here and changes
   nothing. CSS has no "width of the longest line after wrapping".

   One callout already carried style="text-align:center" inline ("Feel
   free to scroll through") - that inline rule is now redundant but is
   left in place because it also sets width, and this rule agrees with
   it rather than fighting it. */
.cs-callout {
  text-align: center;
  border-radius: 1rem;
  padding: clamp(1.5rem, calc(1.324rem + 0.7512vw), 2rem) clamp(2rem, calc(1.736rem + 1.1268vw), 2.75rem);
}

/* Past 1440px the callout was quietly getting tighter, not staying put.
   --text-body has a second tier above this breakpoint (see the :root
   media query) and grows 16px -> 22px, hitting its ceiling at 1920px -
   but the padding above tops out AT 1440px, so the frame stopped while
   the text inside kept growing. Measured as a ratio to its own type, the
   inline padding fell from 2.25x at 1440 to 1.64x at 2560: the same box
   that reads generously on a laptop reads pinched on a large monitor.

   So the padding picks up the type's schedule exactly - grows from this
   breakpoint, reaches its ceiling at 1920px, flat beyond - so it never
   stalls while the type inside it is still growing. No seam at 1440:
   32/44px is exactly where the rule above ends.

   Both arms now move (32 -> 40 block, 44 -> 60 inline). The block arm
   was previously pinned at 30px by .cs-split__body's 32px gap; see the
   base rule above for why the callout's solid fill makes it safe to pass
   that gap, where a borderless block could not. */
@media (min-width: 1440px) {
  .cs-callout {
    padding: clamp(2rem, calc(0.5rem + 1.6667vw), 2.5rem) clamp(2.75rem, calc(-0.25rem + 3.3333vw), 3.75rem);
  }
}

/* .cs-cta also carries .btn-outline, whose :hover state adds a faint
   fill — override it so it stays fully transparent in every state.
   .cs-callout dropped from this rule — it's a flat #171717 fill now
   (see its own rule above), not transparent, so there's nothing to
   protect it from here; it never carried .btn-outline anyway. */
.cs-cta:hover {
  background: transparent;
}

.cs-caption {
  font-size: var(--text-caption);
  line-height: 1.5em;
  /* --copy-ink, the running-prose grey (see its own :root comment) —
     the same colour .cs-para already used, so every paragraph on the
     site that is not inside a button or a callout now reads as one
     voice. */
  color: var(--copy-ink);
  margin-top: 0.875rem;
  max-width: 60ch;
}

.cs-caption--italic {
  font-style: italic;
}

/* The caption's own heading — "Brand guidelines", "WCMIS", "Student and
   advisor approval". display: block makes it its own line, so it reads
   as a title rather than a bolded first phrase, and it takes --ink while
   .cs-caption itself takes --copy-ink: that colour step is what
   separates the two, the same way .hero__desc strong is set against its
   own paragraph. Without it the heading inherits the paragraph grey and
   weight alone has to carry the distinction. */
.cs-caption strong {
  display: block;
  font-weight: 700;
  color: var(--ink);
  margin-bottom: 0.375rem;
}

/* Inline citations inside running prose (e.g. the Steam sources in the
   eShop case study's research section). The global `a` reset strips
   text-decoration — right for the nav, cards and buttons, where the box
   itself signals the link, but in a paragraph it leaves a citation
   indistinguishable from the sentence around it. Underline restored,
   colour deliberately not changed: --ink alone would out-shout the
   paragraph, and a blue would be the only hue on the page. The offset
   keeps the rule off the descenders. Scoped to prose (.cs-para and
   .cs-caption) so no button or card link is caught by it. */
.cs-para a,
.cs-caption a {
  text-decoration: underline;
  text-decoration-thickness: 1px;
  text-underline-offset: 0.2em;
  transition: color 0.2s ease;
}

.cs-para a:hover,
.cs-caption a:hover {
  color: var(--ink);
}

/* guideline captions sit noticeably below their image, not flush */
.cs-guideline-item .cs-caption,
.cs-marketing-grid .cs-caption {
  margin-top: 2rem;
  /* match the size of the other paragraph text (.cs-para) instead of
     the smaller default caption size */
  font-size: var(--text-body);
}

/* square image placeholders — Introduction (~1000px), Highlights
   (~1094px), Guidelines (~711px) are all authored as 1:1 source
   images, so one aspect-ratio utility covers all three; each
   instance is sized by its grid column instead of a fixed width so
   it never overflows the (much narrower) page container */
.cs-square {
  width: 100%;
  aspect-ratio: 1 / 1;
  border-radius: var(--radius-md);
  background: #d9d9d9;
  overflow: hidden;
}

.cs-square img,
.cs-square video {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

/* full-width media block for source images that don't crop well to
   .cs-square's fixed 1:1 (or --wide's fixed 1129:711) ratios — a wide
   document scan, a site-map diagram, a sketch sheet. No aspect-ratio is
   forced here; the image keeps its own natural proportions and the box
   just follows, so nothing gets stretched or cropped into place. */
.cs-wide-media {
  width: 100%;
  border-radius: var(--radius-md);
  background: #d9d9d9;
  overflow: hidden;
}

.cs-wide-media img,
.cs-wide-media video {
  width: 100%;
  height: auto;
  display: block;
}

/* a .cs-wide-media standing on its own under a split, not inside one */
.cs-wide-media--spaced {
  margin-top: clamp(1.5rem, 3vw, 2.25rem);
}

.cs-wide-media--spaced + .cs-caption {
  margin-top: 0.875rem;
}

/* Several images in one row at a shared height, none of them cropped.
   Each child carries its image's width/height ratio as --ar, and a flex
   basis of 0 grown by that ratio makes every child's width proportional
   to it, which at width: 100% / height: auto lands them all on the same
   height. Unlike .cs-highlights-grid, mixed portrait and square sources
   can sit together without either being forced into the other's shape. */
.cs-ratio-row {
  display: flex;
  align-items: flex-start;
  gap: clamp(1rem, calc(0.818rem + 0.777vw), 1.75rem);
}

.cs-ratio-row > * {
  flex: var(--ar, 1) 1 0;
  min-width: 0;
  border-radius: var(--radius-md);
  overflow: hidden;
}

.cs-ratio-row img {
  display: block;
  width: 100%;
  height: auto;
}

/* Marks a media slot whose artwork isn't in the repo yet, so a layout can
   be reviewed before the images land. It fills the box with the same
   #d9d9d9 that .cs-square and .cs-wide-media already sit on while their
   image loads, so an empty slot reads as a reserved space rather than as
   a hole in the page.

   The second rule is only for .cs-wide-media, which deliberately forces
   no aspect-ratio of its own (the image's proportions are what give the
   box its height) and would otherwise collapse to zero while empty.
   Every .cs-square variant already carries a ratio and needs none of it.

   Delete the class from the element when the real image goes in —
   nothing else about the slot has to change. */
.cs-media-placeholder {
  background: #d9d9d9;
}

.cs-wide-media.cs-media-placeholder {
  aspect-ratio: 16 / 9;
}

/* small heading inside a .cs-split__body (e.g. "Clarity", "Understanding")
   labeling which part of a multi-part section a block belongs to — real
   type hierarchy instead of a small pill/tag component, since a tag reads
   as metadata (like the hero's role/timeline chips) rather than a section
   label sitting right above a full callout. Spacing from the content
   below comes for free from .cs-split__body's own flex gap. */
.cs-subheading {
  font-family: var(--font-display);
  font-weight: 400;
  /* --text-subhead, not --text-h2 — on a phone the latter floors at 28px
     against .cs-heading's 29px, so a subheading and the section title
     above it came out the same size. See the token. */
  font-size: var(--text-subhead);
  letter-spacing: -0.01em;
}

/* A .cs-subheading standing directly in a section rather than inside a
   .cs-split__body. Every subheading on the site used to live in a split,
   where that flex container's gap did the spacing — which is why the
   component itself carries no margin at all. Out here it has no such
   parent and .cs-para is margin: 0, so the heading sat flush on top of
   its own paragraph.

   Below it takes .cs-split__body's gap, so a subheading sits exactly as
   far from its paragraph as it does inside a split. */
.cs-layout__body > section > .cs-subheading {
  margin-bottom: clamp(1.5rem, calc(1.3634rem + 0.5825vw), 2rem);
}

/* A subheading that OPENS a section gets no margin here on purpose. The
   distance from the heading and its rule down to the first block is
   .cs-divider's own margin-bottom, and every section on every case study
   uses it — Aira's "Pain points" divider sits exactly that far above its
   first paragraph, Ground FX's above its first .cs-split. A section that
   happens to open with a subheading (or a .cs-lede) is the same moment
   in the page and has to measure the same, or this one case study starts
   its sections lower than the other three.

   Only a subheading arriving PARTWAY DOWN a section takes a margin: it
   is a break inside the section rather than the top of one, and there is
   no divider above it doing the spacing. That is the rule below.

   It is one of the few gaps on the site that deliberately does not top
   out at 1440px — every other clamp reaches its ceiling there and then
   flat-lines, which left a 2560px window with a 1440px window's spacing
   inside a body column a third wider, exactly where these breaks stopped
   reading as breaks. So it keeps climbing: 160px at 1440, 224px at 2560,
   with the floor unchanged below.

   Margin, not padding: it collapses with the previous sibling's
   margin-bottom to whichever is larger, so the gap is exactly this. */
.cs-layout__body > section > :not(.cs-divider) + .cs-subheading {
  margin-top: clamp(6rem, calc(4.857rem + 5.714vw), 14rem);
}

/* ...unless what it follows is the section's own opening paragraph. The
   rule above reads "not straight after the divider" as "partway down the
   section", which held while every section went divider -> subheading or
   divider -> block. A section that opens with a line of framing prose
   before its first subheading breaks that assumption: the subheading is
   still the top of the section's content, so taking the full break gap
   left a chasm between the intro and the first thing it introduces.

   The gap here is exactly twice .cs-subheading's own margin-bottom, so
   the heading sits twice as far from the paragraph above it as from the
   one below, and reads as attached to what follows. */
.cs-layout__body > section > .cs-divider + .cs-para + .cs-subheading {
  margin-top: clamp(3rem, calc(2.7268rem + 1.165vw), 4rem);
}

/* Marketing's 3 video tiles — a thumbnail with a play-button overlay
   that swaps itself for a real YouTube iframe on click (see main.js).
   Nothing loads until it's actually clicked, so it stays as light as
   a plain thumbnail image until then, but playback happens inline —
   no new tab, no leaving the page. */
.cs-video-tile {
  position: relative;
  /* portrait, not the shared 1:1 .cs-square ratio — aspect-ratio takes
     a unitless ratio, not a length, so "711 x 1206" needs no px/rem
     conversion at all; it's already the "responsive unit" here, same
     as every other image ratio on this page */
  aspect-ratio: 711 / 1206;
}

/* the clickable thumbnail button — reset to a plain, full-size block
   so it looks identical to the old linked-thumbnail version */
.cs-video-tile__trigger {
  display: block;
  width: 100%;
  height: 100%;
  padding: 0;
  border: 0;
  background: none;
  cursor: pointer;
  font: inherit;
  color: inherit;
}

/* the embed swapped in on click — fills the tile exactly like the
   thumbnail image did */
.cs-video-tile iframe {
  display: block;
  width: 100%;
  height: 100%;
  border: 0;
}

/* YouTube auto-generates thumbnails for vertical/Shorts-style videos
   on a 16:9 canvas with a blurred, zoomed duplicate of the frame
   filling the left/right margins around the real (portrait) content.
   object-fit: cover already crops most of that away, but a sliver of
   the blurred fill still peeks in at the edges — this nudges the crop
   in further so only the real content shows. */
.cs-video-tile img {
  transform: scale(1.22);
  transform-origin: center;
}

/* tiles whose thumbnail is our own artwork rather than a YouTube frame:
   there is no blurred fill to crop, so the zoom above only cut into the
   picture */
.cs-video-tile--own-thumb img {
  transform: none;
}

/* a square post in this portrait tile: shown whole, letterboxed on the
   tile's --tile-fill (set to the artwork's own ground colour) rather than
   losing its headline off both sides to a cover crop */
.cs-video-tile--contain .cs-video-tile__trigger {
  background: var(--tile-fill, #000);
}

.cs-video-tile--contain img {
  object-fit: contain;
}
.cs-video-tile__play {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 4rem;
  height: 4rem;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(0, 0, 0, 0.55);
  transition: background 0.2s ease, transform 0.2s ease;
}

.cs-video-tile:hover .cs-video-tile__play {
  background: rgba(0, 0, 0, 0.75);
  transform: translate(-50%, -50%) scale(1.08);
}

/* Process's Final prototype / Rough draft box — same footprint as a
   .cs-square (fixed, doesn't grow to fit the image), but the full,
   much-taller screenshot scrolls inside it instead of being cropped.
   Two stacked panels (not a single swapped <img src>) so each one
   keeps its own scroll position when you switch back and forth. */
.cs-scroll-frame {
  position: relative;
  width: 100%;
  aspect-ratio: 1 / 1;
  border-radius: var(--radius-md);
  overflow: hidden;
}

/* The 1:1 above is right for the Faculty of Environment's prototypes,
   which are tall full-page captures the reader scrolls down inside the
   frame. The eShop case study's two game-detail options are single
   landscape screens instead: in a square frame each would paint across
   the top 58% and leave a dead band under it with nothing to scroll to.
   This takes the captures' own 1920:1115 so a panel fills the frame
   exactly, and the toggle above it behaves the same either way. */
.cs-scroll-frame--landscape {
  aspect-ratio: 1920 / 1115;
}

.cs-scroll-panel {
  position: absolute;
  inset: 0;
  overflow-y: auto;
  overscroll-behavior: contain;
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
}

.cs-scroll-panel.is-active {
  opacity: 1;
  visibility: visible;
  pointer-events: auto;
}

.cs-scroll-panel img {
  display: block;
  width: 100%;
  height: auto;
}

.cs-banner {
  width: 100%;
  aspect-ratio: 21 / 6;
  border-radius: var(--radius-md);
  background: #d9d9d9;
  overflow: hidden;
}

.cs-banner img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* Highlights' first content block — a screen-recording of the live
   ENVoices site. Sized to the video's own 16:9 native aspect ratio
   (not the banner's 21:6) so the full frame is always visible with
   no cropping and no letterbox bars. */
.cs-video {
  width: 100%;
  aspect-ratio: 16 / 9;
  border-radius: var(--radius-md);
  overflow: hidden;
  background: #000;
}

.cs-video video {
  width: 100%;
  height: 100%;
  object-fit: contain;
  display: block;
}

/* full-width pill CTAs — "Visit the website here.", "Visit the
   instagram page here" — bigger than the standard .btn-sm */
.cs-cta {
  display: flex;
  width: 100%;
  justify-content: center;
  margin-bottom: 1.75rem;
}

/* text + image split — Introduction, The mission, Process

   The gap ceiling roughly doubles, 48px -> 96px. An even 1fr 1fr split
   with the old gap made the two halves read as one continuous band:
   the media got exactly as much width as the prose and started a couple
   of dozen pixels after it, so nothing marked it as a separate thing.

   The media is NOT the half that pays for the wider gap. It holds the
   exact width it had under the old layout, which is what a track of
   calc((100% - <the old gap>) / 2) reproduces: percentages in
   grid-template-columns resolve against the grid's own content box, so
   this is the same "half the row, less the old gutter" arithmetic the
   1fr 1fr rule did, restated as an explicit width that the new gap
   cannot eat into. That is why the old clamp is kept below as a variable
   rather than deleted — it is still what sizes every image and video
   here, and changing it changes their size.

   The body track absorbs the difference instead (920px -> 873 at 2560).
   It can afford to: .cs-para caps its own measure at 62ch, which lands
   near 860px, so the paragraphs wrap identically to before and only the
   full-width callout under them gives up the ~47px. */
/* .cs-lede rides along on all of it: a section-opening heading in the
   left track with its prose in the right, which is the eShop case
   study's "What's the problem". Geometrically it IS a split — same two
   tracks, same gap, same collapse at 56.25em — so it shares the rule
   rather than restating the arithmetic. It gets its own class because
   the right-hand track holds body copy, not a media tile: writing it as
   .cs-split__media would quietly redefine what that element means
   everywhere else. Its children need no classes of their own; the
   heading and the paragraph are the two tracks. */
.cs-split,
.cs-lede {
  display: grid;
  /* the gutter the media was originally sized against - see above */
  --media-sizing-gap: clamp(1.5rem, calc(1.1359rem + 1.553vw), 3rem);
  grid-template-columns: minmax(0, 1fr) calc((100% - var(--media-sizing-gap)) / 2);
  gap: clamp(2rem, calc(0.8rem + 3.2vw), 6rem);
  align-items: start;
}

/* The reverse variant puts the media first with order: -1, so it lands in
   the FIRST track — the tracks have to swap with it or the flipped blocks
   would hand the image the body's track and squeeze the prose into the
   fixed one. Three of the site's fifteen splits are reverse (two in Aira,
   one in Ground FX), so this is not hypothetical. Equal specificity with
   the .cs-split rule above and declared after it, so it wins for those
   blocks; the 56.25em collapse to a single column is declared later still
   and beats both. */
.cs-split--reverse {
  grid-template-columns: calc((100% - var(--media-sizing-gap)) / 2) minmax(0, 1fr);
}

/* when several .cs-split blocks are stacked back-to-back in the same
   section (e.g. Aira's "The solution", one block per feature) they'd
   otherwise sit flush against each other — the previous block's media
   touching the next block's subheading — since .cs-split itself carries
   no vertical margin. Only fires between siblings, so a single .cs-split
   used on its own (Introduction, Ideation, etc.) is unaffected.

   The second selector is that same case with something other than a
   split in front: a .cs-split opening a sub-block partway down a
   section, under a callout or a grid (the eShop case study does this
   three times). It reads as the same kind of break and takes the same
   gap. Both selectors deliberately skip a split that opens its section —
   the first because there is no preceding split, the second via
   :not(.cs-divider) — since there the divider's own margin-bottom has
   already set the distance, and adding to it would drop the first block
   out of line with every other section on the site. */
.cs-split + .cs-split,
.cs-layout__body > section > :not(.cs-divider) + .cs-split {
  margin-top: clamp(3rem, 6vw, 5rem);
}

/* Whatever follows a .cs-lede gets that same sub-block gap. A lede is a
   section's opening statement — heading and prose across the full row —
   so the block after it is always the start of the section's real
   content, never a continuation of the paragraph above. The tighter
   "intro copy -> grid" spacing that a stacked paragraph takes reads as
   too close here, because the lede is a two-column band rather than a
   single column of text and its lower edge is the whole row's. */
.cs-layout__body > section > .cs-lede + * {
  margin-top: clamp(3rem, 6vw, 5rem);
}

.cs-split--reverse .cs-split__media {
  order: -1;
}

.cs-split__media {
  /* explicit width (not just max-width) — a shrink-to-fit box with
     only percentage-width children (.cs-square) has zero intrinsic
     size, which collapses this to 0px when combined with the
     auto-margin right-alignment below */
  width: 100%;
  max-width: 62.5rem;
  margin-left: auto;
}

.cs-split--reverse .cs-split__media {
  margin-left: 0;
  margin-right: auto;
}

/* 24-32px, down from 40-85px.

   Every .cs-split__body on the site is one tight prose group - a
   subheading, its paragraph, and a callout that comments on it (there is
   no arrangement where the children are unrelated). At 85px the callout
   read as a separate block that happened to sit underneath, rather than
   as part of the paragraph it belongs to: proximity is the only thing
   grouping them, and that much air undoes it. This is now close to the
   copy's own line-height, so the group holds together while the space
   between whole sections (.cs-container section + section, plus each
   section's 64px padding) still clearly dominates it - which is the
   hierarchy that was inverted before. */
.cs-split__body {
  display: flex;
  flex-direction: column;
  gap: clamp(1.5rem, calc(1.3634rem + 0.5825vw), 2rem);
  align-items: flex-start;
}

/* Highlights: banner + two-up squares */
.cs-highlights-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: clamp(1rem, calc(0.818rem + 0.777vw), 1.75rem);
  margin-top: clamp(1rem, calc(0.818rem + 0.777vw), 1.75rem);
}

/* Guidelines: three-up */
.cs-guidelines-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: clamp(1rem, calc(0.818rem + 0.777vw), 1.75rem);
}

.cs-guideline-item .cs-square {
  aspect-ratio: 1 / 1;
}

/* =========================================================
   Event marquee (Ground FX)
   =========================================================
   A continuously travelling strip of event photos, sitting between
   the hero and the row of jump links. Images only — the section it
   used to live in (heading, divider, caption) is gone.

   The seam. The track holds its items TWICE (see the markup), and
   the keyframe moves it by exactly -50% of its own width — so at the
   end of a cycle copy two is sitting pixel-for-pixel where copy one
   started and the reset is invisible. That only holds if the two
   halves are truly identical in width, which is why the spacing
   lives on each item's margin-inline-end rather than on a flex
   `gap`: a gap would add one extra span BETWEEN the halves that
   isn't repeated inside them, and -50% would land half a gap off
   and visibly jump once per loop.

   translate3d, not left/margin: transform is composited, so the
   whole strip animates off the main thread with no layout or paint
   per frame. */
.cs-marquee {
  --cs-marquee-gap: clamp(1rem, calc(0.818rem + 0.777vw), 1.75rem);
  --cs-marquee-item: clamp(11rem, 26vw, 24rem);
  /* the strip's only spacing is the gap down to the jump links it sits
     above — the section's own padding-block handles the hero side. Same
     clamp as .cs-jump__links' gap, so the strip, the row of buttons and
     the space between them are all on one scale. */
  margin-block-end: clamp(1.5rem, calc(1.1359rem + 1.553vw), 3rem);
  overflow: hidden;
  /* the strip runs the full width of the page container, so without
     this it would collide hard with both edges; the mask fades the
     first and last few percent out instead, which also sells the
     "this continues past the frame" read */
  -webkit-mask-image: linear-gradient(90deg, transparent 0, #000 5%, #000 95%, transparent 100%);
  mask-image: linear-gradient(90deg, transparent 0, #000 5%, #000 95%, transparent 100%);
}

.cs-marquee__track {
  display: flex;
  /* max-content, so the track is as wide as all eight tiles laid out
     in a line rather than being squeezed into the container's width —
     the percentage the keyframe translates by is a percentage of THIS */
  width: max-content;
  margin: 0;
  padding: 0;
  list-style: none;
  animation: cs-marquee-travel 44s linear infinite;
  /* default is paused: the strip does not animate until the JS
     observer confirms it is actually on screen (and the tab is
     visible). If the JS never runs, a static strip is the graceful
     degradation. */
  animation-play-state: paused;
}

.cs-marquee__item {
  flex: 0 0 var(--cs-marquee-item);
  width: var(--cs-marquee-item);
  margin-inline-end: var(--cs-marquee-gap);
}

.cs-marquee.is-running .cs-marquee__track {
  animation-play-state: running;
  /* only promoted to its own layer while it is genuinely moving —
     a permanent will-change would hold the memory for a compositor
     layer on every page view, including the ones that never scroll
     this far */
  will-change: transform;
}

/* never scroll a tile out from under keyboard focus. Hover no longer
   pauses it: the strip sits right where the pointer rests on the way to
   the jump links, so it kept stopping without anyone asking it to. */
.cs-marquee.is-running:focus-within .cs-marquee__track {
  animation-play-state: paused;
}

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

/* Reduced motion: no travel at all. The strip becomes a plain
   swipeable/scrollable row instead of a dead frame showing only the
   first few photos — [data-native-scroll] (see main.js) exempts it
   from the page's eased wheel takeover so it scrolls natively. */
@media (prefers-reduced-motion: reduce) {
  .cs-marquee {
    overflow-x: auto;
    -webkit-mask-image: none;
    mask-image: none;
  }

  .cs-marquee.is-running .cs-marquee__track,
  .cs-marquee__track {
    animation: none;
    will-change: auto;
  }
}

/* The Play page's "My process" strip reuses the marquee above. Its
   margin-block-end exists to clear Ground FX's jump links; here the strip
   is the last thing in its section, so the section's own padding does
   that job instead. */
.dm-process .cs-marquee {
  margin-block-end: 0;
  /* larger tiles than Ground FX's strip (clamp(11rem, 26vw, 24rem)):
     here the strip is the section's whole content, not a band above a
     row of buttons */
  --cs-marquee-item: clamp(15rem, 36vw, 34rem);
}

/* heading and its Pause/Play pill on one line, the pill centred on the
   heading's height */
.dm-process__head {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: clamp(0.75rem, 1.5vw, 1.5rem);
}

.dm-process__toggle {
  display: inline-flex;
  align-items: center;
  gap: 0.5em;
}

/* one icon at a time: pause while it runs, play once it is stopped */
.dm-process__toggle-icon--play,
.dm-process__toggle.is-stopped .dm-process__toggle-icon--pause {
  display: none;
}

.dm-process__toggle.is-stopped .dm-process__toggle-icon--play {
  display: block;
}

/* the stop itself. Same weight as .cs-marquee.is-running's rule and
   later in the file, so it wins while the strip is on screen; the
   in-view observer can keep adding and removing .is-running underneath
   without undoing a pause the visitor asked for. */
.cs-marquee.is-stopped .cs-marquee__track {
  animation-play-state: paused;
}

@media (prefers-reduced-motion: reduce) {
  .dm-process__toggle {
    display: none;
  }
}

/* Marketing: three-up (same shape as guidelines, own class so
   copy/spacing can diverge later without fighting specificity) */
.cs-marketing-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: clamp(1rem, calc(0.818rem + 0.777vw), 1.75rem);
}

/* Jaydon MP4 — three portrait clips side by side. This used to be
   .ig-embed-grid, sizing itself around Instagram oEmbed widgets whose
   height only arrived once each iframe reported back; it holds ordinary
   .cs-square--portrait video tiles now (see the comment on the grid in
   digital-media.html), so the ratio is known up front and the row
   reserves its space on the first paint instead of after a round trip.

   Inherits .dm-media-grid's gap and margin-top, same as
   .dm-media-grid--two and --motion — only the column count is its own. */
.dm-media-grid--mp4 {
  /* Full section width, like every other grid on the page: three equal
     columns across .container--wide, so the row lines up edge to edge
     with the View instagram button above it and the Motion design grid
     above that.

     This carried a 1300px cap for a while, which held the tiles to
     415x703. Removing it hands each column the section's full third —
     ~798px — and 711:1206 then makes every tile 798x1354, taller than a
     typical viewport, so a visitor scrolls through one reel at a time
     rather than seeing the row at a glance. That is the trade for the
     tiles spanning the section, and it is the intended look.

     minmax(0, 1fr) rather than a plain 1fr: a grid track's default
     min-width is auto, which refuses to shrink below its content, and a
     video's intrinsic width (1080px here) would hold the columns open
     and push the row past the section on narrow screens. */
  grid-template-columns: repeat(3, minmax(0, 1fr));
  align-items: start;
}

/* Design validation */
.cs-validation-copy {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: clamp(1.5rem, calc(1.1359rem + 1.553vw), 3rem);
  margin-bottom: clamp(1.5rem, calc(1.318rem + 0.777vw), 2.25rem);
}

.cs-validation-shot {
  /* same size as the Mission section's image — same 1:1 aspect ratio,
     sitting in the same .cs-split__media wrapper (max-width 62.5rem) */
  width: 100%;
  aspect-ratio: 1 / 1;
  border-radius: var(--radius-md);
  background: #d9d9d9;
  overflow: hidden;
}

.cs-validation-shot img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: top;
}

.cs-validation-toggle {
  display: flex;
  gap: 0.625rem;
  margin-bottom: clamp(1.5rem, calc(1.318rem + 0.777vw), 2.25rem);
}

/* toggle pills (Process: Final prototype/Rough draft, Design validation:
   Option A/Option B) — text matches .cs-para/.cs-callout's size instead
   of the smaller sitewide .filter-pill default, with padding scaling up
   alongside it (via the same clamp curve) so the pill grows with its
   text rather than the text just getting cramped inside a fixed box. */
.cs-validation-toggle .filter-pill {
  font-size: var(--text-body);
  padding: clamp(0.75rem, calc(0.659rem + 0.388vw), 1.125rem) clamp(1.25rem, calc(1.068rem + 0.777vw), 2rem);
}

/* named-area grid instead of two stacked flex columns — this is what
   actually locks "Opinions from my advisor..." to the same row as
   the Option A/B image (the card), not the toggle pills above it.
   Row 1 = toggle only. Row 2 = card + (label, stacked above quotes). */
.cs-validation-lower {
  display: grid;
  grid-template-columns: 0.8fr 1fr;
  grid-template-areas:
    "toggle ."
    "card   list";
  column-gap: clamp(1rem, calc(0.818rem + 0.777vw), 1.75rem);
  row-gap: 1.25rem;
  align-items: start;
}

.cs-validation-toggle {
  grid-area: toggle;
}

.cs-validation-card {
  grid-area: card;
  border-radius: var(--radius-md);
  background: #d9d9d9;
  aspect-ratio: 3 / 4;
}

/* wraps the label + quotes together so both start at the same top
   edge as .cs-validation-card in the grid row above */
.cs-validation-list {
  grid-area: list;
  display: flex;
  flex-direction: column;
  gap: 1.125rem;
}

/* holds both quote sets stacked on top of each other in normal flow —
   only one (.cs-quote-set.is-active) is ever displayed at a time, so
   no extra positioning is needed here. */
.cs-validation-quotes {
  display: block;
}

/* one 3-quote set per option, swapped by the Option A/B toggle — see
   main.js's [data-cs-toggle-panel] handling. Spaced out more than a
   default .cs-validation-quotes gap would be, per design request. */
.cs-quote-set {
  display: none;
  flex-direction: column;
  gap: clamp(1.25rem, calc(1.1286rem + 0.518vw), 1.75rem);
}

.cs-quote-set.is-active {
  display: flex;
}

/* body copy driven by a [data-cs-toggle] group, so the prose beside a
   set of options can change with them instead of describing both at
   once. Same display swap .cs-quote-set uses just above; block rather
   than flex because what sits in here is a paragraph, and it takes the
   spacing from .cs-split__body's own gap exactly as a bare <p> did. */
.cs-toggle-copy {
  display: none;
}

.cs-toggle-copy.is-active {
  display: block;
}

.cs-validation-list__row {
  border-radius: var(--radius-pill);
  padding: 1.25rem 1.625rem;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
}

.cs-validation-list__row span:first-child {
  font-weight: 600;
  font-size: var(--text-body);
}

.cs-validation-list__row span:last-child {
  font-size: var(--text-caption);
  color: var(--text-muted);
}

.cs-validation-list__label {
  font-weight: 600;
  /* same size as the paragraph text */
  font-size: var(--text-body);
  line-height: 1.5em;
  /* and the same colour as it too (--copy-ink) — this sits on the page
     ground, not inside a callout or a card, so it follows the sitewide
     paragraph colour. The 600 weight above is what still reads it as the
     lead-in to the list below rather than another line of body copy. */
  color: var(--copy-ink);
  margin: 0;
}

/* quote / testimonial cards — feedback from advisor / students. Same
   same flat #171717 callout surface as .cs-callout and
   .cs-takeaway-card (see the shared rule up by .btn), with that
   component's radius and padding too, so a quote reads as the same kind
   of object as every other boxed aside on the page rather than its own
   one-off.

   It used to be dark glass with a 3px --accent bar down the left edge —
   the only place the accent colour was painted into the page's own
   layout rather than into a transient state (it survives in ::selection,
   the :focus-visible rings and the scramble flash), and the reason the
   padding was asymmetric (the extra left inset was clearing the bar). Bar and asymmetry both gone; the bold/muted split
   between the quote and its attribution is what marks this as a
   testimonial now, which is the same two-level relationship
   .cs-caption strong / .cs-caption already uses elsewhere on the
   page. */
.cs-quote {
  border-radius: 1rem;
  padding: clamp(1.5rem, calc(1.324rem + 0.7512vw), 2rem) clamp(2rem, calc(1.736rem + 1.1268vw), 2.75rem);
  display: flex;
  flex-direction: column;
  gap: 0.375rem;
}

/* .cs-callout's own second padding tier, repeated for the card that now
   shares its base padding — see that rule for why the tier exists at
   all. It cannot simply be added to .cs-callout's media query: that
   block sits earlier in the file than the rule above, and at equal
   specificity the later rule wins, so the tier would be declared and
   then silently overridden. */
@media (min-width: 1440px) {
  .cs-quote {
    padding: clamp(2rem, calc(0.5rem + 1.6667vw), 2.5rem) clamp(2.75rem, calc(-0.25rem + 3.3333vw), 3.75rem);
  }
}

.cs-quote__text {
  font-size: var(--text-body);
  font-weight: 700;
  line-height: 1.5em;
  color: var(--ink);
}

/* --copy-ink, not --text-secondary. Two reasons: it is the sitewide
   colour for the quieter of two stacked lines (see .cs-caption, whose
   bold --ink heading over --copy-ink body this card now mirrors), and
   --text-secondary was failing outright in light mode — that token is a
   light lavender picked for the nav's own surfaces, and it measured
   1.83:1 against --chip-bg on the light theme, i.e. all but invisible
   on the very surface this card was about to adopt. --copy-ink holds
   5.6:1 dark / 5.0:1 light against that fill. */
.cs-quote__attribution {
  font-size: var(--text-caption);
  font-weight: 500;
  line-height: 1.5em;
  color: var(--copy-ink);
}

/* My takeaways */
.cs-takeaways-grid {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: clamp(1rem, calc(0.8786rem + 0.518vw), 1.5rem);
  margin-top: clamp(1.5rem, calc(1.318rem + 0.777vw), 2.25rem);
}

/* exactly 3 cards (e.g. Aira's "The next steps") reads badly at 2
   columns — 2 side by side, then a 3rd alone on its own row with a big
   empty gap next to it. :has() + :nth-child(3):last-child is "select a
   grid whose 3rd child is also its last child", i.e. exactly 3 items —
   count-based, not a class added by hand, so this keeps applying
   correctly if a card is ever added/removed, or another case study
   adds its own 3-card group. Doesn't touch 2-, 4-, or 6-card groups
   (Aira's own "What I learned", Faculty of Environment's and Ground
   FX's "My takeaways"), which already read fine at 2 columns. */
.cs-takeaways-grid:has(> :nth-child(3):last-child) {
  grid-template-columns: repeat(3, 1fr);
}

/* same flat #171717/no-stroke treatment as .cs-callout ("The creative
   content...") — just a tighter 21px corner radius. min-height is
   fluid too, so cards don't hold onto a fixed 11.25rem of empty space
   once they're down to one column on a phone. Extra padding all around
   gives each card more breathing room than the plain .cs-callout
   treatment. */
.cs-takeaway-card {
  border-radius: 1.3125rem;
  padding: clamp(1.75rem, calc(1.6286rem + 0.518vw), 2.25rem);
  min-height: clamp(9rem, calc(8.4539rem + 2.33vw), 11.25rem);
  display: flex;
  flex-direction: column;
  /* emoji pinned to the top, text block hugging the bottom — the
     fixed emoji line is what keeps every card's top edge reading
     level across a row, not the (now bottom-anchored) text. */
  justify-content: space-between;
  align-items: flex-start;
}

/* bare glyph, no badge — the emoji carries itself at this size, and a
   box around it only competed with the card's own rounded edge. The
   fixed line-height (rather than a fixed width/height) is what keeps
   the footprint consistent: every card's glyph occupies exactly the
   same vertical band, so the gap down to the heading stays level
   across a row no matter how much the glyph varies in visual weight. */
.cs-takeaway-card__emoji {
  display: block;
  margin-bottom: clamp(1.25rem, calc(1.1286rem + 0.518vw), 1.75rem);
  font-size: clamp(2.75rem, calc(2.6286rem + 0.518vw), 3.25rem);
  line-height: 1;
}

/* the numbered counterpart of the slot above (see "Which experiences
   need adjustment" in the eShop case study). It sits in the same place
   in the card and carries the same bottom margin — so a row can mix
   numbered and emoji-topped cards and every heading still lands on the
   same line — but it reads as an index, not an illustration, so it takes
   body size in --copy-ink rather than 3rem in --ink. Tabular figures
   keep 01/02/03 optically identical in width down the row. */
.cs-takeaway-card__index {
  display: block;
  margin-bottom: clamp(1.25rem, calc(1.1286rem + 0.518vw), 1.75rem);
  font-size: var(--text-body);
  line-height: 1;
  color: var(--copy-ink);
  font-variant-numeric: tabular-nums;
}

.cs-takeaway-card__text {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  text-align: left;
}

.cs-takeaway-card h3 {
  font-family: var(--font-body);
  /* same size as the other paragraph text (.cs-para) */
  font-size: var(--text-body);
  font-weight: 700;
}

.cs-takeaway-card p {
  /* same size as the other paragraph text (.cs-para) */
  font-size: var(--text-body);
  line-height: 1.5em;
  color: var(--ink);
}

/* =========================================================
   Case study: article + sticky table of contents
   =========================================================
   The two-column shell. Everything below the jump links lives in here:
   the article in column one, the contents card in column two.

   The sidebar column is a clamp rather than a fraction on purpose. A
   fractional track would keep growing on a 2560px monitor — .cs-container
   has no max-width (see its own rule above), so the page really is that
   wide — and a 400px-wide list of eight short words looks broken. Capped
   at 20rem it stays a sidebar at every width, and the article column
   absorbs all the extra space instead.

   The 13vw middle arm sets the balance. It was 18vw — close to a fifth
   of the page — back when the card was set at body-copy scale and its
   longest entry needed that room. At --text-nav-switch it does not:
   measured every label the three case studies actually render (the long
   headings carry short data-toc overrides, so "Marketing: Social media
   campaigns" ships as "Marketing"), the widest is "Promotional media" at
   115px of ink. Against the card's inner padding below that wants 179px,
   so the 11.5rem floor still clears it with slack and nothing wraps.
   Every pixel given up here lands in the article track. */
.cs-layout {
  display: grid;
  grid-template-columns: minmax(0, 1fr) clamp(11.5rem, 13vw, 13rem);
  /* minmax(0, 1fr), not plain 1fr: a grid track's default min-width is
     auto, which refuses to shrink below its widest content. Several
     sections in here hold wide media and non-wrapping rows, and with a
     plain 1fr any one of them would push the whole grid wider than the
     viewport and hand the page a horizontal scrollbar. */
  column-gap: clamp(2rem, calc(0.3243rem + 7.111vw), 4.5rem);
  align-items: start;
}

/* Both children are placed explicitly because the card now comes FIRST in
   the markup, ahead of the article it describes. That order is for the
   keyboard: the aside is navigation, and with auto-placement (card last)
   a keyboard user had to tab through every link, video tile and toggle in
   a 3000px article before reaching a control that was on screen the whole
   time. Reading order and tab order now both meet the card where it is
   visible, and these two rules put it back on the right of the article
   where it was. */
.cs-layout__body {
  grid-column: 1;
  grid-row: 1;
}

.cs-toc {
  grid-column: 2;
  grid-row: 1;
}

/* The article's own ceiling, on top of the track sizing above.

   Narrowing the sidebar's share is enough on a laptop, but it barely
   touches a 2560px monitor: .cs-container has no max-width, the sidebar
   stops growing at 20rem, and every remaining pixel lands in the article
   — nearly 1900px of it, which leaves .cs-split's two-up media close to
   950px a side. Capping the article ELEMENT rather than its track is what
   keeps both edges where the design already put them: the track still
   runs the full width, so the card stays pinned to the right margin and
   the article still starts flush with the hero's left edge above it. The
   surplus becomes gutter between the two, as in the reference layout.

   118rem, up from 112rem. The cap is why narrowing the sidebar above is
   not enough on a big monitor: at 2560 the article TRACK was already
   1858px and the element was sitting at 112rem = 1792 inside it, so the
   space the card gave back would have become gutter and the article
   would not have moved at all. Raising the cap in step is what actually
   widens the column here — 1792 to 1888 at 2560, with the gutter landing
   near the 72px the gap itself uses so it still reads as deliberate
   rather than as the column edge. Below about 2500px the cap never
   engages and the grid is the track sizing alone, where the article
   picks up the sidebar's ~72px directly.

   Worth knowing when tuning this number: it is not purely a media
   control. .cs-para's own max-width is 62ch, but ch resolves against that
   element's font-size, which is 22px here (it also carries .hero__desc) —
   so the cap lands near 860px, and a two-up .cs-split body only reaches
   it once the article passes about 1780px. The full-width paragraphs are
   the ones to watch: several of them carry an inline max-width: none,
   which beats this rule and any other, so they take the article's entire
   width and their line length tracks this cap one-for-one.

   No justify-self is needed: a stretched grid item that hits a max-width
   is placed at the start of its track on its own. */
.cs-layout__body {
  max-width: 118rem;
}

/* .cs-container section + section can't reach across the new wrapper —
   .cs-layout is a div, so the hero → jump → article rhythm would lose its
   last gap. Same clamp as that rule so the spacing stays identical. */
.cs-jump + .cs-layout {
  margin-top: clamp(1.5rem, calc(1.1359rem + 1.553vw), 3rem);
}

/* The card itself.

   align-self: start is load-bearing, not cosmetic. A grid item defaults
   to stretch, so this aside would be exactly as tall as the article next
   to it — and an element that already fills its whole containing block
   has nowhere to stick TO, which makes position: sticky silently do
   nothing. Shrinking it to its content height gives it the full column
   to travel down.

   top centres the card in the viewport rather than parking it near the
   top. Sticky pins against the element's TOP edge, so centring means
   offsetting 50vh by half the card's own height — and CSS cannot read
   that height, so 11.125rem (178px) is measured rather than guessed:
   the rows are a uniform 30px and the cards run 342px at eight entries
   (Faculty of Environment, Ground FX) and 372px at nine (Aira), so 178
   is half of the midpoint and every case study lands within ~8px of
   true centre.

   The trailing 4rem lifts it 64px off true centre, which lands the card
   near 45% of the viewport height. Kept as its own term rather than
   folded into the 11.125rem so the two stay readable independently: the
   first is measured from the card and should only change if the rows or
   the padding do, the second is taste and can be tuned freely.

   The max() is the short-window fallback. On a viewport too short to
   centre a card this tall, the centring arm collapses toward zero or
   goes negative, which would push the card under the top of the screen;
   the old clamp is the floor that catches it, and the list's own
   max-height takes over the overflow from there.

   Either arm is a plain viewport offset with no nav compensation,
   matching every other anchor on the site: .nav-wrap is absolute, not
   fixed (see its own rule), so it scrolls away and never overlays the
   card. */
.cs-toc {
  position: sticky;
  top: max(clamp(1.5rem, 5vh, 3rem), calc(50vh - 11.125rem - 4rem));
  align-self: start;
  /* A flat panel, not one of the site's glass controls: solid fill, no
     stroke, no inner glow, no backdrop blur. --chip-bg IS #171717 (see
     :root) - naming the token rather than the literal is what keeps the
     card legible in light mode, where the same token resolves to #e7eaf7
     and the text tokens below invert with it. A hardcoded #171717 would
     leave dark-on-dark text on a white page.
     Padding is deliberately NOT scaled down in step with the type. It
     was briefly cut to 14-18 / 12-16px on the reasoning that padding
     should track font size; that was wrong for this card. What the
     padding does here is separate the list from the card's edge, and
     that job is set by the CARD, not by the type — a 13px list inside a
     tight box reads as cramped, not as small. These sit just under the
     originals (20-28 / 18-26px, set around 20px text) so the box still
     came down overall, with the inset now the most generous thing in it.

     The inline value is what the sidebar floor below has to clear: at
     22px a side plus the rows' own 10px, the 11.5rem floor leaves 131px
     of line against a 115px widest label. */
  background: var(--chip-bg);
  border: 0;
  border-radius: var(--radius-md);
  padding-block: clamp(1.125rem, calc(1.014rem + 0.470vw), 1.5rem);
  padding-inline: clamp(1rem, calc(0.890rem + 0.470vw), 1.375rem);
  box-shadow: none;
}

/* Typography borrowed wholesale from .nav__links a (About me / Resume /
   Contact): same family, same --text-nav-switch size, same --ink colour.
   Only the weight is held at 600, so the label still reads as a heading
   now that the rule under it is gone and nothing else separates it from
   the first entry. */
.cs-toc__title {
  /* the reference sets the title on the same rhythm as the entries below
     it - same size, same left edge, one step of the row gap between - so
     the whole card reads as a single list whose first line happens to be
     bold. margin-bottom therefore matches .cs-toc__link's padding-block
     rather than being a heading margin of its own. */
  margin: 0 0 0.375rem;
  padding-inline: 0.625rem;
  font-family: var(--font-body);
  font-size: var(--text-toc);
  font-weight: 700;
  line-height: 1.35;
  color: var(--ink);
}

/* data-native-scroll in the markup is what stops main.js's eased-wheel
   handler from calling preventDefault on wheel events that land in here
   (see its listener) — without it, wheeling over a list long enough to
   overflow would scroll the page instead of the list.

   The cap is generous: on a tall window nothing ever scrolls and this is
   inert, on a short laptop the longest case study (ten entries) gets a
   scrollbar instead of running off the bottom of the screen. */
.cs-toc__list {
  max-height: calc(100vh - 12rem);
  overflow-y: auto;
  overscroll-behavior: contain;
  scrollbar-width: thin;
  scrollbar-color: rgba(var(--overlay-rgb), 0.25) transparent;
}

.cs-toc__list ol {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
}

/* --text-toc is declared here rather than in :root because nothing else
   on the site uses it, and it now resolves to --text-nav-switch: the
   card is set at exactly the size of the nav's own links (About me /
   Resume / Contact), 13px at 900px and up.

   This used to be its own clamp(17px, .., 20px), scaled to the CARD
   rather than to the page on the reasoning that the type should track
   the sidebar's 18vw width arm. Sharing the nav's token drops it about
   1.5x and gives up that proportionality on purpose - the contents list
   is navigation, and it now reads at the same size as the site's other
   navigation instead of at body-copy scale, where it competed with the
   article it points at. The 20px ceiling that the longest label
   ("Information architecture", Aira) used to set is no longer the
   binding constraint at this size. */
.cs-toc {
  --text-toc: var(--text-nav-switch);
}

.cs-toc__link {
  display: block;
  /* the reference's rhythm: roughly 2.4x the type size between one entry
     and the next, which is a lot of air by this site's standards and is
     the single thing that makes the card read the way it does. Vertical
     padding does the work, not a list gap, so the whole row - not just
     the text - stays a click target even though nothing is painted
     behind it any more. Trimmed 10/12px -> 6/10px alongside the type:
     the ratio the rhythm is actually built on is padding against TYPE
     size, and at 13px the old 10px kept rows at 38px, which read as
     gaps in a list rather than as the list. 6px lands them near 30px. */
  padding: 0.375rem 0.625rem;
  border-radius: var(--radius-sm);
  /* The same --copy-ink the body paragraphs use, so the resting rows and
     the prose they point at sit at one brightness. This used to be
     --text-secondary, a light lavender borrowed from .nav__links a, and
     against greyed body copy it read as the brighter of the two - the
     contents list was competing with the article. The card still has
     exactly one row at full --ink (the current section), so every other
     row has to sit below it; starting them at full white would leave the
     current one with nowhere to go. */
  font-family: var(--font-body);
  font-size: var(--text-toc);
  font-weight: 400;
  line-height: 1.35;
  color: var(--copy-ink);
  text-decoration: none;
  transition: color 0.18s ease;
}

/* Hover brightens toward --ink - the opposite direction to .nav__links a,
   and deliberately so. The nav's links rest at full white, so dimming is
   the only move available to them; these rest dimmed, so lifting is. Both
   read as "this one is live" because both move AWAY from their resting
   state, which is what the feedback actually has to say. */
.cs-toc__link:hover,
.cs-toc__link:focus-visible {
  color: var(--ink);
}

/* the accent ring the removed .cs-top button used to carry - the
   custom cursor's hover state does nothing for keyboard users, and
   these rows are otherwise unpainted */
.cs-toc__link:focus-visible,
.cs-toc__top:focus-visible,
.cs-toc__toggle:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
}

/* No fill behind the current row at all now - the contrast between full
   --ink and the dimmed --text-secondary above does the marking on its
   own, which is how a contents list in print would do it.

   The weight stays. Brightness alone would put the whole signal in one
   channel, and it is the channel that degrades first: on a dim laptop,
   in sunlight, or for anyone with low vision, --ink against
   --text-secondary can flatten out to nothing. Weight survives all
   three, and it is what the reference shows too. (aria-current already
   covers assistive tech; this is for eyes.) */
.cs-toc__link.is-active {
  color: var(--ink);
  font-weight: 600;
}

/* "Go back up", the last row of the card.

   This replaces the 56px round button that used to float in the
   bottom-right corner. That control had to be fixed to the viewport and
   faded in past a screen of scroll to avoid sitting on the hero; the
   card is already pinned and already past the hero, so the same job
   costs one more row here and one less floating object on the page.

   Separated from the entries above by a hairline rather than by a gap
   alone: it is the one row that does not point at a section, and after
   nine or ten labels a plain gap reads as list padding. The rule is
   painted with --overlay-rgb so it inverts with the theme like the ~50
   other translucent marks on the site.

   It is a sibling of .cs-toc__list, not a child, so the list's own
   overflow never scrolls it out of reach on a short window. */
.cs-toc__top {
  display: block;
  margin-top: 0.375rem;
  padding: 0.5rem 0.625rem 0.125rem;
  border-top: 1px solid rgba(var(--overlay-rgb), 0.12);
  font-family: var(--font-body);
  font-size: var(--text-toc);
  font-weight: 400;
  line-height: 1.35;
  color: var(--copy-ink);
  text-decoration: none;
  transition: color 0.18s ease;
}

.cs-toc__top:hover,
.cs-toc__top:focus-visible {
  color: var(--ink);
}

/* collapsed-bar control: desktop has the whole card on screen already,
   so the toggle only exists below the breakpoint (see the media query) */
.cs-toc__toggle {
  display: none;
}

/* ---------- below 1200px: the sidebar becomes a bar at the top ----------
   Not the 56.25em the rest of this file collapses at. .cs-container's
   side padding is 8.738vw, so at 900px the article column would be down
   to roughly 500px while .cs-split is still a two-up grid — 250px
   columns, with the media in them shrunk to match. 75em is the first
   width where a ~200px sidebar still leaves the article comfortable.
   Kept in em to match the unit this whole block uses. */
@media (max-width: 75em) {
  .cs-layout {
    grid-template-columns: minmax(0, 1fr);
  }

  /* The bar is fixed to the top of the screen down here, so a section
     reached from it lands with its heading tucked underneath: a
     section's own padding-top is all the clearance there is, and at
     390px that padding drops to 48px (see the 35em block) against a
     bar whose bottom edge sits at 53px. scroll-margin-top is the
     browser's own hook for exactly this, and this is the only rule in
     the file that needs it - nothing else on the site stays fixed
     while you scroll (.nav-wrap is absolute and scrolls away, which
     is why no other anchor on any page compensates for anything). */
  .cs-layout__body > section {
    scroll-margin-top: 4.5rem;
  }

  /* the aside stops being the surface and becomes just a positioner;
     .cs-toc__panel below picks the card treatment up instead */
  .cs-toc {
    position: fixed;
    top: clamp(0.75rem, 2vh, 1.25rem);
    left: 50%;
    transform: translateX(-50%);
    /* far below .nav-wrap's 999 so the nav stays clickable the whole way
       down. Nothing else on the page is layered any more - the floating
       back-to-top button that used to hold 90 is gone, and its job is
       now the last row of this card. */
    z-index: 80;
    width: min(24rem, calc(100vw - 2.5rem));
    padding: 0;
    background: none;
    border: 0;
    border-radius: 0;
    box-shadow: none;
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
    /* hidden until the reader is past the hero: a control pointing into
       the page has no job on the first screen, and here it would sit on
       top of the title. The threshold outlived the .cs-top button it was
       shared with, which faded in at the same point for the same reason. */
    opacity: 0;
    visibility: hidden;
    pointer-events: none;
    transition: opacity 0.25s ease, visibility 0.25s ease;
  }

  .cs-toc.is-past-hero {
    opacity: 1;
    visibility: visible;
    pointer-events: auto;
  }

  .cs-toc__toggle {
    display: flex;
    align-items: center;
    gap: 0.625rem;
    width: 100%;
    /* min-height, not more padding: the pill has to clear 44px for touch
       (it measured 36) without growing the type or the bar's footprint. */
    min-height: 44px;
    padding: 0.625rem 1rem;
    background: var(--chip-bg);
    border: 0;
    border-radius: var(--radius-pill);
    box-shadow: none;
    font-family: var(--font-body);
    font-size: var(--text-nav-switch);
    color: var(--ink);
    cursor: pointer;
    text-align: left;
  }

  .cs-toc__toggle-label {
    font-weight: 600;
    flex: none;
  }

  /* the current section's name, which is the actual reason to look at a
     collapsed bar. It is the only part allowed to be long, so it gets
     the leftover width and an ellipsis rather than wrapping the pill
     onto a second line. */
  .cs-toc__toggle-current {
    flex: 1;
    min-width: 0;
    overflow: hidden;
    white-space: nowrap;
    text-overflow: ellipsis;
    color: var(--text-secondary);
  }

  .cs-toc__chev {
    flex: none;
    transition: transform 0.25s ease;
  }

  .cs-toc.is-open .cs-toc__chev {
    transform: rotate(180deg);
  }

  .cs-toc__panel {
    /* animating max-height rather than height: the list's real height
       depends on how many sections the page has, which differs per case
       study, so there is no single value to animate to */
    max-height: 0;
    overflow: hidden;
    opacity: 0;
    background: var(--chip-bg);
    border: 0;
    border-radius: var(--radius-md);
    transition: max-height 0.28s ease, opacity 0.2s ease, margin-top 0.28s ease;
  }

  .cs-toc.is-open .cs-toc__panel {
    max-height: min(60vh, 26rem);
    opacity: 1;
    margin-top: 0.5rem;
  }

  .cs-toc__title {
    margin: 0;
    padding: 0.875rem 0.625rem 0.5rem;
  }

  .cs-toc__list {
    /* 7rem, not 3.5: the panel now also holds the title AND the "Go back
       up" row, and only the list is allowed to scroll. Leaving the old
       allowance would let the two of them push the last entries past the
       panel's own max-height, where overflow: hidden would clip them. */
    max-height: min(calc(60vh - 7rem), 18rem);
    padding: 0 0.5rem 0.75rem;
  }

  /* The panel keeps 16px, which is now BIGGER than the desktop card's
     type rather than smaller than it - this override predates the card
     dropping to nav size and its direction has flipped. It stays: the
     panel's rows are touch targets on a phone, where 13px is under the
     16px that keeps a tap label comfortably readable, and the panel has
     the full page width to spend rather than a 13-20rem sidebar. The
     collapsed pill above is left at --text-nav-switch because it is a
     control, not a list. */
  .cs-toc {
    --text-toc: 1rem;
  }

  /* same 44px floor for the panel's rows, which came out at 38px. flex
     centring rather than padding alone keeps the label optically centred
     in the taller row whether it runs to one line or two. */
  .cs-toc__link {
    display: flex;
    align-items: center;
    min-height: 44px;
    padding: 0.5rem 0.75rem;
  }

  /* same 44px floor, and the panel's own side padding rather than the
     list's, so the hairline runs the full width of the card */
  .cs-toc__top {
    display: flex;
    align-items: center;
    min-height: 44px;
    margin: 0 0.5rem 0.25rem;
    padding: 0.5rem 0.75rem;
  }
}

@media (prefers-reduced-motion: reduce) {
  .cs-toc,
  .cs-toc__link,
  .cs-toc__top,
  .cs-toc__panel,
  .cs-toc__chev {
    transition: none;
  }
}

@media (max-width: 56.25em) {
  .cs-split,
  .cs-lede,
  .cs-highlights-grid,
  .cs-validation-copy,
  .cs-takeaways-grid,
  /* the 3-card variant's :has() selector outweighs this plain-class
     rule's specificity — without repeating it here explicitly, a
     3-card group would stay 3-across even at phone width instead of
     collapsing to 1 column like every other card count does */
  .cs-takeaways-grid:has(> :nth-child(3):last-child) {
    grid-template-columns: 1fr;
  }

  /* the meta band collapses to a single stack at the same breakpoint
     every other case-study grid does — three columns of label + wrapped
     sentence stop reading below this width. This is also where it
     returns to the vertical stack it was before the band existed. */
  .cs-hero__meta {
    grid-template-columns: 1fr;
  }

  .cs-validation-lower {
    grid-template-columns: 1fr;
    grid-template-areas:
      "toggle"
      "card"
      "list";
  }

  .cs-split__media,
  .cs-split--reverse .cs-split__media {
    max-width: 100%;
    margin-inline: 0;
  }

  .cs-guidelines-grid,
  .cs-marketing-grid,
  .dm-media-grid--mp4 {
    grid-template-columns: 1fr 1fr;
  }

  .cs-hero__meta {
    min-width: 0;
    width: 100%;
  }
}

@media (max-width: 35em) {
  .cs-guidelines-grid,
  .cs-marketing-grid,
  .dm-media-grid--mp4 {
    grid-template-columns: 1fr;
  }
}
