/* ==========================================================================
   FONTS - self-hosted
   Served from assets/fonts instead of fonts.googleapis.com. Nothing about
   this page reaches a third party now, and the critical path got much
   shorter: Google's stylesheet alone was 119 KB, because asking for Noto
   Sans JP pulls 496 @font-face declarations into a render-blocking file
   that every visitor downloads whatever language they read.

   Space Grotesk is ONE variable file per subset, declared 400-700. Google
   serves 400, 500 and 700 as three separate URLs holding byte-identical
   files (verified: same md5), so a page using all three weights downloaded
   the same 21 KB three times.

   The vietnamese subsets are not shipped - there is no Vietnamese content
   in any of the four languages. Noto Sans JP is not here either; it loads
   from assets/fonts/noto-sans-jp.css only when Japanese is chosen.
   Regenerating: docs/fonts.md.
   ========================================================================== */
@font-face {
  font-family: 'Space Grotesk';
  font-style: normal; font-weight: 400 700; font-display: swap;
  src: url(assets/fonts/space-grotesk-latin.woff2) format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'Space Grotesk';
  font-style: normal; font-weight: 400 700; font-display: swap;
  src: url(assets/fonts/space-grotesk-latin-ext.woff2) format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
  font-family: 'Archivo Black';
  font-style: normal; font-weight: 400; font-display: swap;
  src: url(assets/fonts/archivo-black-latin.woff2) format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'Archivo Black';
  font-style: normal; font-weight: 400; font-display: swap;
  src: url(assets/fonts/archivo-black-latin-ext.woff2) format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
  font-family: 'Anton';
  font-style: normal; font-weight: 400; font-display: swap;
  src: url(assets/fonts/anton-latin.woff2) format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'Anton';
  font-style: normal; font-weight: 400; font-display: swap;
  src: url(assets/fonts/anton-latin-ext.woff2) format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

* { margin: 0; padding: 0; box-sizing: border-box; }
/* Form controls do not inherit font-family - the UA stylesheet gives them a
   system default - so every button and input that was not handed one
   explicitly had been rendering in Arial. That was 15 of them, including the
   about CTA, SEND MESSAGE and COPY. It is also the whole reason the about
   button's arrow looked lighter and shorter than the hero's: a different
   typeface, not a different size. */
button, input, textarea, select { font-family: inherit; }

:root {
  --black: #1e1c17;
  --white: #e6e3db;
  --accent: #ff3b30;
  /* --accent is the display red: big type, borders, drop shadows. At small
     sizes it only reaches 2.77:1 on paper, so accent-ink is the darkened
     version for TEXT and for solid fills, and accent-on is what sits legibly
     on top of it. Measured: ink 5.03:1 as text, 6.45:1 under white. */
  --accent-ink: #b32921;
  --accent-on: #ffffff;
  --yellow: #fffb00;
  --blue: #0a5cff;
  --green: #16d160;
  /* Chip fills for the about-page skill tags. These have to be tokens: the
     overlay's background is var(--white), which flips with the theme, so a
     hardcoded dark tint vanished completely in dark mode. */
  --chip: rgba(30, 28, 23, 0.07);
  --chip-lead: rgba(30, 28, 23, 0.24);
  /* fixed ink/paper: do NOT flip with theme — used by graphic "chip"
     elements (marquee, loader, about slab, footer) that pair with the
     literal --yellow accent and must stay guaranteed-readable in both modes */
  --ink: #111111;
  --paper: #f5f5f0;
  /* GREY SYSTEM — added so the site isn't purely 0%/100% black-white.
     Warm-neutral (R≈G>B) to match the slightly warm off-white --paper
     tone rather than a cold blue-grey. Two roles, each themed like
     --black/--white so they keep working when the theme flips:
       --muted: de-emphasized/secondary TEXT color, used in place of
         opacity-dimmed black/white (opacity blending looks fine over
         neutral surfaces, but turns visibly muddy over saturated ones —
         see --muted-ink below for the one spot that needed it)
       --line: secondary/lightweight divider rules, distinct from the
         heavy black structural borders that still mark primary structure
     Contrast-checked against the surfaces they're meant to sit on:
     --muted ≥ 4.8:1 on --white/--black (passes WCAG AA for normal text). */
  --muted: #62615a;
  --line: #b3b0a3;
  /* fixed grey for de-emphasized text specifically on the fixed --ink
     slabs (about section, footer-adjacent chrome) — parallels the
     ink/paper convention above since those surfaces don't flip with
     theme either. ~7.5:1 contrast on --ink. */
  --muted-ink: #a8a79c;
  /* warning red for the epilepsy notice in the egg menu. Fixed, with no dark
     override, for the same reason as --muted-ink: it sits on --paper, which
     doesn't flip, so --accent-ink would turn into the neon #ff2d55 there and
     fall to ~3.5:1. Measured 5.03:1 on --paper and 5.85:1 on the --yellow
     hover state, so it passes AA in both. */
  --warn-ink: #b32921;
  /* 'Noto Sans JP' is appended to every stack rather than swapped in for
     Japanese: CSS font fallback resolves per glyph, so Latin characters
     still render in Archivo Black / Anton / Space Grotesk and only the
     CJK characters — which those three faces simply do not contain —
     fall through to Noto. That keeps mixed strings like "UXデザイン"
     correct without a single language-conditional font rule. */
  --ff-display: 'Archivo Black', 'Noto Sans JP', sans-serif;
  --ff-body: 'Space Grotesk', 'Noto Sans JP', sans-serif;
  --ff-condensed: 'Anton', 'Noto Sans JP', sans-serif;
}

:root[data-theme="dark"] {
  --black: #e8e5dc;
  --white: #171512;
  /* dark mode gets a more intense, neon-leaning palette — heavier and
     more aggressive than the flat light-mode accents */
  --accent: #ff2d55;
  /* on the dark ground the bright accent already clears 5:1 as text, so it
     needs no darkening — here it's the WHITE that fails on top of it (3.55:1),
     and near-black reads at 5.14:1 instead */
  --accent-ink: #ff2d55;
  --accent-on: #171512;
  --yellow: #f9ff2e;
  --blue: #00d4ff;
  --green: #00ff95;
  /* ~6.7:1 contrast on the dark --white bg */
  --muted: #9c9b92;
  --line: #38372f;
  /* light tint on the dark ground, and a touch stronger: a 7% wash that
     reads fine as dark-on-light disappears as light-on-dark */
  --chip: rgba(232, 229, 220, 0.10);
  --chip-lead: rgba(232, 229, 220, 0.22);
}

/* The nav is fixed and 94px tall (86 on phones), so an un-padded anchor jump
   parked each section heading underneath it: clicking CONTACT lost the top
   24px of the word. Every section sets its h2 70px below its own top edge,
   so a single value on the scroll container clears the bar for all of them
   and leaves ~36px of air. scroll-padding-top (on the scroller) rather than
   scroll-margin-top (per target) so the skip link and #top get it too. */
html { scroll-behavior: smooth; scroll-padding-top: 60px; }

body {
  background: var(--white);
  color: var(--black);
  font-family: var(--ff-body);
  overflow-x: hidden;
  cursor: default;
  transition: background .35s ease, color .35s ease;
}

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

/* keyboard focus — visible everywhere, styled to match the brutalist
   bordered-block language instead of the browser default */
:focus-visible {
  outline: 3px solid var(--accent);
  outline-offset: 3px;
}

/* honeypot: kept in the DOM (so bots that auto-fill it tip their hand)
   but positioned off-screen and unreachable for real visitors/keyboard */
.honeypot-field {
  position: absolute !important;
  left: -9999px !important;
  width: 1px; height: 1px;
  opacity: 0;
  pointer-events: none;
}

/* LOADER */
.loader {
  position: fixed; inset: 0; z-index: 2000;
  background: var(--ink);
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 30px;
  transition: transform .7s cubic-bezier(.7,0,.3,1);
}
.loader-name {
  display: flex; flex-direction: column; align-items: center;
  font-family: var(--ff-display); color: var(--paper);
  font-size: clamp(2.2rem, 8vw, 5rem); line-height: 0.95; letter-spacing: -0.02em;
  overflow: hidden;
}
.loader-name span {
  display: block;
  opacity: 0;
  transform: translateY(100%);
  animation: loader-line .7s cubic-bezier(.2,.8,.2,1) forwards;
}
.loader-name span:nth-child(2) { animation-delay: .12s; color: var(--accent); }
@keyframes loader-line {
  to { opacity: 1; transform: translateY(0); }
}
.loader-bar {
  width: 220px; height: 6px; background: rgba(255,255,255,0.15); overflow: hidden;
}
.loader-bar-fill {
  width: 0%; height: 100%; background: var(--yellow);
  transition: width .25s ease-out;
}
body.is-loading { overflow: hidden; }
.loader.loaded { transform: translateY(-100%); }

/* SCROLL PROGRESS */
.scroll-progress {
  position: fixed; top: 0; left: 0; height: 4px; width: 0%;
  background: var(--accent); z-index: 998;
  transition: width .1s linear;
}

/* noise overlay */
.noise {
  position: fixed; inset: 0; z-index: 999; pointer-events: none;
  opacity: 0.035; mix-blend-mode: multiply;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='3'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}

/* scanline / CRT-static overlay — dark mode only, gives it a heavier,
   more aggressive "screen" personality the light mode doesn't have */
.scanlines {
  position: fixed; inset: 0; z-index: 996; pointer-events: none;
  opacity: 0;
  transition: opacity .4s ease;
  background-image: repeating-linear-gradient(
    to bottom,
    rgba(255,255,255,0.05) 0px,
    rgba(255,255,255,0.05) 1px,
    transparent 1px,
    transparent 3px
  );
  mix-blend-mode: overlay;
}
/* the flicker animation lives only on the dark-mode rule — otherwise an
   animated 'opacity' would override the 0/1 toggle even in light mode */
:root[data-theme="dark"] .scanlines {
  opacity: 1;
  animation: scanline-flicker 6s steps(30) infinite;
}
@keyframes scanline-flicker {
  0%, 100% { opacity: 1; }
  8% { opacity: 0.85; }
  22% { opacity: 1; }
  47% { opacity: 0.9; }
  61% { opacity: 1; }
  78% { opacity: 0.88; }
}

/* custom cursor */
.cursor-dot {
  position: fixed; top: 0; left: 0; width: 18px; height: 18px;
  background: var(--accent); border-radius: 50%; pointer-events: none;
  z-index: 1000; mix-blend-mode: difference;
  transform: translate(-50%, -50%);
  transition: width .25s, height .25s, background .25s;
}
.cursor-dot.big { width: 60px; height: 60px; background: var(--yellow); }
@media (max-width: 900px) { .cursor-dot { display: none; } }

/* NAV */
.nav {
  position: fixed; top: 0; left: 0; right: 0; z-index: 200;
  display: flex; align-items: center; justify-content: space-between;
  padding: 26px 5vw;
  background: transparent;
  border-bottom: 2px solid transparent;
  transition: background .35s ease, border-color .35s ease;
}
.nav.scrolled {
  background: var(--white);
  border-bottom: 2px solid var(--black);
}
.nav-logo {
  font-family: var(--ff-display); font-size: 1.4rem; letter-spacing: 1px;
}
.nav-logo .dot { color: var(--accent); }
.nav-links { display: flex; gap: 36px; }
.nav-links a {
  font-weight: 700; font-size: 0.85rem; letter-spacing: 1.5px;
  position: relative; padding-bottom: 4px;
}
.nav-links a::after {
  content: ''; position: absolute; left: 0; bottom: 0; height: 2px; width: 0;
  background: var(--black); transition: width .3s ease;
}
.nav-links a:hover::after { width: 100%; }

.nav-right { display: flex; align-items: center; gap: 18px; }

.theme-toggle {
  width: 40px; height: 40px;
  display: flex; align-items: center; justify-content: center;
  background: transparent; border: 2px solid var(--black); color: var(--black);
  cursor: pointer; font-size: 1rem;
  transition: background .25s, color .25s, transform .2s;
}
.theme-toggle:hover { transform: translate(-2px,-2px); box-shadow: 4px 4px 0 var(--black); }

/* color vision mode control */
.cvd-control { position: relative; }
.cvd-toggle {
  width: 40px; height: 40px;
  display: flex; align-items: center; justify-content: center;
  background: transparent; border: 2px solid var(--black); color: var(--black);
  cursor: pointer; font-size: 1.1rem;
  transition: background .25s, color .25s, transform .2s;
}
.cvd-toggle:hover { transform: translate(-2px,-2px); box-shadow: 4px 4px 0 var(--black); }
.cvd-toggle.active-mode { background: var(--black); color: var(--white); }
/* visibility, not just opacity. opacity:0 + pointer-events:none hides a menu
   from the eye and the mouse but NOT from the keyboard or a screen reader:
   its buttons stayed in the tab order while it was closed, so tabbing from
   the nav walked through invisible controls with no visible focus ring on
   them. Audited across the page: 10 such stops, 4 here, 4 in the colour-
   vision menu, 2 in the easter-egg menu. visibility:hidden removes them from
   both, and transitioning it keeps the fade-out - visibility holds `visible`
   for the duration and flips at the end. */
.cvd-menu {
  position: absolute; top: calc(100% + 12px); right: 0;
  min-width: 200px;
  background: var(--paper); border: 3px solid var(--ink);
  box-shadow: 6px 6px 0 var(--ink);
  display: flex; flex-direction: column;
  opacity: 0; visibility: hidden; pointer-events: none;
  transform: translateY(-8px);
  transition: opacity .2s ease, transform .2s ease, visibility .2s ease;
  z-index: 300;
}
.cvd-menu.open { opacity: 1; visibility: visible; pointer-events: auto; transform: translateY(0); }
.cvd-option {
  background: none; border: none; cursor: pointer;
  display: flex; align-items: center; justify-content: space-between;
  text-align: left; padding: 13px 18px;
  font-weight: 700; font-size: 0.75rem; letter-spacing: 1.5px;
  color: var(--ink);
  border-bottom: 2px solid var(--ink);
}
.cvd-option:last-child { border-bottom: none; }
.cvd-option:hover { background: var(--yellow); }
.cvd-option.active { background: var(--ink); color: var(--paper); }
.cvd-option.active::after { content: '●'; color: var(--accent); margin-left: 10px; }

/* the filters themselves — applied to <html> so they cover the whole
   rendered page including fixed-position chrome */
html.cvd-deuteranopia { filter: url(#cvd-deuteranopia); }
html.cvd-protanopia { filter: url(#cvd-protanopia); }
html.cvd-highcontrast { filter: contrast(1.45) saturate(1.15); }

.nav-toggle {
  display: none; flex-direction: column; justify-content: center; gap: 5px;
  width: 40px; height: 40px;
  background: none; border: none; cursor: pointer;
  position: relative; z-index: 210;
}
.nav-toggle span {
  width: 26px; height: 3px; background: var(--black);
  /* background deliberately NOT transitioned — it swaps between the
     nav-bar and drawer contexts, and easing it makes the X fade through
     the drawer's own color on the way */
  transition: transform .3s ease, opacity .3s ease;
}
.nav-toggle.open span:nth-child(1) { transform: translateY(8px) rotate(45deg); }
.nav-toggle.open span:nth-child(2) { opacity: 0; }
.nav-toggle.open span:nth-child(3) { transform: translateY(-8px) rotate(-45deg); }
/* once open, the X sits ON TOP of the drawer, whose background is also
   var(--black) — leaving the bars black made the only close control
   invisible (1:1 contrast) in both light and dark mode. --white is the
   drawer's own text color, so it stays correct when the theme flips. */
.nav-toggle.open span { background: var(--white); }

.nav-overlay {
  position: fixed; inset: 0; z-index: 150;
  background: rgba(0,0,0,0.4);
  opacity: 0; pointer-events: none;
  transition: opacity .3s ease;
}
.nav-overlay.open { opacity: 1; pointer-events: auto; }

@media (max-width: 760px) {
  .nav-links {
    position: fixed; top: 0; right: 0;
    height: 100vh; width: min(78vw, 340px);
    flex-direction: column; justify-content: center;
    gap: 36px;
    background: var(--black);
    padding: 0 12vw;
    transform: translateX(100%);
    transition: transform .45s cubic-bezier(.2,.8,.2,1);
    z-index: 200;
  }
  .nav-links.open { transform: translateX(0); }
  /* 27px tall was well under the 44px touch guideline */
  .nav-links a { color: var(--white); font-size: 1.1rem; padding: 10px 0; min-height: 44px;
                 display: flex; align-items: center; }
  .nav-links a::after { background: var(--white); }
  .nav-toggle { display: flex; }
  /* nav chrome sat at 40px, just under the 44px touch-target guideline */
  .nav-toggle, .theme-toggle, .cvd-toggle { width: 44px; height: 44px; }
}

/* HERO */
.hero {
  position: relative;
  min-height: 100vh;
  display: flex; flex-direction: column; justify-content: center;
  padding: 110px 5vw 0;
  background: var(--white);
  overflow: hidden;
}
.hero-shapes {
  position: absolute; inset: 0; z-index: 0;
  pointer-events: none;
}
.shape {
  position: absolute;
  top: 0; left: 0;
  pointer-events: none;
  will-change: transform;
  transition: color .35s ease;
}
.shape-circle.outline, .shape-square.outline {
  border: 3px solid currentColor;
  background: transparent;
}
.shape-circle.filled, .shape-square.filled {
  background: currentColor;
  border: none;
}
.shape-circle { border-radius: 50%; }
.shape-triangle {
  overflow: visible;
  transform-box: fill-box;
  transform-origin: center;
}
/* typewriter blinking caret after the cycling tagline text */
.hero-tag::after {
  content: '|';
  margin-left: 2px;
  animation: hero-tag-blink 1s steps(1) infinite;
}
@keyframes hero-tag-blink { 50% { opacity: 0; } }

.hero-grid {
  position: relative; z-index: 2;
  display: grid;
  grid-template-columns: 1.4fr 1fr;
  align-items: end;
  gap: 40px;
  /* fixed --ink, matching the marquee strip directly below — using the
     theme-following --black here was the bug: it flips to a light color
     in dark mode, creating a pale line right above the always-dark ticker */
  border-bottom: 6px solid var(--ink);
  padding-bottom: 50px;
}

.badge-available {
  display: inline-flex; align-items: center; gap: 10px;
  width: fit-content;
  border: 2px solid var(--black);
  padding: 8px 14px;
  font-size: 0.75rem; font-weight: 700; letter-spacing: 1.5px;
  cursor: pointer;
  transition: transform .25s ease, box-shadow .25s ease, background .25s ease, color .25s ease;
}
.badge-available:hover {
  transform: translate(-3px, -3px);
  box-shadow: 5px 5px 0 var(--accent);
  background: var(--black); color: var(--white);
}
.badge-dot {
  width: 9px; height: 9px; border-radius: 50%;
  background: var(--green);
  /* was a literal rgba() matching only the light-mode green — silently
     wrong (a muted olive glow) once dark mode's brighter neon green
     took over; color-mix keeps the pulse tracking whichever --green
     the current theme actually uses */
  box-shadow: 0 0 0 0 color-mix(in srgb, var(--green) 60%, transparent);
  animation: badge-pulse 1.8s ease-out infinite;
}
@keyframes badge-pulse {
  0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--green) 55%, transparent); }
  70% { box-shadow: 0 0 0 9px color-mix(in srgb, var(--green) 0%, transparent); }
  100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--green) 0%, transparent); }
}
.hero-name {
  font-family: var(--ff-display);
  font-size: clamp(4.6rem, 15vw, 12rem);
  line-height: 0.84;
  letter-spacing: -0.04em;
  text-transform: uppercase;
}
.hero-name .line { display: block; overflow: hidden; }
.hero-name .accent { color: var(--accent); }

