/* ── Tokens (see DESIGN.md) ─────────────────────────────────────────── */
:root {
  --bg: oklch(0.99 0 0);
  --surface: oklch(0.965 0.004 230);
  --surface-2: oklch(91.305% 0.0046 215.415 / 0.806);
  --ink: oklch(0.18 0.02 230);
  --muted: oklch(0.46 0.015 230);

  --teal: oklch(0.72 0.12 200);
  --teal-glow: oklch(0.72 0.12 200 / 0.35);
  --teal-ink: oklch(0.46 0.11 200);

  --navy: oklch(0.20 0.035 230);
  --navy-2: oklch(0.27 0.04 230);

  --yellow: oklch(0.875 0.179 92.7); /* #ffd100 */

  /* pale tinted surfaces — used as full-bleed section backgrounds to break
     up long runs of white, without introducing new saturated UI colors */
  --tint-teal: oklch(0.96 0.014 200);

  /* pink accent — used sparingly (tag pills, hover indicators), not as a
     fill. Same hue family as --pop-pink but lighter/more saturated so it
     reads as a clean, vivid hot pink rather than a muddy magenta. */
  --pink: oklch(0.65 0.24 355);
  --pink-ink: oklch(0.46 0.20 355);
  --pink-tint: oklch(0.95 0.035 355);

  /* decorative-only "pop" confetti hues — pulled from the showreel's own
     motion-graphics palette. Never used for text, buttons, or fills with
     text on top; squiggles + dots only. */
  --pop-red: oklch(0.6 0.21 28);
  --pop-blue: oklch(0.55 0.15 250);
  --pop-green: oklch(0.66 0.16 150);
  --pop-pink: oklch(0.6 0.22 350);

  /* Chromatic-aberration pair for .glitch-text (see ── Hero ──) --
     the classic RGB-split red/cyan combo, not tied to the rest of the
     palette on purpose: the effect reads as "misregistered colour
     channels," which only works with genuine red/cyan, not a
     brand-matched pair. */
  --glitch-red: #ff4757;
  --glitch-cyan: #00d9ff;

  --radius-sm: 6px;
  --radius-md: 10px;
  --radius-lg: 20px;

  --space-xs: 0.5rem;
  --space-sm: 1rem;
  --space-md: 2rem;
  --space-lg: 4rem;
  --space-xl: 8rem;

  /* Sections' own top/bottom padding, 20% less than the --space-lg/-xl
     they're derived from -- kept as their own tokens rather than
     scaling --space-lg/-xl directly, since those two also drive
     unrelated horizontal clamps (--section-pad-x, .hero's own
     horizontal padding, .contact's outer margin) and internal
     component gaps elsewhere that this request wasn't about. */
  --section-pad-y: calc(var(--space-xl) * 0.8);
  --section-pad-y-sm: calc(var(--space-lg) * 0.8);

  /* Section horizontal padding, as a token rather than inlined --
     lets .reel-frame's mobile full-bleed cancel out exactly this
     value (see ── Sections ──). */
  --section-pad-x: clamp(var(--space-sm), 6vw, var(--space-xl));

  --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
  --ease-back: cubic-bezier(0.34, 1.56, 0.64, 1);
  --font-display: 'Inter', system-ui, sans-serif;
  --font-body: 'Inter', system-ui, sans-serif;
}

/* ── Reset ───────────────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

/* Mobile browsers flash their own blue-ish tap-highlight overlay on any
   tappable element by default. Every custom-styled a/button on this
   site already has its own hover/active treatment, so that flash only
   ever reads as a glitch on top of it -- global here instead of
   per-component (a handful of components -- work-toggle/-desc/-foot,
   the reel deep-link cover -- aren't actual a/button elements underneath
   their click handlers, so still need their own declaration below). */
a, button { -webkit-tap-highlight-color: transparent; }

html { scroll-behavior: smooth; }

@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  *, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; }
}

body {
  background: var(--bg);
  color: var(--ink);
  font-family: var(--font-body);
  font-size: 16px;
  line-height: 1.6;
  -webkit-font-smoothing: antialiased;
}

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

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

ul { list-style: none; }

h1, h2, h3, h4 { font-family: var(--font-display); text-wrap: balance; }

p { text-wrap: pretty; }

:focus-visible {
  outline: 2px solid var(--teal-ink);
  outline-offset: 3px;
  border-radius: var(--radius-sm);
}

.skip-link {
  position: absolute;
  left: -9999px;
  top: auto;
}
.skip-link:focus {
  left: var(--space-sm);
  top: var(--space-sm);
  z-index: 100;
  background: var(--navy);
  color: #fff;
  padding: var(--space-xs) var(--space-sm);
  border-radius: var(--radius-sm);
}

/* ── Nav ─────────────────────────────────────────────────────────────── */
.nav {
  position: fixed;
  inset: 0 0 auto 0;
  z-index: 50;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: var(--space-sm) clamp(var(--space-sm), 5vw, var(--space-lg));
  color: #fff;
  transition: background-color 0.4s var(--ease-out-expo), backdrop-filter 0.4s var(--ease-out-expo), color 0.4s var(--ease-out-expo), border-color 0.4s var(--ease-out-expo);
  border-bottom: 1px solid transparent;
}

.nav.is-scrolled {
  /* Same blue as the start of .contact's own gradient (see ── Contact
     ── below) -- the bar reads as a continuation of that section
     rather than a different, unrelated surface. */
  background: oklch(0.69 0.12 200 / 0.85);
  backdrop-filter: blur(12px);
  color: #fff;
  border-color: oklch(1 0 0 / 0.12);
}

/* Hidden over the transparent hero -- the wordmark is already right
   there, so a second copy of the name in the corner is redundant until
   there's an actual bar (.nav.is-scrolled) to anchor it to. */
.nav-mark {
  font-weight: 800;
  font-size: clamp(0.95rem, 2.4vw, 1.1rem);
  letter-spacing: -0.01em;
  text-transform: lowercase;
  white-space: nowrap;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.4s var(--ease-out-expo);
}
.nav.is-scrolled .nav-mark {
  opacity: 1;
  pointer-events: auto;
}
.nav-mark.cursor-hidden, .nav-mark.cursor-hidden * { cursor: none; }

/* Custom emoji "cursor" shown over the logo — see main.js. Fixed +
   tracked via mousemove since CSS can't restyle the native cursor. */
.cursor-emoji {
  position: fixed;
  top: 0;
  left: 0;
  font-size: 2.8rem;
  line-height: 1;
  pointer-events: none;
  z-index: 1000;
  /* Position tracks the pointer via --x/--y (set from main.js's
     mousemove handler) baked into this same transform, rather than
     left/top -- those are layout-affecting properties, and writing
     them on every mousemove was forcing a synchronous layout recalc
     right as a hover-triggered transition (e.g. the letter-wave on
     this logo) was trying to start, delaying its first visible frame
     by a few frames. transform-only updates stay on the compositor,
     so tracking no longer competes with anything else's paint. */
  transform: translate3d(var(--x, -100px), var(--y, -100px), 0) translate(-50%, -50%) scale(0);
  transition: transform 0.25s var(--ease-back);
}
.cursor-emoji.is-active { transform: translate3d(var(--x, -100px), var(--y, -100px), 0) translate(-50%, -50%) scale(1); }

/* Clicking the logo while its emoji cursor is showing cycles to the
   next emoji (see main.js) instead of the usual click-anywhere
   firework -- this little pop is the feedback that the click landed
   and did something, in place of that firework. */
@keyframes cursor-emoji-pop {
  0% { transform: translate3d(var(--x, -100px), var(--y, -100px), 0) translate(-50%, -50%) scale(1); }
  45% { transform: translate3d(var(--x, -100px), var(--y, -100px), 0) translate(-50%, -50%) scale(1.5); }
  100% { transform: translate3d(var(--x, -100px), var(--y, -100px), 0) translate(-50%, -50%) scale(1); }
}
.cursor-emoji.is-popping { animation: cursor-emoji-pop 0.35s var(--ease-back); }
.nav-mark .nav-dot {
  display: inline-block;
  color: #fff;
  transition: transform 0.35s var(--ease-back), color 0.3s var(--ease-out-expo);
}
.nav-mark:hover .nav-dot { color: var(--navy); transform: translateY(-3px) scale(1.2); }
.nav.is-scrolled .nav-mark:hover .nav-dot { color: var(--teal); }

/* ── Letter-by-letter wave (nav links, logo, headings) — see main.js
   splitLetters(). Each .letter gets a staggered transition-delay from
   its --i custom property, alternating tilt direction so the effect
   reads as a little wave. Nav links & the logo trigger on hover; section
   titles instead trigger off .wave-active, set by main.js on whichever
   section is dominating the scroll view, so the wave reads as "this
   section is active" as you scroll rather than requiring a mouseover. */
.letters { display: inline-block; }
/* Each .letter is display:inline-block for the wave transform, which
   makes it an atomic inline box -- and atomic inlines get an implicit
   soft-wrap opportunity on *both* sides regardless of adjacent
   whitespace, so without this wrapper the line can break between any
   two letters, not just between words (splitting "something" mid-word
   on narrow screens). Grouping each word's letters under a nowrap
   .word span removes those in-word break points while leaving the
   real spaces between .word spans free to wrap normally. */
.word { white-space: nowrap; }
.letter {
  display: inline-block;
  transition: transform 0.4s var(--ease-back), color 0.3s var(--ease-out-expo);
  transition-delay: calc(var(--i, 0) * 25ms);
}
/* .nav-mark's letters used to get their color only by inheriting
   .nav's own color (set on .nav.is-scrolled, see above), never an
   explicit rule on .letter itself -- and staggering transition-delay
   for an *inherited* property that changes because an ancestor's style
   changed, rather than the element's own style, hits what looks like a
   genuine Chromium bug: confirmed by direct measurement that only the
   delay:0 letter (the first) actually started transitioning -- every
   later letter, despite an individually-correct calc()'d delay, never
   started at all. .nav-links and the h2 wave (main section.wave-active
   h2 .letter) never had this problem, and the one thing different
   about both is that they set color directly on .letter itself rather
   than inheriting it -- so instead of dropping the stagger (which
   loses the letter-by-letter wave entirely), this gives .nav-mark's
   letters that same explicit rule, keeping the wave and sidestepping
   the inheritance path that broke it. */
.nav.is-scrolled .nav-mark .letter {
  color: #fff;
}
.nav-links a:hover .letter,
.nav-mark:hover .letter,
main section.wave-active h2 .letter {
  transform: translateY(-0.12em);
}
/* Same blue as the header bar / .contact's own gradient (see
   .nav.is-scrolled and ── Contact ── below) rather than --pop-blue --
   that confetti hue reads darker/duller as heading text than intended
   (it's meant for decorative dots/squiggles only, see the --pop-*
   tokens' own comment up top), so the wave lands on this instead. */
main section.wave-active h2 .letter {
  color: oklch(0.69 0.12 200);
}

/* Contact's teal background needs a dark letter color to read -- black
   instead of the shared blue. */
.contact.wave-active h2 .letter {
  color: var(--ink);
}

.nav-links {
  display: flex;
  gap: clamp(var(--space-xs), 3vw, var(--space-md));
  font-size: clamp(0.78rem, 1.6vw, 0.85rem);
  font-weight: 600;
}

.nav-links a {
  position: relative;
  color: currentColor;
  opacity: 1;
  transition: opacity 0.25s var(--ease-out-expo), color 0.25s var(--ease-out-expo);
}
/* :focus-visible deliberately left out of this trio -- clicking a link
   gives it focus, and focus sticks around (through scrolling included)
   until something else steals it. Bundled in here, that read as the
   link being permanently "stuck active" regardless of which section
   was actually in view, decoupled entirely from .is-active's own
   scroll-driven state. Keyboard focus still gets its own indicator
   from the global :focus-visible outline rule near the top of this
   file -- this only stops it from also impersonating "in view." */