.hero-name.glitch { animation: glitch-flicker 0.24s linear; }
@keyframes glitch-flicker {
  0%   { transform: translate(0, 0); clip-path: inset(0 0 0 0); }
  8%   { transform: translate(-3px, 1px); clip-path: inset(10% 0 65% 0); text-shadow: -3px 0 var(--accent), 3px 0 var(--blue); }
  16%  { transform: translate(3px, -1px); clip-path: inset(55% 0 15% 0); text-shadow: 3px 0 var(--accent), -3px 0 var(--blue); }
  24%  { transform: translate(-2px, 2px); clip-path: inset(30% 0 40% 0); text-shadow: -2px 0 var(--blue), 2px 0 var(--accent); }
  32%  { transform: translate(2px, -2px); clip-path: inset(0 0 80% 0); text-shadow: none; }
  40%  { transform: translate(-3px, 0); clip-path: inset(70% 0 0 0); }
  48%  { transform: translate(3px, 1px); clip-path: inset(20% 0 55% 0); text-shadow: 3px 0 var(--blue), -3px 0 var(--accent); }
  60%  { transform: translate(0, 0); clip-path: inset(0 0 0 0); text-shadow: none; }
  100% { transform: translate(0, 0); clip-path: inset(0 0 0 0); text-shadow: none; }
}
.hero-side { display: flex; flex-direction: column; gap: 22px; padding-bottom: 1.2rem; }
.hero-tag {
  font-size: 1.05rem; font-weight: 500; max-width: 360px;
  line-height: 1.5;
  /* min-height is set in JS (locked to the tallest tagline at the
     actual rendered column width) — a fixed em value here guessed
     wrong since the column is much narrower than max-width at most
     viewport sizes, letting longer taglines wrap past the reserve */
}
.btn-block {
  display: inline-flex; align-items: center; gap: 8px;
  background: var(--black); color: var(--white);
  padding: 16px 26px; font-weight: 700; font-size: 0.85rem; letter-spacing: 1.5px;
  width: fit-content; border: 2px solid var(--black);
  transition: background .25s, color .25s, transform .25s;
}
.btn-block:hover { background: var(--white); color: var(--black); transform: translate(-3px,-3px); box-shadow: 6px 6px 0 var(--accent); }
/* The arrow drifts, to say there is more below without shouting it. Pulled
   out of the translated string and into a pseudo-element for the same reason
   the about CTA's was: I18N.apply() assigns textContent, so any span inside
   the label is wiped on the first language switch - and an animation needs
   its own element to move. 4px of travel over 3s reads as a drift rather
   than a bounce; the button itself never moves, so the hover lift is
   unaffected. */
.btn-block::after {
  content: '\2193';
  display: inline-block;
  animation: cta-drift 3s ease-in-out infinite;
}
/* scaleY lengthens the shaft without touching the shape he liked. It has to
   live inside the keyframes: the animation drives `transform`, so a static
   transform on the element would simply be overridden every frame. */
@keyframes cta-drift {
  0%, 100% { transform: translateY(-2px) scaleY(1.55); }
  50%      { transform: translateY(2px) scaleY(1.55); }
}
@media (prefers-reduced-motion: reduce) {
  .btn-block::after { animation: none; transform: scaleY(1.55); }
}

/* HERO — narrow viewports.
   The 1.4fr/1fr split has no min-width floor, so below ~900px the two
   tracks stop fitting: the name column is sized by "MURRAY." at its
   min-content width and pushes the side column past the right edge,
   where .hero { overflow: hidden } silently clips it. On a phone that
   erased the availability badge, the tagline, the role line, and the
   primary VIEW PROJECTS call to action entirely. Stack to one column
   instead — DOM order already reads name → badge → tagline → CTA. */
@media (max-width: 900px) {
  .hero-grid {
    grid-template-columns: 1fr;
    align-items: start;
    gap: 30px;
    padding-bottom: 40px;
  }
  /* the 1.2rem was optical alignment against the name's baseline in the
     two-column layout; stacked, it's just a stray gap */
  .hero-side { padding-bottom: 0; }
  /* full measure now that the column spans the viewport */
  .hero-tag { max-width: 34ch; }
  /* the 4.6rem floor overflowed a 375px viewport ("MURRAY." rendered
     ~345px into a 338px column). Scale with the viewport instead and
     keep a cap so it can't outgrow the stacked column on large phones. */
  .hero-name { font-size: clamp(3.4rem, 16vw, 8rem); }
}

.marquee {
  position: relative; z-index: 2;
  overflow: hidden; border: none; background: var(--ink);
  /* significantly taller than the original strip — a real design
     statement rather than a thin ticker line */
  padding: 40px 0;
  /* pulls the strip 1px into the hero-grid border above, and bleeds 1px
     below — masks the subpixel hairline that flex rounding can leave
     between adjacent elements */
  margin-top: -1px;
  box-shadow: 0 1px 0 0 var(--ink);
  line-height: 0;
}
.marquee-track {
  display: flex; width: max-content;
  animation: scroll-left 22s linear infinite;
}
.marquee-group {
  display: flex; flex-shrink: 0; white-space: nowrap;
}
.marquee-track span {
  font-family: var(--ff-display); font-size: clamp(1.6rem, 3.2vw, 2.6rem); color: var(--yellow);
  letter-spacing: 3px; white-space: nowrap;
}
/* WCAG 2.2.2 wants a way to stop scrolling content. A visible pause button
   would wreck the band, so the strip stops on hover instead: invisible until
   someone actually wants to read it. Anyone with "reduce motion" set at the
   OS level gets it stopped outright by the global block further down.
   .is-paused rather than :hover because a pointer merely crossing the strip
   on its way somewhere else shouldn't yank it to a stop - script.js only
   adds the class once the pointer has dwelt there (see marqueeHoverPause). */
.marquee.is-paused .marquee-track,
.marquee:focus-within .marquee-track { animation-play-state: paused; }

@keyframes scroll-left {
  from { transform: translateX(0); }
  to { transform: translateX(-50%); }
}

/* shared section heading */
.section-head {
  display: flex; justify-content: space-between; align-items: baseline;
  padding: 70px 5vw 30px;
  border-bottom: 4px solid var(--black);
}
.section-head h2 {
  font-family: var(--ff-display);
  font-size: clamp(2.3rem, 6.5vw, 4.6rem);
  letter-spacing: -0.02em;
}
/* was opacity:.6 on the inherited (themed) text color everywhere —
   fine over neutral white/black surfaces, but on .contact's saturated
   red background an alpha-blended black reads as a muddy rust-brown
   rather than a clean grey. Split into a deliberate color per surface
   instead of one opacity value applied blindly to three different
   background contexts. */
.section-num { font-size: 0.8rem; font-weight: 700; letter-spacing: 2px; color: var(--muted); }
.about .section-num { color: var(--muted-ink); }
.contact .section-num { color: var(--ink); }

/* WORK / GALLERY */
.work { position: relative; background: var(--white); padding-bottom: 40px; }

.filters {
  display: flex; flex-wrap: wrap; gap: 12px;
  padding: 40px 5vw 10px;
}
.filter-btn {
  font-family: var(--ff-body); font-weight: 700; font-size: 0.8rem; letter-spacing: 1.5px;
  background: transparent; border: 2px solid var(--black); color: var(--black);
  padding: 10px 18px; cursor: pointer;
  transition: background .25s, color .25s, transform .2s;
}
.filter-btn:hover { transform: translate(-2px,-2px); box-shadow: 4px 4px 0 var(--black); }
.filter-btn.active { background: var(--black); color: var(--white); }

.card.hidden-card { display: none; }

.gallery {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 0;
}
.card {
  position: relative;
  border: 3px solid var(--black);
  margin: -1.5px;
  overflow: hidden;
  cursor: pointer;
  background: var(--white);
  transition: transform .35s cubic-bezier(.2,.8,.2,1), box-shadow .35s;
}
.card:hover {
  transform: translate(-6px, -6px) rotate(-0.4deg);
  box-shadow: 10px 10px 0 var(--black);
  z-index: 5;
}
.card-media { overflow: hidden; aspect-ratio: 4/3; }
.card-info {
  padding: 22px 24px 28px;
  display: flex; flex-direction: column; gap: 6px;
  border-top: 3px solid var(--black);
}
.card-info h3 { font-family: var(--ff-display); font-size: 1.3rem; letter-spacing: -0.02em; }
.card-info p { font-size: 0.82rem; font-weight: 400; color: var(--muted); }
/* .card-info is a flex column, so two bare tags would stack vertically —
   this row keeps them side by side and wrapping only if they must */
.card-tags { margin-top: 8px; display: flex; flex-wrap: wrap; gap: 8px; }
.card-tags .tag { margin-top: 0; align-self: auto; }
.tag {
  margin-top: 8px; align-self: flex-start;
  font-size: 0.7rem; font-weight: 700; letter-spacing: 1.5px;
  background: var(--black); color: var(--white);
  padding: 5px 10px;
}
@media (max-width: 800px) {
  .gallery { grid-template-columns: 1fr; }
}

/* PROJECT DETAIL OVERLAY */
.project-overlay {
  position: fixed; inset: 0; z-index: 500;
  background: var(--white);
  transform: translateY(100%);
  transition: transform .55s cubic-bezier(.2,.8,.2,1);
  overflow-y: auto;
}
.project-overlay.open { transform: translateY(0); }

.project-detail {
  max-width: 1100px; margin: 0 auto;
  padding: 68px 5vw 100px;
}
.project-back {
  font-family: var(--ff-body); font-weight: 700; font-size: 0.85rem; letter-spacing: 1.5px;
  background: var(--black); color: var(--white);
  border: 2px solid var(--black); padding: 12px 20px; cursor: pointer;
  margin-bottom: 36px;
  transition: background .25s, color .25s, transform .2s;
  /* Sticks to the top of its overlay instead of scrolling away. These pages
     run several screens long and the only way back was to scroll all of it
     again - Escape works but nobody discovers it. Sticky rather than a
     second button at the foot: one control, in one place, available the
     whole way down instead of only at the two ends. It is its own scrolling
     ancestor (.project-overlay / .about-overlay are the overflow-y: auto
     elements), so this needs no JS. */
  position: sticky; top: 14px; z-index: 5;
  /* the content scrolls underneath, so it needs an edge of its own to sit
     on rather than blending into whatever happens to pass behind it */
  box-shadow: 4px 4px 0 var(--accent);
}
.project-back:hover { background: var(--white); color: var(--black); transform: translate(-3px,-3px); box-shadow: 5px 5px 0 var(--accent); }

.project-media { margin-bottom: 40px; }

.project-media.layout-single {
  border: 4px solid var(--black);
  aspect-ratio: 16/9;
  overflow: hidden;
}
.project-media.layout-single svg { width: 100%; height: 100%; display: block; }

/* scrollable photo gallery grid */
.project-media.layout-gallery {
  border: 4px solid var(--black);
  max-height: 620px;
  overflow-y: auto;
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 3px;
  background: var(--black);
}
.project-gallery-item { aspect-ratio: 4/3; overflow: hidden; }
.project-gallery-item svg { width: 100%; height: 100%; display: block; }
@media (max-width: 700px) {
  .project-media.layout-gallery { grid-template-columns: 1fr; max-height: 70vh; }
}

/* video placeholder with a play button */
/* The 16:9 frame moved off .project-media onto an inner element so supporting
   stills can sit BELOW the player. While the aspect-ratio and overflow:hidden
   lived on .project-media itself, anything appended after the video was
   clipped out of existence. */
.project-media.layout-video { position: relative; cursor: pointer; }
.project-video-frame {
  position: relative;
  border: 4px solid var(--black);
  /* per-project override: a split-screen capture is 32/9, not 16/9, and the
     default would letterbox it into a strip */
  aspect-ratio: var(--video-ratio, 16/9);
  overflow: hidden;
}

/* stills under the player — sized by height so each keeps its own ratio and
   nothing is cropped; a portrait poster stays a portrait poster */
.project-video-details {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 12px;
  margin-top: 12px;
}
.project-video-details > div {
  flex: 0 0 auto;
  /* 340 not 380: three stills (two photos plus the tall standup banner) sum
     to 918px at this height and so stay on one row inside the 956px column */
  height: 340px;
  border: 3px solid var(--black);
  overflow: hidden;
}
/* A lone still is a plate, not a thumbnail. The fixed-height row above is
   sized for a strip of several images; with one item it leaves most of the
   row empty and reads as a mistake. Size a single item by width instead so
   a portrait gets real height, and let it shrink on narrow screens rather
   than overflowing the way a fixed height would. */
.project-video-details > div:only-child {
  height: auto;
  width: min(400px, 100%);
}
.project-media .project-video-details > div:only-child img {
  width: 100%;
  height: auto;
}
/* overrides the global .project-media img rule, which forces width/height to
   100% and cover-crops — wrong for a designed poster */
.project-media .project-video-details img {
  height: 100%;
  width: auto;
  max-width: none;
  object-fit: contain;
  display: block;
}
@media (max-width: 700px) {
  .project-video-details > div { height: 260px; }
}
.project-media.layout-video .video-bg { width: 100%; height: 100%; display: block; filter: grayscale(0.25) brightness(0.75); }
.project-video-overlay {
  position: absolute; inset: 0;
  display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 18px;
  background: rgba(0,0,0,0.25);
  transition: background .3s ease;
}
.play-btn {
  width: 84px; height: 84px; border-radius: 50%;
  background: var(--white); border: 4px solid var(--black);
  display: flex; align-items: center; justify-content: center;
  cursor: pointer;
  transition: transform .3s ease, background .3s ease;
}
.play-btn svg { width: 28px; height: 28px; fill: var(--black); }
.project-media.layout-video:hover .play-btn { transform: scale(1.12); }

/* play button over the real player, shown only before the first play */
.video-play {
  position: absolute; inset: 0;
  display: flex; align-items: center; justify-content: center;
  background: rgba(0, 0, 0, 0.22);
  border: 0; padding: 0; margin: 0;
  cursor: pointer;
  transition: background .3s ease;
}
.project-video-frame:not(.is-idle) .video-play { display: none; }
.video-play:hover { background: rgba(0, 0, 0, 0.10); }
.video-play:hover .play-btn { transform: scale(1.12); }
.video-play:focus-visible { outline: 4px solid var(--accent); outline-offset: -8px; }
.video-caption {
  font-family: var(--ff-display); color: #fff;
  font-size: 0.85rem; letter-spacing: 1.5px; text-transform: uppercase;
  text-shadow: 0 1px 6px rgba(0,0,0,0.6);
}
.project-media.layout-video.playing .project-video-overlay { background: rgba(0,0,0,0.55); }
.project-media.layout-video.playing .play-btn { background: var(--accent); }
.project-media.layout-video.playing .play-btn svg { fill: #fff; }

/* masonry photography grid — mixed image sizes via CSS grid row/col spans.
   On its own this would internally scroll, but inside .is-masonry the
   page-level sticky layout below takes over and it scrolls with the page. */
.project-media.layout-masonry {
  border: 4px solid var(--black);
  background: var(--black);
  padding: 6px;
  display: grid;
  /* three columns, not four: at four the portrait cells came out 126px wide,
     which reads as a contact sheet rather than a gallery. Three lifts every
     cell by about a third with no change to the layout's proportions, and
     the spans below are retuned so the aspect ratios stay just as close. */
  grid-template-columns: repeat(3, 1fr);
  /* 60px rows rather than 80px: it's the unit the spans below are tuned
     against, and a finer unit lets each cell land much closer to its
     photo's real aspect ratio. */
  grid-auto-rows: 60px;
  gap: 6px;
  /* mixed spans leave holes otherwise — dense backfills them */
  grid-auto-flow: dense;
}
.masonry-item { overflow: hidden; }
.masonry-item svg { width: 100%; height: 100%; display: block; }

/* Cell shape follows the PHOTO's orientation. Every cell used to come from a
   fixed size cycle regardless of what was in it, so a 3:2 landscape landed in
   a portrait box and lost roughly half its width to the cover crop. Measured
   against a ~126px column: a landscape shot at 3:2 (1.50) gets a 1.35 cell, a
   portrait at 2:3 (0.67) gets a 0.66 cell, and the full-width feature is 1.62. */
/* tall and wide share a row height, so a landscape + a portrait fill one
   row exactly and the wall keeps a clean baseline instead of going ragged */
.masonry-item.size-tall    { grid-column: span 1; grid-row: span 4; }
.masonry-item.size-wide    { grid-column: span 2; grid-row: span 4; }
.masonry-item.size-feature { grid-column: span 3; grid-row: span 5; }

/* the original cycle, kept so any other masonry project still renders */
.masonry-item.size-small { grid-row: span 3; }
.masonry-item.size-medium { grid-row: span 4; }
.masonry-item.size-large { grid-row: span 5; grid-column: span 2; }
@media (max-width: 700px) {
  .project-media.layout-masonry { grid-template-columns: repeat(2, 1fr); grid-auto-rows: 60px; }
  /* two columns only: a feature spans the full width, a landscape takes both
     columns, and portraits stay single-column so two sit side by side */
  /* On a phone every cell spans the full width, so the wall becomes a simple
     vertical stack. Leaving portraits at one column would strand a gap beside
     each of them, since the row packing above is built for three columns.
     Landscapes take 3 rows (ratio 1.66) and portraits 7 (0.69) — both within
     a few percent of native, and every row fills completely. */
  .masonry-item.size-large,
  .masonry-item.size-feature,
  .masonry-item.size-wide { grid-column: span 2; grid-row: span 3; }
  .masonry-item.size-tall { grid-column: span 2; grid-row: span 7; }
}

/* Full-page sticky scroll: media runs down the left at natural page scroll
   while the write-up stays pinned on the right. Opted into by layout:'masonry'
   or by `sidebar: true` on any project — it suits a long write-up with
   secondary images just as well as it suits a photo wall. */
.project-detail.has-sidebar {
  display: grid;
  /* minmax(0, ...) rather than a bare 1.5fr/1fr: an `fr` track's automatic
     minimum is its min-content, so the info column's content was overriding
     the declared split and inverting it — the media column came out 419px
     against the sidebar's 487px, ratio 0.86 where 1.5 was asked for. */
  grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr);
  gap: 50px;
  align-items: start;
}
/* 1 / -1 so the button owns its own row above both columns, but justify-self
   so it stays its own width in it. Without this it stretched the full grid -
   a 922px bar in the two sidebar projects (Caterpillar, Monmouth) against a
   178px button everywhere else. Same control, same size, every layout. */
.project-detail.has-sidebar .project-back { grid-column: 1 / -1; justify-self: start; }
.project-detail.has-sidebar .project-media { margin-bottom: 0; }
/* the sidebar is ~360px wide; the full-size project title is set for a
   1100px column and simply doesn't belong at that scale here */
.project-detail.has-sidebar .project-body h2 {
  font-size: clamp(1.7rem, 3vw, 2.6rem);
}
.project-detail.has-sidebar .project-body {
  position: sticky;
  top: 110px;
  align-self: start;
}
@media (max-width: 900px) {
  .project-detail.has-sidebar { grid-template-columns: 1fr; }
  .project-detail.has-sidebar .project-body { position: static; }
}

/* Video layout: film full width, then the write-up and the poster side by
   side. The poster is a grandchild (.project-media > .project-video-details)
   while the copy is .project-media's sibling, so the two can't share a grid
   until the wrapper is flattened — hence `display: contents` on it. That
   removes the wrapper's box, so its `margin-bottom: 40px` stops applying and
   the spacing has to come from row-gap instead. */
.project-detail.is-video-aside {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 400px);
  column-gap: 50px;
  row-gap: 40px;
  align-items: start;
}
.project-detail.is-video-aside .project-back {
  grid-column: 1 / -1; grid-row: 1;
  justify-self: start;
  margin-bottom: 0;          /* row-gap owns this spacing now */
}
.project-detail.is-video-aside .project-media.layout-video { display: contents; }
.project-detail.is-video-aside .project-video-frame { grid-column: 1 / -1; grid-row: 2; }
.project-detail.is-video-aside .project-body { grid-column: 1; grid-row: 3; }
.project-detail.is-video-aside .project-video-details {
  grid-column: 2; grid-row: 3;
  margin-top: 0;             /* the 12px strip gap would double the row-gap */
}
/* the title is set for a 956px column and doesn't fit a ~500px one — same
   reduction .is-masonry already makes for its sidebar */
.project-detail.is-video-aside .project-body h2 {
  font-size: clamp(1.7rem, 3vw, 2.6rem);
}
@media (max-width: 900px) {
  /* back to normal flow; the wrapper needs its box back so its margin returns */
  .project-detail.is-video-aside { display: block; }
  .project-detail.is-video-aside .project-media.layout-video { display: block; }
  .project-detail.is-video-aside .project-back { margin-bottom: 36px; }
  .project-detail.is-video-aside .project-video-details { margin-top: 12px; }
}

/* large hero image + smaller detail shots below */
.project-media.layout-mixed { display: flex; flex-direction: column; gap: 14px; }
.project-mixed-hero { border: 4px solid var(--black); aspect-ratio: 16/9; overflow: hidden; }
.project-mixed-hero svg { width: 100%; height: 100%; display: block; }
/* flex rather than grid: equal grid columns forced every tile to the same
   width, which meant the landscape shot was cropped at both ends while the
   portrait ones were squeezed. Flex lets width vary by orientation while
   align-items:stretch keeps every tile the same height. The row's height
   comes from the container's aspect-ratio so it still scales with the page. */
.project-mixed-details {
  display: flex;
  gap: 12px;
}
.project-mixed-details > div {
  flex: 1 1 0;
  min-width: 0;
  border: 3px solid var(--black);
  overflow: hidden;
  aspect-ratio: 1;
}
/* Each tile's aspect-ratio is set EQUAL to its flex-grow, which makes every
   tile the same height without stating a height anywhere: width comes out as
   (grow x unit), so height = width / aspect = unit for all of them. Putting
   aspect-ratio on the container instead lost to content sizing and stretched
   the row from 311px to 408px. */