.nav-links a.is-active,
.nav-links a:hover { opacity: 1; color: var(--navy); }
.nav.is-scrolled .nav-links a.is-active,
.nav.is-scrolled .nav-links a:hover { color: var(--ink); }
.nav-links a::after {
  content: '';
  position: absolute;
  left: 0; bottom: -6px;
  width: 0; height: 2px;
  background: currentColor;
  transition: width 0.3s var(--ease-out-expo);
}
.nav-links a.is-active::after,
.nav-links a:hover::after { width: 100%; }

/* ── Buttons ─────────────────────────────────────────────────────────── */
.btn {
  display: inline-flex;
  align-items: center;
  gap: 0.5em;
  padding: 0.85em 1.6em;
  border-radius: var(--radius-lg);
  border: none;
  font-family: inherit;
  font-weight: 700;
  font-size: 0.95rem;
  cursor: pointer;
  transition: transform 0.3s var(--ease-out-expo), box-shadow 0.3s var(--ease-out-expo), background-color 0.3s var(--ease-out-expo);
}
.btn:hover { transform: translateY(-2px); }

.btn-primary {
  /* Flat fill, plus a black border whose own alpha fades top-to-bottom
     (more opaque/visible at the top, nearly gone at the bottom) --
     border-image can't do this and keep border-radius's rounding, so
     it's the double-background-clip trick instead: the flat fill
     clipped to padding-box leaves the border ring transparent so the
     alpha-gradient (clipped to border-box) shows through just that
     ring -- over background-color's own --navy backstop underneath
     both layers, so the ring reads as the translucent gradient sitting
     on top of the fill, not on top of whatever's behind the button. */
  background:
    linear-gradient(var(--navy), var(--navy)) padding-box,
    linear-gradient(to bottom, oklch(0 0 0 / 0.28), oklch(0 0 0 / 0.06)) border-box;
  background-color: var(--navy);
  border: 0.15rem solid transparent;
  color: #fff;
}
.btn-primary:hover { box-shadow: 0 8px 24px -6px oklch(0 0 0 / 0.35); }