.project-mixed-details > div.is-landscape { flex-grow: 1.24; aspect-ratio: 1.24; }
.project-mixed-details > div.is-portrait  { flex-grow: 0.88; aspect-ratio: 0.88; }
/* A lone supporting photo runs the full width under the hero, so the two
   read as one stacked pair rather than a big image and an orphaned tile.
   The width nudges above exist to even out two tiles side by side and have
   nothing to balance here.
   It keeps the photo's own square shape rather than being cut to the hero's
   proportions - which means it stands taller than the hero, deliberately:
   the hero is a wide room shot and this is three people standing up. */
.project-mixed-details > div:only-child,
.project-mixed-details > div.is-landscape:only-child,
.project-mixed-details > div.is-portrait:only-child {
  flex: 1 1 100%;
  aspect-ratio: 1;
}
.project-mixed-details svg { width: 100%; height: 100%; display: block; }
@media (max-width: 700px) {
  /* Two-up and wrapping: the width nudge is pointless once tiles are half a
     phone wide, so squares come back.
     Both declarations are written at .is-landscape/.is-portrait specificity —
     a media query adds no specificity of its own, so a plainer
     `> div` rule in here would lose to the class rules above and the reset
     would silently not apply.
     flex-grow is 0 so a lone third tile stays half-width instead of
     stretching to fill its row. */
  .project-mixed-details { flex-wrap: wrap; }
  .project-mixed-details > div,
  .project-mixed-details > div.is-landscape,
  .project-mixed-details > div.is-portrait {
    flex: 0 1 calc(50% - 6px);
    aspect-ratio: 1;
  }
  /* a solo photo stays full width on a phone too - half a phone wide is not
     a supporting photo, it is a thumbnail */
  .project-mixed-details > div:only-child,
  .project-mixed-details > div.is-landscape:only-child,
  .project-mixed-details > div.is-portrait:only-child {
    flex: 1 1 100%;
    aspect-ratio: 1;
  }
}

.project-cat {
  display: inline-block;
  font-size: 0.75rem; font-weight: 700; letter-spacing: 1.5px;
  background: var(--black); color: var(--white);
  padding: 6px 12px; margin-bottom: 18px;
}
.project-body h2 {
  font-family: var(--ff-display);
  font-size: clamp(2.3rem, 6.5vw, 4.6rem);
  letter-spacing: -0.02em; margin-bottom: 22px;
}
.project-desc {
  font-size: 1.1rem; font-weight: 400; line-height: 1.6; max-width: 640px; margin-bottom: 40px;
}
.project-meta {
  display: flex; flex-wrap: wrap; gap: 50px;
  border-top: 3px solid var(--black); padding-top: 26px;
}
.project-meta > div { display: flex; flex-direction: column; gap: 6px; }
.meta-label { font-size: 0.75rem; font-weight: 700; letter-spacing: 1.5px; color: var(--muted); }
.project-meta span:last-child { font-weight: 700; font-size: 1rem; }

/* FULL ABOUT PAGE OVERLAY */
.about-overlay {
  position: fixed; inset: 0; z-index: 600;
  background: var(--white);
  transform: translateY(100%);
  transition: transform .55s cubic-bezier(.2,.8,.2,1);
  overflow-y: auto;
}
.about-overlay.open { transform: translateY(0); }
.about-page { max-width: 1100px; margin: 0 auto; padding: 68px 5vw 110px; }
.about-back {
  font-family: var(--ff-body); font-weight: 700; font-size: 0.85rem; letter-spacing: 1.5px;
  background: var(--black); color: var(--white);
  border: 2px solid var(--black); padding: 12px 20px; cursor: pointer;
  margin-bottom: 46px;
  transition: background .25s, color .25s, transform .2s;
  /* Sticks to the top of its overlay instead of scrolling away. These pages
     run several screens long and the only way back was to scroll all of it
     again - Escape works but nobody discovers it. Sticky rather than a
     second button at the foot: one control, in one place, available the
     whole way down instead of only at the two ends. It is its own scrolling
     ancestor (.project-overlay / .about-overlay are the overflow-y: auto
     elements), so this needs no JS. */
  position: sticky; top: 14px; z-index: 5;
  /* the content scrolls underneath, so it needs an edge of its own to sit
     on rather than blending into whatever happens to pass behind it */
  box-shadow: 4px 4px 0 var(--accent);
}
.about-back:hover { background: var(--white); color: var(--black); transform: translate(-3px,-3px); box-shadow: 5px 5px 0 var(--accent); }

.about-page-top {
  display: grid; grid-template-columns: 0.8fr 1.2fr; gap: 60px;
  margin-bottom: 38px;
}
/* grid items default to min-width:auto, which equals their content's
   min-content size — combined with aspect-ratio on the photo box, this
   was overriding the 0.8fr/1.2fr split entirely (computed as ~548px/
   ~298px instead), squeezing the bio text into a too-narrow column and
   making it wrap tightly enough to look like it was overlapping itself */
.about-page-top > * { min-width: 0; }
/* The frame is the grid item and the photo is absolute inside it. height:100%
   on the <img> alone did NOT hold: a replaced element contributes its own
   intrinsic height to an auto-sized grid row, so once the column was wide
   enough the photo set the row height rather than the text column. Measured
   overhang past the resume button: 0px at a 900px page, 62px at 1100px, 256px
   at 1300px, 422px at 1500px. An empty frame contributes nothing, so the row
   is the text's height and the photo fills it - bottom edge level with the
   button at every width. */
.about-photo-frame { position: relative; }
.about-photo {
  position: absolute; inset: 0;
  width: 100%; height: 100%; object-fit: cover; display: block;
  border: 4px solid var(--black);
}
/* the pair between the intro and the timeline: everything below here is text,
   and the page needs a breath before it */
.about-photos { display: flex; gap: 20px; margin: 0 0 80px; }
.about-photos img {
  height: auto; min-width: 0; display: block; object-fit: cover;
  border: 4px solid var(--black);
}
.about-photos .wide { flex: 1.778 1 0; aspect-ratio: 16/9; }
.about-photos .square { flex: 1.026 1 0; aspect-ratio: 1400/1365; }
@media (max-width: 760px) {
  /* the portrait's native ratio is ~1:2, which on a 375px screen runs over
     700px tall before the reader reaches a word of the bio */
  /* One column here, so there is no row for the photo to inflate and the
     frame has no job - both go back into normal flow and the ratio governs. */
  .about-photo-frame { position: static; }
  .about-photo {
    position: static; height: auto;
    aspect-ratio: 4/5; object-fit: cover; object-position: 50% 28%;
  }
  .about-photos { flex-direction: column; margin-bottom: 48px; }
  .about-photos .wide, .about-photos .square { flex: none; width: 100%; }
}
.about-page-intro h2 {
  font-family: var(--ff-display);
  font-size: clamp(2.2rem, 5vw, 3.6rem);
  letter-spacing: -0.02em; margin: 14px 0 22px;
}
.about-page-intro p {
  font-size: 1.05rem; line-height: 1.65; max-width: 620px; margin-bottom: 18px;
}

/* Inline link in running prose. The global reset is `a { color: inherit;
   text-decoration: none }`, so a link inside a paragraph has to re-declare
   everything or it reads as plain text - and this one sits inside a bolded
   phrase, which would otherwise look like emphasis and nothing more. The
   underline is the signal; the weight is the emphasis. Red rather than
   yellow because this is text: --accent-ink measures 5.03:1 on paper where
   the display red only reaches 2.77:1.
   The highlight is the hover, not the resting state - a yellow block sitting
   permanently in the middle of a paragraph fights the sentence. */
.prose-link {
  font-weight: 700;
  color: inherit;
  text-decoration: underline;
  text-decoration-color: var(--accent-ink);
  text-decoration-thickness: 2px;
  text-underline-offset: 3px;
  /* box-decoration-break so the highlight wraps onto a second line as two
     filled boxes instead of one that stops at the first line's end. */
  box-decoration-break: clone;
  -webkit-box-decoration-break: clone;
  padding: 0 2px; margin: 0 -2px;
  cursor: pointer;
  transition: background-color .2s ease, text-decoration-color .2s ease;
}
/* --ink, not --black, on the yellow. --black INVERTS with the theme - it is
   #e8e5dc in dark mode - so inheriting it would have laid near-white text on
   a near-white yellow the moment anyone switched themes. --ink is #111111 in
   both themes, and the yellow is bright in both, so one declaration covers
   them: ~17:1 either way. */
@media (hover: hover) {
  .prose-link:hover {
    background: var(--yellow);
    color: var(--ink);
    text-decoration-color: var(--ink);
  }
}
.prose-link:focus-visible {
  outline: 3px solid var(--accent-ink);
  outline-offset: 2px;
  background: var(--yellow);
  color: var(--ink);
  text-decoration-color: var(--ink);
}
@media (prefers-reduced-motion: reduce) {
  .prose-link { transition: none; }
}

.about-page-subhead {
  font-family: var(--ff-display);
  font-size: clamp(1.6rem, 3.5vw, 2.4rem);
  letter-spacing: -0.02em;
  border-bottom: 4px solid var(--black);
  padding-bottom: 16px; margin-bottom: 30px;
}
.about-timeline-section { margin-bottom: 70px; }
.about-timeline { list-style: none; display: flex; flex-direction: column; }
.about-timeline li {
  /* the 2px column between the year and the role is the rail; ::before draws
     the line through the full row height and ::after sits the dot on it */
  display: grid; grid-template-columns: 150px 2px 1fr 1.3fr; gap: 0 24px;
  padding: 22px 0;
  /* secondary/content-level divider — lighter than the heavy black
     rules used for primary structure (section-head, hero-grid), so
     repeated rows don't all shout at the same volume */
  border-bottom: 1px solid var(--line);
  align-items: baseline;
  position: relative;
}
.about-timeline li::before {
  content: ''; grid-column: 2; grid-row: 1;
  align-self: stretch; justify-self: center;
  width: 2px; background: var(--accent);
  /* reach past the row's padding so the line is continuous between entries */
  margin: -22px 0;
}
.about-timeline li::after {
  content: ''; grid-column: 2; grid-row: 1;
  justify-self: center; align-self: start;
  width: 11px; height: 11px; border-radius: 50%;
  background: var(--accent);
  /* ring in the page colour so the dot reads as sitting on top of the line */
  box-shadow: 0 0 0 4px var(--white);
  margin-top: 4px; z-index: 1;
}
/* the pending entry gets a hollow dot: filled dots are things that happened */
.about-timeline li.is-next::after {
  background: var(--white);
  box-shadow: inset 0 0 0 3px var(--accent), 0 0 0 4px var(--white),
              0 0 10px color-mix(in srgb, var(--accent) 38%, transparent);
}
.about-timeline li.is-next .timeline-role { color: var(--accent-ink); }

/* Two ripples off the pending dot, the second half a cycle behind the first,
   so there is always one in flight and it reads as a steady signal rather than
   a lonely blink. It rides in its own grid cell on the rail, so there are no
   hard-coded offsets: change the column widths and it stays centred.
   Animating transform/opacity rather than box-shadow spread keeps it on the
   compositor, so the edge stays smooth instead of stepping as it grows.
   Under prefers-reduced-motion the global block collapses the duration and
   fill-mode none returns both rings to opacity 0, leaving the plain dot. */
.about-timeline li.is-next .tl-ping {
  grid-column: 2; grid-row: 1;
  justify-self: center; align-self: start;
  position: relative; width: 11px; height: 11px; margin-top: 4px;
  pointer-events: none;
}
.about-timeline li.is-next .tl-ping::before,
.about-timeline li.is-next .tl-ping::after {
  content: ''; position: absolute; inset: 0; border-radius: 50%;
  border: 2px solid var(--accent);
  opacity: 0;
  /* Growth and fade run as two animations so each gets its own curve: the ring
     eases outward while the opacity falls linearly. Sharing one curve made the
     ring reach full size in the first third and then sit there dimming, which
     reads as a flick rather than a ripple. */
  animation: tl-ripple-grow 3.4s cubic-bezier(0.22, 0.61, 0.36, 1) infinite,
             tl-ripple-fade 3.4s linear infinite;
  will-change: transform, opacity;
}
.about-timeline li.is-next .tl-ping::after {
  animation-delay: 1.7s, 1.7s;
}
/* border-width winds down as the ring scales up: transform scales the stroke
   too, so a flat 2px border would render ~5.8px thick by the end. 2 / 2.9 is
   about 0.7, which holds the apparent weight steady the whole way out. */
@keyframes tl-ripple-grow {
  0%        { transform: scale(0.9); border-width: 2px; }
  55%, 100% { transform: scale(2.9); border-width: 0.7px; }
}
/* fades up from nothing over the first beat so the ring is never born mid-air */
@keyframes tl-ripple-fade {
  0%        { opacity: 0; }
  12%       { opacity: 0.6; }
  55%, 100% { opacity: 0; }
}
}

/* the first and last entries stop the line at their dot rather than running
   it off into the heading above and the rule below */
.about-timeline li:first-child::before { margin-top: 0; }
.about-timeline li:last-child::before { margin-bottom: 0; height: 26px; align-self: start; }
.timeline-year { font-weight: 700; font-size: 0.85rem; letter-spacing: 1px; color: var(--accent-ink); }
.timeline-role { font-family: var(--ff-display); font-size: 1.1rem; letter-spacing: -0.01em; }
.timeline-desc { font-size: 0.95rem; line-height: 1.5; color: var(--muted); }
@media (max-width: 760px) {
  .about-page-top { grid-template-columns: 1fr; }
  .about-timeline li { grid-template-columns: 1fr; gap: 8px; }
  /* the rail, its dot and the ripple all live in grid column 2. Hiding only
     the pseudo-elements left .tl-ping behind, and its explicit grid-column
     conjured an implicit second column that squashed the NEXT row. */
  .about-timeline li::before, .about-timeline li::after,
  .about-timeline li .tl-ping { display: none; }
}

.about-skills-section .skill-group { margin-bottom: 28px; }
.skill-group:last-child { margin-bottom: 0; }
.skill-group-label {
  font-family: var(--ff-body);
  font-size: 0.68rem; font-weight: 700; letter-spacing: 2px;
  color: var(--muted); margin: 0 0 12px;
}
.about-skill-tags {
  display: flex; flex-wrap: wrap; gap: 10px;
}
/* Labels, not controls. These read as a filter toolbar you can click, and
   three things were doing it: a 2px border, which is the border every button
   on this site has; a :hover that filled them yellow, when nothing that does
   not respond to a click should respond to a pointer; and .lead, which was
   background black + white text - the exact treatment of .btn-block.
   Flat tint, no border, no hover, no transition. The cursor already treated
   them correctly (they are not in CURSOR_TARGETS and are not focusable), so
   the appearance was the whole of the lie. */
.about-skill-tags span {
  border: 0;
  background: var(--chip);
  color: var(--black);
  /* 500, not 600: Space Grotesk is loaded at 400/500/700 only, so 600 fell
     through to the 700 file and these rendered identically to .lead below -
     the weight half of that distinction did nothing. */
  padding: 9px 15px; font-size: 0.78rem; font-weight: 500; letter-spacing: 1.2px;
}
/* the lead skill in each band still reads first so a reader scanning four
   groups picks up the headline of each without reading all 29 - now by
   weight and a deeper tint rather than by looking pressable */
.about-skill-tags span.lead {
  background: var(--chip-lead);
  font-weight: 700;
}
/* The last two chips of every group travel together so neither can be left
   hanging on a line of its own at an in-between window width. A div, not a
   span, so the chip rule above does not style the wrapper as a chip too. */
.about-skill-tags .tag-pair { display: flex; gap: 10px; }
/* flex-wrap, and no media query. A pair is one flex item, so without this it
   cannot break and spills out of a column narrower than the widest pair
   (MOTION DESIGN + PHOTOGRAPHY, 277px) - measured 23px past the edge at a
   200px column. Wrapping makes its automatic minimum its own min-content,
   i.e. its widest chip, so the pair holds one line wherever there is room
   and stacks internally where there isn't. The same 10px gap applies on both
   axes, so a stacked pair is indistinguishable from ordinary wrapping.
   Container-driven rather than a viewport breakpoint, which also means it
   survives text zoom - the chips grow, the column doesn't. */
.about-skill-tags .tag-pair { flex-wrap: wrap; }

/* ABOUT */
.about { background: var(--ink); color: var(--paper); padding-bottom: 34px; position: relative; }
.about .section-head {
  border-bottom: 4px solid var(--paper);
  /* tighter than the shared 70/30: this section has to read in one screen,
     and it's the only one carrying a photo, stats, a CTA pair and a band.
     Scoped so #work and #contact keep the roomier default, and so the 70px
     heading offset that scroll-padding-top is tuned to stays put for them.
     The right inset clears the photo panel, which spans the full section. */
  padding: 30px var(--about-inset) 16px 5vw;
}
#aboutTitle { cursor: pointer; transition: color .25s ease; }
#aboutTitle:hover { color: var(--yellow); }

.about-readmore-btn {
  display: flex; align-items: center; gap: 10px;
  margin: 10px 5vw 0;
  background: transparent; color: var(--paper);
  border: 2px solid var(--paper);
  padding: 14px 22px; font-weight: 700; font-size: 0.8rem; letter-spacing: 1.5px;
  cursor: pointer;
  transition: background .25s, color .25s, transform .25s, box-shadow .25s;
}
.about-readmore-btn:hover {
  background: var(--paper); color: var(--ink);
  transform: translate(-3px, -3px);
  box-shadow: 5px 5px 0 var(--yellow);
}
.about-cta-row {
  display: flex; flex-wrap: wrap; align-items: stretch; gap: 16px;
  /* inside the reading column now, so it aligns to the copy above it
     rather than to the page gutter */
  margin: 0;
}
.about-cta-row .about-readmore-btn { margin: 0; }

/* Both grow so the pair still aligns, but the primary gets the extra
   horizontal room and the larger type: it should be the biggest thing you
   can click in this section, because it's the only one we want clicked. */
/* Sized to its own label, not to the column. Stretched to the full 849px
   with space-between, the arrow ended up ~700px from the words it belongs
   to, so it read as a separate mark rather than part of the control - and a
   68px-tall bar that wide is out of proportion to a three-word label. It
   keeps the larger type and padding; it just stops at the end of the
   sentence. */
.about-cta-row { display: block; }
/* The yellow slab is what does the shouting. The red fill alone is only
   2.93:1 against this section - a dark block on a dark field, so no amount
   of size made it read as emphatic. Offsetting a 9px yellow shadow behind it
   puts a 17:1 edge on the object while the fill stays brand red, and it is
   the same raised-block language every other button on the site uses on
   hover. This one just wears it at rest, because it is the only control in
   the section. */
.about-cta-row .about-readmore-btn {
  width: auto;
  display: inline-flex;
  justify-content: flex-start;
  gap: 14px;
  padding: 24px 40px; font-size: 1.15rem; letter-spacing: 2.5px;
  box-shadow: 9px 9px 0 var(--yellow);
}
/* The arrow was part of the translated string, which made it a single text
   node that space-between had nothing to push apart. As a pseudo-element
   it's a second flex item, and it stays out of the four translations and
   out of what a screen reader reads. */
/* The display face draws this one lighter and shorter than the hero's, so
   at 1.15em it read as a different weight class from its own label. Bumped
   and set bold to match the type it sits next to. */
.about-cta-row .about-readmore-btn::after {
  content: '\2192';
  font-size: 1.5em; line-height: 1; font-weight: 700;
  transition: transform .25s ease;
}
.about-cta-row .about-readmore-btn:hover::after { transform: translateX(5px); }
@media (prefers-reduced-motion: reduce) {
  .about-cta-row .about-readmore-btn::after { transition: none; }
  .about-cta-row .about-readmore-btn:hover::after { transform: none; }
}

/* The only action in this section. The resume used to sit beside it as a
   filled accent button while this was a plain outline, so the weaker-looking
   control was the one we wanted clicked - and the resume was a terminal
   click that ended the visit anyway. It now lives only on the about page,
   where it IS the next step and keeps its filled treatment. */
.about-cta-row .about-readmore-btn {
  background: var(--accent-ink); color: var(--accent-on);
  border-color: var(--accent-ink);
}
/* Lifts further off the slab rather than inverting. The old hover dropped
   the fill and set the label in accent-ink, which on this near-black section
   is 2.01:1 - the label became close to unreadable at exactly the moment
   someone was pointing at it. Holding the fill keeps it at 6.45:1 throughout
   and matches .btn-block and .form-submit, which also lift into a hard
   offset shadow. */
.about-cta-row .about-readmore-btn:hover,
.about-cta-row .about-readmore-btn:focus-visible {
  background: var(--accent-ink); color: var(--accent-on);
  transform: translate(-4px, -4px);
  box-shadow: 14px 14px 0 var(--yellow);
}
@media (prefers-reduced-motion: reduce) {
  .about-cta-row .about-readmore-btn:hover,
  .about-cta-row .about-readmore-btn:focus-visible { transform: none; }
}

.cv-btn {
  display: inline-flex; align-items: center; gap: 8px;
  text-decoration: none;
  background: var(--accent-ink); color: var(--accent-on);
  border: 2px solid var(--accent-ink);
  padding: 14px 22px; font-weight: 700; font-size: 0.8rem; letter-spacing: 1.5px;
  cursor: pointer;
  transition: background .25s, color .25s, transform .25s, box-shadow .25s;
}
.cv-btn:hover {
  background: transparent; color: var(--accent-ink);
  transform: translate(-3px, -3px);
  box-shadow: 5px 5px 0 var(--yellow);
}
.about-page-intro .cv-btn { margin-top: 8px; }

/* dark-mode-only static texture inside the about slab */
:root[data-theme="dark"] .about::before {
  content: '';
  position: absolute; inset: 0; pointer-events: none;
  background-image: repeating-linear-gradient(
    to bottom,
    rgba(255,255,255,0.04) 0px,
    rgba(255,255,255,0.04) 1px,
    transparent 1px,
    transparent 4px
  );
  mix-blend-mode: overlay;
}
/* The photo is a full-height panel pinned to the right of the whole
   section, not a box inside the reading grid. Sizing it off the text column
   gave the two photos a 2.09:1 and a 1.38:1 slot cut from sources that are
   0.56:1 and 0.8:1, so both were cropped to a letterbox. The panel is as tall
   as the section (~0.58:1 at desktop), which is jack-centrale.jpg's own
   shape - the crop is now effectively nothing. Being absolutely positioned,
   it also costs the section no height: 726px here against 762px before. */
.about { position: relative; --about-panel: 420px; }
/* panel + the gap between it and the copy, so the text never runs under it */
.about { --about-inset: calc(var(--about-panel) + 56px); }
.about-panel {
  position: absolute; top: 0; right: 0; bottom: 0;
  width: var(--about-panel);
  overflow: hidden;
}
.about-panel .about-portrait {
  /* -5%, not 0. The tilt is a 3D rotation, so the far edge of the photo
     swings back and lands inside the panel - up to 13px of bare ink in the
     top corners at full tilt, which is the artefact that showed. Oversizing
     the photo past that worst case keeps its edges outside the panel's clip,
     so the panel stays completely filled at every cursor position.
     A percentage rather than a pixel figure because the gap grows with the
     panel's own height: 16px held at the 732px panel this viewport renders
     (1.7px to spare) but went 11px negative at 1100px, which would have put
     the artefact straight back on a taller section. -5% keeps at least 6px
     of cover at every panel size measured, from 300x650 to 490x760. */
  position: absolute; inset: -5%;
  border: 0;
}
/* the 4px rule that runs under the section heading, continued down the seam:
   the panel reads as part of the same frame rather than a pasted-on picture.
   It lives on the panel, not on the photo, so it stays straight while the
   photo tilts against it on hover - the fixed edge is what makes the tilt
   legible at all. */
.about-panel { border-left: 4px solid var(--paper); }
.about-panel img { width: 100%; height: 100%; object-fit: cover; object-position: 50% 40%; }
/* one reading column; the panel is out of flow beside it */
.about-grid {
  display: block;
  /* the bottom is the section's own breathing room now that the marquee is
     gone and the button is the last thing in the column */
  padding: 22px var(--about-inset) 34px 5vw;
}
/* the photo is the link, so it gets the same lift-and-shadow the buttons
   use - it has to read as pressable, not as an illustration. Inset rather
   than translated, because a panel flush to the viewport edge would slide
   off it. */
.about-portrait {
  display: block;
  /* the card's own curve on transform, which is what makes the tilt settle
     back rather than snap when the pointer leaves */
  transition: transform .35s cubic-bezier(.2,.8,.2,1), box-shadow .3s, filter .3s;
}
/* The ring sits on the panel, not on the photo. On the photo it rode along
   with the tilt, which put a yellow frame in the middle of the picture with
   more picture visible outside it - the thing that read as broken corners.
   On the panel it is a fixed window the photo moves behind, so the frame
   stays square to the page however far the photo leans. z-index because the
   photo's 3D transform gives it a stacking context of its own. */
.about-panel::after {
  content: ''; position: absolute; inset: 0; pointer-events: none; z-index: 2;
  box-shadow: inset 0 0 0 0 var(--yellow);
  transition: box-shadow .3s ease;
}
/* hover:hover, because a tap on a touch screen leaves :hover matching until
   something else is tapped - measured stuck on at 375px wide, ring and all,
   with no way for a finger to dismiss it. Keyboard focus is outside the
   query so it still works everywhere. */
@media (hover: hover) {
  .about-panel:hover::after { box-shadow: inset 0 0 0 6px var(--yellow); }
}
.about-panel:has(.about-portrait:focus-visible)::after {
  box-shadow: inset 0 0 0 6px var(--yellow);
}
/* No transform here. The tilt is the cards' own [data-tilt] handler, which
   tracks the pointer and writes an inline transform - a rule in this file
   would either be overridden by it or fight it. What is left for CSS is the
   part the handler does not touch: the ring, and the brightness lift. */
/* Black and white. The filter sits on the anchor, which is also what the
   brightness lift uses - so the hover has to restate grayscale(1) or hovering
   would drop it and flash the photo back into colour. */
.about-panel .about-portrait { filter: grayscale(1); }
@media (hover: hover) {
  .about-panel .about-portrait:hover { filter: grayscale(1) brightness(1.06); }
}
.about-panel .about-portrait:focus-visible { filter: grayscale(1) brightness(1.06); }

@media (prefers-reduced-motion: reduce) {
  .about-panel::after { transition: none; }
  .about-portrait { transition: none; }
}
.about-portrait img { display: block; }
/* No zoom on hover, because the project cards do not zoom either. They used
   to look like they might: a .card-media svg rule scaled the placeholder art,
   and it kept matching nothing once every .card-media held an <img>. The rule
   is gone now. The cards tilt and nothing else, and this does the same. */

/* one column, read straight down: statement, stats, hook, button. Every
   step is smaller and quieter than the last until the button, which jumps
   back up - the funnel shape is what makes the CTA feel like an arrival. */
/* Explicit steps rather than one uniform gap, because the four steps are
   not equal. The big break is above the hook: everything before it is
   credentials, everything after it is the ask. The hook then sits tight to
   the button so the two read as a single terminal unit - the hook is the
   button's reason, not a separate paragraph. Measured: 30 / 44 / 24. */
.about-intro { display: flex; flex-direction: column; gap: 0; }
.about-statement { margin-bottom: 30px; }
.about-stats { margin-bottom: 44px; }
/* One clause is set heavy so the paragraph has something to catch on. Four
   lines at an even weight is a wall you either read or skip; a weight jump
   gives the eye a landing point and the gist survives a two-second scan.
   Weight, not colour - yellow belongs to the CTA, and this keeps the whole
   paragraph at the same contrast. */