.btn-ghost {
  border: 1.5px solid oklch(1 0 0 / 0.5);
  color: #fff;
}
.btn-ghost:hover { border-color: #fff; background: oklch(1 0 0 / 0.08); }

.btn-accent {
  /* Flat fill, plus a black border whose own alpha fades top-to-bottom
     -- see the comment on .btn-primary for the double-background-clip
     trick and why background-color's own backstop matters here. */
  background:
    linear-gradient(var(--yellow), var(--yellow)) padding-box,
    linear-gradient(to top, oklch(0 0 0 / 0.18), oklch(0 0 0 / 0.1)) border-box;
  background-color: var(--yellow);
  border: 0.15rem solid transparent;
  color: var(--navy);
  font-size: 1.1rem;
  margin: var(--space-md) 0;
  box-shadow: 0 6px 16px -6px oklch(0.5 0.12 90 / 0.4);
  /* .btn's own shared border-radius (--radius-lg, 20px) is a fixed
     value tuned for the smaller ghost/primary buttons -- at this one's
     larger font-size/padding it's shorter than half the button's own
     height, so the sides read as rounded-rect rather than a true
     pill. Covers both live uses of this class: the hero CTA and the
     contact form's "Send it over" (.form-submit). */
  border-radius: 999px;
    text-shadow: 1px 1px rgba(255, 255, 255, 0.3);
}
.btn-accent:hover {
  box-shadow: 0 10px 24px -6px oklch(0.4 0.1 90 / 0.4);
}

/* ── Hero ────────────────────────────────────────────────────────────── */
.hero {
  position: relative;
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  justify-content: center;
  padding: clamp(4.8rem, 9.6vh, 6.4rem) clamp(var(--space-sm), 6vw, var(--space-xl)) var(--section-pad-y-sm);
  overflow: hidden;
  background: linear-gradient(338deg, oklch(0.59 0.12 200), var(--teal) 70%);
  color: #fff;
  /* .hero-name is a Lottie-rendered logotype, not plain text -- its
     glyphs carry real side-bearing, and unevenly: measured ~0.208em of
     left bearing but ~0.538em on the right (both relative to the
     wordmark's own font-size), so the ink sits well off-center within
     its own box, not just shifted in from the box's left edge.
     .hero-name-wrap below shifts the whole wordmark right by half that
     difference to self-center it -- shifting doesn't change either gap
     on its own (both move with the box), it just redistributes the
     existing total between them, so --hero-ink-inset (the *resulting*
     left-ink position after that shift, for lining up the elements
     below against it) is the centered gap (0.208+0.538)/2, while
     --hero-ink-total (for sizing .hero-role's own width to match the
     wordmark's ink *span*, not its outer box) stays the shift-independent
     sum of both original gaps, 0.208+0.538. */
  --hero-name-fs: clamp(3.2rem, 11vw, 7.2rem);
  --hero-ink-inset: calc(var(--hero-name-fs) * 0.373);
  --hero-ink-total: calc(var(--hero-name-fs) * 0.746);
}

.hero-glow {
  position: absolute;
  top: -25%;
  right: -15%;
  width: 55vw;
  height: 55vw;
  max-width: 640px;
  max-height: 640px;
  background: radial-gradient(circle, oklch(1 0 0 / 0.16), transparent 70%);
  filter: blur(20px);
  pointer-events: none;
}

.hero-kicker {
  color: oklch(1 0 0 / 0.85);
  font-weight: 700;
  font-size: 0.85rem;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  margin-bottom: var(--space-sm);
  margin-left: var(--hero-ink-inset);
  opacity: 0;
  animation: rise 2.16s var(--ease-out-expo) 0.36s forwards;
}

/* Full width of the hero's padded content box at every viewport size --
   tracking the container via % rather than vw means it scales
   continuously with the available space instead of jumping at a
   breakpoint, and stays full-width on small screens too. Capped so it
   doesn't get absurd on ultrawide monitors. margin-left nudges the
   whole wordmark right to self-center its own off-balance ink (see the
   --hero-ink-inset comment on .hero above) -- the box stays the same
   size, it's allowed to overflow .hero's right padding edge by that
   same small amount, which .hero's overflow:hidden clips invisibly
   since that strip is blank margin in the artwork anyway, not ink. */
.hero-name-wrap {
  position: relative;
  width: min(100%, 1200px);
  margin-left: calc(var(--hero-name-fs) * 0.165);
  aspect-ratio: 1009 / 484;
  /* No opacity/rise of its own -- the letters inside (.wm-g) already
     own their full entrance choreography. Fading the wrap on top of
     that just double-dims everything while both animations overlap. */
}

/* Real text content for screen readers/SEO -- the visible wordmark
   below is a decorative (aria-hidden) SVG, its custom letterforms
   extracted from the original Lottie JSON's own vertex data, animated
   with plain CSS rather than the Lottie player. */
.hero-name-sr {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

.hero-name-wrap.cursor-hidden, .hero-name-wrap.cursor-hidden * { cursor: none; }

/* ── Hero wordmark reveal (custom SVG letterforms, plain CSS anim) ──────
   Inlined rather than loaded as an external file/<img> -- an <img>'s
   internal animation clock starts whenever the image resource finishes
   loading/decoding, which varies run to run and drifts out of sync with
   this page's own timers (the after-wordmark firework trigger below
   included), reading as erratic/inconsistent timing. Inline keeps it on
   this document's one timeline. danson's letters slide up (wm-up) into
   their row; digital's slide down (wm-down) into theirs. Each word is
   clipped to its own side of the row gap (danson-mask/digital-mask,
   defined per-letter-group in the SVG markup) so incoming letters are
   hidden while still in transit through the other word's territory,
   reading as sliding out from behind a mask rather than visibly
   crossing over one another. */
.wm-svg {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  overflow: visible;
  pointer-events: none;
}
.wm-svg path { fill: #fff; pointer-events: auto; }
.wm-g { opacity: 0; }
/* Holds the ENTIRE hero intro sequence at its pre-start frame until main.js
   confirms the tab is actually visible (Page Visibility API) -- without
   this, a page opened in a background tab plays the whole sequence unseen,
   since CSS animations keep running regardless of tab focus. This is every
   element with its own page-load-relative animation-delay in the hero
   (wordmark 0.5s-1.709s, kicker 0.36s, tagline 1.8s-3.048s, lede 3.48s, CTA
   3.42s, stats 5.76s) -- same set as the prefers-reduced-motion override
   just below, which is the authoritative list of what's actually part of
   this sequence. Gating only some of them left the rest racing ahead on
   their original page-load clock while the gated ones waited for a release
   that might come much later. Scoped to the shared .hero section, not any
   one element, so everything releases from the same moment and the
   original relative choreography between them holds regardless of when
   that release actually happens. animation-play-state preserves each
   element's own animation-delay rather than needing JS to recompute
   timing. */
.wm-paused .wm-g,
.wm-paused .hero-kicker,
.wm-paused .hero-role-line,
.wm-paused .hero-lede,
.wm-paused .hero-actions,
.wm-paused .hero-stats { animation-play-state: paused; }
.wm-g--up { animation-name: wm-up; animation-timing-function: cubic-bezier(0.05, 0.85, 0.1, 1); animation-fill-mode: forwards; }
.wm-g--down { animation: wm-down 0.08s var(--ease-out-expo) forwards; }
@keyframes wm-up {
  from { opacity: 0; transform: translateY(182.58px); }
  to { opacity: 1; transform: translateY(0); }
}
@keyframes wm-down {
  from { opacity: 0; transform: translateY(-258.66px); }
  to { opacity: 1; transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
  .wm-g { opacity: 1 !important; animation: none !important; transform: none !important; }
}

/* Letter-shaped cutout window onto a mouse-tracking emoji — see main.js.
   A plain overlay carrying the emoji as a background-image, masked to
   assets/lottie/dansondigital-mask.svg -- letterform paths extracted
   directly from the Lottie JSON, so they align pixel-for-pixel with
   the actual animated logo (the HTML fallback text is a different
   font and doesn't line up closely enough for a text-clip trick). */
.hero-name-mask {
  position: absolute;
  inset: 0;
  pointer-events: none;
  mask-image: url('../assets/lottie/dansondigital-mask.svg');
  mask-size: 100% 100%;
  mask-repeat: no-repeat;
  -webkit-mask-image: url('../assets/lottie/dansondigital-mask.svg');
  -webkit-mask-size: 100% 100%;
  -webkit-mask-repeat: no-repeat;
  opacity: 0;
  transition: opacity 0.2s var(--ease-out-expo);
}
.hero-name-mask.is-active { opacity: 1; }

/* The click-to-cycle pop for this one (see main.js) can't be a
   transform like .cursor-emoji's -- this element IS the letterform
   mask, not just the emoji, so scaling the element up scales the crop
   shape right along with the emoji and pushes it past the actual
   (unscaled) letter edges rendered underneath. Animated via
   background-size/-position in JS instead (mask-size stays fixed at
   100% 100%), so the emoji visibly grows while the crop it's seen
   through never moves. */

/* ── Firework field — scattered, looping firework bursts in the back of
   the hero/contact bookends. Decorative, behind all content -- needs
   an explicit negative z-index for that, since position:absolute with
   no z-index still paints above plain static in-flow text/buttons by
   default stacking order, not below them. Scoped to .hero/.contact's
   own stacking context (both position:relative), so it only drops
   behind its own section's content, not the page underneath. ─────── */
.firework-field {
  position: absolute;
  inset: 0;
  z-index: -1;
  overflow: hidden;
  pointer-events: none;
}

.firework-dot {
  position: absolute;
  width: clamp(70px, 9vw, 120px);
  height: clamp(70px, 9vw, 120px);
}

/* Hero fireworks run bigger and spread across the full header (left and
   right of the logo, not just the right-hand gap) -- they sit behind
   the wordmark in z-order (earlier in the DOM, no z-index), so it's
   fine for them to pass behind it. */
.firework-field--hero .firework-dot {
  width: clamp(140px, 17vw, 240px);
  height: clamp(140px, 17vw, 240px);
  /* Held invisible until the wordmark's own reveal has fully finished
     (see data-trigger="after-wordmark" and .is-revealed in
     fireworks.js) -- appending the burst pops it straight in at its
     first frame, no fade of its own, so this is what actually makes
     that moment a fade-in instead of a jump-cut. */
  opacity: 0;
  transition: opacity 0.8s var(--ease-out-expo);
}
.firework-field--hero .firework-dot.is-revealed {
  opacity: 1;
}
.firework-field--hero .firework-dot--1 { top: 8%; left: 6%; }
.firework-field--hero .firework-dot--2 { top: 58%; left: 22%; }
.firework-field--hero .firework-dot--3 { top: 12%; right: 8%; }
.firework-field--hero .firework-dot--4 { top: 66%; right: 16%; }

.firework-field--contact .firework-dot--1 { top: 6%; left: 6%; }
.firework-field--contact .firework-dot--2 { top: 58%; left: 16%; }
.firework-field--contact .firework-dot--3 { top: 12%; right: 8%; }
.firework-field--contact .firework-dot--4 { top: 62%; right: 24%; }

@media (max-width: 760px) {
  .firework-dot { width: 60px; height: 60px; }
}

/* Click-anywhere firework burst, spawned at the click point by main.js */
.click-firework {
  position: fixed;
  top: 0;
  left: 0;
  width: clamp(70px, 9vw, 120px);
  height: clamp(70px, 9vw, 120px);
  transform: translate(-50%, -50%);
  pointer-events: none;
  z-index: 999;
}

/* Fireworks burst hooked to the contact CTA — empty/invisible until a
   fireworks.json is exported and loads successfully; harmless either way. */
.cta-wrap {
  position: relative;
  display: inline-flex;
}

.cta-wrap .btn-accent {
  position: relative;
  z-index: 1;
}

.lottie-anim--burst {
  position: absolute;
  inset: -70px;
  z-index: 0;
  pointer-events: none;
}

/* Large screens: all three inline as one flowing row, nowrap so
   main.js's fitHeroRoleWidth() (resizes .hero-role's font-size to
   match .hero-name-wrap's width) always has exactly one line to size.
   Medium (see below) re-enables wrapping to kick "Zero headache." onto
   its own line while the first two stay paired; small stacks and
   centers all three. */
.hero-role {
  margin-top: var(--space-md);
  margin-left: var(--hero-ink-inset);
  /* Same cap as .hero-name-wrap, minus --hero-ink-total (both the
     wordmark's left AND right ink gaps, not just the left one this box
     is shifted in by) -- so this box's right edge lands on the
     wordmark's actual ink-right, matching its ink-left on the other
     side, rather than overshooting out to the wordmark's outer box
     edge. This is the box's real, final width -- main.js only chooses
     a font-size to make the text fill it, it doesn't touch the box
     itself. */
  width: calc(min(100%, 1200px) - var(--hero-ink-total));
  display: flex;
  flex-wrap: nowrap;
  align-items: baseline;
  column-gap: 0.45em;
  row-gap: 0.05em;
  font-size: clamp(1.7rem, 4.4vw, 2.5rem);
  line-height: 1.1;
  font-weight: 800;
  letter-spacing: -0.015em;
  color: #fff;
}

/* Three short claims, each its own rise beat -- landing one after
   another reads as three distinct promises rather than a single block
   of text arriving all at once, which is most of what makes bumping
   the size alone feel flat. */
.hero-role-line {
  opacity: 0;
  animation: rise 2.16s var(--ease-out-expo) forwards;
}
.hero-role-line:nth-child(1) { animation-delay: 1.8s; }
.hero-role-line:nth-child(2) { animation-delay: 2.424s; }
.hero-role-line:nth-child(3) { animation-delay: 3.048s; }

/* "headache" glitches on demand rather than looping on a fixed CSS
   cycle -- a CSS @keyframes loop always repeats *identically*, so no
   matter how chaotic one cycle looks, the timing itself is perfectly
   regular and eventually reads as mechanical. Irregular gaps between
   bursts, and cycling through the burst "shapes" below in order, is
   main.js's job -- see the comment there. This file just supplies six
   burst shapes (.is-glitching / -medium / -subtle / -flicker / -stutter
   / -skew) for it to trigger as one-shot animations. steps(1, end)
   jump-cuts between keyframe values instead of tweening smoothly, which
   reads as "digital glitch" rather than "shaky hand." */
.glitch-text {
  display: inline-block;
  position: relative;
}
.glitch-text.is-glitching {
  animation: glitch-burst-big 0.7s steps(1, end);
}
.glitch-text.is-glitching-medium {
  animation: glitch-burst-medium 0.55s steps(1, end);
}
.glitch-text.is-glitching-subtle {
  animation: glitch-burst-small 0.45s steps(1, end);
}
.glitch-text.is-glitching-flicker {
  animation: glitch-burst-flicker 0.35s steps(1, end);
}
.glitch-text.is-glitching-stutter {
  animation: glitch-burst-stutter 0.5s steps(1, end);
}
.glitch-text.is-glitching-skew {
  animation: glitch-burst-skew 0.4s steps(1, end);
}
@keyframes glitch-burst-big {
  0%, 100% { transform: translate(0, 0) skew(0deg); text-shadow: none; }
  14% { transform: translate(-6px, 2px) skew(-4deg); text-shadow: 3px 0 var(--glitch-cyan), -3px 0 var(--glitch-red); }
  28% { transform: translate(5px, -2px) skew(3deg); text-shadow: -4px 0 var(--glitch-cyan), 4px 0 var(--glitch-red); }
  42% { transform: translate(-7px, 0); text-shadow: 2px 0 var(--glitch-red), -2px 0 var(--glitch-cyan); }
  56% { transform: translate(0, -3px); text-shadow: none; }
  70% { transform: translate(6px, 2px) skew(-3deg); text-shadow: -3px 0 var(--glitch-red), 3px 0 var(--glitch-cyan); }
  84% { transform: translate(-4px, 0); text-shadow: 5px 0 var(--glitch-cyan), -5px 0 var(--glitch-red); }
}
/* Halfway between big and subtle -- some shift, no skew or vertical
   jitter, one colour swap partway through instead of several. */
@keyframes glitch-burst-medium {
  0%, 100% { transform: translate(0, 0); text-shadow: none; }
  20% { transform: translate(-4px, 1px); text-shadow: 2px 0 var(--glitch-cyan), -2px 0 var(--glitch-red); }
  45% { transform: translate(3px, -1px); text-shadow: -3px 0 var(--glitch-cyan), 3px 0 var(--glitch-red); }
  70% { transform: translate(-2px, 0); text-shadow: none; }
}
/* Quieter variant -- smaller offsets, no skew, no vertical jitter, so
   not every trigger hits the same intensity. */
@keyframes glitch-burst-small {
  0%, 100% { transform: translate(0, 0); text-shadow: none; }
  30% { transform: translate(-1px, 0); text-shadow: 1px 0 var(--glitch-cyan), -1px 0 var(--glitch-red); }
  60% { transform: translate(1px, 0); text-shadow: -1px 0 var(--glitch-cyan), 1px 0 var(--glitch-red); }
}
/* A different texture entirely rather than just a smaller version of
   the others -- no positional shift at all, just a rapid colour/opacity
   strobe. Reads as a signal flicker rather than a physical jolt. */
@keyframes glitch-burst-flicker {
  0%, 100% { text-shadow: none; opacity: 1; }
  15% { text-shadow: 2px 0 var(--glitch-red), -2px 0 var(--glitch-cyan); opacity: 0.7; }
  30% { text-shadow: none; opacity: 1; }
  45% { text-shadow: -2px 0 var(--glitch-red), 2px 0 var(--glitch-cyan); opacity: 0.6; }
  60% { text-shadow: none; opacity: 1; }
  75% { text-shadow: 1px 0 var(--glitch-cyan), -1px 0 var(--glitch-red); opacity: 0.8; }
}
/* Double-vision -- two quick jolts with a full snap back to rest in
   between, rather than one continuous shift. Reads as a stutter/repeat
   rather than a single push. */
@keyframes glitch-burst-stutter {
  0%, 100% { transform: translate(0, 0); text-shadow: none; }
  10% { transform: translate(-2px, 0); text-shadow: 2px 0 var(--glitch-red), -2px 0 var(--glitch-cyan); }
  20% { transform: translate(0, 0); text-shadow: none; }
  30% { transform: translate(2px, 0); text-shadow: -2px 0 var(--glitch-red), 2px 0 var(--glitch-cyan); }
  40% { transform: translate(0, 0); text-shadow: none; }
  55% { transform: translate(-1px, 0); text-shadow: 3px 0 var(--glitch-cyan), -3px 0 var(--glitch-red); }
  65% { transform: translate(0, 0); text-shadow: none; }
}
/* A sharp skew jolt rather than a shift -- reads as the text itself
   warping/slanting for an instant, distinct from the others' straight
   left-right push. */
@keyframes glitch-burst-skew {
  0%, 100% { transform: skew(0deg); text-shadow: none; }
  20% { transform: skew(-12deg); text-shadow: 2px 0 var(--glitch-cyan), -2px 0 var(--glitch-red); }
  40% { transform: skew(9deg); text-shadow: -2px 0 var(--glitch-cyan), 2px 0 var(--glitch-red); }
  60% { transform: skew(-5deg); text-shadow: none; }
  80% { transform: skew(2deg); }
}

/* Layered on top of the shadow/shift jitter above: two full copies of
   the word (content: attr(data-text), so it never has to be kept in
   sync with the real text by hand), each colour-shifted and torn into
   moving clip-path slices -- the "torn RGB layers" look from
   https://codepen.io/fabiowallner/pen/ozZoYo, adapted from that pen's
   standalone/always-on heading treatment into a one-shot burst like
   the rest of this effect. That source pen generates its clip rects
   from a Sass @for loop with a random() call baked in at compile time
   -- there's no preprocessor here, so these are hand-picked slice
   positions/left-offsets, deliberately landing at different moments
   than glitch-burst's own keyframes (and than each other) so the two
   RGB layers don't move in lockstep. The subtle variant skips these
   slices entirely (only the shadow/shift above), which is most of what
   makes it read as quieter rather than just "the same glitch, smaller." */
.glitch-text::before,
.glitch-text::after {
  content: attr(data-text);
  position: absolute;
  inset: 0;
  opacity: 0;
}
.glitch-text.is-glitching::before {
  color: var(--glitch-red);
  animation: glitch-slice-1 0.7s steps(1, end);
}
.glitch-text.is-glitching::after {
  color: var(--glitch-cyan);
  animation: glitch-slice-2 0.7s steps(1, end);
}
@keyframes glitch-slice-1 {
  0%, 10%, 25%, 100% { opacity: 0; }
  11% { opacity: 1; left: 5px; clip-path: inset(8% 0 78% 0); }
  16% { left: -4px; clip-path: inset(62% 0 8% 0); }
  21% { left: 6px; clip-path: inset(30% 0 55% 0); }
  47% { opacity: 1; left: -6px; clip-path: inset(70% 0 10% 0); }
  54% { left: 5px; clip-path: inset(5% 0 85% 0); }
  61% { opacity: 0; }
}
@keyframes glitch-slice-2 {
  0%, 13%, 27%, 100% { opacity: 0; }
  14% { opacity: 1; left: -5px; clip-path: inset(60% 0 12% 0); }
  19% { left: 6px; clip-path: inset(18% 0 48% 0); }
  24% { left: -6px; clip-path: inset(78% 0 8% 0); }
  50% { opacity: 1; left: 5px; clip-path: inset(8% 0 68% 0); }
  57% { left: -4px; clip-path: inset(45% 0 30% 0); }
  64% { opacity: 0; }
}

/* Medium: force just the third line onto a new row -- flex-basis:100%
   on a flex-wrap item always starts a fresh line (nothing else fits
   beside a full-width item), which is more reliable than hoping
   content width happens to wrap there on its own. width:auto drops the
   large-screen ink-span narrowing (see the base rule) -- that's sized
   to make a single nowrap line stop exactly at the wordmark's ink-right,
   which means nothing once the row is wrapping into multiple lines,
   and was otherwise just leaving unused space on the right, reading as
   off-center. Still starts at the ink-matched left margin, just fills
   the rest of the row instead of stopping short. */
@media (max-width: 900px) {
  .hero-role {
    flex-wrap: wrap;
    width: auto;
  }
  .hero-role-line:nth-child(3) { flex-basis: 100%; }
}

/* Small: give up on inline pairing entirely and stack + center all
   three -- at this width even two phrases side by side gets tight.
   margin-left:0 drops the wordmark-ink alignment from the base rule
   too, since centering makes a left-edge match meaningless. (.hero-actions
   gets the same treatment further down, next to its own base rule.) */
@media (max-width: 600px) {
  /* Less top clearance than the clamp() above gives at desktop/tablet
     sizes -- with .hero also min-height:100vh, that much top padding
     on a short phone viewport crowds the wordmark down further than
     it needs to be. */
  .hero { padding-top: 2.5rem; }
  .hero-role {
    flex-direction: column;
    align-items: center;
    row-gap: 0;
    margin-left: 0;
  }
  .hero-role-line { text-align: center; }
}

.hero-lede {
  margin-top: var(--space-sm);
  margin-left: var(--hero-ink-inset);
  max-width: 56ch;
  color: oklch(1 0 0 / 0.92);
  font-size: 1.05rem;
  font-weight: 500;
  opacity: 0;
  animation: rise 2.4s var(--ease-out-expo) 3.48s forwards;
}

.hero-actions {
  display: flex;
  gap: var(--space-sm);
  margin-top: var(--space-md);
  margin-left: var(--hero-ink-inset);
  flex-wrap: wrap;
  opacity: 0;
  /* The CTA gets its own slower, more deliberate entrance rather than
     sharing .rise with everything above it -- longest delay on the
     page (everything else has landed by ~2.76s, wordmark included), so
     it reads as the one thing worth waiting for and lingering on
     rather than just the last item in the same staggered list. Its own
     pause once got an *extra* 25% cut on top of a uniform speedup
     everything else here also got (since carried forward through a
     couple of across-the-board speedups since), so it's not scaled by
     quite the same factor as its neighbours -- deliberate, not a
     rounding artifact. */
  animation: cta-entice 3.84s var(--ease-out-expo) 3.42s forwards;
}

/* Small: matches .hero-role's own small-screen switch above -- centers
   the CTA instead of leaving it left-anchored while the text above it
   centers, which read as two different alignments in the same column. */
@media (max-width: 600px) {
  .hero-actions {
    justify-content: center;
    margin-left: 0;
  }
}

.hero-stats {
  display: flex;
  gap: clamp(var(--space-md), 5vw, var(--space-lg));
  margin-top: var(--space-xl);
  margin-left: var(--hero-ink-inset);
  flex-wrap: wrap;
  opacity: 0;
  animation: rise 2.4s var(--ease-out-expo) 5.76s forwards;
}

.stat { display: flex; flex-direction: column; gap: 0.2em; }

.stat-num {
  font-size: clamp(1.8rem, 3vw, 2.4rem);
  font-weight: 900;
  color: #fff;
}

.stat-label {
  font-size: 0.8rem;
  color: oklch(1 0 0 / 0.78);
  max-width: 18ch;
  font-weight: 500;
}

@keyframes rise {
  from { opacity: 0; transform: translateY(18px); }
  to { opacity: 1; transform: translateY(0); }
}

/* CTA-only: rises further than .rise (28px vs 18px), a plain fade-up
   with no overshoot/bounce -- tried a scale-and-settle version first,
   but the "return to position" motion read as too much movement for
   what's meant to be a slow, inviting arrival rather than a bouncy one. */
@keyframes cta-entice {
  from { opacity: 0; transform: translateY(28px); }
  to { opacity: 1; transform: translateY(0); }
}

.scroll-cue {
  position: absolute;
  bottom: var(--space-md);
  left: clamp(var(--space-sm), 6vw, var(--space-xl));
  width: 26px;
  height: 42px;
  border: 1.5px solid oklch(1 0 0 / 0.4);
  border-radius: 20px;
  display: flex;
  justify-content: center;
  padding-top: 8px;
}

.scroll-cue span {
  width: 4px;
  height: 8px;
  border-radius: 2px;
  background: #fff;
  animation: scroll-pulse 1.8s ease-in-out infinite;
}

@keyframes scroll-pulse {
  0% { transform: translateY(0); opacity: 1; }
  60% { opacity: 0.2; }
  100% { transform: translateY(14px); opacity: 0; }
}

@media (prefers-reduced-motion: reduce) {
  .hero-kicker, .hero-role-line, .hero-lede, .hero-actions, .hero-stats {
    opacity: 1; animation: none;
  }
  .scroll-cue span { animation: none; opacity: 0.6; }
}

/* ── Sections ────────────────────────────────────────────────────────── */
section { padding: var(--section-pad-y) var(--section-pad-x); }

@media (max-width: 600px) {
  /* --section-pad-y-sm is exactly half of --section-pad-y -- 50% less
     top/bottom breathing room on phones, where that much vertical
     padding reads as empty scrolling rather than separation. */
  section { padding-top: var(--section-pad-y-sm); padding-bottom: var(--section-pad-y-sm); }
}

/* Background rhythm down the page: teal (hero), navy (reel), white
   (services, work — neutral; pink is an accent only), pale teal (about),
   teal (contact). */
.reel {
  position: relative;
  overflow: hidden;
  background-color: var(--navy);
  color: #fff;
  /* Without this, jumping straight to #reel via the nav link lands
     its heading flush with the viewport's own top edge -- behind the
     fixed nav, since nothing otherwise reserves room for it the way
     .contact already does. */
  scroll-margin-top: 4rem;
}

/* Same reasoning as .reel just above -- #work has no bespoke rule
   block of its own otherwise, relying entirely on the generic section
   padding, which scroll-margin-top isn't part of. */
.work {
  scroll-margin-top: 4rem;
}

@media (max-width: 600px) {
  /* ~10px less clearance than the shared 4rem above -- landed a
     little high (too much gap under the nav) on mobile specifically. */
  .reel, .work {
    scroll-margin-top: calc(4rem - 10px);
  }
}

/* Colour changes wipe in from the top rather than crossfading or
   snapping in place -- two full-cover layers alternate, each starting
   translated fully off past the top edge and sliding down to
   translateY(0) to sweep over whichever one is currently showing (see
   wipeBgTo() in main.js, which also swaps z-index on every call --
   position:0 here is just the shared starting point). Sits below the
   section's actual content, which all needs position+z-index to paint
   above a positioned, same-stacking-context sibling regardless of DOM
   order. */
.reel-bg-layer {
  position: absolute;
  inset: 0;
  z-index: 0;
  background-color: var(--navy);
  pointer-events: none;
}

.reel > .section-head {
  position: relative;
  z-index: 1;
  /* Deliberately not a margin-top on .reel-stage instead, since
     adjacent sibling margins collapse to their max rather than
     summing, which silently ate an earlier attempt at this. Stays at
     the normal section-head gap until playback starts -- only then
     does .reel-tabs slide up out of the frame (see .is-playing below)
     and need the bigger gap to land in, so the space above the video
     only grows when something's actually about to occupy it, instead
     of reserving room for that up front. */
  margin-bottom: var(--space-lg);
  transition: margin-bottom 0.5s var(--ease-out-expo);
}
.reel.is-playing > .section-head { margin-bottom: var(--space-xl); }
.reel .section-head h2 { color: #fff; }
.reel .section-head p { color: oklch(1 0 0 / 0.7); }


.about { background: var(--tint-teal); }

.section-head { max-width: 64ch; margin-bottom: var(--space-md); }

.section-head h2 {
  font-size: clamp(2rem, 4vw, 3rem);
  font-weight: 800;
  letter-spacing: -0.02em;
  margin-bottom: var(--space-xs);
}

.section-head p { color: var(--muted); font-size: 1.05rem; }

/* ── Scroll reveals ──────────────────────────────────────────────────────
   A default fade-up, plus a few bouncier variants (.reveal-pop,
   .reveal-zoom, .reveal-slide) so different section types get a
   distinct, springier entrance rather than one uniform fade. */
.reveal {
  opacity: 0;
  transform: translateY(28px);
  transition: opacity 0.7s var(--ease-out-expo), transform 0.7s var(--ease-out-expo);
}
.reveal.is-visible { opacity: 1; transform: translateY(0); }

.reveal-pop {
  opacity: 0;
  transform: translateY(36px) scale(0.85) rotate(-3deg);
  transition: opacity 0.6s var(--ease-out-expo), transform 0.7s var(--ease-back);
  /* A scale() this size means a work-card's children aren't just
     faded but visibly foreshortened toward the card's center while
     this hasn't settled -- their painted (and so click-hit-tested)
     positions genuinely stop lining up with their real flex layout,
     to the point .work-desc and .work-enquire's rendered boxes can
     overlap. Rather than chase that, nothing in an unsettled card is
     clickable at all until is-visible lands and the transform clears. */
  pointer-events: none;
}
.reveal-pop:nth-child(even) { transform: translateY(36px) scale(0.85) rotate(3deg); }
.reveal-pop.is-visible {
  opacity: 1;
  transform: translateY(0) scale(1) rotate(0);
  pointer-events: auto;
}

.reveal-zoom {
  opacity: 0;
  transform: scale(0.88);
  transition: opacity 0.6s var(--ease-out-expo), transform 0.6s var(--ease-back);
}
.reveal-zoom.is-visible { opacity: 1; transform: scale(1); }

.reveal-slide {
  opacity: 0;
  transform: translateX(-44px);
  transition: opacity 0.6s var(--ease-out-expo), transform 0.6s var(--ease-back);
}
.reveal-slide.is-visible { opacity: 1; transform: translateX(0); }

@media (prefers-reduced-motion: reduce) {
  .reveal, .reveal-pop, .reveal-zoom, .reveal-slide {
    opacity: 1;
    transform: none;
    transition: none;
  }
}

/* ── Work grid ───────────────────────────────────────────────────────── */
/* Flex, not grid -- auto-fit's column count tracks the container width
   continuously (1, 2, 3, 4, 5... as it grows), but the brief here is
   exactly 1/2/4, never 3, at fixed breakpoints, with the group
   centering as a block once cards have hit their max-width (below)
   rather than continuing to grow. flex-wrap plus explicit widths per
   breakpoint gets the column counts; margin-inline:auto plus a
   matching max-width on .work-grid itself (set in the same
   breakpoints) is what does the centering -- capping the *container*,
   not just the cards, is the part that actually matters: max-width
   on .work-card alone would still let the wrap algorithm (which packs
   each line using post-clamp sizes) squeeze a 3rd smaller-than-its-
   share card onto what was meant to be a 2-card row once maxed out,
   so the row has to be capped down to exactly fit its tier's column
   count instead. justify-content:center on top of that still earns
   its keep for a ragged last row (an odd card count leaving 1 or 2
   short of a full row, which clusters mid-row rather than the grid's
   -1fr- equivalent stretching them wide or sitting left-aligned). */
.work-grid {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: var(--space-md);
  /* Flex items stretch to their row's tallest item by default -- without
     this, expanding one card (see .work-detail) would stretch every
     other card sharing its row to match, rather than just growing its
     own. */
  align-items: flex-start;
  margin-inline: auto;
  /* Single source of truth for how wide a card is allowed to get --
     referenced by .work-card's own max-width and by this container's
     per-tier max-width below (which has to grow in lockstep, see that
     comment), so bumping how large cards are allowed to grow only
     needs changing here. */
  --work-card-max: 34rem;
}

.work-card {
  /* flex-grow:0 is what keeps a card at its own natural width instead
     of stretching to fill leftover space in its row -- both a ragged
     last row (see the .work-grid comment above) and every row once
     max-width below is what's actually capping each card's width. */
  flex: 0 1 100%;
  min-width: 23rem;
  max-width: var(--work-card-max);
}

/* Breakpoints are deliberately well past the width where each tier's
   column count first *could* squeeze in -- .work-card's min-width
   above is a hard floor, so if a tier switched on right at its tightest
   fit, that floor would force an involuntary extra wrap (4 columns
   claiming a row too narrow for 4 real min-width cards silently drops
   to 3 + 1) -- the exact "3 in a row" this is meant to rule out, just
   arrived at sideways instead of via a deliberate 3-column tier. These
   thresholds instead keep a comfortable margin above each tier's actual
   minimum (2 cards: 2*23rem + 1 gap; 4 cards: 4*23rem + 3 gaps, both
   against .work section's own horizontal padding), so 2 or 4 real,
   unclamped columns always fit before that tier switches on. */
@media (min-width: 940px) {
  .work-grid { max-width: calc(2 * var(--work-card-max) + var(--space-md)); }
  .work-card { flex-basis: calc((100% - var(--space-md)) / 2); }
}

@media (min-width: 1900px) {
  .work-grid { max-width: calc(4 * var(--work-card-max) + 3 * var(--space-md)); }
  .work-card { flex-basis: calc((100% - 3 * var(--space-md)) / 4); }
}

.work-card {
  background: var(--surface);
  border-radius: var(--radius-lg);
  overflow: hidden;
  display: flex;
  flex-direction: column;
  /* transition is a non-merging shorthand -- since .reveal-pop (see
     ── Scroll reveals ──) sets its own transition on this same
     element for its opacity/transform reveal animation, and this rule
     comes later in the cascade at equal specificity, leaving out
     those two here would silently replace reveal-pop's transition
     instead of adding to it, snapping the reveal in with no animation
     at all. Durations/easings match reveal-pop's own exactly. */
  transition: opacity 0.6s var(--ease-out-expo), transform 0.7s var(--ease-back), box-shadow 0.4s var(--ease-out-expo);
}

/* Skip the hover outline once a card is open -- it reads as a
   restless distraction on a card whose content you're actively
   reading, rather than an affordance on something still closed.
   .is-scroll-active is the touch-device equivalent of :hover (see
   main.js) -- there's no pointer sitting over anything on a phone, so
   the same highlight instead follows whichever card is most centered
   in view as you scroll. */
.work-card:hover:not(:has(.work-toggle[aria-expanded="true"])),
.work-card.is-scroll-active:not(:has(.work-toggle[aria-expanded="true"])) {
  box-shadow: 0 20px 40px -20px oklch(0.2 0.03 230 / 0.25), 0 0 0 2px var(--teal);
}

.work-card--feature { grid-column: span 2; }

@media (max-width: 760px) {
  .work-card--feature { grid-column: span 1; }
}

/* The card's collapsed face (image + title) is a single disclosure
   button -- clicking/tapping anywhere on it, or pressing Enter/Space
   once focused, reveals .work-detail below. Kept deliberately terse
   while collapsed (see .work-detail) so the grid reads as a quick
   scan of range, with the story reserved for whoever clicks in. */
.work-desc,
.work-foot {
  /* .work-toggle/.work-enquire (real <button>s) are already covered by
     the global a/button rule near the top of this file -- these two
     are clickable via the same toggleCard handler (see main.js) but
     aren't actual button/link elements underneath, so still need it
     declared directly. */
  -webkit-tap-highlight-color: transparent;
}

.work-toggle {
  display: block;
  width: 100%;
  border: none;
  margin: 0;
  padding: 0;
  background: none;
  font: inherit;
  color: inherit;
  text-align: left;
  cursor: pointer;
}

.work-media {
  position: relative;
  aspect-ratio: 3 / 1;
  overflow: hidden;
  background: var(--surface-2);
  transition: aspect-ratio 0.5s var(--ease-out-expo);
}

/* Opens up to 3:2 once the card's expanded -- a wide, short banner
   crop is enough to identify the project at a glance in the closed
   grid, but undersells the image once you've actually asked to see
   more. */
.work-card:has(.work-toggle[aria-expanded="true"]) .work-media {
  aspect-ratio: 3 / 2;
}

/* Thumbnail crop alignment -- attach to .work-media itself (in the
   markup, not the <img>) so it covers whichever image ends up inside:
   the static poster, or a slideshow's dynamically-injected slides
   once its manifest loads (see main.js loadSlideshow()) -- both share
   this same object-position rather than needing the class repeated on
   each. Default (no class) is object-fit:cover's own center crop;
   .thumb-center is only here for symmetry/explicitness with the other
   two. */
.work-media.thumb-top img { object-position: top; }
.work-media.thumb-center img { object-position: center; }
.work-media.thumb-bottom img { object-position: bottom; }

.work-media img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  transition: transform 0.6s var(--ease-out-expo);
}

.work-card:hover:not(:has(.work-toggle[aria-expanded="true"])) .work-media img { transform: scale(1.05); }

/* Stays zoomed in once opened, rather than easing back out to 1 --
   whatever scale it was at when you clicked (typically 1.05, since
   you're almost always hovering what you're about to click) just
   holds, so there's a single grow (the .work-media aspect-ratio change
   above) rather than a shrink immediately followed by a grow. Doesn't
   care about :hover at all -- deliberately not scoped to it, so moving
   the pointer off and back on an open card never changes anything
   here either. */
.work-card:has(.work-toggle[aria-expanded="true"]) .work-media img {
  transform: scale(1.05);
}

/* Populated by main.js only once a card is expanded, from
   assets/img/work/<slug>/manifest.json -- see build-slideshow-
   manifest.js. Stacks over the static image and crossfades between
   whatever's listed; collapsing the card clears it back to empty, so
   the static image is what's left showing. Silently stays empty if
   the project has no slideshow folder yet. */
.work-media-slideshow { position: absolute; inset: 0; }

.work-media-slide {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  opacity: 0;
  transition: opacity 1.2s ease;
}

.work-media-slide.is-active { opacity: 1; z-index: 1; }

.work-heading {
  padding: var(--space-md) var(--space-md) 0.2em;
}

.work-heading h3 {
  font-size: 1.05rem;
  font-weight: 700;
  letter-spacing: -0.01em;
}

/* The card's description: one truncated, ellipsis'd line while
   closed, typed out in full (see main.js) when it opens -- one
   element carrying the real copy throughout rather than a duplicate
   teaser + full-paragraph pair, so there's a single padding to keep
   in sync and nothing to keep in sync between two copies of the same
   text. .is-typing (added by main.js for the duration of the type-out)
   drops the truncation so the growing text can wrap normally. */
.work-desc {
  padding: 0 var(--space-md) var(--space-sm);
  color: var(--muted);
  font-size: 0.92rem;
  max-width: 65ch;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  cursor: pointer;
  /* main.js drives this with an explicit inline max-height (measured
     up front, not grown step-by-step as text arrives) so the card's
     height animates as one smooth motion on its own fixed timeline,
     independent of however fast the type-out itself is going. Duration
     here is just the fallback -- main.js sets its own inline
     transition-duration per direction (opening lingers, closing is
     twice as fast), overriding only that one longhand. */
  transition: max-height 0.4s var(--ease-out-expo);
}
.work-desc.is-typing {
  white-space: normal;
}
/* Line 2 onward, built as one span per word by main.js -- present
   (and so already counted toward the box's height) from the moment
   the card opens, just invisible until its word-reveal timer reaches
   it. Eased rather than an instant snap -- words land close enough
   together that consecutive fades overlap into one soft cascade
   rather than reading as separate individual pops. */
.work-desc-word {
  opacity: 0;
  transition: opacity 0.3s var(--ease-out-expo);
}
.work-desc-word.is-shown {
  opacity: 1;
}
.work-desc strong { color: var(--ink); font-weight: 700; }

/* Always-visible footer: the tag row plus the +/- icon, sitting below
   .work-detail (the collapsible description + enquire button) rather
   than inside .work-toggle -- it has its own click handler in main.js
   (toggleCard, shared with the header button) so the tags area is
   just as clickable as the header, not dead space underneath it. */
/* Tags column + icon sit side by side, bottom-aligned -- the icon is
   a fixed-size sibling (not a flex item inside .work-tags itself), so
   .work-tags only ever gets the remaining width after it (min-width:0
   lets it actually shrink/wrap instead of overflowing) and its pills
   can never run into or past the icon's own column on any line, only
   ever wrap earlier onto a new one. */
.work-foot {
  display: flex;
  align-items: flex-end;
  gap: 0.6em;
  padding: 0 var(--space-md) var(--space-md);
  cursor: pointer;
}
.work-tags {
  flex: 1 1 auto;
  min-width: 0;
  /* .tags li's own left padding (0.8em at its 0.75rem font-size = 0.6rem)
     otherwise reads as extra indent versus .work-desc's text starting
     right at the same left edge above it -- pull the row back by
     exactly that much so the first pill's *text*, not its pill
     background, lines up with the description. */
  margin-left: -0.6rem;
}

/* Closed: a single row that overflows horizontally rather than
   wrapping -- flex-wrap:nowrap plus flex-shrink:0 on the pills
   themselves (below) stops the last one from either dropping to a
   hidden second line or getting squeezed to fit, so it instead runs
   past the row's own width and gets clipped mid-pill by
   overflow:hidden. The mask fades that cut edge to transparent over
   its last ~1.75rem instead of a hard chop, reading as "more tags,
   scroll/expand for them" rather than a ragged edge -- harmless when
   everything actually fits, since the fade then just falls across
   empty space with nothing under it to fade. The icon (a flex
   sibling, not part of this row) sits beside that single line via
   .work-foot's align-items:flex-end above. Open: none of this
   applies, so the box is however tall the full, potentially
   multi-line tag list actually needs. */
.work-card:not(:has(.work-toggle[aria-expanded="true"])) .work-tags {
  flex-wrap: nowrap;
  overflow: hidden;
  mask-image: linear-gradient(to right, #000 calc(100% - 1.75rem), transparent);
  -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 1.75rem), transparent);
}
.work-tags li {
  flex-shrink: 0;
}

/* Down-caret disclosure indicator -- two short bars meeting in a "v"
   at rest; expanding rotates the whole icon 180° so the same two bars
   point up instead, reading as "collapse" without needing separate
   icon assets. Teal by default (not the muted grey the tag pills use)
   so it reads as the card's live control rather than another tag;
   inverts to a solid navy circle with teal bars once open. */
.work-toggle-icon {
  position: relative;
  flex-shrink: 0;
  width: 28px;
  height: 28px;
  border: 1.5px solid var(--teal);
  border-radius: 50%;
  background: #fff;
  transition: background-color 0.3s var(--ease-out-expo), transform 0.3s var(--ease-back);
}
/* Goes solid teal and grows slightly when the *card* is hovered or --
   on touch, where nothing can hover -- most centered in view
   (.is-scroll-active, see main.js), not just when the pointer is
   directly over the icon itself. No rotation here -- that's reserved
   for the open/close state below, where it actually means something;
   this is just the bouncy overshoot-and-settle pop below. */
.work-card:hover:not(:has(.work-toggle[aria-expanded="true"])) .work-toggle-icon,
.work-card.is-scroll-active:not(:has(.work-toggle[aria-expanded="true"])) .work-toggle-icon {
  background: var(--teal);
  /* Two animations on the same property, in sequence -- toggle-pop's
     forwards holds it scaled from 0.5s onward, but toggle-pulse's own
     active period *also* starts right at that 0.5s mark (its delay),
     and a later-listed animation wins control of a shared property
     over an earlier one once both are active -- so it seamlessly
     takes over from there, nudging the icon again every couple of
     seconds for as long as the card stays hovered/active instead of
     it going motionless after the initial pop. */
  animation:
    toggle-pop 0.5s var(--ease-out-expo) forwards,
    toggle-pulse 2s var(--ease-out-expo) 0.5s infinite;
}
@keyframes toggle-pop {
  0% { transform: scale(1); }
  40% { transform: scale(1.35); }
  65% { transform: scale(1.05); }
  85% { transform: scale(1.2); }
  100% { transform: scale(1.15); }
}
/* Starts and ends each cycle at scale(1.15) -- exactly where toggle-pop
   above leaves off -- so the handoff between the two, and every loop
   after, is seamless. Holds there for the back half of the cycle
   (60%-100%) rather than looping the bounce back-to-back, so repeats
   read as an occasional nudge, not a constant jitter. */
@keyframes toggle-pulse {
  0%, 60%, 100% { transform: scale(1.15); }
  15% { transform: scale(1.35); }
  30% { transform: scale(1.1); }
  45% { transform: scale(1.22); }
}
.work-toggle-icon::before,
.work-toggle-icon::after {
  content: '';
  position: absolute;
  /* The two bars share a pivot (their meeting point, at the icon's
     horizontal center) that rotate() below also uses as its origin --
     but a "v"'s vertex isn't its own visual center, its arms only
     extend one way from it. Offsetting the pivot down by half the
     arms' vertical reach (8px bars at 45° => ~5.66px tall => 2.83px
     half) centers the whole chevron shape -- not just its vertex -- on
     the icon's true center (14px), so rotating it 180° swaps a
     centered "v" for a centered "^" instead of visibly drifting. */
  top: 15.83px;
  width: 8px;
  height: 2px;
  background: var(--navy);
  transition: background-color 0.3s var(--ease-out-expo);
}
.work-toggle-icon::before { left: 6px; transform-origin: right center; transform: rotate(45deg); }
.work-toggle-icon::after { right: 6px; transform-origin: left center; transform: rotate(-45deg); }
/* The icon lives outside .work-toggle (see .work-foot), so its
   expanded state keys off the card as a whole rather than a
   .work-toggle descendant selector. Rotating the icon itself (rather
   than re-angling each bar individually) carries the "v" along with
   it, flipping it to point up. */
.work-card:has(.work-toggle[aria-expanded="true"]) .work-toggle-icon {
  background: var(--navy);
  transform: rotate(180deg);
}
.work-card:has(.work-toggle[aria-expanded="true"]) .work-toggle-icon::before,
.work-card:has(.work-toggle[aria-expanded="true"]) .work-toggle-icon::after { background: var(--teal); }

/* Description & the enquire CTA -- collapsed to zero height by
   default via the 0fr/1fr grid-row trick (animatable, unlike
   height:auto) rather than hidden entirely, so expanding never has to
   wait on layout/display swaps. */
.work-detail {
  display: grid;
  grid-template-rows: 0fr;
  overflow: hidden;
  /* A CSS transition takes its duration/timing-function from whichever
     rule the animated property is transitioning *into* -- so this
     (the closed/base state) is what runs when the card is minimising.
     Linear rather than ease-out-expo: ease-out's whole point is a slow
     tail as it settles, which just reads as sluggish on the way down. */
  transition: grid-template-rows 0.5s linear;
  cursor: pointer;
}

/* .work-detail is no longer the immediate next sibling of
   .work-toggle (see .work-desc in between, added for the description
   type-out), so this keys off the card as a whole rather than a "+"
   adjacent-sibling selector -- a plain "+" here would just silently
   never match. */
.work-card:has(.work-toggle[aria-expanded="true"]) .work-detail {
  grid-template-rows: 1fr;
  transition: grid-template-rows 0.5s var(--ease-out-expo);
}

.work-detail-inner {
  min-height: 0;
  display: flex;
  flex-direction: column;
  gap: 0.5em;
  padding: 0 var(--space-md) var(--space-md);
  opacity: 0;
  transition: opacity 0.3s var(--ease-out-expo);
}
/* With box-sizing:border-box, an element can't shrink below its own
   padding no matter what min-height says -- so .work-detail-inner's
   own bottom padding was silently flooring the "collapsed" .work-detail
   row at var(--space-md) instead of the true 0 the 0fr trick above is
   going for (invisible since this box is opacity:0 while closed, but
   still real layout height, stacking on top of the .work-desc/.work-tags
   gap this whole file section is about). Dropping it only while closed
   -- restored the instant the card opens, for its normal breathing
   room above the enquire button. */
.work-card:not(:has(.work-toggle[aria-expanded="true"])) .work-detail-inner {
  padding-bottom: 0;
}

.work-card:has(.work-toggle[aria-expanded="true"]) .work-detail-inner {
  opacity: 1;
  transition-delay: 0.15s;
}


.tags {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4em;
}

.tags li {
  font-size: 0.75rem;
  color: var(--muted);
  background: var(--surface-2);
  padding: 0.3em 0.8em;
  border-radius: 999px;
  font-weight: 600;
}

.work-enquire {
  display: inline-flex;
  align-self: flex-start;
  align-items: center;
  gap: 0.5em;
  margin-top: 0.3em;
  padding: 0.65em 0.9em;
  border-radius: 999px;
  /* Flat fill, plus a black border whose own alpha fades top-to-bottom
     -- see the comment on .btn-primary for the double-background-clip
     trick and why background-color's own backstop matters here. */
  background:
    linear-gradient(var(--yellow), var(--yellow)) padding-box,
    linear-gradient(to top, oklch(0 0 0 / 0.10), oklch(0 0 0 / 0.06)) border-box;

  background-color: var(--yellow);
  border: 0.15rem solid transparent;
  font-family: inherit;
  font-size: 1rem;
  font-weight: 700;
  color: var(--navy);
  cursor: pointer;
  transition: transform 0.25s var(--ease-back);
}
.work-enquire svg {
  /* Visually first without touching DOM/reading order -- the icon
     stays aria-hidden and after the label in markup, order:-1 just
     flips where it paints in this flex row. */
  order: -1;
  width: 20px;
  height: 20px;
  flex-shrink: 0;
  transition: transform 0.25s var(--ease-out-expo);
}
.work-enquire:hover, .work-enquire:focus-visible {
  transform: translateY(-2px);
}
/* Nudges left (away from the label) rather than right now that the
   icon leads instead of trails -- reads as the envelope "sending"
   off to the side instead of drifting back into the text. */
.work-enquire:hover svg, .work-enquire:focus-visible svg { transform: translateX(-3px); }

/* A work-card's inner horizontal breathing room (.work-heading,
   .work-desc, .work-foot, .work-detail-inner) all share var(--space-md)
   -- 2rem, tuned for the grid's desktop card width. On phones the card
   is nearly the full viewport width, so that same 2rem reads as
   proportionally too much inset; tightened to 1.75rem here rather than
   touching --space-md itself, which plenty of unrelated spacing
   elsewhere also depends on. */
@media (max-width: 600px) {
  .work-heading { padding: 1.75rem 1.75rem 0.2em; }
  .work-desc { padding: 0 1.75rem var(--space-sm); }
  .work-foot { padding: 0 1.75rem 1.75rem; }
  /* padding-bottom here is moot while the card's closed -- the
     existing .work-card:not(:has(...)) .work-detail-inner rule (higher
     specificity, see its own comment above) still zeroes it out
     regardless of this value, same as at desktop widths. */
  .work-detail-inner { padding: 0 1.75rem 1.75rem; }
}

/* ── About ───────────────────────────────────────────────────────────── */
.about-lede {
  font-size: 1.15rem;
  line-height: 1.75;
  max-width: 70ch;
}
.about-lede strong { color: var(--teal-ink); font-weight: 700; }

/* ── Showreel ────────────────────────────────────────────────────────── */
/* Floats as a control bar over the vertical center of the slideshow
   rather than sitting in its own row above the frame -- each tab
   doubles as that category's play button (see js/main.js), so a dark,
   blurred backdrop keeps the pill legible over whatever photo happens
   to be showing. top:50% + translateY(-50%) centers it regardless of
   its own height; on play, .is-playing swaps both to slide the whole
   bar up and out above the frame instead, clearing it so the Vimeo
   iframe underneath is unobstructed -- .reel-stage has no
   overflow:hidden of its own, so the bar is free to travel past the
   frame's top edge rather than getting clipped. */
.reel-tabs {
  position: absolute;
  left: var(--space-sm);
  right: var(--space-sm);
  top: 50%;
  transform: translateY(-50%);
  z-index: 2;
  display: flex;
  justify-content: center;
  gap: 0.6em;
  flex-wrap: wrap;
  transition: top 0.5s var(--ease-out-expo), transform 0.5s var(--ease-out-expo);
}

.reel-tabs.is-playing {
  top: 0;
  transform: translateY(calc(-100% - var(--space-md) - var(--space-sm)));
}

.reel-tab {
  display: inline-flex;
  align-items: center;
  flex-shrink: 0;
  gap: 0.6em;
  font-family: inherit;
  font-size: 1.3rem;
  font-weight: 700;
  text-shadow: 1px 1px 7px rgba(0, 0, 0, 0.5);
  white-space: nowrap;
  padding: 0.75em 1.5em 0.75em 1.1em;
  border-radius: 999px;
  border: 1.5px solid oklch(1 0 0 / 0.25);
  border: 4px solid rgb(0 173 181 / 50%);
  background: oklch(0.2 0.03 230 / 0.55);
  backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px);
  color: #fff;
  cursor: pointer;
  transition: transform 0.25s var(--ease-back), background-color 0.2s var(--ease-out-expo), border-color 0.2s var(--ease-out-expo), color 0.2s var(--ease-out-expo), font-size 0.5s var(--ease-out-expo), padding 0.5s var(--ease-out-expo);
}

.reel-tab-play {
  flex-shrink: 0;
  width: 18px;
  height: 18px;
  border-radius: 50%;
  background: oklch(1 0 0 / 0.18);
  padding: 6px;
  box-sizing: content-box;
  transition: background-color 0.2s var(--ease-out-expo), width 0.5s var(--ease-out-expo), height 0.5s var(--ease-out-expo), padding 0.5s var(--ease-out-expo);
}

.reel-tab:hover { 
  transform: scale(1.06); 
  border: 4px solid rgb(0 173 181 / 80%);}

.reel-tab:hover .reel-tab-play { background:  var(--teal); }

.reel-tab.is-active {
  background: var(--teal);
  border-color: var(--teal);
  color: var(--navy);
}

.reel-tab.is-active .reel-tab-play { background: oklch(1 0 0 / 0.3); }

/* Once playback moves the bar up out of the frame (.is-playing above),
   shrink the tabs -- they're no longer the focal control, just a small
   category switcher sitting above the video. */
.reel-tabs.is-playing .reel-tab {
  font-size: 1rem;
  padding: 0.55em 1.1em 0.55em 0.85em;
}
.reel-tabs.is-playing .reel-tab-play {
  width: 14px;
  height: 14px;
  padding: 5px;
}

/* Below ~600px the frame itself is short (16:9 at phone width), so
   floating the tab bar over its vertical center -- or sliding it up
   just above the frame once playing -- leaves too little room: three
   pills at this size either overflow the frame's edges or crowd right
   up against the section heading above. Simplest fix is to stop
   overlaying altogether and let the bar sit in normal flow right
   above the frame instead (see the markup order in index.html), in
   both states. */
@media (max-width: 600px) {
  /* flex:1 on each pill below is what actually guarantees the row
     fills 100% of the width, growing/shrinking the three pills to
     divide up whatever space exists -- font-size here (not on
     .reel-tab) is just for readability across the range now, not
     doing double duty as the thing that has to add up to "fits the
     row" the way it did before (padding/vw-tuning could get close but
     always left a gap once the font hit its clamp() ceiling and
     stopped growing while the available width kept climbing). Still in
     em below (gap, padding, .reel-tab-play's size) so the whole bar
     scales together off this one value. */
  .reel-tabs,
  .reel-tabs.is-playing {
    position: static;
    transform: none;
    margin-bottom: var(--space-sm);
    font-size: clamp(0.8rem, 4.2vw, 1.3rem);
    gap: 0.18em;
    /* Break out of the section's own horizontal padding to 95% of the
       viewport, rather than sitting inset with the heading/frame above
       and below it -- width:95vw plus this margin (the standard
       "center a vw-wide element regardless of its padded parent" pair)
       instead of the negative-margin trick .reel-frame uses further
       down, since that one specifically targets 100% edge-to-edge and
       cares about scrollbar-width drift; 95vw has slack to spare. */
    width: 95vw;
    margin-left: calc(50% - 47.5vw);
  }
  .reel-tab,
  .reel-tabs.is-playing .reel-tab {
    /* flex-basis:auto (the "1 1 auto" shorthand below), not 0 -- each
       pill starts from its own content width (so "Events" stays
       narrower than "Education") and only the leftover space beyond
       that gets split, equally in *amount* added to each rather than
       forcing all three to the same final width. Padding is one shared
       value across all three (below), so once width isn't artificially
       equalized, the padding actually looks even too instead of
       "Events" reading as more padded than the longer words. */
    flex: 1 1 auto;
    justify-content: center;
    /* .reel-tab has its own font-size at every other breakpoint (1.3rem
       base, 1rem once playing) -- inherit here specifically so it picks
       up .reel-tabs' clamp() above instead of one of those fixed values. */
    font-size: inherit;
    gap: 0.16em;
    padding: 0.7em 0.3em;
  }
  /* Bare triangle at this size -- no circle badge -- both to match the
     request and because the badge's own padding was most of what
     wouldn't let all three pills fit on one line. em-sized so it scales
     with the rest of the bar (see above) instead of a fixed px value. */
  .reel-tab-play,
  .reel-tabs.is-playing .reel-tab-play {
    width: 1.15em;
    height: 1.15em;
    background: none;
    padding: 0;
  }
  /* Less clearance before the tab row than the desktop default (--space-lg,
     4rem) -- that much gap between the intro text and the Events/
     Education/Animation buttons reads as too much empty space on a
     short phone viewport. */
  .reel > .section-head { margin-bottom: var(--space-md); }
  /* The tab bar never overlays the frame at this width (see above), so
     it never needs the extra clearance .reel.is-playing reserves above. */
  .reel.is-playing > .section-head { margin-bottom: var(--space-lg); }

  /* Break the frame out of the section's own side padding so it runs
     full-bleed edge to edge. Cancels out --section-pad-x with an equal
     negative margin rather than the more common 100vw +
     translateX(-50%) trick -- 100vw includes the scrollbar's width,
     which the section's own padding-driven layout doesn't, so that
     trick overshoots and drifts the frame sideways on any page with a
     vertical scrollbar (this one). Heading text and the tabs/share
     button below keep the normal inset; only the video goes edge to
     edge. */
  .reel-frame {
    margin-inline: calc(-1 * var(--section-pad-x));
    border-radius: 0;
  }
}

.reel-stage {
  position: relative;
  z-index: 1;
}

.reel-frame {
  position: relative;
  z-index: 1;
  aspect-ratio: 16 / 9;
  border-radius: var(--radius-lg);
  overflow: hidden;
  background: var(--navy);
  border: 1px solid oklch(1 0 0 / 0.12);
  /* Before anything's playing, clicking anywhere on the poster/
     slideshow starts the first tab (see the reelFrame click handler
     in main.js) -- the tabs floating on top are still the more
     deliberate pick target, but the frame itself is clickable too at
     every width now, not just below the ~600px breakpoint where they
     stop overlaying it. */
  cursor: pointer;
}

.reel-poster {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* Fading slideshow filling the frame, with the tab bar (play buttons)
   floating on top -- built from whatever images main.js finds listed
   in the current tab's data-slideshow folder manifest (see
   scripts/build-slideshow-manifest.js; falls back to the plain static
   poster above if no manifest loads). Each .reel-slide is a full .reel-
   poster image absolutely stacked on the others; .is-active crossfades
   it in via opacity while the rest sit at 0. */
.reel-slideshow {
  position: relative;
  width: 100%;
  height: 100%;
}

.reel-slide {
  position: absolute;
  inset: 0;
  opacity: 0;
  z-index: 0;
  transition: opacity 1.2s ease;
}

.reel-slide.is-active {
  opacity: 1;
  z-index: 1;
}

/* Covers the preloaded-but-paused iframe (see playReel() in main.js)
   so Vimeo's own transport bar stays hidden until playback starts --
   the iframe underneath keeps loading regardless, so this is purely
   visual, not a delay. */
.reel-play-cover {
  position: absolute;
  inset: 0;
  z-index: 1;
  /* A <div>, not an actual button/link, so the global a/button rule
     near the top of this file doesn't reach it -- but main.js gives it
     a click handler and cursor:pointer, so it needs the same treatment. */
  -webkit-tap-highlight-color: transparent;
}

/* The centered play button (main.js builds it with the exact same
   markup as an inactive reel-tab -- icon + label) is a literal
   duplicate of e.g. the "Education" pill above, just scaled up and
   centered over the frame -- transform:scale keeps every proportion
   (padding, icon, text) identical to the tab it's copying rather than
   re-deriving sizing by hand. Starts in the tabs' resting (not
   .is-active) look and lights up teal on hover -- keyed off the whole
   frame/slideshow, not just the button, so mousing over the preview
   image itself is enough to invite the click. */
.reel-play {
  position: absolute;
  inset: 0;
  z-index: 2;
  margin: auto;
  width: max-content;
  height: max-content;
  transform: scale(1.6);
}

.reel-frame:hover .reel-play {
  transform: scale(1.7);
  background: var(--teal);
  border-color: var(--teal);
  color: var(--navy);
}

.reel-frame:hover .reel-play .reel-tab-play {
  background: oklch(1 0 0 / 0.3);
}

.reel-frame iframe {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  border: 0;
}

.reel-link {
  position: relative;
  z-index: 1;
  display: inline-block;
  margin-top: var(--space-sm);
  color: var(--teal);
  font-size: 0.9rem;
  font-weight: 700;
  transition: opacity 0.25s var(--ease-out-expo);
}
.reel-link:hover, .reel-link:focus-visible { opacity: 0.7; }

/* Enquire + share, side by side once a reel's playing -- sits below
   the frame, centred as its own row so it reads as a deliberate pair
   of calls to action rather than a footnote link. */
/* Collapsed to zero height by default (0fr/1fr grid-row trick, same
   idea as .work-detail) rather than just opacity:0 on the buttons
   themselves -- there's nothing to enquire about or share until a
   reel's actually playing (see .reel.is-playing, set by main.js once
   playback starts), so the space below the frame stays tight until
   then instead of reserving full button height for something that
   doesn't exist yet. Grows open only once playback starts. */
.reel-share-wrap {
  display: grid;
  grid-template-rows: 0fr;
  /* A grid item's margin still counts toward its track's rendered
     size even with min-height:0 on the item (min-height:0 only drops
     the *content*-driven automatic minimum -- see the .work-detail-inner
     comment above for the same gotcha with padding) -- so the
     var(--space-md) gap above the row lives here, on the wrapper's own
     padding-top, instead of as the row's margin-top. Padding on the
     wrapper is independent of the row-track sizing, so it only grows
     in step with grid-template-rows via its own transition, rather
     than permanently flooring the collapsed height. */
  padding-top: 0;
  overflow: hidden;
  /* overflow itself doesn't need to (and, being a discrete property,
     can't smoothly) animate -- 0s duration with a delay matching the
     grid-row grow above just holds it clipped throughout that
     animation, then flips to visible right as it finishes, same
     technique commonly used to delay a visibility:hidden switch. */
  transition: grid-template-rows 0.4s var(--ease-out-expo), padding-top 0.4s var(--ease-out-expo), overflow 0s 0.4s;
}
.reel.is-playing .reel-share-wrap {
  grid-template-rows: 1fr;
  padding-top: var(--space-md);
  /* Once open there's nothing left to enforce the 0fr collapse trick
     against, so nothing needs clipping any more -- switching to
     visible here is what stops .reel-enquire/.reel-share's own hover
     scale(1.06) (see below) from getting its bottom edge (border
     included) shaved off by this wrapper's overflow, since the row
     was otherwise sized to fit them at exactly their un-scaled size
     with zero headroom to grow into on hover. */
  overflow: visible;
}

.reel-actions {
  display: flex;
  align-items: center;
  justify-content: center;
  flex-wrap: wrap;
  gap: 0.75em;
  /* The grid item .reel-share-wrap actually collapses (see above) --
     needs the same min-height:0 override itself, one level down, or
     its own automatic content-driven minimum would floor the row
     short of true 0 same as everything else on this page's collapse
     tricks. */
  min-height: 0;
}

.reel-enquire,
.reel-share {
  position: relative;
  z-index: 1;
  display: flex;
  width: fit-content;
  align-items: center;
  gap: 0.6em;
  /* Vertical padding is each button's own remaining contribution to
     .reel-share-wrap's collapsed height (a border-box element can't
     shrink below its own padding regardless of any ancestor's
     min-height -- same gotcha as the wrapper's margin above), so it's
     0 by default and only opens up once playing, same as the
     wrapper's grid-row/padding-top. Horizontal padding stays fixed --
     it doesn't affect the row's height either way. */
  padding: 0 1.1em;
  border-radius: 999px;
  font-family: inherit;
  font-size: 1rem;
  font-weight: 700;
  cursor: pointer;
  opacity: 0;
  pointer-events: none;
  transition: transform 0.25s var(--ease-back), box-shadow 0.25s var(--ease-out-expo), opacity 0.3s var(--ease-out-expo), padding 0.4s var(--ease-out-expo), background-color 0.2s var(--ease-out-expo), border-color 0.2s var(--ease-out-expo);
}
.reel.is-playing .reel-enquire,
.reel.is-playing .reel-share {
  padding-top: 0.8em;
  padding-bottom: 0.8em;
  opacity: 1;
  pointer-events: auto;
}

/* Primary -- same yellow pill as a work-card's own "Enquire about
   this" (.work-enquire, reused here for its data-project click
   handler in main.js too, see the click handler up near the top of
   this file's ── Buttons ── section), so it reads as the same action
   wherever it appears on the page. */
.reel-enquire {
  /* Cancels out .work-enquire's own align-self/margin-top (this
     button carries that class too, purely so main.js's generic
     data-project click handler picks it up -- see the comment above)
     which would otherwise misalign it against .reel-share in this
     centred row. */
  align-self: auto;
  margin: 0;
  /* Flat fill, plus a black border whose own alpha fades top-to-bottom
     -- see the comment on .btn-primary. Overrides .work-enquire's own
     copy of this (same border-radius/border still apply from there,
     since this rule doesn't touch those properties). */
  background:
    linear-gradient(var(--yellow), var(--yellow)) padding-box,
    linear-gradient(to bottom, oklch(0 0 0 / 0.28), oklch(0 0 0 / 0.06)) border-box;
  background-color: var(--yellow);
  color: var(--navy);
  box-shadow: 0 6px 16px -6px oklch(0 0 0 / 0.4);
}
.reel-enquire:hover, .reel-enquire:focus-visible {
  transform: scale(1.06);
  box-shadow: 0 10px 24px -6px oklch(0 0 0 / 0.5);
}

/* Secondary -- a ghost pill (same idea as .btn-ghost) rather than a
   second solid yellow fill sitting right next to the first: two
   identical bright CTAs side by side would leave neither reading as
   the primary one. */
.reel-share {
  border: 1.5px solid oklch(1 0 0 / 0.5);
  background: transparent;
  color: #fff;
}
.reel-share:hover, .reel-share:focus-visible {
  transform: scale(1.06);
  border-color: #fff;
  background: oklch(1 0 0 / 0.08);
}

/* ── Services ────────────────────────────────────────────────────────── */
.service-list {
  border-top: 1px solid var(--surface-2);
}

.service-row {
  display: grid;
  grid-template-columns: 1.1fr 1.4fr auto;
  gap: var(--space-md);
  align-items: center;
  padding: var(--space-md) 0;
  border-bottom: 1px solid var(--surface-2);
  border-left: 3px solid transparent;
  transition: padding-left 0.3s var(--ease-out-expo), background-color 0.3s var(--ease-out-expo), border-left-color 0.3s var(--ease-out-expo);
}

.service-row:hover {
  padding-left: var(--space-sm);
  background: var(--surface);
  border-left-color: var(--pink);
}

.service-row h3 {
  font-size: 1.4rem;
  font-weight: 700;
  letter-spacing: -0.01em;
}

.service-row p {
  color: var(--muted);
  font-size: 0.95rem;
  max-width: 50ch;
}

.service-row .tags { justify-content: flex-end; margin-top: 0; }
.service-row .tags li { color: var(--pink-ink); background: var(--pink-tint); }

@media (max-width: 760px) {
  .service-row {
    grid-template-columns: 1fr;
    gap: 0.6em;
  }
  .service-row .tags { justify-content: flex-start; }
}

/* ── Contact ─────────────────────────────────────────────────────────── */
.contact {
  position: relative;
  overflow: hidden;
  text-align: center;
  background: linear-gradient(180deg, oklch(0.69 0.12 200), var(--teal) 45%);
  color: #fff;
  border-radius: var(--radius-lg);
  margin: 0 clamp(var(--space-sm), 6vw, var(--space-xl)) var(--section-pad-y);
  /* The fixed nav (~60px tall) would otherwise sit flush over the
     rounded top edge when jumping straight to #contact -- this clears
     it with room to spare so the curve lands fully below the bar. */
  scroll-margin-top: 5.5rem;
}

@media (max-width: 780px) {
  /* Full-bleed on narrow screens -- the side margins and rounded
     corners just read as wasted padding once the box is nearly as
     wide as the viewport anyway. Heading/copy/email button switch to
     left-aligned at the same point -- centered text over a
     near-full-width block reads oddly once it's this close to the
     viewport edges, unlike on desktop where the box is visibly
     inset. The form further down (.contact-form) already sets its
     own text-align and isn't affected by this. */
  .contact {
    margin-left: 0;
    margin-right: 0;
    border-radius: 0;
    text-align: left;
    /* The base 5.5rem exists to clear the rounded top corner (see its
       own comment above) -- moot here since border-radius is 0 at this
       width anyway, so it was just leaving a large gap of plain
       background between the nav and the heading for no reason. Tight
       enough to clear the nav itself (~57px) with a small gap -- minus
       another ~10px on top of that (matching .reel/.work's own mobile
       adjustment), landed a little high otherwise. */
    scroll-margin-top: calc(4rem - 10px);
  }
}

.contact h2 {
  font-size: clamp(2rem, 5vw, 3.4rem);
  font-weight: 800;
  letter-spacing: -0.02em;
}

.contact p {
  color: oklch(1 0 0 / 0.9);
  max-width: 46ch;
  margin: var(--space-sm) auto 0;
  font-weight: 500;
}

@media (max-width: 780px) {
  /* margin:auto centers the paragraph as a block regardless of the
     parent's text-align (see .contact's own left-align above) --
     needs its own override, placed after the base rule above so it
     actually wins the cascade at this width. */
  .contact p { margin-left: 0; margin-right: 0; }
}

.contact-links {
  display: flex;
  gap: var(--space-md);
  justify-content: center;
  font-size: 0.9rem;
  font-weight: 600;
  color: oklch(1 0 0 / 0.85);
}

.contact-links a { transition: opacity 0.25s var(--ease-out-expo); }
.contact-links a:hover, .contact-links a:focus-visible { opacity: 0.7; }

/* ── Contact form ────────────────────────────────────────────────────── */
/* Flip card: front face is the live form, back face is a "Posted"
   thank-you revealed by rotating the whole card 180° on submit (see
   main.js flipToThankYou()). JS sets an explicit height on
   .contact-flip before flipping so the card doesn't jump (the back
   face is absolutely positioned, so it can't otherwise size itself). */
.contact-flip {
  position: relative;
  z-index: 1;
  margin: var(--space-sm) auto 0;
  max-width: 560px;
  perspective: 1800px;
}

.contact-flip-inner {
  position: relative;
  width: 100%;
  height: 100%;
  transform-style: preserve-3d;
  transition: transform 0.9s var(--ease-back);
}
.contact-flip.is-sent .contact-flip-inner { transform: rotateY(180deg); }

.contact-flip-front,
.contact-flip-back {
  backface-visibility: hidden;
  -webkit-backface-visibility: hidden;
}

.contact-flip-back {
  position: absolute;
  inset: 0;
  transform: rotateY(180deg);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  text-align: center;
  gap: 0.4em;
  background: var(--bg);
  color: var(--ink);
  border-radius: var(--radius-lg);
  padding: var(--space-md);
  box-shadow: 0 30px 60px -20px oklch(0.2 0.03 230 / 0.35);
}

.postmark {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 84px;
  height: 84px;
  border: 3px dashed var(--teal);
  border-radius: 50%;
  color: var(--teal-ink);
  font-weight: 800;
  font-size: 0.95rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  transform: rotate(-12deg) scale(0);
  margin-bottom: 0.2em;
}
.contact-flip.is-sent .postmark {
  animation: postmark-stamp 0.5s var(--ease-back) 0.55s forwards;
}
@keyframes postmark-stamp {
  to { transform: rotate(-12deg) scale(1); }
}

.flip-thanks {
  font-size: clamp(1.8rem, 4vw, 2.4rem);
  font-weight: 800;
}
.flip-thanks .letter {
  display: inline-block;
  opacity: 0;
  transform: translateY(14px);
}
.contact-flip.is-sent .flip-thanks .letter {
  animation: letter-rise 0.5s var(--ease-back) forwards;
  animation-delay: calc(0.75s + var(--i, 0) * 45ms);
}
@keyframes letter-rise {
  to { opacity: 1; transform: translateY(0); }
}

.contact-flip-back p {
  color: var(--muted);
  max-width: 32ch;
}

.contact-form {
  position: relative;
  text-align: left;
  background: var(--bg);
  color: var(--ink);
  border-radius: var(--radius-lg);
  padding: var(--space-md);
  display: flex;
  flex-direction: column;
  gap: var(--space-sm);
  box-shadow: 0 30px 60px -20px oklch(0.2 0.03 230 / 0.35);
}

@media (max-width: 600px) {
  /* --space-sm is half of --space-md -- the form's own sides don't
     need to double up on the section's mobile-reduced side padding. */
  .contact-form { padding-left: var(--space-sm); padding-right: var(--space-sm); }
}

.form-row {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--space-sm);
}

@media (max-width: 800px) {
  .form-row { grid-template-columns: 1fr; }
}

.form-field {
  display: flex;
  flex-direction: column;
  gap: 0.35em;
  font-size: 0.85rem;
  font-weight: 700;
  text-align: left;
}

.form-field span { color: var(--muted); }

.form-field input,
.form-field select,
.form-field textarea {
  font-family: inherit;
  font-size: 0.95rem;
  font-weight: 500;
  color: var(--ink);
  background: var(--surface);
  border: 1.5px solid var(--surface-2);
  border-radius: var(--radius-sm);
  padding: 0.7em 0.9em;
  transition: border-color 0.2s var(--ease-out-expo);
}

.form-field input:focus,
.form-field select:focus,
.form-field textarea:focus {
  outline: none;
  border-color: var(--teal);
}

.form-field textarea { resize: vertical; }

.form-field input.is-invalid,
.form-field textarea.is-invalid {
  border-color: var(--pop-red);
}

.field-error {
  font-size: 0.8rem;
  font-weight: 600;
  color: var(--pop-red);
}
.field-error:empty { display: none; }

.iron-triangle {
  border: 0;
  border-radius: var(--radius-md);
  padding: var(--space-sm) 0;
  text-align: center;
}

.iron-triangle-body {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
}

.iron-triangle legend {
  font-size: 0.85rem;
  font-weight: 700;
  color: var(--muted);
  padding: 0 0.3em;
  margin: 0 auto;
}

.iron-triangle-options {
  display: flex;
  gap: 0.6em;
  flex-wrap: wrap;
  justify-content: center;
}

.iron-toggle {
  font-family: inherit;
  font-size: 0.9rem;
  font-weight: 700;
  padding: 0.55em 1.1em 0.65em 0.66em;
  border-radius: 999px;
  border: 3px solid var(--surface-2);
  background: var(--surface);
  color: var(--ink);
  cursor: pointer;
  transition: transform 0.25s var(--ease-back), background-color 0.2s var(--ease-out-expo), border-color 0.2s var(--ease-out-expo), color 0.2s var(--ease-out-expo);
}

/* scale(), not translateY() -- a vertical lift physically moves the
   button away from a cursor parked near its bottom edge, un-hovering
   it, which drops it back onto the cursor and re-triggers hover in a
   jittering loop. Scaling from center never moves an edge away from
   the pointer that's already over it. */
.iron-toggle:hover { transform: scale(1.06); }

.iron-toggle.is-selected {
  background: var(--pink);
  border-color: var(--pink);
  color: #fff;
}

.iron-toggle.is-bounce { animation: iron-bounce 0.35s var(--ease-back); }

@keyframes iron-bounce {
  0%, 100% { transform: translateY(0); }
  50% { transform: translateY(-6px) rotate(-4deg); }
}

/* Hidden until a third pick is attempted, then slides open -- see
   main.js (adds .is-revealed on the bump path). The grid-template-rows
   0fr -> 1fr trick animates smoothly to the content's natural height
   without needing to measure it in JS. */
.iron-triangle-note-wrap {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 0.5s var(--ease-back);
}
.iron-triangle-note-wrap.is-revealed { grid-template-rows: 1fr; }

.iron-triangle-note-wrap .iron-triangle-note {
  overflow: hidden;
  min-height: 0;
  margin: 0.6em 0 0;
  font-size: 0.85rem;
  font-weight: 600;
  color: var(--muted);
}

/* .contact-form's own flex gap (see above) inserts the usual
   --space-sm above every child including this one -- pull it back out
   specifically here so the submit button sits flush under whatever
   precedes it instead. */
.form-submit {
  align-self: center;
  margin-top: calc(-1 * var(--space-sm));
  /* Overrides .btn-accent's own shadow (both resting and hover) --
     scoped to this button specifically so the hero CTA sharing that
     class keeps its shadow. */
  box-shadow: none;
}
.form-submit:hover { box-shadow: none; }

/* Filled in by main.js, not this markup -- see the comment there for
   why (keeping the literal address out of the page source deters the
   simple regex-scraping bots that most email harvesting relies on). */
.contact-email {
  align-self: center;
  color: var(--muted);
  font-size: 0.85rem;
  font-weight: 600;
}
.contact-email:hover, .contact-email:focus-visible { color: var(--teal-ink); }

.form-status {
  text-align: center;
  font-size: 0.9rem;
  font-weight: 600;
}
.form-status.is-success { color: var(--teal-ink); }
.form-status.is-error { color: var(--pop-red); }

/* ── Footer ──────────────────────────────────────────────────────────── */
.site-footer {
  text-align: center;
  padding: var(--space-md) var(--space-sm) var(--space-lg);
}

/* Photo + bio as one centred row -- text-align:left inside so the
   copy wraps naturally next to the photo rather than re-centering
   itself line by line under the site-footer's own centered text. */
.footer-bio {
  display: flex;
  /* stretch (the flex default, spelled out here since it'd otherwise
     read as an oversight) is what makes .footer-bio-photo match the
     text's own height below -- align-items:center would instead give
     the photo only its own intrinsic (width-driven) height. */
  align-items: stretch;
  gap: var(--space-sm);
  max-width: 52ch;
  margin: 0 auto var(--space-md);
  text-align: left;
}

.footer-bio-photo {
  position: relative;
  flex-shrink: 0;
  width: 110px;
  /* No explicit height -- stretch (above) sizes it to match
     .footer-bio-text's own height instead, however many lines that
     ends up being. */
  border-radius: var(--radius-md);
  overflow: hidden;
  background: var(--surface-2);
}

/* Fading photo slideshow, same idea as .reel-slide/.work-media-slide --
   each slide stacks absolutely over the others; .is-active crossfades
   it in via opacity while the rest sit at 0. Cycled by main.js. */
.footer-bio-slide {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  opacity: 0;
  transition: opacity 1.2s ease;
}
.footer-bio-slide.is-active { opacity: 1; }

.footer-bio-text {
  color: var(--ink);
  font-size: 0.95rem;
  font-weight: 500;
  line-height: 1.6;
}

@media (max-width: 480px) {
  /* The row still fits two-up at phone widths (the photo's small and
     the text wraps), just tighter -- only drops to a smaller photo
     rather than stacking, so the bio still reads as one connected
     unit instead of a photo sitting orphaned above a paragraph. */
  .footer-bio-photo { width: 84px; }
}

.footer-copy {
  color: var(--muted);
  font-size: 0.8rem;
  font-weight: 500;
}