.about-statement b {
  /* 700 is the heaviest face loaded; 800 was silently resolving to it */
  font-weight: 700;
  /* the display face is tighter at this size; -0.01em stops the bold clause
     from running visibly wider than the text around it */
  letter-spacing: -0.01em;
}
.about-statement {
  font-size: clamp(1.4rem, 2.6vw, 2.1rem);
  /* 400, not 500. Space Grotesk tops out at 700, so the bold clauses cannot
     get heavier - the only way to make them read heavier is to lighten what
     they sit against. Measured in ink coverage: 500 vs 700 was 4570 vs 5367
     (+17%), 400 vs 700 is 3775 vs 5367 (+42%). At 1.4-2.1rem the regular
     face still has plenty of body; 500 was buying almost nothing here. */
  font-weight: 400; line-height: 1.35;
  /* the wide column left "other." alone on a fourth line; pretty pulls a
     word down to keep the last line from being a single orphan. Ignored by
     browsers that don't support it, which just get the old ragged break. */
  text-wrap: pretty;
}
/* Paper, not yellow. This is a hue change, not a contrast one: paper is
   17.27:1 against the section and yellow was 17.13:1, so the line is just as
   legible. What it gives up is the accent. In the accent it was competing
   chromatically with the one control it exists to hand you to - and winning,
   since the button's fill was 2.93:1, a dark block on a dark field, against
   24,730px2 of saturated type. Yellow now appears exactly once in this
   column, on the CTA. Weight stays at 600 so the line is still its own beat
   rather than more statement; the size already separates them. */
.about-hook {
  font-size: clamp(1.1rem, 1.6vw, 1.38rem);
  /* 700 is what 600 was already rendering as - only 400/500/700 are loaded */
  font-weight: 700; line-height: 1.4;
  color: var(--paper);
  /* 50ch, not 44. The two sentences now sit on a line each, and at 44ch the
     second one overran by a hair - it broke after "crucial for" and left
     "design." alone on a 92px third line, which is the exact rag the break
     was added to remove. Measured in ch so it tracks the clamped font size
     rather than fixing a pixel width that only works at one viewport. */
  max-width: 50ch;
  margin-bottom: 24px;
  text-wrap: pretty;
  /* A rule down the side so it is structurally a pull-quote rather than a
     second paragraph. Without it the section read as two blocks of prose in
     two sizes, which is the thing that makes a visitor decide not to read
     either. Now one is a statement and one is an aside, and you can tell
     which is which before reading a word. */
  position: relative;
  padding-left: 22px;
}
.about-hook::before {
  content: ''; position: absolute; left: 0; top: 4px; bottom: 4px;
  width: 4px; background: var(--paper);
}
/* a row, not a 2x2 block: four short columns read as one horizontal sweep
   that carries the eye across and down to the hook, where a block made a
   second thing to read. */
/* Ruled like a table, not floated in the dark. The hairlines were
   rgba(...,0.16) - barely visible, so four numbers sat loose in the middle
   of the column with nothing holding them. 2px solid is the border weight
   this whole site uses, and it makes the band read as one object you can
   scan across in a single pass. */
/* Ruled and filled like a spec panel. Paper-on-ink the whole way down left
   the section reading as one undivided grey block, and four numbers floating
   loose in the middle of it. A faint fill plus hard rules between the cells
   makes the band its own object, so the column now reads as three distinct
   things - statement, data, the ask - rather than one column of text.
   It bleeds: left to the page edge, right to the photo panel, the same span
   as the rule under the ABOUT heading. Held to the text column the fill
   ended in a hard vertical edge just left of the first number, and a filled
   block that stops mid-air reads as cropped rather than as a panel. The
   negative margins cancel .about-grid's padding; padding-left puts the
   content back on the 5vw gutter, so the first number still starts on the
   same left edge as the statement, the hook and the button. */
.about-stats {
  display: grid; grid-template-columns: repeat(4, 1fr); gap: 0;
  background: rgba(245,245,240,0.055);
  border-top: 2px solid rgba(245,245,240,0.3);
  border-bottom: 2px solid rgba(245,245,240,0.3);
  margin-left: -5vw;
  /* 56px is the gap baked into --about-inset, so this lands the right edge
     exactly on the panel rather than under it */
  margin-right: -56px;
  padding: 22px 0 22px 5vw;
}
.stat { padding: 0 22px; border-left: 2px solid rgba(245,245,240,0.45); }
.stat:first-child { padding-left: 0; border-left: 0; }
.stat { display: flex; flex-direction: column; gap: 7px; }

/* These were 3.1rem in full yellow, which made four supporting numbers the
   loudest thing in the section - brighter and half again larger than the
   sentence they support. Dropped to paper and below the statement's size so
   they read as evidence, not headline. */
/* Emphasis carried by size and weight rather than by the accent, so the
   numbers stay loud while yellow still means one thing in this column.
   ~36px at 1440, set in the display face against the statement's body face -
   different enough to read as data, not as another sentence. */
/* Yellow, at 17.13:1 against the section - the same luminance contrast as
   paper, so nothing is lost in legibility and the hue is pure gain. Red was
   the other candidate and measures 5.32:1: more brand, but dimmer than the
   paper it would replace, which is the opposite of the brief.
   This does mean yellow appears twice in the column. It reads fine because
   the two are different kinds of object - 36px of type against a filled
   block carrying a 40px slab - and the CTA still wins on mass. */
.stat-num {
  font-family: var(--ff-display); font-size: clamp(1.75rem, 2.5vw, 2.3rem);
  letter-spacing: -0.02em; color: var(--yellow); line-height: 1;
}
/* Every number carries the accent now, so is-now needs no colour of its
   own - it is already the only stat that is a word rather than a number,
   and it sits in its own ruled cell. */
.stat-label { font-size: 0.74rem; font-weight: 400; letter-spacing: 1px; color: var(--muted-ink); }
/* Between a laptop and a desktop a fixed 420px panel takes too big a bite
   out of the reading column, so it scales with the viewport until it is
   worth keeping at all. */
@media (max-width: 1100px) {
  .about { --about-panel: 34vw; }
}
@media (max-width: 800px) {
  /* A phone has no second column to pin anything to, so the panel rejoins
     the flow under the copy. Held at its native 676/1200 it filled only
     210px of a 338px column and read as a mistake, so here it takes the full
     width at 4:5 - the one place a crop is the better trade, because it is
     the only way the photo lines up with everything above it. 4:5 is 52vh,
     which still leaves the copy on screen first. */
  .about { --about-panel: 0px; --about-inset: 5vw; }
  .about-panel {
    /* relative, not static: the ring is a ::after pinned to this box */
    position: relative; width: auto;
    margin: 0 5vw 28px;
    /* the photo carries a full 4px border of its own down here, so the
       panel's seam would draw a second line on top of it */
    border-left: 0;
  }
  /* 3:4 rather than 4:5. The platform photo is a full-length figure in a
     0.574 frame; a 4:5 window shows 71.75% of its height, which is not quite
     the 72% between the top of his head and his feet, so 4:5 clipped his
     shoes. 3:4 shows 76.5% and holds the whole figure. Costs 28px. */
  .about-panel .about-portrait {
    position: relative;
    /* the desktop rule's -16px would offset a relatively-positioned box
       rather than oversize it, dragging the photo up and left */
    inset: auto;
    aspect-ratio: 3 / 4;
    border: 4px solid var(--paper);
  }
  .about-panel img { object-position: 50% 50%; }
  .about-panel img { height: 100%; }
  .about-hook { max-width: none; }
  /* four across is ~80px a column on a phone, which breaks every label. In a
     2x2 the vertical rules would cut between rows as well as columns, so the
     cells go back to plain gaps and the band keeps only its fill and the two
     horizontal rules. */
  /* bleeds both sides here - there is no photo panel to stop at, and the
     -56px right margin would otherwise push it past the gutter */
  .about-stats {
    grid-template-columns: 1fr 1fr; gap: 20px 24px;
    margin-left: -5vw; margin-right: -5vw;
    padding: 22px 5vw;
  }
  .stat, .stat:first-child { padding: 0; border-left: 0; }
  /* full width here: it is the only action on the screen and a thumb should
     not have to find a 260px target */
  .about-cta-row .about-readmore-btn { width: 100%; justify-content: center; }
}

/* MAGNETIC TYPOGRAPHY — extremely tall condensed type, edge to edge */
.magnetic-section {
  background: var(--white);
  /* near-zero vertical padding, no min-height — the section now hugs
     its content tightly instead of forcing extra empty space */
  padding: 2px 2vw;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  overflow: visible;
  /* explicit stacking context with a z-index higher than the (also
     position:relative) .contact below it, so letters that drift
     downward while overflow:visible paint OVER the contact section
     instead of being covered by it */
  position: relative;
  z-index: 5;
}
.magnetic-text {
  display: flex; flex-direction: column; align-items: center;
  width: 100%;
  font-family: var(--ff-condensed);
  font-size: clamp(4rem, 16vw, 13rem);
  /* 0.82 was tighter than Anton's natural ascent+descent, so stacked
     rows visually overlapped — 0.94 keeps it tight without collision */
  line-height: 0.94;
  letter-spacing: -0.01em;
  text-transform: uppercase;
  text-align: center;
  color: var(--black);
  cursor: default;
}
.magnetic-row {
  display: flex; flex-wrap: nowrap; justify-content: center;
  width: 100%;
  overflow: visible;
}
.magnetic-row + .magnetic-row { margin-top: 0.03em; }
.magnetic-word { display: inline-flex; }
.magnetic-word + .magnetic-word { margin-left: 0.22em; }
.magnetic-letter {
  display: inline-block;
  will-change: transform;
  /* soft yellow glow in light mode (dark mode gets its own punchier
     accent/blue glow below, overriding this) */
  text-shadow: 0 0 10px var(--yellow);
}

/* CONTACT — bg is a fixed accent red regardless of theme, so its
   internal tones stay on fixed ink/paper to guarantee AA contrast */
.contact {
  position: relative;
  background:
    repeating-linear-gradient(45deg, rgba(0,0,0,0.055) 0 2px, transparent 2px 16px),
    var(--accent);
  color: var(--ink);
  padding-bottom: 50px;
}
.contact .section-head { border-bottom: 4px solid var(--ink); }

.contact-grid {
  display: grid; grid-template-columns: 1fr 1fr;
  gap: 60px;
  padding: 46px 5vw 0;
  align-items: start;
}
.contact-right { display: flex; flex-direction: column; gap: 40px; }

.contact-email-row {
  display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
  margin-top: -16px;
}
.contact-email-text {
  font-size: 1rem; font-weight: 700; letter-spacing: 0.5px;
}
.copy-email-btn {
  background: transparent; color: var(--ink);
  border: 2px solid var(--ink);
  padding: 6px 14px; font-weight: 700; font-size: 0.7rem; letter-spacing: 1.5px;
  cursor: pointer;
  transition: background .2s, color .2s, transform .2s;
}
.copy-email-btn:hover { transform: translate(-2px, -2px); box-shadow: 3px 3px 0 var(--ink); }
.copy-email-btn.copied { background: var(--ink); color: var(--paper); }
/* 29px tall was well under the 44px touch-target guideline — pad it out
   on touch-sized viewports only, so the desktop proportions are kept */
@media (max-width: 760px) {
  .copy-email-btn { min-height: 44px; padding: 6px 18px; }
}
.contact-statement {
  font-family: var(--ff-display);
  font-size: clamp(2rem, 4.4vw, 3.6rem);
  line-height: 1.03;
  letter-spacing: -0.03em;
  text-transform: uppercase;
}

.contact-socials-big {
  display: flex; flex-direction: column;
  border-top: 3px solid var(--ink);
}
.social-link {
  position: relative;
  display: flex; align-items: center; justify-content: space-between;
  padding: 22px 6px;
  border-bottom: 3px solid var(--ink);
  font-family: var(--ff-display);
  font-size: clamp(1.3rem, 2.8vw, 2rem);
  letter-spacing: -0.01em;
  color: var(--ink);
  overflow: hidden;
  transition: color .3s ease, padding-left .3s ease;
}
.social-link-left {
  position: relative; z-index: 1;
  display: flex; align-items: center; gap: 14px;
}
.social-icon {
  width: 26px; height: 26px; flex-shrink: 0;
  color: currentColor;
}
.social-link-text, .social-link-arrow { position: relative; z-index: 1; }
.social-link-arrow { font-size: 1.3rem; transition: transform .35s cubic-bezier(.2,.8,.2,1), color .3s ease; }
.social-link::before {
  content: ''; position: absolute; inset: 0; width: 0;
  background: var(--ink);
  transition: width .35s cubic-bezier(.2,.8,.2,1);
  z-index: 0;
}
.social-link:hover { color: var(--paper); padding-left: 18px; }
.social-link:hover::before { width: 100%; }
.social-link:hover .social-link-arrow { color: var(--yellow); transform: translate(5px, -5px) rotate(8deg); }
/* The last row keeps its rule, like every other row - what made the bottom
   look like one thick band was that rule sitting directly on top of the
   strip below it with nothing in between. The gap is what fixes it, not
   removing the line. */
.contact-socials-big { margin-bottom: 26px; }

@media (max-width: 900px) {
  .contact-grid { grid-template-columns: 1fr; gap: 16px; }
}

.contact-form {
  padding: 0 0 40px;
  display: flex; flex-direction: column; gap: 26px;
  max-width: 640px;
}
.form-row { display: flex; flex-direction: column; gap: 8px; }
.form-row label {
  font-size: 0.75rem; font-weight: 700; letter-spacing: 1.5px;
}
.form-row input, .form-row textarea {
  font-family: var(--ff-body); font-size: 1rem;
  background: var(--paper); color: var(--ink);
  border: 3px solid var(--ink);
  padding: 14px 16px;
  resize: vertical;
  transition: transform .2s, box-shadow .2s;
}
.form-row input::placeholder, .form-row textarea::placeholder { color: var(--ink); opacity: 0.45; }
.form-row input:focus, .form-row textarea:focus {
  outline: none;
  transform: translate(-3px, -3px);
  box-shadow: 5px 5px 0 var(--ink);
}
.form-submit {
  align-self: flex-start;
  background: var(--ink); color: var(--paper);
  border: 2px solid var(--ink);
  padding: 16px 28px; font-weight: 700; font-size: 0.85rem; letter-spacing: 1.5px;
  cursor: pointer;
  transition: background .25s, color .25s, transform .25s, box-shadow .25s;
}
.form-submit:hover { background: var(--paper); color: var(--ink); transform: translate(-3px,-3px); box-shadow: 6px 6px 0 var(--paper); }
.form-submit:disabled { cursor: default; opacity: 0.7; }
.form-status {
  font-size: 0.85rem; font-weight: 700; letter-spacing: 1px;
  min-height: 1.2em;
}
.contact-bottom {
  padding: 20px 5vw 0; border-top: 3px solid var(--ink);
}
.contact-loc { font-size: 0.8rem; font-weight: 700; letter-spacing: 1.5px; }

/* FOOTER */
.footer {
  display: flex; justify-content: space-between; align-items: center;
  padding: 22px 5vw; background: var(--ink); color: var(--paper);
  font-size: 0.75rem; font-weight: 700; letter-spacing: 1.5px;
}
.back-top:hover { color: var(--yellow); }

.live-clock {
  font-family: 'Courier New', monospace;
  letter-spacing: 2px;
  opacity: 0.75;
  border: 1px solid var(--paper);
  padding: 4px 10px;
}
@media (max-width: 600px) { .live-clock { display: none; } }

.egg-trigger {
  position: relative;
  background: none; border: none; cursor: pointer;
  color: inherit; font-size: 1.05rem;
  opacity: 0.55; margin-left: 8px;
  transition: opacity .25s ease, transform .25s ease, color .25s ease;
}
/* It breathes because nobody was finding it. At 0.3 opacity in the footer it
   was invisible unless you happened to sweep a cursor over it - the sparkle
   on hover is the reward, but something has to earn the hover first. Slow
   enough (5s) to read as a pulse rather than a blink, and the opacity rides
   along with the scale so it brightens as it grows. */
.egg-trigger { animation: egg-breathe 5s ease-in-out infinite; }
/* Size comes from font-size first (0.8rem -> 1.05rem, so 12.8px -> 16.8px of
   actual glyph) and only then from the pulse, because scaling a small
   character up just blurs it. On top of that the floor matters more than the
   ceiling: the first version sat back at scale 1 / opacity 0.3 for half of
   every cycle, which is exactly where it was before it breathed at all. */
@keyframes egg-breathe {
  0%, 100% { opacity: 0.55; transform: scale(1.2); }
  50%      { opacity: 0.9;  transform: scale(1.45); }
}
/* the animation drives transform and opacity, so it would win over the hover
   state's values - it has to stop for the hover to be visible at all */
.egg-trigger:hover, .egg-trigger:focus-visible { animation: none; }
.egg-trigger:hover { opacity: 1; transform: rotate(25deg) scale(1.7); color: var(--yellow); }
@media (prefers-reduced-motion: reduce) {
  /* no pulse, but it keeps the brighter, bigger end of it so it is still
     findable without moving */
  .egg-trigger { animation: none; opacity: 0.8; transform: scale(1.3); }
}

.egg-particle {
  position: fixed; z-index: 1700; pointer-events: none;
  font-size: 0.8rem; color: var(--yellow);
  animation: egg-particle-fly .7s ease-out forwards;
}
@keyframes egg-particle-fly {
  0% { opacity: 1; transform: translate(0, 0) scale(1) rotate(0deg); }
  100% { opacity: 0; transform: translate(var(--px), var(--py)) scale(0.3) rotate(180deg); }
}

/* easter egg menu — pick stickers or chaos mode */
.egg-menu {
  position: fixed; bottom: 64px; left: 20px; z-index: 1500;
  background: var(--paper); border: 3px solid var(--ink);
  box-shadow: 6px 6px 0 var(--ink);
  display: flex; flex-direction: column;
  transform: translateY(10px) scale(0.92);
  opacity: 0; visibility: hidden; pointer-events: none;
  transition: opacity .2s ease, transform .2s ease, visibility .2s ease;
}
.egg-menu.open { opacity: 1; visibility: visible; transform: translateY(0) scale(1); pointer-events: auto; }
.egg-menu button {
  background: none; border: none; cursor: pointer;
  text-align: left; padding: 14px 24px;
  font-weight: 700; font-size: 0.8rem; letter-spacing: 1.5px;
  color: var(--ink);
  border-bottom: 2px solid var(--ink);
  white-space: nowrap;
}
.egg-menu button:last-child { border-bottom: none; }
.egg-menu button:hover { background: var(--yellow); }

/* Chaos mode flashes faster than WCAG 2.3.1 allows (scanlines ~23Hz, hero
   shake ~9Hz, whole-page saturation swing ~12Hz), so the menu item carries
   the warning rather than putting it behind the click. The button stacks
   instead of staying on one line, so nowrap has to be released here. */
#eggMenuChaos {
  display: flex; flex-direction: column; align-items: flex-start;
  gap: 5px; white-space: normal;
}
.egg-warn {
  display: inline-flex; align-items: center; gap: 5px;
  font-size: 0.6rem; letter-spacing: 1px; line-height: 1;
  color: var(--warn-ink);
}
.egg-warn svg { width: 12px; height: 12px; flex: none; }


/* CHAOS MODE — 8 seconds of corrupted-signal glitching (desaturate/
   oversaturate swings + a small stutter-shake), not a violent strobe.
   Inverting colors was the old version; this one reads as "broken
   broadcast" rather than "flashing lights". */
@keyframes chaos-tv-jitter {
  0%, 100% { transform: translate(0, 0); filter: saturate(1) contrast(1); }
  14%  { transform: translate(-1px, 1px); filter: saturate(0.35) contrast(1.05); }
  28%  { transform: translate(2px, -1px); filter: saturate(1.9) contrast(1.2); }
  42%  { transform: translate(-2px, 0); filter: saturate(0.4) contrast(1); }
  57%  { transform: translate(1px, 2px); filter: saturate(1.6) contrast(1.15); }
  71%  { transform: translate(-1px, -2px); filter: saturate(0.5) contrast(1.05); }
  85%  { transform: translate(2px, 1px); filter: saturate(1.3) contrast(1.1); }
}
/* The jitter goes on the page sections, NOT on body. `transform` and `filter`
   both make an element the containing block for its position:fixed
   descendants, so animating body re-anchored #projectOverlay and #aboutOverlay
   to the document instead of the viewport. Their closed state is
   translateY(100%), which then parked them a full page-height below the
   footer: scrollHeight doubled (5218 -> 10439) and you could scroll down into
   the closed About page. Both overlays are divs inside <main>, so targeting
   header/section keeps them out of it. The animations stay in sync because
   they all start the instant the class lands. */
body.chaos-mode > main > header,
body.chaos-mode > main > section,
body.chaos-mode > footer {
  animation: chaos-tv-jitter 0.6s steps(7) infinite;
}
body.chaos-mode .scanlines {
  opacity: 1 !important;
  animation: scanline-flicker 0.35s steps(8) infinite !important;
}
body.chaos-mode .section-head h2 {
  color: var(--accent) !important;
}
/* brief static bursts — toggled on/off by JS at random intervals,
   rather than a continuous flash */
.noise.static-flicker {
  opacity: 0.85 !important;
  mix-blend-mode: difference;
}

/* chaos mode hits the hero hard: the name shakes/glitches violently,
   the availability badge flickers like a dying light, and the marquee
   rips past at several times its normal speed */
@keyframes chaos-hero-shake {
  0%   { transform: translate(0, 0) skew(0deg); text-shadow: none; }
  20%  { transform: translate(-6px, 3px) skew(-2deg); text-shadow: 3px 0 var(--accent), -3px 0 var(--blue); }
  40%  { transform: translate(5px, -4px) skew(1.5deg); text-shadow: -3px 0 var(--blue), 3px 0 var(--accent); }
  60%  { transform: translate(-4px, -3px) skew(-1deg); text-shadow: 2px 0 var(--accent); }
  80%  { transform: translate(6px, 4px) skew(2deg); text-shadow: -2px 0 var(--blue); }
  100% { transform: translate(0, 0) skew(0deg); text-shadow: none; }
}
body.chaos-mode .hero-name {
  animation: chaos-hero-shake 0.22s steps(2) infinite;
}
@keyframes chaos-badge-flicker {
  0%, 19%, 21%, 50%, 52%, 100% { opacity: 1; }
  20%, 51%, 70% { opacity: 0.15; }
  85% { opacity: 0.5; }
}
body.chaos-mode .badge-available {
  animation: chaos-badge-flicker 0.9s steps(1) infinite;
}
/* overrides the inline animation-duration JS sets for the seamless
   loop — !important only while chaos-mode is on the body, so the
   original speed resumes automatically the instant it's removed */
body.chaos-mode .marquee-track {
  animation-duration: 2.5s !important;
}

.chaos-stop-btn {
  display: none;
  position: fixed; top: 100px; left: 50%; transform: translateX(-50%);
  z-index: 1850;
  background: var(--ink); color: var(--paper);
  border: 3px solid #ff2d55;
  padding: 14px 26px;
  font-family: var(--ff-display); font-size: 0.95rem; letter-spacing: 1.5px;
  cursor: pointer;
  align-items: center; gap: 10px;
  box-shadow: 6px 6px 0 #ff2d55;
}
.chaos-stop-btn #chaosCountdown {
  color: #f9ff2e;
}
body.chaos-mode .chaos-stop-btn { display: inline-flex; }
@media (max-width: 600px) {
  .chaos-stop-btn { top: 90px; padding: 10px 18px; font-size: 0.8rem; }
}

/* chaos mode takes over the whole gallery area with one big centered
   PLAY button — the user isn't browsing the portfolio mid-chaos anyway */
.chaos-work-cover {
  display: none;
  position: absolute; inset: 0; z-index: 50;
  align-items: center; justify-content: center;
  background: rgba(0, 0, 0, 0.86);
}
body.chaos-mode .chaos-work-cover { display: flex; }
.chaos-play-btn-large {
  background: #ff2d55; color: #fff;
  border: 4px solid #f9ff2e;
  padding: 32px 56px;
  font-family: var(--ff-display); font-size: clamp(1.4rem, 3vw, 2.2rem); letter-spacing: 2px;
  cursor: pointer;
  animation: chaos-play-jitter 0.5s steps(2) infinite, chaos-play-glow 1.1s ease-in-out infinite;
}
@keyframes chaos-play-jitter {
  0% { transform: translate(0, 0) skew(0deg); }
  50% { transform: translate(-2px, 1px) skew(-2deg); }
  100% { transform: translate(2px, -1px) skew(2deg); }
}
@keyframes chaos-play-glow {
  0%, 100% { box-shadow: 0 0 20px 4px rgba(255,45,85,0.85), 0 0 42px 12px rgba(249,255,46,0.4); }
  50% { box-shadow: 0 0 38px 10px rgba(255,45,85,1), 0 0 64px 20px rgba(249,255,46,0.65); }
}
@media (max-width: 600px) {
  .chaos-play-btn-large { padding: 22px 32px; }
}

/* mini Pong floating window */
.pong-window {
  display: none;
  position: fixed; top: 50%; left: 50%; z-index: 1900;
  transform: translate(-50%, -50%) rotate(-0.4deg);
  background: #0c0c0c;
  border: 3px solid var(--yellow);
  box-shadow: 0 0 50px rgba(255, 45, 85, 0.55), 10px 10px 0 rgba(0,0,0,0.4);
}
.pong-window.open { display: block; }
.pong-header {
  display: flex; justify-content: space-between; align-items: center;
  background: #111; color: var(--yellow);
  padding: 8px 12px;
  font-family: 'Courier New', monospace; font-size: 0.75rem; letter-spacing: 1px;
  border-bottom: 2px solid var(--yellow);
}
#pongClose { background: none; border: none; color: var(--accent); cursor: pointer; font-size: 1rem; }
.pong-canvas-wrap { position: relative; }
#pongCanvas { display: block; background: #000; image-rendering: pixelated; max-width: 90vw; }
.pong-footer {
  color: #29ff7a; font-family: 'Courier New', monospace; font-size: 0.7rem;
  padding: 6px 10px; border-top: 1px solid #333; background: #0c0c0c;
}

/* win/lose screen — glitchy brutalist message over the canvas */
.pong-end {
  display: none;
  position: absolute; inset: 0; z-index: 2;
  flex-direction: column; align-items: center; justify-content: center; gap: 18px;
  background: rgba(0,0,0,0.82);
}
.pong-end.open { display: flex; }
.pong-end-msg {
  font-family: var(--ff-display); font-size: 1.8rem; letter-spacing: 1px;
  color: #f9ff2e;
  text-shadow: 2px 0 #ff2d55, -2px 0 #00e1ff;
  animation: chaos-play-jitter 0.4s steps(2) infinite;
}
#pongRematch {
  background: #ff2d55; color: #000;
  border: 2px dashed #f9ff2e;
  padding: 10px 20px;
  font-family: var(--ff-display); font-size: 0.8rem; letter-spacing: 1.5px;
  cursor: pointer;
}
#pongRematch:hover { background: #f9ff2e; }

/* STICKER PACK EASTER EGG */
.sticker-tray {
  position: fixed; bottom: 22px; left: 50%;
  transform: translateX(-50%) translateY(140%);
  z-index: 1500;
  background: var(--paper); border: 3px solid var(--ink);
  padding: 16px 22px; display: flex; align-items: center; gap: 18px;
  box-shadow: 8px 8px 0 var(--ink);
  transition: transform .4s cubic-bezier(.2,.8,.2,1);
  flex-wrap: wrap; justify-content: center;
}
.sticker-tray.open { transform: translateX(-50%) translateY(0); }
.sticker-tray-label {
  font-size: 0.7rem; font-weight: 700; letter-spacing: 1.5px;
  color: var(--ink); white-space: nowrap;
}
.sticker-pack { display: flex; gap: 10px; }
.sticker {
  font-size: 2.1rem; cursor: grab; user-select: none; touch-action: none;
  width: 52px; height: 52px; display: flex; align-items: center; justify-content: center;
  transition: transform .2s ease;
}
.sticker:hover { transform: scale(1.18) rotate(-6deg); }
.sticker-tray-close {
  background: none; border: none; cursor: pointer;
  font-size: 0.7rem; font-weight: 700; letter-spacing: 1.5px;
  color: var(--ink); opacity: 0.55;
}
.sticker-tray-close:hover { opacity: 1; }

.placed-sticker {
  position: fixed; z-index: 1400;
  font-size: 2.4rem; cursor: grab;
  user-select: none; touch-action: none; will-change: transform;
  filter: drop-shadow(3px 5px 0 rgba(0,0,0,0.3));
  transition: transform .25s ease, opacity .25s ease;
}
.placed-sticker.dragging { cursor: grabbing; z-index: 1600; transition: none; }
.placed-sticker.deleting { transform: scale(0) rotate(180deg) !important; opacity: 0; }

/* sticker tray also acts as a drop-to-delete zone while dragging */
.sticker-tray.drop-armed { box-shadow: 8px 8px 0 var(--accent); }

.sticker-trash {
  position: fixed; bottom: 28px; right: 28px; z-index: 1500;
  width: 76px; height: 76px; border-radius: 50%;
  background: var(--paper); border: 3px solid var(--ink);
  /* fixed --ink (never flips with theme) so the icon always reads
     against the also-fixed --paper background — without this it
     inherited the themed --black text color, which in dark mode is
     light, rendering an invisible light icon on the light paper bg */
  color: var(--ink);
  display: flex; align-items: center; justify-content: center;
  font-size: 2.1rem;
  opacity: 0; transform: scale(0.6) translateY(20px);
  pointer-events: none;
  transition: opacity .25s ease, transform .25s ease, background .2s ease, border-color .2s ease, color .2s ease;
}
.sticker-trash.visible { opacity: 1; transform: scale(1) translateY(0); }
.sticker-trash.armed { background: var(--accent); border-color: var(--accent); transform: scale(1.15) translateY(0); }

@media (max-width: 600px) {
  .sticker-tray { left: 12px; right: 12px; transform: translateY(140%); width: auto; }
  .sticker-tray.open { transform: translateY(0); }
}

/* DARK MODE INTENSITY — heavier, more aggressive personality:
   neon glow on accent text, harsher hover shadows. Colors themselves
   already shift via the --accent/--yellow/--blue/--green overrides above. */
/* The strip is painted in fixed --ink so it stays dark in both themes. In
   dark mode that put it 1.04:1 from the warm page black: a hue shift with
   no brightness step, which read as a smudge rather than a band. Drop it to
   the page color and let hard rules carry the edge, the way the rest of the
   site already works. */
:root[data-theme="dark"] .marquee {
  background: var(--white);
  border-top: 2px solid var(--black);
  border-bottom: 2px solid var(--black);
  box-shadow: none;
  padding: 38px 0;
}
/* the hero grid's 6px ink rule is the top of that black mass in light mode;
   in dark mode it would leave an invisible ink gap above the new top rule */
:root[data-theme="dark"] .hero-grid { border-bottom-width: 0; }

:root[data-theme="dark"] .marquee-track span,
:root[data-theme="dark"] .stat-num {
  text-shadow: 0 0 14px currentColor;
}
:root[data-theme="dark"] .badge-dot {
  box-shadow: 0 0 10px 2px var(--green);
}
:root[data-theme="dark"] .card:hover {
  box-shadow: 10px 10px 0 var(--black), 0 0 32px color-mix(in srgb, var(--accent) 45%, transparent);
}
:root[data-theme="dark"] .btn-block:hover {
  box-shadow: 6px 6px 0 var(--accent), 0 0 26px color-mix(in srgb, var(--accent) 50%, transparent);
}
:root[data-theme="dark"] .hero-name .accent {
  text-shadow: 0 0 18px var(--accent);
}
:root[data-theme="dark"] .magnetic-letter {
  text-shadow: 0 0 16px var(--accent), 0 0 34px color-mix(in srgb, var(--blue) 50%, transparent);
}

/* jagged star/spike hero shapes — dark mode swaps smooth circles/squares/
   triangles for sharp, aggressive geometry (rebuilt in JS on theme change) */
.shape-star {
  overflow: visible;
  transform-box: fill-box;
  transform-origin: center;
}

/* reveal animation */
.reveal {
  opacity: 0;
  transform: translateY(40px);
  transition: opacity .8s ease, transform .8s cubic-bezier(.2,.8,.2,1);
}
.reveal.in-view {
  opacity: 1;
  transform: translateY(0);
}

/* respect the OS-level "reduce motion" preference. This is a heavily
   animated site (floating shapes, glitch flicker, marquees, chaos mode,
   spring physics) — for users who've asked for less motion, collapse
   every CSS animation/transition to effectively instant rather than
   trying to hand-pick exceptions. JS-driven continuous motion (hero
   shapes drift, magnetic letters, lagged cursor, cycling tagline) is
   separately gated in script.js since this can't reach requestAnimationFrame loops. */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.001ms !important;
    scroll-behavior: auto !important;
  }
}

/* ==========================================================================
   LANGUAGE SWITCHER
   Deliberately built as a sibling of .cvd-control rather than sharing its
   classes: the two controls look alike on purpose, but they're independent
   features and coupling their selectors would mean every future tweak to
   one silently moves the other.
   ========================================================================== */
.lang-control { position: relative; }
.lang-toggle {
  width: 40px; height: 40px;
  display: flex; align-items: center; justify-content: center;
  background: transparent; border: 2px solid var(--black); color: var(--black);
  cursor: pointer;
  font-family: var(--ff-body);
  transition: background .25s, color .25s, transform .2s;
}
.lang-toggle:hover { transform: translate(-2px,-2px); box-shadow: 4px 4px 0 var(--black); }
.lang-code {
  font-size: 0.72rem; font-weight: 700; letter-spacing: 1px;
  /* the code is always a 2-letter Latin abbreviation (EN/ES/JA/IT) even
     while the rest of the UI is in Japanese — it has to stay legible as a
     compact button label, and "日本語" doesn't fit a 40px square */
  font-family: 'Space Grotesk', sans-serif;
}
.lang-menu {
  position: absolute; top: calc(100% + 12px); right: 0;
  min-width: 190px;
  background: var(--paper); border: 3px solid var(--ink);
  box-shadow: 6px 6px 0 var(--ink);
  display: flex; flex-direction: column;
  opacity: 0; visibility: hidden; pointer-events: none;
  transform: translateY(-8px);
  transition: opacity .2s ease, transform .2s ease, visibility .2s ease;
  z-index: 300;
}
.lang-menu.open { opacity: 1; visibility: visible; pointer-events: auto; transform: translateY(0); }
.lang-option {
  background: none; border: none; cursor: pointer;
  display: flex; align-items: center; justify-content: space-between;
  text-align: left; padding: 13px 18px;
  font-family: var(--ff-body);
  font-weight: 700; font-size: 0.75rem; letter-spacing: 1.5px;
  color: var(--ink);
  border-bottom: 2px solid var(--ink);
  /* 44px minimum touch target — the CVD options next door sit at 40-42px,
     which is already under the guideline; no reason to repeat that here */
  min-height: 44px;
}
.lang-option:last-child { border-bottom: none; }
.lang-option:hover { background: var(--yellow); }
.lang-option.active { background: var(--ink); color: var(--paper); }
.lang-option.active::after { content: '●'; color: var(--accent); margin-left: 10px; }
/* each option is labelled in its OWN language and carries a matching lang=""
   attribute in the markup, so a screen reader announces "日本語" with a
   Japanese voice rather than spelling it out in the page's current language */
.lang-option[lang="ja"] { letter-spacing: 0; }

/* four controls now share the nav's right cluster (language, colour vision,
   theme, menu) — 4x40px plus the original 18px gaps overflowed a 375px
   viewport once the logo was accounted for */
@media (max-width: 480px) {
  .nav-right { gap: 10px; }
  .nav { padding: 20px 4vw; }
}

/* ==========================================================================
   JAPANESE TYPOGRAPHY
   Archivo Black and Anton are single-weight (400) faces that *look* black.
   Noto Sans JP at 400 is a normal weight, so Japanese glyphs falling
   through to it rendered visibly lighter than the Latin they sit beside.
   Bumping to 900 in Japanese restores the intended weight relationship.
   ========================================================================== */
html[lang="ja"] .loader-name,
html[lang="ja"] .nav-logo,
html[lang="ja"] .hero-name,
html[lang="ja"] .marquee-track span,
html[lang="ja"] .section-head h2,
html[lang="ja"] .card-info h3,
html[lang="ja"] .video-caption,
html[lang="ja"] .project-body h2,
html[lang="ja"] .about-page-intro h2,
html[lang="ja"] .about-page-subhead,
html[lang="ja"] .timeline-role,
html[lang="ja"] .stat-num,
html[lang="ja"] .magnetic-letter,
html[lang="ja"] .contact-statement,
html[lang="ja"] .chaos-play-btn-large,
html[lang="ja"] .pong-end-msg {
  font-weight: 900;
}

/* Japanese sets without word spaces and wraps at almost any character, so
   the tracking tuned for uppercase Latin reads as broken spacing instead of
   emphasis. Reset it on the display type and let the browser break lines
   the way Japanese actually breaks. */
html[lang="ja"] .hero-name,
html[lang="ja"] .section-head h2,
html[lang="ja"] .contact-statement,
html[lang="ja"] .about-page-intro h2,
html[lang="ja"] .magnetic-letter {
  letter-spacing: 0;
  line-break: strict;
}
/* the hero name is the one string that stays Latin in every language
   ("JACK MURRAY" is a person, not a phrase), so it keeps its own tracking */
html[lang="ja"] .hero-name { letter-spacing: -0.04em; }

/* ==========================================================================
   SKIP LINK
   Off-screen until focused rather than display:none — a display:none link
   is removed from the tab order entirely, which would defeat the purpose.
   ========================================================================== */
.skip-link {
  position: fixed;
  top: 0; left: 0;
  z-index: 3000;
  transform: translateY(-120%);
  background: var(--ink);
  color: var(--paper);
  border: 3px solid var(--yellow);
  padding: 14px 22px;
  font-family: var(--ff-body);
  font-weight: 700; font-size: 0.85rem; letter-spacing: 1.5px;
  transition: transform .2s ease;
}
.skip-link:focus {
  transform: translateY(0);
}
/* the <main> landmark takes programmatic focus from the skip link; it should
   never show the focus ring, since the visible result is the page scrolling */
#main:focus { outline: none; }

/* ==========================================================================
   REAL MEDIA
   The layouts were built around inline SVG, which scales to its container
   for free. Raster images need to be told to do the same, and to crop
   rather than distort when the photo's aspect ratio doesn't match the frame
   the layout gives it.
   ========================================================================== */
.card-media img,
.project-media img,
.project-gallery-item img,
.masonry-item img,
.project-mixed-hero img,
.project-mixed-details img {
  width: 100%;
  height: 100%;
  display: block;
  object-fit: cover;
  /* the frames are already aspect-ratio'd by the layout; cover crops to fill
     them so a portrait photo in a 4:3 slot doesn't letterbox or stretch */
}
/* placeholder tint while a photo decodes — without it a lazy-loaded image
   pops in against the page background, which reads as a flash */
.card-media,
.project-gallery-item,
.masonry-item {
  background: var(--line);
}

.project-video {
  width: 100%;
  display: block;
  background: var(--ink);
  /* Must track the frame, which can be overridden per project. Left at a
     hard 16/9 this renders twice as tall as a 32/9 frame and the frame's
     overflow:hidden clips the bottom half of the picture. The custom
     property is set on the frame and inherits down to here. */
  aspect-ratio: var(--video-ratio, 16/9);
}

/* long-form project write-up, under the one-line description */
.project-body-copy {
  max-width: 640px;
  margin-bottom: 40px;
}
.project-body-copy p {
  font-size: 1rem;
  line-height: 1.7;
  color: var(--muted);
}
/* Stops the one-word last line. text-wrap: pretty tells the browser to look
   ahead over the last few lines and rebalance them so a paragraph never ends
   on a lone word — it changes where lines break, not the column width, so
   nothing shifts position. Browsers without it just wrap as before. */
.project-body-copy p,
.project-desc,
.card-info p,
.about-page-intro p,
.timeline-desc {
  text-wrap: pretty;
}
.project-body-copy p + p { margin-top: 1em; }

/* ==========================================================================
   LIGHTBOX
   ========================================================================== */
.lightbox {
  position: fixed; inset: 0; z-index: 1800;
  display: flex; flex-direction: column;
  align-items: center; justify-content: center;
  gap: 18px;
  padding: 5vh 5vw;
  background: rgba(10, 10, 10, 0.94);
  opacity: 0;
  pointer-events: none;
  transition: opacity .28s ease;
}
.lightbox.open { opacity: 1; pointer-events: auto; }
.lightbox-img {
  max-width: 100%;
  max-height: 82vh;
  width: auto; height: auto;
  display: block;
  border: 4px solid var(--paper);
  /* object-fit is deliberately NOT set here: the whole point of the zoom is
     to see the image uncropped, unlike the cover-cropped in-page tiles */
  object-fit: contain;
  background: #0a0a0a;
}
.lightbox-caption {
  color: var(--paper);
  font-size: 0.85rem;
  line-height: 1.5;
  max-width: 70ch;
  text-align: center;
  opacity: 0.85;
}
.lightbox-close {
  position: absolute; top: 24px; right: 24px;
  width: 48px; height: 48px;   /* 44px minimum touch target */
  display: flex; align-items: center; justify-content: center;
  background: transparent;
  border: 2px solid var(--paper);
  color: var(--paper);
  font-size: 1.1rem;
  cursor: pointer;
  transition: background .2s ease, color .2s ease, transform .2s ease;
}
.lightbox-close:hover {
  background: var(--paper); color: var(--ink);
  transform: translate(-2px, -2px);
  box-shadow: 4px 4px 0 var(--accent);
}
#projectMedia img[data-full]:focus-visible {
  outline: 3px solid var(--accent);
  outline-offset: 3px;
}
