/* Guy Gibson – Service Designer */
/* Identity: Quiet confidence. Every detail considered. */

:root {
  --bg: #FFFFFF;
  --text: #1A1A1A;
  --text-body: #444444;
  --text-light: #666666;
  --border: #E8E8E8;
  --soft: #F5F5F3;
  --focus: #2563EB;
  --accent: #1A1A1A;
  --font: 'Outfit', -apple-system, sans-serif;
  --font-display: 'Schibsted Grotesk', 'Outfit', sans-serif;
  --max-w: 820px;
  --gap: 1.25rem;
  --gap-section: 2rem;
  /* Body leading as a token so the Reading guide can raise it through normal inheritance.
     Previously .a11y-spacing set line-height on <html>, but body's own `line-height:1.75`
     blocked it from ever reaching a single descendant - so the feature only worked on the
     <p>/<li> that had an explicit !important rule. */
  --lh-body: 1.75;
  /* Content lists declare their own leading, which meant the Reading guide never reached them -
     and on case studies the findings/TL;DR lists ARE the reading content. Same token pattern as
     --lh-body so the feature propagates without a per-component override. */
  --lh-list: 1.6;

  /* TYPE SCALE. Every size is bound to its leading, because this file's recurring bug was the
     two drifting apart in separate rules. Values are the ones already in use - naming them
     changes nothing visually today, it just makes the pair the unit you edit.
     Chrome (buttons, labels, single-line UI) is deliberately NOT tokenised: leading is
     irrelevant there and forcing a value would change layout for no benefit. */
  --text-lede: clamp(1.15rem, 2vw, 1.35rem);  --lh-lede: 1.7;
  --text-quote: 1.2rem;                       --lh-quote: 1.55;
  --text-read: 1.05rem;                       --lh-read: 1.7;
  --text-card: 0.95rem;                       --lh-card: 1.65;
  --text-caption: 0.875rem;                   --lh-caption: 1.5;
  --text-micro: 0.72rem;                      --lh-micro: 1.45;

  /* MOTION. The file had 23 distinct transition durations - 0.19/0.22/0.26/0.32/0.33 were
     accidental, not designed. These four keep the two dominant values (0.25/0.3) exactly as
     they were and only absorb the strays, so the site feels consistent without feeling
     retimed. Durations above 0.45s are deliberate (underline draw, scramble) and stay literal. */
  --t-fast: 0.15s;
  --t-base: 0.25s;
  --t-slow: 0.3s;
  --t-slower: 0.45s;
  /* One easing, named once. It was already the only curve in the file, but written two
     different ways and copied 13 times. */
  --ease: cubic-bezier(0.22, 1, 0.36, 1);

  /* SHAPE. Superellipse shoulders - the iOS-icon curve. Applied only inside @supports below,
     so browsers without corner-shape silently keep ordinary rounding. */
  --corner-sm: superellipse(1.3);
  --corner-md: superellipse(1.5);
  --corner-lg: superellipse(1.8);
}

/* Squircles on rounded RECTANGLES only. Deliberately excludes every circular or pill control
   (.accent-dot, .a11y-toggle, .theme-toggle, .scroll-top, .a11y-bubble, .cv-link, .a11y-btn):
   corner-shape applied over border-radius:50% turns a circle into a rounded square, which is
   a regression, not a refinement. Radii are unchanged here - only the curve between them.
   Only elements with a NON-ZERO radius are listed - .project-card, .a11y-panel and .stat-row
   are all border-radius:0, where corner-shape does nothing at all. At these radii (4-10px) the
   effect is subtle by nature; it reads as slightly softer shoulders, not as a visible restyle. */
@supports (corner-shape: superellipse(1)) {
  .cmdk-panel, .shortcuts-panel { corner-shape: var(--corner-md); }
  .card-img, .img-desc, .project-media, .highlight-box, .bio-table { corner-shape: var(--corner-sm); }
}
[data-theme="dark"] {
  --bg: #141414;
  --text: #F0F0F0;
  --text-body: #B8B8B8;
  --text-light: #888888;
  --border: #2A2A2A;
  --soft: #1E1E1E;
  --focus: #60A5FA;
  --accent: #F0F0F0;
}
/* Color theme - Australian ochre: landscape, warmth, human-centered design, First Nations connection */
/* --accent was #A16207. Measured off rendered pixels, accent text on --soft (#FEF3C7)
   came out at 4.42:1 - under the 4.5:1 AA floor. axe passed it, so this was only found
   by sampling the painted output. #955A06 measures 5.04:1 on --soft and 5.41:1 on --bg. */
[data-color="ochre"] { --bg:#FFFBEB; --text:#713F12; --text-body:#92400E; --text-light:#B45309; --border:#FDE68A; --soft:#FEF3C7; --accent:#955A06; }
[data-theme="dark"][data-color="ochre"] { --bg:#1C1108; --text:#FEF3C7; --text-body:#FDE68A; --text-light:#D97706; --border:#713F12; --soft:#422006; --accent:#FBBF24; }



*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
img { max-width: 100%; height: auto; }
html {
  font-size: 16px; scroll-behavior: smooth; -webkit-font-smoothing: antialiased;
  /* Mobile Chrome/Brave auto-inflate small text inside narrow blocks ("font boosting").
     It scales font-size but not the rem-based padding around it, so boosted text grows
     into fixed spacing - which is what pushed the a11y panel descriptions onto their
     row dividers. Opt out; text scaling is offered deliberately via the Larger text
     toggle, and pinch-zoom is untouched. */
  -webkit-text-size-adjust: 100%; text-size-adjust: 100%;
}
html.snap-scroll { scroll-snap-type: y mandatory; }
html.snap-scroll [id] { scroll-margin-top: 0; }
/* The footer used to sit inside .contact, so contact's snap point covered it. Moving the
   contentinfo landmark out of <main> made the footer a snap-container child with no
   alignment of its own - and under `mandatory` the scroller has to rest on a snap point,
   so scrolling to the bottom bounced straight back to .contact and the footer was
   unreachable. `end` makes the document bottom a valid resting place. */
html.snap-scroll > body > footer { scroll-snap-align: end; }
/* Anchor targets land clear of the sticky nav. */
[id] { scroll-margin-top: 65px; }
body { font-family: var(--font); padding-bottom: env(safe-area-inset-bottom, 0px); color: var(--text-body); background: var(--bg); line-height: var(--lh-body); overflow-x: hidden; opacity: 1; transition: opacity var(--t-base) ease, background var(--t-slower) ease, color var(--t-slower) ease; }
img { max-width: 100%; height: auto; display: block; }

/* Skip link */
.skip-link { position: absolute; top: -100%; left: 1rem; background: var(--text); color: var(--bg); padding: 0.5rem 1rem; font-size: 0.85rem; z-index: 200; border-radius: 0 0 4px 4px; text-decoration: none; }
.skip-link:focus { top: 0; }

/* Focus */
a:focus-visible, button:focus-visible, input:focus-visible, [tabindex]:focus-visible {
  outline: 3px solid var(--focus); outline-offset: 3px; border-radius: 2px;
}
/* High contrast mode */
@media (prefers-contrast: more) {
  :root { --text-body: #222222; --text-light: #444444; --border: #999999; }
  [data-theme="dark"] { --text-body: #E0E0E0; --text-light: #BBBBBB; --border: #666666; }
  .accent-word::after { height: 3px; }
}

/* ==================== */
/* TYPOGRAPHY           */
/* ==================== */
h1 { font-family: var(--font-display); font-size: clamp(2.8rem, 12vw, 9rem); font-weight: 700; line-height: 1.0; letter-spacing: -0.05em; color: var(--text); }
h2 { font-size: 0.72rem; font-weight: 500; text-transform: uppercase; letter-spacing: 0.14em; color: var(--text-light); margin-bottom: var(--gap); }
h3 { font-size: 1.25rem; font-weight: 500; color: var(--text); line-height: 1.4; margin-bottom: 0.3rem; }
h4 { font-size: 0.92rem; font-weight: 600; color: var(--text); margin-bottom: 0.25rem; }
p { margin-bottom: 0.75rem; font-weight: 300; }
p:last-child { margin-bottom: 0; }
a { color: var(--text); }
strong, b { font-weight: 500; color: var(--text); }

.container { max-width: var(--max-w); margin: 0 auto; padding: 0 2rem; }

/* ==================== */
/* CUSTOM CURSOR         */
/* ==================== */
@media (pointer: fine) {
  html, body, * { cursor: none !important; }
  a, button, input, [role="button"] { cursor: none !important; }
  .cursor {
    position: fixed; top: 0; left: 0; z-index: 10010;
    pointer-events: none;
    transform: translate(-4px, -2px);
    transition: transform var(--t-fast) ease, opacity var(--t-fast);
  }
  .cursor svg { width: 20px; height: 24px; filter: drop-shadow(0 1px 2px rgba(0,0,0,0.15)); transition: transform var(--t-fast) ease; }
  .cursor.hover-card svg { transform: scale(1.25); }
  .cursor.hover-link svg { transform: scale(1.15); }
  /* Zoom state. `.img-zoomable { cursor: zoom-in }` exists but never renders, because the
     custom-cursor rule above sets `cursor: none !important` on `*` AND on `[role="button"]` -
     which is exactly what the lightbox puts on these images. So the only way to signal
     "this enlarges" on a pointer device is through the custom cursor itself. */
  .cursor.hover-zoom svg { opacity: 0; }
  .cursor.hover-zoom::before {
    content: ''; position: absolute; left: -19px; top: -19px;
    width: 38px; height: 38px; border-radius: 50%;
    border: 1.5px solid var(--text); background: var(--bg);
    box-shadow: 0 1px 5px rgba(0,0,0,0.16);
    animation: zoomCursorIn var(--t-fast) var(--ease) both;
  }
  /* The plus sign, both bars drawn as one element with two gradients. */
  .cursor.hover-zoom::after {
    content: ''; position: absolute; left: -6px; top: -6px;
    width: 12px; height: 12px;
    background:
      linear-gradient(var(--text), var(--text)) center / 12px 2px no-repeat,
      linear-gradient(var(--text), var(--text)) center / 2px 12px no-repeat;
    animation: zoomCursorIn var(--t-fast) var(--ease) both;
  }
  @keyframes zoomCursorIn { from { transform: scale(0.7); opacity: 0 } to { transform: scale(1); opacity: 1 } }
  .cursor.hover-zoom.clicking::before { transform: scale(0.88); }
  .cursor.clicking svg { transform: scale(0.85); }
}

/* Accent word - underline draws in to mark clarity */
.accent-word {
  color: var(--accent); transition: color var(--t-slow);
  position: relative; display: inline;
}
/* A ruled line, not a drawn one: the word is "clarity", and a hand-drawn scrawl argues the
   opposite of the thing the sentence claims. Precision is the point. */
.accent-word::after {
  content: ''; position: absolute; left: 0; bottom: 0.04em;
  width: 0; height: 2px; background: var(--accent);
  transition: width 0.6s var(--ease);
}
.accent-word.revealed::after { width: 100%; }
[data-theme="dark"] .accent-word { text-shadow: 0 0 20px rgba(240,240,240,0.3), 0 0 40px rgba(240,240,240,0.1); }
[data-theme="dark"][data-color="ochre"] .accent-word { text-shadow: 0 0 20px rgba(251,191,36,0.35), 0 0 40px rgba(251,191,36,0.15); }

/* ==================== */
/* GREETING ROTATOR      */
/* ==================== */
.greeting {
  font-size: 1.15rem; font-weight: 300;
  color: var(--accent); letter-spacing: 0.02em;
  height: 1.5em; overflow: hidden;
  font-style: italic;
  transition: opacity var(--t-slow) ease;
}
.greeting.fading { opacity: 0; }
.greeting.typing::after {
  content: '|'; font-style: normal; font-weight: 300;
  color: var(--accent); margin-left: 1px;
  animation: caret 1.15s infinite;
}
/* A real text caret doesn't flash 50/50 - it holds solid while you're reading it, then
   blinks quickly. The asymmetric timeline (long hold, fast off-on, short hold) reads as a
   cursor rather than a strobing pipe. Kept as steps so it snaps rather than fades. */
@keyframes caret {
  0%, 62%   { opacity: 1 }
  63%, 78%  { opacity: 0 }
  79%, 100% { opacity: 1 }
}

/* ==================== */
/* KONAMI CONFETTI       */
/* ==================== */
.confetti-piece {
  position: fixed; width: 8px; height: 8px; z-index: 9998;
  pointer-events: none; border-radius: 1px;
  animation: confettiFall 1.5s ease-out forwards;
}
@keyframes confettiFall {
  0% { transform: translateY(0) rotate(0deg); opacity: 1; }
  100% { transform: translateY(100vh) rotate(720deg); opacity: 0; }
}

/* ==================== */
/* KEYBOARD SHORTCUTS    */
/* ==================== */
.shortcuts-overlay {
  position: fixed; inset: 0; z-index: 300;
  background: rgba(0,0,0,0.6); -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px);
  display: flex; align-items: center; justify-content: center;
  opacity: 0; pointer-events: none; visibility: hidden;
  transition: opacity var(--t-base) ease, visibility 0s linear var(--t-base);
}
.shortcuts-overlay.open { opacity: 1; pointer-events: auto; visibility: visible; transition: opacity var(--t-base) ease, visibility 0s; }
.shortcuts-panel {
  background: var(--bg); border-radius: 8px; padding: 2rem 2.5rem;
  max-width: 380px; width: 90%; border: 1px solid var(--border);
  box-shadow: 0 8px 30px rgba(0,0,0,0.12);
}
[data-theme="dark"] .shortcuts-panel { box-shadow: 0 8px 30px rgba(0,0,0,0.4); }
.shortcuts-panel-header {
  display: flex; justify-content: space-between; align-items: center;
  margin-bottom: 1.2rem;
}
.shortcuts-panel h3 {
  font-size: 0.82rem; font-weight: 600; text-transform: uppercase;
  letter-spacing: 0.14em; color: var(--text); margin: 0;
}
.shortcut-row {
  display: flex; justify-content: space-between; align-items: center;
  padding: 0.45rem 0; border-bottom: 1px solid var(--border);
  font-size: 0.82rem; font-weight: 300; color: var(--text-body);
}
.shortcut-row:last-child { border-bottom: none; }
.shortcut-key {
  display: inline-block; background: var(--soft); border: 1px solid var(--border);
  border-radius: 4px; padding: 0.15rem 0.5rem; font-size: 0.72rem;
  font-weight: 500; font-family: var(--font); color: var(--text); min-width: 1.8rem;
  text-align: center;
}

/* Pull quote followed by subsection gets extra breathing room */
.pq + .cs-text { margin-top: calc(var(--gap) * 2.5) !important; }

/* ==================== */
/* READING PROGRESS BAR  */
/* ==================== */
.progress-bar {
  position: fixed; top: 0; left: 0; height: 2px; z-index: 200;
  background: var(--accent); width: 100%;
  transform: scaleX(0); transform-origin: 0 50%;
  transition: transform 0.05s linear;
  pointer-events: none;
}
/* Scroll-driven version: the bar is tied to scroll position by the compositor instead of by a
   JS transform written on every rAF. Chromium + Safari today; Firefox has not shipped
   scroll-timelines, and there it simply keeps using the JS path (which guards on the same
   feature test, so exactly one of the two ever runs). */
@supports (animation-timeline: scroll()) {
  .progress-bar {
    transition: none;
    animation: progressScrub linear both;
    animation-timeline: scroll(root block);
  }
  @keyframes progressScrub { from { transform: scaleX(0) } to { transform: scaleX(1) } }
}

/* ==================== */
/* SCROLL TO TOP         */
/* ==================== */
.scroll-top {
  position: fixed; bottom: 2rem; right: 2rem; z-index: 150;
  width: 36px; height: 36px; border-radius: 50%;
  background: var(--soft); border: 1px solid var(--border);
  color: var(--text-light); font-size: 0.85rem;
  display: flex; align-items: center; justify-content: center;
  text-decoration: none; opacity: 0; pointer-events: none;
  /* visibility (not just opacity) so the hidden button leaves the tab order - otherwise
     keyboard users tab to an invisible "Scroll to top" control. Delayed 0.3s on the way
     out so it doesn't cut the fade; same pattern as .a11y-panel. */
  visibility: hidden;
  transition: opacity var(--t-slow), transform var(--t-slow), background var(--t-base), visibility 0s linear var(--t-slow);
  transform: translateY(8px);
}
.scroll-top.visible {
  opacity: 1; pointer-events: auto; transform: none; visibility: visible;
  transition: opacity var(--t-slow), transform var(--t-slow), background var(--t-base), visibility 0s;
}
.scroll-top.spinning { animation: spinUp 0.5s ease; }
@keyframes spinUp { from { transform: rotate(0); } to { transform: rotate(-360deg); } }
@media (pointer: fine) { .scroll-top { cursor: none !important; } }

/* ==================== */
/* ACCENT COLOR PICKER   */
/* ==================== */
.accent-picker {
  /* 12px dot + 12px gap = a 24px pitch, so the 24px hit areas below sit edge to edge
     instead of overlapping. At 10px they overlapped and neither dot owned a full target. */
  display: flex; gap: 12px; align-items: center;
}
.nav-divider {
  width: 1px; height: 16px; background: var(--border);
  flex-shrink: 0;
}
/* The dot stays 12px visually, but WCAG 2.2 SC 2.5.8 needs a 24x24 target - so ::before
   expands the hit area invisibly. Same technique already used on .a11y-toggle. */
.accent-dot::before {
  content: ''; position: absolute; inset: -6px; border-radius: 50%;
}
.accent-dot {
  width: 12px; height: 12px; border-radius: 50%; border: none;
  transition: transform var(--t-base), box-shadow var(--t-base);
  background: var(--dot-color); position: relative;
}
@media (hover: none) { .accent-dot:hover { transform: none; } }
.accent-dot.active { box-shadow: 0 0 0 1.5px var(--bg), 0 0 0 3px var(--dot-color); }
.accent-dot::after {
  content: attr(aria-label); position: absolute; top: calc(100% + 8px); left: 50%;
  transform: translateX(-50%); white-space: nowrap;
  font-size: 0.6rem; font-family: var(--font); font-weight: 400;
  color: var(--text-light); letter-spacing: 0.02em;
  background: var(--bg); border: 1px solid var(--border); border-radius: 3px;
  padding: 0.15rem 0.45rem;
  opacity: 0; pointer-events: none; transition: opacity var(--t-base); z-index: 10;
}
@media (pointer: fine) { .accent-dot { cursor: none !important; } }

/* ==================== */
/* NAV - shrinks on scroll*/
/* ==================== */
/* nav is fixed-height chrome, so it declares its own leading here in the base layer - an own
   declaration beats any inherited value, so no .a11y-spacing override is needed to protect it. */
nav { position: fixed; top: 0; left: 0; right: 0; z-index: 100; background: var(--bg); -webkit-backdrop-filter: blur(12px); backdrop-filter: blur(12px); line-height: 1.75; transition: background var(--t-slow), height var(--t-slow), box-shadow var(--t-slow); }
nav .container { display: flex; justify-content: space-between; align-items: center; height: 55px; border-bottom: 1px solid var(--border); transition: height var(--t-slow); }
nav.scrolled .container { height: 46px; }
nav.scrolled { box-shadow: 0 1px 8px rgba(0,0,0,0.04); }
[data-theme="dark"] nav.scrolled { box-shadow: 0 1px 8px rgba(0,0,0,0.2); }

/* ==================== */
/* PAGE TRANSITIONS      */
/* ==================== */
body.fade-out { opacity: 0; }

/* ==================== */
/* NAV LINKS             */
/* ==================== */
.nav-name { font-weight: 600; font-size: 0.85rem; text-decoration: none; color: var(--text); letter-spacing: 0.02em; transition: opacity var(--t-base); }
.nav-links { display: flex; gap: 1.8rem; align-items: center; flex-shrink: 0; }
.nav-links a {
  font-size: 0.78rem; font-weight: 400; color: var(--text-light);
  text-decoration: none; letter-spacing: 0.01em;
  position: relative; transition: color var(--t-base), transform var(--t-base) ease-out;
  display: inline-block;
}
.nav-links a::after {
  content: ''; position: absolute; left: 50%; bottom: -2px;
  width: 0; height: 1px; background: var(--text);
  transition: width var(--t-slow) ease, left var(--t-slow) ease;
}
.nav-links a.active { color: var(--text); }
.nav-links a.active::after { width: 100%; left: 0; }

/* Dark toggle */
.shortcuts-btn {
  background: none; border: 1px solid var(--border); border-radius: 50%;
  width: 30px; height: 30px; cursor: pointer; display: flex; align-items: center;
  justify-content: center; color: var(--text-light); transition: color var(--t-base), border-color var(--t-base);
  font-size: 0.75rem; font-weight: 500; font-family: var(--font); line-height: 1; padding: 0;
}
.theme-toggle {
  background: none; border: 1px solid var(--border); border-radius: 50%;
  width: 30px; height: 30px; cursor: pointer; display: flex; align-items: center;
  justify-content: center; color: var(--text-light); transition: color var(--t-base), border-color var(--t-base);
  font-size: 0.8rem; line-height: 1; padding: 0;
}
.theme-toggle .icon-sun { display: none; }
.theme-toggle .icon-moon { display: block; }
[data-theme="dark"] .theme-toggle .icon-sun { display: block; }
[data-theme="dark"] .theme-toggle .icon-moon { display: none; }

/* ==================== */
/* HERO - full viewport  */
/* ==================== */
.hero-full {
  min-height: 100vh; min-height: 100dvh;
  /* `align-items: stretch` (not center) so .container owns the full height and can lay
     the headline and the scroll hint out in flow. */
  display: flex; align-items: stretch;
  position: relative; overflow: hidden;
  padding-top: calc(55px + 1rem);
  padding-bottom: 2rem;
  scroll-snap-align: start;
  scroll-snap-stop: always;
}
/* The scroll hint used to be absolutely positioned with a padding-bottom on the hero
   guessing at its height. On a short viewport with Larger text the headline grew past
   that guess and ran straight through the mouse icon. Laying both out in one column
   makes the collision structurally impossible instead of numerically unlikely: the
   headline's auto margins centre it in whatever space is left above the hint. */
.hero-full .container {
  text-align: center;
  display: flex; flex-direction: column; min-width: 0;
}
.hero-full .hero-inner { margin: auto; }
/* The hero column is narrower than the site's 820px so the headline fills its box instead of
   leaving a hole on the right. Measured at 1440px: the longest line filled 78% of an 820px box
   (165px dead space) and 96% of a 680px one (25px). Same at 1600/1920; 1100 improves too.
   1280 and 900 each gain a line in exchange. Below 680px viewport this is inert, so mobile is
   untouched. Verified: 132 samples across 3 engines x 11 widths x 4 a11y modes, no overflow and
   no collision with the scroll indicator. */
.hero-full .container { max-width: 680px; }

/* Constellation - living network of drifting connected dots */
.dot-field {
  position: absolute; inset: 0; z-index: 1; pointer-events: none;
  overflow: hidden;
}
.dot-field canvas { display: block; }
.dot-field i {
  position: absolute; width: 4px; height: 4px;
  background: var(--accent); border-radius: 50%;
  opacity: 0.5; left: 0; top: 0;
  will-change: transform;
}
.hero-inner {
  position: relative; z-index: 2; display: inline-block; text-align: left;
  will-change: transform, opacity;
}

/* Greeting */
.hero-full .greeting {
  font-size: clamp(0.9rem, 1.5vw, 1.15rem);
  margin-bottom: clamp(0.5rem, 1vw, 0.8rem);
  display: block;
}

/* Headline */
.hero-full h1 {
  font-size: clamp(2.4rem, 5.5vw, 5rem);
  line-height: 1.18; letter-spacing: -0.035em;
  margin-bottom: 0;
  animation: fadeUp 0.6s ease both;
}

/* Scroll hint - hero version (absolute) */
.hero-full .scroll-hint {
  /* In flow at the end of the hero column - see the note on .hero-full .container. */
  position: relative; z-index: 2; flex: none;
  margin: 0 auto; padding-top: 1rem;
  display: flex; flex-direction: column; align-items: center; gap: 0.5rem;
}
/* Persistent scroll hint - subtle chevron between snap sections */
.scroll-persist {
  position: fixed; bottom: 1.2rem; left: 50%;
  transform: translateX(-50%); z-index: 90;
  opacity: 0; transition: opacity 0.5s;
  pointer-events: none;
}
.scroll-persist.visible { opacity: 0.4; }
.scroll-persist svg {
  width: 26px; height: 14px;
  stroke: var(--accent); stroke-width: 1.5;
  fill: none; stroke-linecap: round; stroke-linejoin: round;
  animation: chevronBob 2s ease-in-out infinite;
}
@keyframes chevronBob {
  0%,100% { transform: translateY(0); opacity: 0.4; }
  50% { transform: translateY(5px); opacity: 0.8; }
}
.scroll-mouse {
  width: 22px; height: 34px;
  border: 1.5px solid var(--text-light);
  border-radius: 11px; opacity: 0.3;
  position: relative;
}
.scroll-mouse::after {
  content: ''; display: block; width: 2.5px; height: 6px;
  background: var(--accent); border-radius: 2px;
  position: absolute; top: 6px; left: 50%; transform: translateX(-50%);
  animation: scrollDot 2s ease-in-out infinite;
}
@keyframes scrollDot {
  0%,100% { transform: translateX(-50%) translateY(0); opacity: 0.8; }
  50% { transform: translateX(-50%) translateY(10px); opacity: 0.2; }
}
.scroll-label {
  font-size: 0.55rem; font-weight: 500; text-transform: uppercase;
  letter-spacing: 0.18em; color: var(--text-light);
  /* Was opacity:0.3, which sampled at 1.53:1 in light and 1.55:1 in dark off actual
     rendered pixels. axe and Lighthouse both read this as 5.2:1 because neither factors
     opacity into contrast. var(--text-light) is already muted enough on its own. */
}

/* ==================== */
/* INTRO - full viewport */
/* ==================== */
.intro-full {
  min-height: 100vh; min-height: 100dvh;
  display: flex; align-items: center;
  position: relative;
  scroll-snap-align: start;
  scroll-snap-stop: always;
  padding-top: 55px;
}
.intro-inner { max-width: 620px; }
.intro-text {
  font-size: 1.35rem; color: var(--text-light); font-weight: 300;
  line-height: 1.7; margin-bottom: 2rem;
  opacity: 0; transform: translateY(24px);
  transition: opacity 0.8s ease, transform 0.8s ease, color 1.2s ease;
}
.intro-text.visible { opacity: 1; transform: none; color: var(--text-body); }
.intro-line {
  width: 40px; height: 1px; background: var(--accent);
  transform: scaleX(0); transform-origin: left;
  transition: transform 0.6s ease var(--t-slow);
}
.intro-text.visible + .intro-line { transform: scaleX(1); }

/* Entrance */
@keyframes fadeUp {
  from { opacity: 0; transform: translateY(16px); }
  to { opacity: 1; transform: translateY(0); }
}

/* ==================== */
/* PROJECT CARDS         */
/* ==================== */
.projects { counter-reset: project; }
.projects > .container > h2 {
  font-size: 0.78rem; font-weight: 600; letter-spacing: 0.18em;
  color: var(--text); margin-bottom: var(--gap-section);
}

.project-card {
  perspective: 900px;
  display: flex; flex-direction: column; justify-content: center;
  text-decoration: none; color: var(--text);
  counter-increment: project; position: relative;
  min-height: 100vh; min-height: 100dvh;
  scroll-snap-align: start;
  scroll-snap-stop: always;
  overflow: hidden;
  padding-top: 55px;
  box-sizing: border-box;
}
.project-card:last-child { margin-bottom: 0; }
.project-card .card-img {
  width: 100%; border-radius: 5px; transform-origin: center center;
  opacity: 0; transition: opacity var(--t-slower) ease, transform 0.6s ease, filter 0.6s ease;
  will-change: transform;
}
.project-card .card-img.blur-load { filter: blur(8px); transform: scale(1.02); }
.project-card .card-img.loaded { opacity: 1; filter: blur(0); transform: scale(1); }
.project-card .card-tags {
  font-size: 0.78rem; font-weight: 400; color: var(--text-light);
  margin-top: 0.2rem; margin-bottom: 0.5rem; letter-spacing: 0.01em;
}
.project-card .card-tags::before {
  content: counter(project, decimal-leading-zero) " – ";
  font-weight: 500; font-variant-numeric: tabular-nums;
}
.project-card h3 { font-family: var(--font-display); font-size: 1.5rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.01em; margin-top: 1.2rem; margin-bottom: 0.15rem; transition: letter-spacing var(--t-slow); }
.project-card .card-desc { color: var(--text-body); font-size: var(--text-card); font-weight: 300; margin-bottom: 0.8rem; line-height: var(--lh-card); }
.project-card .card-cta {
  font-size: 0.78rem; font-weight: 600; color: var(--text);
  text-decoration: none; text-transform: uppercase; letter-spacing: 0.1em;
  display: inline-flex; align-items: center; gap: 0.4rem;
  transition: gap var(--t-slow) ease;
}
.project-card .card-cta .cta-text {
  position: relative;
}
.project-card .card-cta .cta-text::after {
  content: ''; position: absolute; left: 0; bottom: -2px;
  width: 0; height: 1px; background: var(--accent);
  transition: width var(--t-slower) ease;
}

/* ==================== */
/* 16:9 MEDIA FRAME      */
/* ==================== */
.project-media {
  width: 100%; aspect-ratio: 16 / 9;
  display: flex; align-items: center; justify-content: center;
  overflow: hidden; background: transparent; border-radius: 5px;
}
.project-media img {
  width: 100%; height: 100%;
  object-fit: contain; object-position: center center;
  display: block; opacity: 0; transition: opacity var(--t-slower) ease;
}
.project-media img.blur-load { filter: blur(8px); transform: scale(1.02); }
.project-media img.loaded { opacity: 1; filter: blur(0); transform: none; }
/* The eager hero is the LCP element: never gate it behind JS. It is the first
   thing on screen, so there is nothing for it to fade in from. */
.project-media img[fetchpriority="high"] { opacity: 1; filter: none; }
.project-media img[fetchpriority="high"].blur-load { filter: none; transform: none; }

.project-media.shift-lofi img { transform: translate(-1.2%, -3.6%); }
.project-media.shift-olympic-hero img { transform: translate(-0.25%, -11.6%); }
.project-media.shift-parent-hero img { transform: translate(-1.2%, -12.4%); }
.project-media.photo img { object-fit: cover; }
.project-media.padded { padding: 1rem; border-radius: 6px; }

/* ==================== */
/* CASE STUDY HERO       */
/* ==================== */
.case-hero { padding: 100px 0 0; }
.case-hero h1 { font-family: var(--font-display); text-transform: uppercase; letter-spacing: -0.02em; line-height: 1.06; margin-bottom: 0.6rem; font-size: 2.6rem; animation: fadeUp 0.5s ease both; }
.case-hero .case-desc { font-size: var(--text-read); color: var(--text-body); font-weight: 300; margin-bottom: 0.5rem; line-height: var(--lh-read); animation: fadeUp 0.5s ease 0.1s both; }
.reading-time { font-size: 0.75rem; font-weight: 500; color: var(--text-light); text-transform: uppercase; letter-spacing: 0.1em; margin-bottom: var(--gap); }
/* Deep-link section headings: hover to reveal, click to copy a link */
.h-anchor {
  display: inline-block; margin-left: 0.35em; font-weight: 400; text-decoration: none;
  color: var(--text-light); opacity: 0; transform: translateX(-4px);
  transition: opacity var(--t-base) ease, transform var(--t-base) var(--ease), color var(--t-base) ease;
}
/* Keyboard focus must reveal this on ANY device. The reveal used to live only inside
   (hover: hover) and (pointer: fine); a touchscreen laptop matches neither that nor the
   (hover: none) hide rule, so the anchor stayed invisible while still being tabbable. */
.h-anchor:focus-visible { opacity: 1 !important; }
.h-anchor.copied { opacity: 1; color: var(--accent); }
.h-anchor.copied::after {
  content: " link copied"; font-size: 0.55em; letter-spacing: 0.1em;
  text-transform: uppercase; vertical-align: middle;
}
/* The # is a hover-to-reveal copy-link affordance; on touch there is no hover so it sat there
   permanently at 40% beside every heading. Removed on touch. */
@media (hover: none) { .h-anchor { display: none; } }
@media (prefers-reduced-motion: reduce) { .h-anchor { transition: color var(--t-base) ease; transform: none; } }
/* 2026 typography: balanced headings, orphan-free paragraphs. Degrades to normal wrapping. */
h1, h2, h3, h4, .case-desc { text-wrap: balance; }
p, li, figcaption, .about-bio { text-wrap: pretty; }
/* Fixed 2-column grid, NOT flex-wrap. With flex-wrap the number of columns depended on how long
   each value happened to be, so Olympic Eats (short values) rendered Role/Outcome side by side
   while Biodiversity (long values) stacked all four - the same component looking different on
   every case study. A grid makes it identical everywhere regardless of content. */
.case-meta {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 1.4rem 3rem;
  padding: 1.1rem 0;
  border-top: 1px solid var(--border);
  border-bottom: 1px solid var(--border);
}
.meta-item .meta-label { font-size: 0.75rem; font-weight: 500; text-transform: uppercase; letter-spacing: 0.12em; color: var(--text-light); margin-bottom: 0.1rem; }
.meta-item p { font-size: 0.875rem; color: var(--text-body); font-weight: 300; margin: 0; }

/* ==================== */
/* SECTIONS + UNIFORM    */
/* ==================== */
.cs { padding: var(--gap-section) 0; }
.cs .container > * + * { margin-top: var(--gap); }
/* Neutralise trailing margins on block components (bio-sl, ba-list, timelines,
   figures) so every section's bottom whitespace is just its padding — keeps
   section-to-section spacing uniform regardless of what a section ends with. */
.cs .container > *:last-child,
.cs .container > *:last-child > *:last-child { margin-bottom: 0 !important; }
.cs .container > h2 {
  margin-top: 0;
  font-size: 1.75rem;
  font-weight: 700;
  color: var(--text);
  text-transform: none;
  letter-spacing: -0.01em;
  line-height: 1.2;
}

/* Extra breathing room before chapter headings when following content */
.cs .container > * + h2 { margin-top: calc(var(--gap) * 4.5); }
.cs .container > h2 + * { margin-top: 0; }

/* Process subsection dividers */
.cs-text h3 { margin-top: calc(var(--gap) * 1.5); padding-top: calc(var(--gap) * 0.8); border-top: 1px solid var(--border); }
.cs-text h3:first-child { margin-top: 0; padding-top: 0; border-top: none; }

/* Stats */
.stat-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 2rem; padding: 1rem 0; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); }
.stat-item { text-align: left; }
.stat-num { font-size: 2rem; font-weight: 700; color: var(--text); display: block; line-height: 1.2; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
.stat-desc { font-size: var(--text-caption); color: var(--text-light); font-weight: 300; max-width: 200px; margin-top: 0.15rem; line-height: var(--lh-caption); }

/* ==================== */
/* HIGHLIGHT BOXES       */
/* ==================== */
/* No explicit grid-template-rows: each box spans 2 implicit rows, so the track count
   follows however many boxes there are. Declaring `auto auto` only made room for one row of
   boxes, which left .cols-2 (4 boxes, 2 rows) ragged and unequal in height - measured. */
.highlight-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(220px, 100%), 1fr)); gap: var(--gap); }
.highlight-box {
  display: grid; grid-row: span 2; grid-template-rows: subgrid; align-content: start; gap: 0.2rem;
  background: var(--soft); padding: 1.1rem; border-radius: 4px;
  border-left: 2px solid transparent;
  transition: border-color var(--t-slow) ease;
}
.highlight-box h3 { font-size: 0.9375rem; margin-bottom: 0; font-weight: 600; align-self: start; }
.highlight-box p { font-size: var(--text-caption); color: var(--text-body); margin: 0; line-height: 1.6; font-weight: 300; }

/* ==================== */
/* PULL QUOTE - editorial*/
/* ==================== */
.pq {
  font-size: var(--text-quote); font-weight: 400; font-style: italic;
  line-height: var(--lh-quote); color: var(--text); padding: 0 0 0 1.25rem;
  border-left: 2px solid var(--accent);
}

/* ==================== */
/* FEATURE BLOCKS        */
/* ==================== */
.feat + .feat { margin-top: calc(var(--gap) * 1.3); }
.feat h3 { font-size: 1rem; margin-bottom: 0.25rem; font-weight: 600; }
.feat p { font-size: 1rem; font-weight: 300; line-height: 1.7; }
.feat .project-media { margin-top: 0.5rem; }

/* ==================== */
/* FINDINGS LIST         */
/* ==================== */
.find-list { list-style: none; padding: 0; }
.find-list li { padding: 0.5rem 0; border-bottom: 1px solid var(--border); font-size: 1rem; font-weight: 300; line-height: var(--lh-list); }
.find-list li strong { font-weight: 500; }
.find-list li:last-child { border-bottom: none; }

/* SUS */
.sus { display: inline-flex; align-items: center; gap: 1rem; padding: 1.1rem 1.6rem; background: var(--soft); border-radius: 4px; }
.sus-num { font-size: 2.6rem; font-weight: 700; color: var(--text); line-height: 1; letter-spacing: -0.03em; font-variant-numeric: tabular-nums; }
.sus-text { font-size: 0.875rem; color: var(--text-light); line-height: 1.5; font-weight: 300; }

/* ==================== */
/* NEXT PROJECT          */
/* ==================== */
.next { padding: var(--gap-section) 0; border-top: 1px solid var(--border); }
.next .next-label { font-size: 0.75rem; font-weight: 400; text-transform: uppercase; letter-spacing: 0.12em; color: var(--text-light); margin-bottom: 0.2rem; }
.next a {
  font-size: 1.05rem; font-weight: 600; text-decoration: none;
  color: var(--text); letter-spacing: -0.01em;
  display: inline-flex; align-items: center; gap: 0.3rem;
  transition: gap var(--t-slow) ease;
}

/* ==================== */
/* ABOUT                 */
/* ==================== */
.about { padding: 0 0 calc(var(--gap-section) * 2); padding-top: calc(55px + 5rem); background: var(--bg); position: relative; z-index: 1; scroll-snap-align: start; scroll-snap-stop: always; min-height: 100vh; min-height: 100dvh; box-sizing: border-box; }
.about::before {
  content: ''; display: block; position: absolute;
  top: calc(55px + 1rem); left: 50%;
  transform: translateX(-50%);
  width: min(calc(100% - 4rem), var(--max-w));
  height: 1px; background: var(--border);
}
.about .container > * + * { margin-top: var(--gap); }
.about .container > h2 + * { margin-top: 0; }
.about-bio { font-size: clamp(1.1rem, 1.5vw, 1.3rem); font-weight: 300; line-height: 1.65; color: var(--text-body); margin-bottom: 0; margin-top: 0; }
.about-bio strong { font-weight: 500; color: var(--text); }

/* About intro: bio + education side-by-side on desktop */
.about-intro {
  display: flex;
  align-items: flex-start;
  gap: calc(var(--gap) * 4);
  margin-bottom: calc(var(--gap) * 3);
}

.about-bio {
  flex: 3;
  min-width: 0;
  align-self: center;
}

.edu {
  flex: 2;
  min-width: 0;
}

/* About section title */
.about .container > h2:first-child {
  font-size: 1.75rem;
  font-weight: 700;
  color: var(--text);
  text-transform: none;
  letter-spacing: -0.01em;
  line-height: 1.2;
  margin-bottom: var(--gap);
}
.skill-grid {
  /* Three horizontal BANDS, not three vertical columns. The groups hold 10 / 10 / 6 items;
     in a fixed 3-column grid that always reads as one short column. As bands, a different
     count per row is just a different line length. */
  display: block; gap: 0;
  border-top: 1px solid var(--border);
  padding-top: calc(var(--gap) * 2.5);
  margin-top: 0;
}

.skill-col {
  min-width: 0; display: grid;
  grid-template-columns: minmax(0, 0.85fr) minmax(0, 2.15fr);
  grid-template-rows: auto 1fr;
  column-gap: calc(var(--gap) * 2); row-gap: 0.35rem;
  align-items: start;
  padding: calc(var(--gap) * 1.5) 0;
  border-right: none; border-bottom: 1px solid var(--border);
}
.skill-col h3 { grid-column: 1; grid-row: 1; }
.skill-col .skill-desc { grid-column: 1; grid-row: 2; margin-bottom: 0; }
.skill-col ul { grid-column: 2; grid-row: 1 / span 2; }
.skill-col:last-child { border-bottom: none; }

.skill-col:first-child { padding-top: calc(var(--gap) * 0.75); }
.skill-col:last-child  { border-right: none; padding-right: 0; }


/* Children align to top of their row - don't stretch */
.skill-col h2,
.skill-desc,
.skill-col ul { min-width: 0; max-width: 100%; align-self: start; }
.skill-col h2 {
  font-size: 1.25rem;
  font-weight: 600;
  text-transform: none;
  letter-spacing: 0;
  color: var(--text);
  margin-bottom: 0.75rem;
  line-height: 1.3;
  align-self: start;
}
.skill-desc { font-size: 0.8rem; color: var(--text-light); font-weight: 300; font-style: italic; line-height: 1.65; margin-bottom: 1rem; }
.skill-col ul { list-style: none; padding-top: 0.2rem; display: flex; flex-wrap: wrap; gap: 0.45rem; margin: 0; }
.skill-col li {
  /* Pills restored. Without a container the entries lost their boundaries and a horizontal
     run read as one long sentence - harder to scan, worse under magnification, and it gave
     each label no discernible extent. The BAND layout (not the pill) is what fixes the
     10/10/6 imbalance, and a full-width band is wide enough that a whole label always fits,
     which is what broke when these sat in a 217px column. */
  display: inline-block; white-space: nowrap; max-width: 100%;
  font-size: 0.8rem; color: var(--text-body); font-weight: 400; line-height: 1.2;
  padding: 0.45rem 0.75rem;
  background: var(--soft); border-radius: 4px;
  border: 1px solid transparent;
  transition: color var(--t-base) ease, border-color var(--t-base) ease, background var(--t-base) ease;
}


@media (prefers-reduced-motion: reduce) { .skill-col li:hover { color: var(--text); border-color: var(--border); } }
.edu {
  background: var(--soft);
  border: 1px solid var(--border);
  border-radius: 12px;
  padding: calc(var(--gap) * 1.4) calc(var(--gap) * 1.5);
}
.edu p { font-size: 0.9rem; color: var(--text-body); font-weight: 300; line-height: 1.6; }

/* Education heading */
.edu h2 {
  font-size: 1rem;
  font-weight: 600;
  color: var(--text);
  text-transform: none;
  letter-spacing: 0;
  margin-top: 0;
  margin-bottom: calc(var(--gap) * 0.75);
}
/* CV download link */
.cv-link {
  display: inline-flex;
  align-items: center;
  gap: 0.45rem;
  margin-top: calc(var(--gap) * 1.5);
  font-size: 0.74rem;
  font-weight: 600;
  color: var(--text);
  text-decoration: none;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  background: var(--bg);
  border: 1.5px solid var(--border);
  border-radius: 999px;
  padding: 0.55rem 1.05rem;
  transition: border-color var(--t-base) var(--ease), color var(--t-base), transform var(--t-base) var(--ease);
}
.cv-link:active { transform: scale(0.94); }
@media (prefers-reduced-motion: reduce) { .cv-link:hover, .cv-link:active { transform: none; } }


/* ==================== */
/* CONTACT               */
/* ==================== */
.contact { text-align: center; scroll-snap-align: start; scroll-snap-stop: always; min-height: 100vh; min-height: 100dvh; padding: 55px 0 0; box-sizing: border-box; display: flex; flex-direction: column; position: relative; overflow: hidden; }
.contact h2 { font-size: clamp(1.4rem, 3vw, 2.2rem); font-weight: 700; font-family: var(--font-display);
  letter-spacing: -0.03em; text-transform: none; color: var(--text); margin-bottom: 0.8rem; }
.contact p { font-size: 1rem; color: var(--text-body); font-weight: 300; margin-bottom: 1.2rem; }
.contact a {
  font-size: 0.82rem; font-weight: 600; color: var(--text);
  text-decoration: none; letter-spacing: 0.08em; text-transform: uppercase;
  position: relative; padding-bottom: 3px;
  transition: color var(--t-base);
}
.contact a::after {
  content: ''; position: absolute; left: 0; bottom: 0;
  width: 100%; height: 1px; background: var(--accent);
  transform: scaleX(0); transform-origin: right;
  transition: transform var(--t-slower) ease;
}

/* The email address is the conversion point of the whole site, so it carries the type
   weight in this section - the "Get in touch" heading above it is only a label. It was
   inheriting .contact a at 0.82rem uppercase, which made it SMALLER than the body copy
   directly above it and inverted the section's hierarchy.
   This is the element's own designed type declared in the base layer - not a value
   restated in an override layer, which is the pattern that caused the reading-guide bug. */
/* PRIMARY CTA — a real button, not display type with an arrow after it.
   40px text with a trailing glyph read as a headline, not something pressable. This is the
   filled counterpart to .cv-link's outlined pill, which gives the page an explicit hierarchy:
   filled = the one action, outlined = the secondary one (download CV). Same radius and the same
   uppercase/tracking treatment, so it belongs to the existing system rather than importing a
   new one. */
.contact .email-link {
  display: inline-flex; align-items: center; gap: 0.55rem;
  font-family: var(--font); font-size: 0.82rem; font-weight: 600;
  letter-spacing: 0.08em; text-transform: uppercase;
  color: var(--bg); background: var(--accent);
  border: 1.5px solid var(--accent); border-radius: 999px;
  padding: 0.95rem 1.9rem; min-height: 48px; box-sizing: border-box;
  text-decoration: none; padding-bottom: 0.95rem;
  transition: background var(--t-base) var(--ease), color var(--t-base) var(--ease),
              transform var(--t-base) var(--ease), box-shadow var(--t-base) var(--ease);
}
/* The animated underline belongs to text links, not to a filled button. */
.contact .email-link::after { content: none; }
.contact .email-link .email-arrow { transition: transform var(--t-base) var(--ease); }
@media (hover: hover) and (pointer: fine) {
  .contact .email-link:hover {
    background: var(--bg); color: var(--text);
    transform: translateY(-2px);
    box-shadow: 0 6px 18px rgba(0,0,0,0.10);
  }
  .contact .email-link:hover .email-arrow { transform: translateX(0.22em); }
}
.contact .email-link:active { transform: translateY(0) scale(0.985); }
@media (prefers-reduced-motion: reduce) {
  .contact .email-link, .contact .email-link:hover, .contact .email-link:active { transform: none; }
}
.a11y-motion .contact .email-link,
.a11y-motion .contact .email-link:hover,
.a11y-motion .contact .email-link:active { transform: none !important; }
/* Underline-links mode must not draw a rule through a filled button. */
.a11y-links .contact .email-link { text-decoration: none !important; }

/* Hover the sign-off and a small peace sign appears. Hover-only (never on touch, where it
   would either never fire or stick), and it animates in only when motion is allowed. */
footer .sign-off { position: relative; }
@media (hover: hover) and (pointer: fine) {
  footer .sign-off::after {
    content: ' \270C\FE0F';
    /* Absolutely positioned so the hidden glyph takes NO layout width - otherwise it padded the
       line and visibly knocked the centred footer text off-centre on mobile. */
    position: absolute; left: 100%; top: 0; white-space: nowrap;
    opacity: 0; margin-left: 0.15em;
    transition: opacity var(--t-base) var(--ease), transform var(--t-base) var(--ease);
    display: inline-block; transform: translateY(2px) scale(0.85);
  }
  footer .sign-off:hover::after { opacity: 1; transform: translateY(0) scale(1); }
}
.a11y-motion footer .sign-off::after { transition: none !important; }

/* ==================== */
/* FOOTER                */
/* ==================== */
/* Keep the node field alive around the CTA, but fade it out before the footer so
   the small footer text stays clean and readable. */
.contact .dot-field {
  -webkit-mask-image: linear-gradient(to bottom, #000 52%, transparent 80%);
  mask-image: linear-gradient(to bottom, #000 52%, transparent 80%);
}
.contact > .container { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; position: relative; z-index: 2; }
.contact > footer { margin-top: auto; width: 100%; align-self: stretch; position: relative; z-index: 2; }
footer { padding: 1.5rem 0; border-top: 1px solid var(--border); }
footer .container { display: flex; justify-content: space-between; align-items: center; }
/* `footer .container p`, NOT `footer p`: on index the <footer> is nested inside
   <section class="contact">, and `.contact p` (0,1,1) outranks `footer p` (0,0,2) - so every
   footer paragraph rule, including the mobile ones, was dead and the metadata rendered as
   16px contact body copy. Adding the container class makes it (0,1,2) and the footer owns its
   own type again, on every page. */
footer .container p { font-size: 0.72rem; color: var(--text-light); font-weight: 300; margin: 0; }
footer a { color: var(--text-body); text-decoration: none; font-weight: 400; position: relative; transition: color var(--t-base); }
footer a::after {
  content: ''; position: absolute; left: 50%; bottom: -1px;
  width: 0; height: 1px; background: var(--text);
  transition: width var(--t-slow) ease, left var(--t-slow) ease;
}

/* ==================== */
/* CUSTOM SELECTION      */
/* ==================== */
::selection { background: var(--accent); color: var(--bg); }

/* ==================== */
/* SCROLL REVEAL         */
/* ==================== */
.reveal {
  opacity: 0;
  transition: opacity 0.5s ease;
}
.reveal.visible {
  opacity: 1;
}


/* Staggered card reveal - elements cascade into view */
.project-card.reveal { transform: none; transition: opacity var(--t-slower) ease; }
.project-card.reveal > * {
  opacity: 0;
  transition: opacity var(--t-slower) ease;
}
.project-card.reveal.visible > * { opacity: 1; transform: none; }
.project-card.reveal.visible > :nth-child(1) { transition-delay: 0.05s; }
.project-card.reveal.visible > :nth-child(2) { transition-delay: 0.12s; }
.project-card.reveal.visible > :nth-child(3) { transition-delay: 0.19s; }
.project-card.reveal.visible > :nth-child(4) { transition-delay: 0.26s; }
.project-card.reveal.visible > :nth-child(5) { transition-delay: 0.33s; }
.a11y-motion .project-card.reveal > * { opacity: 1 !important; transform: none !important; transition: none !important; }

/* Stagger children inside highlight rows and find lists */
.highlight-row .highlight-box { transition-delay: calc(var(--i, 0) * 0.1s); }
.find-list li { transition-delay: calc(var(--i, 0) * 0.05s); }

/* Stat row items stagger */
.stat-row .stat-item { transition-delay: calc(var(--i, 0) * 0.12s); }

/* ==================== */
/* ACCESSIBILITY         */
/* ==================== */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; scroll-behavior: auto !important; }
}
@media (pointer: coarse) {
  .nav-links a { padding: 0.6rem 0; min-height: 44px; display: inline-flex; align-items: center; }
  footer a { padding: 0.4rem 0; min-height: 44px; display: inline-flex; align-items: center; }
  .next a { padding: 0.5rem 0; min-height: 44px; display: inline-flex; align-items: center; }
  /* Base .a11y-row is already top-aligned with em padding; touch only needs the 44px
     minimum target height. Do not restate padding here - that is how these rows drifted
     out of sync with the base rule in the first place. */
  .a11y-row { min-height: 44px; }
  .a11y-toggle { margin-top: 0.15rem; }
}

/* ==================== */
/* RESPONSIVE            */
/* ==================== */
@media (max-width: 768px) {
  /* Snap */
  html.snap-scroll { scroll-snap-type: none; }
  .hero-full, .intro-full, .contact, .about, .project-card {
    scroll-snap-align: none; scroll-snap-stop: normal;
  }
  /* Spacing */
  :root { --gap: 1rem; --gap-section: 1.5rem; }
  .container { padding: 0 1.5rem; }
  .hero-full .container { padding: 0 1.5rem; }
  /* Nav - a11y to floating bubble, keep colour dots */
  .shortcuts-btn { display: none; }
  .a11y-btn { display: none !important; }
  .a11y-bubble { display: flex !important; }
  .nav-divider { height: 14px; }
  .accent-dot { width: 20px; height: 20px; position: relative; }
  .accent-dot::before { content: ''; position: absolute; inset: -12px; }
  .accent-picker { gap: 12px; }
  .accent-dot::after { display: none; }
  .nav-links { gap: 1.15rem; }
  /* padding lifts the tap target past the 24px minimum without changing the visual */
  .nav-links a { font-size: 0.82rem; padding: 5px 2px; }
  .nav-links a::after { display: none; }
  .theme-toggle { width: 32px; height: 32px; font-size: 0.85rem; }
  /* Layout */
  .dot-field i { width: 3px; height: 3px; opacity: 0.35; }
  .intro-full { min-height: 70vh; }
  .intro-text { font-size: 1.15rem; }
  .case-hero { padding: 95px 0 1.5rem; }
  .case-hero h1 { font-size: 2.1rem; margin-bottom: 0.5rem; }

.cs .container > h2 { font-size: 1.35rem; }
  .case-hero .case-desc { font-size: 0.95rem; line-height: 1.65; margin-bottom: 1rem; }
  .case-meta { grid-template-columns: 1fr; gap: 1rem; }
  .stat-row { grid-template-columns: 1fr; gap: 0.8rem; }
  .highlight-row { grid-template-columns: 1fr; }
  /* About spacing fix */
  .about { min-height: auto; display: block; padding-top: calc(var(--gap-section) * 2); margin-top: 2rem; }

.about-intro {
  flex-direction: column;
  gap: calc(var(--gap) * 2.5);
}

.about-bio, .edu {
  flex: none;
  width: 100%;
  min-width: 0;
  align-self: stretch;
}

.edu { margin-top: 0; }
  .about::before { content: none; }
  .about { border-top: 1px solid var(--border); }
  .skill-grid {
  grid-template-columns: 1fr;
  gap: 0;
  border-top: none;
  padding-top: calc(var(--gap) * 2);
}

.skill-col {
  display: block;
  grid-column: auto;
  grid-row: auto;
  padding: calc(var(--gap) * 1.5) 0;
  border-right: none;
  border-top: 1px solid var(--border);
}

.skill-col:first-child {
  border-top: none;
  padding-top: calc(var(--gap) * 0.5);
  padding-left: 0;
}

.skill-col:last-child {
  padding-right: 0;
}
  .project-card { margin-bottom: 3rem; min-height: auto; display: block; }
  .project-card:last-child { margin-bottom: 3rem; }
  .project-card h3 { font-size: 1.4rem; }
  .sus { flex-direction: column; align-items: flex-start; gap: 0.3rem; padding: 1rem; }
  .sus-num { font-size: 2.2rem; }
  footer { margin-top: 3rem; padding-bottom: 1rem; }
  .contact > footer { margin-top: 3rem; }
  /* Footer metadata was 10px at opacity:0.5 over --text-light - roughly 1.9:1, an AA failure,
     and simply hard to read. The colour token is already muted; the extra opacity was doing the
     damage. Size raised to a legible floor and the opacity removed. */
  footer .container { flex-direction: column; align-items: center; justify-content: center; gap: 0.45rem; text-align: center; }
  footer .container p { font-size: 0.72rem; opacity: 1; white-space: normal; }
  footer a::after { display: none; }
  .cursor { display: none; }
  .contact { min-height: auto; display: block; padding-top: calc(var(--gap-section) * 2); border-top: 1px solid var(--border); }
  .scroll-top { bottom: calc(1.6rem + env(safe-area-inset-bottom, 0px)); right: 1.6rem; width: 32px; height: 32px; font-size: 0.75rem; }
}
@media (max-width: 480px) {
  :root { --gap: 0.85rem; --gap-section: 1.2rem; }
  .container { padding: 0 1.1rem; }
  .hero-full h1 { font-size: clamp(2.4rem, 9vw, 4rem); }
  .hero-full .container { padding: 0 1.1rem; }
  /* One entry per row below 480px. Variable-width chips can only fit one or two across a
     390px screen, so wrapping always produces a broken half-column - a gap beside a chip that
     reads as a failed grid rather than a deliberate flow. Chip groups also scan badly at this
     count (26 here). Full-width rows give an even, predictable rhythm and a large hit area,
     which is the point of the container in the first place. Desktop keeps the flowing group,
     where the band is wide enough for three or four per line. */
  .skill-col ul { display: block; }
  .skill-col li {
    display: block; width: 100%; white-space: normal;
    font-size: 0.85rem; padding: 0.6rem 0.75rem; margin-bottom: 0.35rem;
  }
  .skill-col li:last-child { margin-bottom: 0; }
  .scroll-label { font-size: 0.6rem; letter-spacing: 0.1em; }
  .intro-text { font-size: 1rem; }
  .intro-full { min-height: 60vh; }
  .case-hero { padding: 80px 0 1.2rem; }
  .case-hero h1 { font-size: 1.75rem; margin-bottom: 0.4rem; }

.cs .container > h2 { font-size: 1.15rem; }
  .case-hero .case-desc { font-size: 0.88rem; line-height: 1.6; }
  .project-card { margin-bottom: 2rem; }
  .project-card h3 { font-size: 1.25rem; }
  .pq { font-size: 1rem; padding-left: 1rem; }
  .nav-divider { height: 12px; }
  .nav-name { font-size: 0.8rem; }
  .nav-links { gap: 10px; }
  .nav-links a { font-size: 0.82rem; }
  .theme-toggle { width: 28px; height: 28px; font-size: 0.75rem; }
  .accent-dot { width: 16px; height: 16px; }
  .accent-dot::before { inset: -14px; }
  .accent-picker { gap: 8px; }
  /* Nav a11y-large: scale text up but compress gap to prevent overflow */
  .a11y-large .nav-links { gap: 9px !important; }
  /* Do not shrink below the 24px target pitch (WCAG 2.2 SC 2.5.8). */
  .a11y-large .accent-picker { gap: 8px !important; }
  /* Hero h1 in a11y-large: 2.7rem×18px=48.6px min is too wide for mobile (1 word/line) */
  html.a11y-large .hero-full h1 { font-size: clamp(2.4rem, 8vw, 3.5rem) !important; line-height: 1.2 !important; }
  footer .container { gap: 0.4rem; }
  footer .container p { font-size: 0.7rem; }
}

/* Copy feedback tooltip */
.copy-feedback {
  position: absolute; top: calc(100% + 0.5rem); left: 50%; transform: translateX(-50%);
  font-size: 0.68rem; font-weight: 500; color: var(--accent);
  letter-spacing: 0.08em; text-transform: uppercase;
  opacity: 0; transition: opacity var(--t-slow);
  pointer-events: none; white-space: nowrap;
}
.copy-feedback.show { opacity: 1; }
.email-wrap { position: relative; display: inline-block; }

/* ==================== */
/* ACCESSIBILITY PANEL   */
/* ==================== */
.a11y-btn {
  background: none; border: 1px solid var(--border); border-radius: 20px;
  height: 28px; display: inline-flex; align-items: center; gap: 0.3rem;
  color: var(--text-light); transition: color var(--t-base), border-color var(--t-base);
  font-size: 0.72rem; font-weight: 500; font-family: var(--font);
  letter-spacing: 0.04em; padding: 0 0.7rem;
  text-transform: uppercase;
}
.a11y-panel {
  position: fixed; top: env(safe-area-inset-top, 0px); right: -320px; z-index: 250;
  width: 300px; height: calc(100% - env(safe-area-inset-top, 0px)); background: var(--bg);
  border-left: 1px solid var(--border);
  box-shadow: -4px 0 20px rgba(0,0,0,0.08);
  transition: right var(--t-slow) ease, visibility 0s linear var(--t-slow);
  visibility: hidden;
  padding: 1.5rem; padding-bottom: calc(1.5rem + env(safe-area-inset-bottom, 0px)); overflow-y: auto; -webkit-overflow-scrolling: touch; overscroll-behavior: contain;
  /* BLOCK, not flex. A fixed-height flex column can shrink its children below their content
     height, and engines differ on when they do it inside a scroll container - which is exactly
     the symptom reported on iOS (row border drawn part-way up, text spilling into the next row)
     and which I could never reproduce in Chromium. Block flow has no shrink behaviour at all:
     a block box is always at least as tall as its content, on every engine. Spacing that `gap`
     used to provide is now margins. */
  display: block;
}
/* THE ACTUAL BUG, fixed here rather than in the rows.
   The panel is a FIXED-HEIGHT flex column, so its children are flex items with the default
   `flex-shrink: 1`. Once the 11 rows exceed the panel height the browser COMPRESSES every row:
   the row box becomes shorter than its own content, so the border-bottom draws part-way up
   (between a label and its description) and the text spills into the row below.
   That is why it looked fine on a tall desktop viewport, broke on a short phone one, and got
   dramatically worse with Larger text - and why changing the row between grid, flex and block
   made no difference. Rows must keep their natural height and let the panel scroll. */
.a11y-panel > .a11y-row { margin: 0; }
.a11y-header { margin-bottom: 1rem; }
[data-theme="dark"] .a11y-panel { box-shadow: -4px 0 20px rgba(0,0,0,0.3); }
.a11y-panel.open { right: 0; visibility: visible; transition: right var(--t-slow) ease, visibility 0s; }
/* Sticky header - always visible with close button */
.a11y-header {
  position: sticky; top: -1.5rem; z-index: 2;
  background: var(--bg);
  margin: -1.5rem -1.5rem 0; padding: 1.5rem 1.5rem 0.6rem;
  display: flex; justify-content: space-between; align-items: flex-start;
}
/* line-height lives HERE, in the base rule, not in the .a11y-spacing/.a11y-large layers.
   The sticky header must not grow or it covers the first row - but an element's own
   declaration already beats any value inherited from html, so declaring it once here
   protects it in every mode without a single override or !important. */
.a11y-header h3 {
  font-size: 0.78rem; font-weight: 600; text-transform: uppercase;
  letter-spacing: 0.12em; line-height: 1.2; color: var(--text); margin: 0;
}
.a11y-header p {
  font-size: 0.65rem; color: var(--text-light); font-weight: 300;
  line-height: 1.4; margin: 0.3rem 0 0; max-width: 200px;
}
/* Top-aligned everywhere, not just on touch: a two-line description must sit level with
   its toggle, and the padding below it has to clear the divider no matter how many lines
   the description wraps to. Padding is in em so it scales with the row's own text. */
/* Asymmetric padding on purpose: the divider belongs to the row ABOVE it, so the space between
   a description and the line under it must be clearly larger than the space between that line
   and the next label. At 0.85em the two were nearly equal, which reads as the line touching the
   text - especially at phone pixel density where a 13px gap is a hair's breadth.
   em (not rem) so the whole rhythm scales if the row's own text scales. */
/* EXPLICIT GRID, not flex-with-a-nested-span.
   The description used to live INSIDE the label span, so its height depended on inline layout
   that Safari and Chrome resolve differently once the text wraps - which is exactly why this
   looked fine on desktop (one line) and broke on mobile (two or three lines).
   Now: label = row 1 col 1, toggle = row 1 col 2, description = row 2 col 1. The description has
   its own track, so it can never escape the row and the border-bottom is always beneath it. */
/* PLAIN BLOCK FLOW - deliberately the simplest possible layout.
   Grid and flex both looked right in Chromium and failed on iOS here. In normal block flow the
   row's height is just label + description + padding, so the border-bottom is ALWAYS below both.
   The toggle is taken out of flow entirely, so it cannot affect the row's height; the right
   padding reserves its lane so text never runs under it. */
.a11y-row {
  position: relative;
  padding: 1.05rem 3.6rem 1.15rem 0;
  border-bottom: 1px solid var(--border);
}
/* Explicit placement: label and toggle share row 1, the description owns row 2. Never rely on
   auto-placement here - an earlier edit silently removed these and the layout only looked right
   by accident. */
.a11y-row > .a11y-label { display: block; }
.a11y-row > .a11y-sub   { display: block; margin: 0.3rem 0 0; }
.a11y-row > .a11y-toggle { position: absolute; right: 0; top: 1.05rem; }
.a11y-row:last-child { border-bottom: none; }

.a11y-label { font-size: 0.78rem; font-weight: 400; color: var(--text-body); min-width: 0; }
.a11y-sub { font-size: var(--text-micro); color: var(--text-light); font-weight: 300; line-height: var(--lh-micro); margin: 0; min-width: 0; }

/* Enhanced focus styles *:focus-visible, and Disable-single-key-shortcuts guards the ? d t n
   keys. On a touch-primary device there is no keyboard, so neither can do anything. Hidden only
   where there is genuinely no hover AND a coarse pointer, so desktop - and anything reporting a
   fine pointer, e.g. a tablet with a trackpad - still shows them. */
@media (hover: none) and (pointer: coarse) and (not (any-pointer: fine)) {
  .a11y-row[data-keyboard-only="true"] { display: none; }
}
/* Toggle switch */
.a11y-toggle {
  -webkit-appearance: none; appearance: none;
  position: relative; width: 36px; height: 20px; flex: 0 0 auto; flex-shrink: 0;
  background: var(--border); border-radius: 10px; border: none;
  transition: background var(--t-base); padding: 0; outline: none; line-height: 1 !important;
  cursor: pointer; -webkit-tap-highlight-color: transparent;
}
.a11y-toggle::before {
  content: ''; position: absolute; inset: -12px;
  z-index: 0;
}
.a11y-toggle::after {
  content: ''; position: absolute; top: 2px; left: 2px;
  width: 16px; height: 16px; border-radius: 50%;
  background: var(--bg); transition: transform var(--t-base); transform: translateX(0) !important;
  box-shadow: 0 1px 3px rgba(0,0,0,0.15);
  z-index: 1;
}
.a11y-toggle[aria-checked="true"] { background: var(--accent); }
.a11y-toggle[aria-checked="true"]::after { transform: translateX(16px) !important; }
.a11y-close {
  background: none; border: none; color: var(--text-light);
  font-size: 1.2rem; line-height: 1; padding: 0.25rem;
  transition: color var(--t-base); flex-shrink: 0;
  min-width: 32px; min-height: 32px;
  display: flex; align-items: center; justify-content: center;
}
/* Accessibility modes */
.a11y-motion * { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; scroll-behavior: auto !important; }
html.a11y-motion { scroll-behavior: auto !important; }
.a11y-motion .dot-field { display: none !important; }
@media (prefers-reduced-motion: reduce) {
  .dot-field { display: none !important; }
  .scroll-persist { display: none !important; }
  .scroll-mouse::after { animation: none !important; }
}
.a11y-motion .scroll-persist { display: none !important; }
.a11y-motion .scroll-mouse::after { animation: none !important; }
/* --text was missing, so High contrast did nothing to headings or emphasised text -
   i.e. it skipped exactly the largest, most-read elements on the page. */
.a11y-contrast { --text: #000000 !important; --text-body: #111111 !important; --text-light: #333333 !important; }
[data-theme="dark"].a11y-contrast { --text: #FFFFFF !important; --text-body: #F5F5F5 !important; --text-light: #DDDDDD !important; }
/* Larger text raises the leading TOKEN rather than pinning p/li to a literal, so combining it
   with the Reading guide can never come out lower than the guide alone. */
.a11y-large { font-size: 18px !important; --lh-body: 1.85; --lh-list: 1.8; }
.a11y-large h1 { line-height: 1.25 !important; }
.a11y-large .hero-full h1 {
  /* Max was 4.5rem = 81px vs the base 5rem/80px - a +1.25% "increase" at wide viewports while
     body text grew +12.5%. Raised so Larger text actually enlarges the headline.
     line-height is not restated here; the base rule declares 1.18 and already wins. */
  font-size: clamp(2.7rem, 9vw, 5rem) !important;
}
/* Raising the token propagates to every element that doesn't declare its own line-height -
   which is what "taller line height" is supposed to mean. No !important, no per-element rules. */
.a11y-spacing { letter-spacing: 0.03em; word-spacing: 0.08em; --lh-body: 1.9; --lh-list: 1.85; }
.a11y-spacing .card-tags,
.a11y-spacing .meta-label,
.a11y-spacing footer,
.a11y-spacing .greeting,
.a11y-spacing .accent-word,
.a11y-spacing .a11y-bubble,
.a11y-spacing .stat-num,
.a11y-spacing .sus-num,
.a11y-spacing .shortcut-key,
.a11y-spacing .reading-time { word-spacing: 0 !important; }
/* The hero H1 declares its own letter-spacing (-0.035em, see .hero-full h1 above),
   and an element's own declaration always beats a value inherited from html - so the
   reading guide's letter-spacing never reached it and needs no "protection" here.
   The old rule hardcoded -0.05em, a stale copy of a since-changed design value, which
   meant enabling a feature that promises WIDER spacing actually tightened the headline.
   Never restate a base value in an override layer; let the base rule own it. */
/* Panel isolation - prevent reading guide from expanding row content in the narrow
   panel label column (~200px desktop, ~160px mobile) */
/* Only word-spacing is suppressed, to stop the narrow (~200px/~160px) panel column
   wrapping oddly. line-height and letter-spacing are deliberately NOT touched: the panel
   labels must get the taller leading like everything else, or the reading-guide toggle's
   own label becomes harder to read the moment you enable it. */
.a11y-spacing .a11y-label { word-spacing: 0 !important; }
.a11y-spacing .a11y-sub { word-spacing: 0 !important; }
/* The sticky header's line-height is locked in the BASE rules above, so nothing is needed
   here beyond suppressing word-spacing in the narrow column. */
.a11y-spacing .a11y-header h3 { word-spacing: 0 !important; }
.a11y-spacing .a11y-header p { word-spacing: 0 !important; }
/* nav's leading is locked in its own base rule above - nothing needed here. */
.a11y-spacing .nav-name { letter-spacing: 0.05em !important; word-spacing: normal !important; }
.a11y-spacing .nav-links a { letter-spacing: 0.03em !important; word-spacing: normal !important; }
.a11y-spacing .nav-links { word-spacing: normal !important; }
/* Wrap fix only. The old `line-height:1.3` rode along here and TIGHTENED the greeting
   from the inherited 1.75 - the opposite of what the reading guide promises. */
.a11y-spacing .greeting { height: auto !important; overflow: visible !important; }
.a11y-spacing .skill-col li { letter-spacing: 0 !important; word-spacing: normal !important; }
/* was line-height:1.4 - TIGHTER than the base 1.5. Removed; the caption now receives the
   feature's taller leading like every other block of prose. */
.a11y-spacing .pq { line-height: 1.7 !important; }
/* was line-height:1.7 - TIGHTER than body's inherited 1.75, on the site's conversion-point
   paragraph. Removed. */
.a11y-spacing .contact h2 { margin-bottom: 1rem !important; }
.a11y-spacing .about-bio { line-height: 1.8 !important; }

/* CTAs are inline-flex. The layout problem was never word-spacing itself - it was the
   literal whitespace text node between the label and the arrow, which becomes an
   anonymous flex item that word-spacing then inflates. That whitespace is now removed
   from the markup and the flex `gap` owns the spacing, so the reading guide can apply
   here like it does everywhere else. Only wrapping is still suppressed. */
.a11y-spacing .project-card .card-cta,
.a11y-spacing .next a {
  white-space: nowrap !important;
}

.a11y-spacing .cs { padding-top: calc(var(--gap-section) * 1.3); padding-bottom: calc(var(--gap-section) * 1.3); }
.a11y-spacing .cs-text h3 { margin-top: calc(var(--gap) * 2); padding-top: calc(var(--gap) * 1); }
.a11y-spacing .stat-row { padding: 1.2rem 0; }
.a11y-spacing .case-meta { padding: 1.2rem 0; }
/* .a11y-large .sus-num was 2.2rem: at the 18px root that is 39.6px vs the base 2.6rem/41.6px,
   so enabling "Larger text" SHRANK the SUS score. Removed - the base rem value now scales. */
.a11y-large .about { min-height: auto !important; }
.a11y-large .project-card { min-height: auto !important; }

.a11y-large .project-card .card-cta {
  white-space: nowrap !important;
  font-size: 1rem !important;
}
/* Next project CTA on case study pages - explicit boost */
.a11y-large .next a { font-size: 1.1rem !important; }
/* .a11y-large .stat-num was 1.8rem = 32.4px vs base 2rem/32px: +1.25% while body text grew
   +12.5%, so the headline stat lost its hierarchy over its own caption. Removed. */
.a11y-large h3 { line-height: 1.55 !important; }
.a11y-large .pq { line-height: 1.75 !important; }


.a11y-large .case-desc { line-height: 1.8 !important; }
.a11y-links a { text-decoration: underline !important; text-underline-offset: 3px !important; }
.a11y-links .a11y-bubble { text-decoration: none !important; }
.a11y-links .scroll-top { text-decoration: none !important; }
.a11y-links .accent-dot { text-decoration: none !important; }
.a11y-links .nav-links a::after { display: none !important; }
/* .cv-link's border is the pill outline, not a text underline - stripping border-bottom left
   a broken open capsule whenever "Underline links" was on. Rule removed. */
.a11y-links .project-card { text-decoration: none !important; }
.a11y-links .project-card .cta-text { text-decoration: underline !important; text-underline-offset: 3px !important; }
.a11y-images img { display: none !important; }
.a11y-images .card-img { display: none !important; }
.a11y-images .project-media {
  aspect-ratio: auto !important; min-height: 0 !important;
  overflow: visible !important; display: block !important;
  border-radius: 0 !important; padding: 0 !important;
}
.a11y-images .project-card { min-height: auto !important; justify-content: flex-start !important; }
/* Image descriptions - visible when images hidden */
.img-desc {
  display: none; padding: 0.75rem 1rem; background: var(--soft);
  border: 1px dashed var(--border); border-radius: 4px;
  font-size: 0.75rem; color: var(--text-body); font-weight: 300;
  font-style: italic; line-height: 1.5; margin: 0.5rem 0;
  text-align: left; width: 100%; box-sizing: border-box;
}
.img-desc::before {
  content: 'Image: '; font-style: normal; font-weight: 500;
  color: var(--text-light); font-size: 0.65rem;
  text-transform: uppercase; letter-spacing: 0.08em;
}
.a11y-images .img-desc { display: block; }
/* Hide images: collapse the before/after slider apparatus entirely and stack feat rows */
.a11y-images .ba-slider,
.a11y-images .ba-labels,
.a11y-images .ba-caption { display: none !important; }
.a11y-images .feat-split { display: block !important; }
.a11y-images .feat-split .img-desc { margin-top: 0.75rem; }
/* Reading line - horizontal bar tracks cursor to aid line-by-line reading */
.reading-line {
  position: fixed; left: 0; right: 0; height: 40px;
  pointer-events: none; z-index: 90;
  background: linear-gradient(
    to bottom,
    transparent 0%, rgba(0,0,0,0.04) 30%,
    rgba(0,0,0,0.04) 70%, transparent 100%
  );
  opacity: 0; transition: opacity var(--t-slow);
  display: none;
}
[data-theme="dark"] .reading-line {
  background: linear-gradient(
    to bottom,
    transparent 0%, rgba(255,255,255,0.06) 30%,
    rgba(255,255,255,0.06) 70%, transparent 100%
  );
}
.a11y-reading .reading-line { display: block; opacity: 1; }

@media (max-width: 768px) {
  /* 260px squeezed descriptions to three lines. Widen, and keep the panel's own 1.5rem
     bottom padding - the old rule replaced it with the safe-area inset alone, which is
     0px on most phones, leaving the last row flush against the screen edge. */
  .a11y-panel { width: min(300px, 82vw); padding-bottom: calc(1.5rem + env(safe-area-inset-bottom, 0px)); }
  /* Fix greeting height */
  .a11y-large .greeting { height: auto !important; overflow: visible !important; }
  /* Nav a11y-large: scale text up but compress gap to prevent overflow */
  .a11y-large .nav-name { font-size: 14.5px !important; white-space: nowrap !important; }
  .a11y-large .nav-links a { font-size: 13.5px !important; }
  .a11y-large .nav-links { gap: 8px !important; }
  .a11y-large .accent-picker { gap: 8px !important; }
  /* Panel: descriptors grow with Larger text so the setting is visible in the panel too */
  .a11y-large .a11y-label { font-size: 0.95rem !important; }
  .a11y-large .a11y-sub { font-size: 0.8rem !important; }
  /* Removed: it swapped the base's em padding (which scales with the row's own text) back to
     rem, and `min-height:auto` cancelled the 44px touch minimum from the pointer:coarse rule. */
  /* The sticky header's line-height is declared in the base .a11y-header h3/p rules, which
     already beat any inherited value - no override needed here. */
}


/* Floating a11y bubble - Apple AssistiveTouch */
.a11y-bubble {
  position: fixed; z-index: 200;
  width: 46px; height: 46px; border-radius: 50%;
  background: var(--accent); color: var(--bg);
  border: none;
  box-shadow: 0 2px 8px rgba(0,0,0,0.2);
  display: none; align-items: center; justify-content: center;
  touch-action: none; -webkit-user-select: none; user-select: none;
  will-change: transform;
  /* Was 0.35 (and JS faded it to 0.25 when idle). Measured 2.19:1 for the label
     against the page - WCAG 1.4.3 needs 4.5:1. Every alpha below 1.0 fails in at
     least one colour theme, so this control stays solid. */
  opacity: 1;
  font-size: 0.6rem; font-weight: 700; font-family: var(--font);
  letter-spacing: 0.03em; text-transform: uppercase;
}
[data-theme="dark"] .a11y-bubble { box-shadow: 0 2px 8px rgba(0,0,0,0.5); }
/* While the panel is open it slides over the bubble's right-edge snap position.
   Hop the bubble to the left edge, above the panel, so it stays reachable as a
   close control instead of being buried. */
.a11y-panel.open ~ .a11y-bubble {
  left: 6px !important; z-index: 260;
  opacity: 1 !important; transform: none !important;
}

/* ==================== */
/* PRINT                 */
/* ==================== */



/* a11y-motion extras */
.a11y-motion .greeting::after { animation: none !important; content: none !important; }
.a11y-motion .greeting.fading { opacity: 1 !important; }


/* === ENHANCED FOCUS: Visibly distinct from default focus === */
.a11y-focus *:focus-visible {
  outline: 4px solid #2563EB !important;
  outline-offset: 4px !important;
  box-shadow: 0 0 0 2px #FFFFFF, 0 0 8px 2px rgba(37, 99, 235, 0.5) !important;
}
/* NOT a descendant combinator: `data-theme` and `a11y-focus` are BOTH on <html>, so
   `[data-theme="dark"] .a11y-focus` requires one to be inside the other and never matches.
   Enhanced focus therefore kept the light-mode ring in dark mode (7.25:1 -> 3.56:1).
   Compare .a11y-contrast below, which already had this right. */
[data-theme="dark"].a11y-focus *:focus-visible {
  outline-color: #60A5FA !important;
  box-shadow: 0 0 0 2px #141414, 0 0 8px 2px rgba(96, 165, 250, 0.5) !important;
}

/* === TOGGLE ISOLATION: prevent line-height/font-size cascade distorting toggles === */
/* Transform handled by core rules with !important above - no override needed here */
.a11y-large .a11y-toggle,
.a11y-spacing .a11y-toggle {
  line-height: 1 !important;
  font-size: 16px !important;
}

/* ==================== */
/* LIVE EMBED (case study)  */
/* ==================== */

/* ==================== */
/* IMAGE PLACEHOLDER (temporary, black) */
/* ==================== */

/* ==================== */
/* BIODIVERSITY NATIVE DIAGRAMS (theme-aware) */
/* ==================== */


.bio-gapbar { margin-top: 1rem; background: var(--accent); color: var(--bg); border-radius: 8px; padding: 0.95rem 1.15rem; font-family: var(--font-display); font-weight: 500; font-size: 0.96rem; }

/* ==================== */
/* BIODIVERSITY - decision, shifts, timeline, tables (theme-aware) */
/* ==================== */
/* system chips */

/* generic bio table */
.bio-table { width: 100%; border-collapse: collapse; margin: 1.4rem 0; font-size: 0.9rem; }
.bio-table caption { text-align: left; font-size: 0.75rem; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-light); margin-bottom: 0.6rem; font-family: var(--font-display); }
.bio-table th, .bio-table td { text-align: left; padding: 0.75rem 0.9rem; border-bottom: 1px solid var(--border); vertical-align: top; }
.bio-table thead th { font-family: var(--font-display); font-size: 0.72rem; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-light); font-weight: 700; border-bottom: 1.5px solid var(--border); }
.bio-table tbody th { font-weight: 600; color: var(--text); width: 26%; }
.bio-table td { color: var(--text-body); font-weight: 300; line-height: 1.5; }

/* five shifts from -> to */
.bio-shifts { display: grid; gap: 0.55rem; margin: 1.5rem 0; }
.bio-shift { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 0.8rem; background: var(--soft); border: 1px solid var(--border); border-radius: 8px; padding: 0.7rem 1rem; }
.bio-shift .from { color: var(--text-light); font-size: 0.88rem; }
.bio-shift .to { color: var(--text); font-weight: 500; font-size: 0.9rem; }
.bio-shift .mid { color: var(--accent); font-weight: 700; font-size: 1.05rem; }
@media (max-width: 560px) { .bio-shift { grid-template-columns: 1fr; gap: 0.15rem; text-align: left; } .bio-shift .mid { transform: rotate(90deg); width: 1rem; } }

/* backcasting timeline */
.bio-timeline { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0; margin: 1.6rem 0; position: relative; }
/* Same alignment problem as .highlight-box: the phase names wrap to different line counts,
   which pushed each phase's bullet list to a different height (measured 26px apart). Subgrid
   puts year / name / list on shared rows across all four phases. The ::before and ::after
   are position:absolute so they stay out of the grid. */
.bio-phase { position: relative; padding: 1.6rem 0.9rem 0; display: grid; grid-row: span 3; grid-template-rows: subgrid; align-content: start; }
.bio-phase::before { content: ""; position: absolute; top: 0.45rem; left: 0; right: 0; height: 2px; background: var(--border); }
.bio-phase::after { content: ""; position: absolute; top: 0; left: 0; width: 14px; height: 14px; border-radius: 50%; background: var(--accent); }
.bio-phase .yr { font-family: var(--font-display); font-weight: 700; color: var(--accent); font-size: 0.9rem; margin-bottom: 0.15rem; }
.bio-phase .ph { font-family: var(--font-display); font-weight: 600; color: var(--text); font-size: 0.92rem; margin-bottom: 0.5rem; }
.bio-phase ul { margin: 0; padding-left: 1rem; }
.bio-phase li { font-size: 0.82rem; color: var(--text-light); line-height: 1.45; margin-bottom: 0.35rem; font-weight: 300; }
.bio-future { margin-top: 1rem; background: var(--accent); color: var(--bg); border-radius: 10px; padding: 1rem 1.2rem; font-size: 0.92rem; line-height: 1.55; }
.bio-future strong { font-family: var(--font-display); color: inherit; }
@media (max-width: 760px) { .bio-timeline { grid-template-columns: 1fr; } .bio-phase { padding: 1rem 0 1rem 1.4rem; } .bio-phase::before { top: 0; bottom: 0; left: 0.4rem; right: auto; width: 2px; height: auto; } .bio-phase::after { top: 0.2rem; left: 0; } }

/* strengths vs limitations */
.bio-sl { display: grid; grid-template-columns: 1fr 1fr; gap: var(--gap); margin: 1.5rem 0; }
.bio-sl-col h3 { font-family: var(--font-display); font-size: 0.75rem; letter-spacing: 0.1em; text-transform: uppercase; margin-bottom: 0.7rem; }
.bio-sl-col.s h3 { color: var(--text-light); }
.bio-sl-col.l h3 { color: var(--text-light); }
.bio-sl-item { border-left: 2px solid var(--text-light); padding: 0 0 0 0.9rem; margin-bottom: 0.9rem; }
.bio-sl-item strong { display: block; font-size: 0.9rem; color: var(--text); margin-bottom: 0.2rem; font-weight: 600; }
.bio-sl-item span { font-size: 0.83rem; color: var(--text-body); line-height: 1.5; font-weight: 300; }
@media (max-width: 640px) { .bio-sl { grid-template-columns: 1fr; } }

/* bio-table responsive */
.bio-table { table-layout: fixed; }
.bio-table th, .bio-table td { overflow-wrap: anywhere; }
@media (max-width: 560px) {
  .bio-table { font-size: 0.8rem; }
  .bio-table th, .bio-table td { padding: 0.5rem 0.5rem; }
  .bio-table tbody th { width: 38%; }

  /* A 3-column decision board squeezed to ~113px per column produced three tall, ragged
     columns of different lengths. On narrow screens each column becomes its own full-width
     block, headed by its own label (from data-col), so every item gets the full measure.
     `display:block` on table parts strips the implicit table roles from the accessibility
     tree, which is why the markup carries explicit role="table/rowgroup/row/cell". */
  .bio-table.stackable,
  .bio-table.stackable tbody,
  .bio-table.stackable tr,
  .bio-table.stackable td { display: block; width: 100%; }
  /* The caption must be switched too: an orphaned `display:table-caption` inside a table that
     is now `display:block` shrink-wraps to its min-content width (measured 99px) and wraps
     two words per line. */
  .bio-table.stackable caption { display: block; width: 100%; }
  .bio-table.stackable { table-layout: auto; border-collapse: separate; }
  /* The visual header row is replaced by the per-block labels below. */
  .bio-table.stackable thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
  .bio-table.stackable td {
    border: 1px solid var(--border); border-radius: 8px;
    padding: 0.85rem 0.95rem 0.95rem; margin: 0 0 0.7rem;
    background: var(--soft);
  }
  .bio-table.stackable td:last-child { margin-bottom: 0; }
  .bio-table.stackable td::before {
    content: attr(data-col);
    display: block; margin-bottom: 0.5rem; padding-bottom: 0.45rem;
    border-bottom: 1px solid var(--border);
    font-family: var(--font-display); font-weight: 700;
    font-size: 0.68rem; letter-spacing: 0.09em; text-transform: uppercase;
    color: var(--text);
  }
  .bio-table.stackable td ul { margin: 0; padding-left: 1.05rem; }
  .bio-table.stackable td li { margin: 0 0 0.35rem; line-height: 1.5; }
  .bio-table.stackable td li:last-child { margin-bottom: 0; }
}


/* decision matrix heatmap + gap chain arrows */

.bio-causes { display:flex; gap:0.6rem; flex-wrap:wrap; margin:1.4rem 0 0.4rem; }
/* The 3px left edge marks the card, so it needs to read as different from the 1px frame.
   --text-light matches the same marker role on .bio-sl-item; --border made it vanish. */
.bio-cause { flex:1 1 240px; border:1px solid var(--border); border-left:3px solid var(--text-light); border-radius:8px; padding:0.7rem 0.9rem; background:var(--soft); }
.bio-cause .t { font-family:var(--font-display); font-weight:600; font-size:0.88rem; color:var(--text); margin-bottom:0.2rem; }
.bio-cause .d { font-size:0.83rem; color:var(--text-light); line-height:1.45; }
/* guard: no page-level horizontal bleed in any theme/a11y combination.
   'clip' (not hidden) avoids creating a scroll container; intentional */
html, body { overflow-x: clip; }


/* circular BAL loop (inline SVG, theme-aware) */
.loop-fig { margin: 2rem 0; }
.loop-svg { width: 100%; height: auto; display: block; }
.loop-svg .ring { stroke: var(--border); stroke-width: 3; fill: none; }
.loop-svg .fb { stroke: var(--accent); stroke-width: 4; stroke-dasharray: 9 7; fill: none; }
.loop-svg .node { fill: var(--accent); }
.loop-svg .num { fill: var(--bg); font-family: var(--font-display); font-weight: 700; font-size: 30px; }
.loop-svg .ttl { fill: var(--text); font-family: var(--font-display); font-weight: 600; font-size: 24px; }
.loop-svg .fbl { fill: var(--accent); font-family: var(--font-display); font-weight: 600; font-size: 21px; }
.loop-svg .cpill { fill: var(--text); }
.loop-svg .ctext { fill: var(--bg); font-family: var(--font-display); font-weight: 700; font-size: 22px; letter-spacing: 0.04em; }
.loop-svg .csub { fill: var(--text-light); font-size: 19px; }
.loop-svg .ah { fill: var(--border); }
.loop-svg .ahb { fill: var(--accent); }

/* The loop draws itself as you scroll it into view: four arcs stroke on in sequence, the five
   nodes land with them, and the dashed feedback arc - the actual argument of the whole diagram,
   that outcomes return to the next decision - arrives last.
   Scroll-driven, so it is scrubbable rather than a fire-once animation: scroll back up and it
   un-draws. Nothing here is ambient; it only moves because the reader is moving.
   Everything is inside @supports, so any engine without view-timelines (Firefox today) simply
   gets the finished diagram, which is the current behaviour. */
@supports (animation-timeline: view()) {
  @media not (prefers-reduced-motion: reduce) {
    html:not(.a11y-motion) .loop-svg .ring {
      stroke-dasharray: 1; stroke-dashoffset: 1;
      animation: loopDraw linear both;
      animation-timeline: view();
      animation-range: entry 18% cover 46%;
    }
    html:not(.a11y-motion) .loop-svg .ring:nth-of-type(2) { animation-range: entry 26% cover 54%; }
    html:not(.a11y-motion) .loop-svg .ring:nth-of-type(3) { animation-range: entry 34% cover 62%; }
    html:not(.a11y-motion) .loop-svg .ring:nth-of-type(4) { animation-range: entry 42% cover 70%; }
    html:not(.a11y-motion) .loop-svg .node,
    html:not(.a11y-motion) .loop-svg .num {
      animation: loopPop linear both;
      animation-timeline: view();
      animation-range: entry 20% cover 50%;
      transform-box: fill-box; transform-origin: center;
    }
    /* The payoff, last. */
    html:not(.a11y-motion) .loop-svg .fb,
    html:not(.a11y-motion) .loop-svg .fbl {
      animation: loopFade linear both;
      animation-timeline: view();
      animation-range: cover 58% cover 78%;
    }
    @keyframes loopDraw { to { stroke-dashoffset: 0 } }
    @keyframes loopPop  { from { opacity: 0; scale: 0.82 } to { opacity: 1; scale: 1 } }
    @keyframes loopFade { from { opacity: 0 } to { opacity: 1 } }
  }
}
/* No reduce-motion OVERRIDE here on purpose. An override cannot win: once these animations
   exist they run with `fill: backwards`, pinning stroke-dashoffset at its `from` value, and that
   beats even an inline !important (measured). Undoing it afterwards would leave the arcs
   invisible for exactly the users who asked for less motion. The `html:not(.a11y-motion)` guard
   on the rules above means the animated state is never applied in the first place. */
.loop-mobile { display: none; }
.loop-rotate { display: none; }
@media (max-width: 640px) {
  .loop-svg { display: none; }
  .loop-mobile { display: block; }
  /* The cycle reads as a loop, not a dead-end list: a dashed spine returns to step 1 */
  .loop-mobile .lm-cycle { position: relative; padding-left: 14px; }
  .loop-mobile .lm-cycle::before {
    content: ""; position: absolute; left: 0; top: 14px; bottom: 30px;
    width: 1.5px; background: repeating-linear-gradient(to bottom, var(--accent) 0 5px, transparent 5px 11px);
  }
  .loop-mobile .lm-row { display: flex; align-items: center; gap: 0.7rem; padding: 0.55rem 0; }
  .loop-mobile .lm-n { flex: none; width: 30px; height: 30px; border-radius: 50%; background: var(--accent); color: var(--bg); font-family: var(--font-display); font-weight: 700; display: flex; align-items: center; justify-content: center; font-size: 0.9rem; }
  .loop-mobile .lm-t { font-family: var(--font-display); font-weight: 600; color: var(--text); font-size: 0.95rem; }
  .loop-mobile .lm-d { text-align: center; color: var(--text-light); line-height: 1; padding: 0.1rem 0 0.1rem 14px; }
  .loop-mobile .lm-return {
    display: flex; align-items: center; gap: 0.5rem; margin-top: 0.35rem;
    font-size: 0.82rem; color: var(--accent); font-weight: 500; font-style: italic;
  }
  .loop-mobile .lm-ra { font-style: normal; font-size: 1.1rem; transform: scaleX(-1); display: inline-block; }
  .loop-mobile .lm-fb { margin-top: 0.6rem; border: 1.5px dashed var(--accent); border-radius: 8px; padding: 0.7rem 0.9rem; color: var(--accent); font-family: var(--font-display); font-weight: 600; font-size: 0.9rem; }
}
/* Portrait phones: offer the real diagram via rotation. Hidden in landscape (SVG shows) */
@media (max-width: 640px) and (orientation: portrait) {
  .loop-rotate {
    display: flex; align-items: center; gap: 0.5rem;
    margin: 0 0 0.9rem; padding: 0.6rem 0.8rem;
    background: var(--soft); border: 1px solid var(--border); border-radius: 6px;
    font-size: 0.78rem; color: var(--text-light); font-weight: 400; line-height: 1.4;
  }
  .loop-rotate .lr-ic { font-size: 1rem; color: var(--accent); flex: none; }
}
/* Landscape phones: show the loop, fit it to the short viewport */
@media (max-width: 940px) and (orientation: landscape) {
  .loop-svg { display: block; max-height: 86vh; margin: 0 auto; }
  .loop-mobile { display: none; }
}

/* ===== process rail: vertical, always aligned, no orphan cards ===== */
.rail { position: relative; margin: 1.8rem 0; padding-left: 2.1rem; }
.rail::before { content: ""; position: absolute; left: 9px; top: 6px; bottom: 6px; width: 2px; background: var(--border); }
.rail-item { position: relative; padding: 0 0 1.35rem 0; }
.rail-item:last-child { padding-bottom: 0; }
.rail-item::before { content: ""; position: absolute; left: calc(-2.1rem + 3px); top: 4px; width: 14px; height: 14px; border-radius: 50%; background: var(--accent); border: 3px solid var(--bg); }
.rail-k { font-family: var(--font-display); font-size: 0.68rem; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; color: var(--accent); margin-bottom: 0.2rem; }
.rail-t { font-family: var(--font-display); font-weight: 600; font-size: 1.02rem; color: var(--text); margin-bottom: 0.25rem; }
.rail-d { font-size: 0.92rem; font-weight: 300; color: var(--text-body); line-height: 1.55; max-width: 62ch; }
.rail-d em { color: var(--text); font-style: italic; }
/* rail terminus (e.g. the implementation-gap bar) */
.rail-end { position: relative; margin-left: 2.1rem; }

/* Change log: one full-width row per change, so the block is inherently even */
.chg { display: grid; gap: 0.55rem; margin: 1.5rem 0; }
.chg-row {
  display: grid; grid-template-columns: 8.5rem 1fr; gap: 0.9rem;
  background: var(--soft); border: 1px solid var(--border);
  border-radius: 10px; padding: 0.85rem 1.1rem;
}
.chg-k {
  font-family: var(--font-display); font-size: 0.68rem; font-weight: 700;
  letter-spacing: 0.12em; text-transform: uppercase; color: var(--accent);
  padding-top: 0.15rem;
}
.chg-c { font-size: 0.9rem; color: var(--text); font-weight: 500; line-height: 1.5; }
.chg-c .was { color: var(--text-light); font-weight: 300; }
.chg-c .arw { color: var(--accent); font-weight: 700; padding: 0 0.35rem; }
.chg-n { display: block; font-size: 0.84rem; color: var(--text-body); font-weight: 300; line-height: 1.5; margin-top: 0.3rem; }
@media (max-width: 640px) { .chg-row { grid-template-columns: 1fr; gap: 0.35rem; } .chg-k { padding-top: 0; } }

/* 4-item insight rows: force balanced 2x2 */
.highlight-row.cols-2 { grid-template-columns: repeat(2, 1fr); }

@media (max-width: 560px) { .highlight-row.cols-2 { grid-template-columns: 1fr; } }

/* screen-reader-only utility + embed a11y note */
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }


/* Research merge figure: three sources, one arrow, one outcome */
.merge-fig-wrap { margin: 1.6rem 0; }
.merge-fig { display: flex; align-items: center; gap: 1.2rem; max-width: 620px; margin: 0 auto; }
.merge-srcs { display: grid; gap: 0.6rem; flex: 1 1 55%; min-width: 0; }
.merge-src { background: var(--soft); border: 1px solid var(--border); border-radius: 6px; padding: 0.55rem 0.9rem; }
.merge-src strong { display: block; font-size: 0.88rem; font-weight: 600; color: var(--text); line-height: 1.35; }
.merge-src span { font-size: 0.78rem; color: var(--text-light); line-height: 1.4; }
.merge-join { flex: none; color: var(--text-light); font-size: 1.35rem; line-height: 1; font-weight: 300; }
.merge-down { display: none; }
.merge-out { flex: 1 1 40%; min-width: 0; background: var(--soft); border: 1.4px solid var(--accent); border-radius: 6px; padding: 0.8rem 1rem; }
.merge-out strong { display: block; font-family: var(--font-display); font-size: 1.35rem; font-weight: 700; color: var(--accent); line-height: 1.3; }
.merge-out span { font-size: 0.78rem; color: var(--text-light); line-height: 1.4; }
@media (max-width: 640px) {
  .merge-fig { flex-direction: column; align-items: stretch; gap: 0.6rem; }
  .merge-right { display: none; }
  .merge-down { display: block; text-align: center; }
}



/* Hide-images mode: replace the loop SVG with its text fallback, keep text-based figures as-is */
.a11y-images .loop-svg { display: none !important; }
.a11y-images .loop-mobile { display: block !important; }




/* Spec figures: bespoke annotated drawings per case study */
.spec-fig { margin: 2rem auto 1.4rem; max-width: 560px; }
.spec-fig svg { width: 100%; height: auto; display: block; }
.spec-fig .b-lead { stroke: var(--text-light); stroke-width: 1; stroke-dasharray: 2 4; fill: none; }
.spec-fig .b-a { stroke: var(--accent); fill: none; }
.spec-fig .b-af { fill: var(--accent); }
.spec-fig text { font-family: var(--font); fill: var(--text); font-size: 14px; }
.spec-fig .b-dim { fill: var(--text-light); font-size: 12px; font-family: var(--font-display); letter-spacing: 0.05em; }
.spec-fig figcaption { text-align: center; font-size: 0.78rem; color: var(--text-light); margin-top: 0.7rem; }
.a11y-images .spec-fig svg { display: none !important; }

.spec-fig .b-box { fill: var(--soft); stroke: var(--border); }
.spec-fig .b-mtx { fill: var(--text-light); }
.spec-fig .b-ar { fill: var(--text-light); }
.spec-fig .b-ara { fill: var(--accent); }

/* Iteration before/after: pairs a problem state with its resolved state,
   for case-study iteration sections that would otherwise read as prose. */
.ba-list { display: grid; gap: 1rem; margin: 1.5rem 0; }
.ba-item { border: 1px solid var(--border); border-radius: 10px; padding: 1.1rem 1.3rem; background: var(--soft); }
.ba-item h3 { font-family: var(--font-display); font-size: 0.95rem; font-weight: 600; color: var(--text); margin-bottom: 0.8rem; }
.ba-pair { display: grid; grid-template-columns: 1fr auto 1fr; gap: 1rem; align-items: start; }
.ba-before, .ba-after { min-width: 0; }
.ba-k { display: block; font-family: var(--font-display); font-size: 0.65rem; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; margin-bottom: 0.3rem; }
.ba-before .ba-k { color: var(--text-light); }
.ba-after .ba-k { color: var(--accent); }
.ba-before p { font-size: 0.87rem; line-height: 1.5; color: var(--text-light); font-weight: 300; margin: 0; }
.ba-after p { font-size: 0.87rem; line-height: 1.5; color: var(--text-body); font-weight: 400; margin: 0; }
.ba-arrow { color: var(--text-light); font-size: 1.1rem; padding-top: 1.5rem; }
@media (max-width: 620px) {
  .ba-pair { grid-template-columns: 1fr; gap: 0.6rem; }
  .ba-arrow { display: none; }
  .ba-before { padding-bottom: 0.6rem; border-bottom: 1px dashed var(--border); }
}


/* Dense table cells (e.g. the Pin decision board) read as scannable lists
   instead of middot-separated run-on sentences. */
.bio-table td ul { margin: 0; padding-left: 1.05em; }
.bio-table td li { margin-bottom: 0.35rem; }
.bio-table td li:last-child { margin-bottom: 0; }


/* ==================== */
/* VIEW TRANSITIONS      */
/* ==================== */
/* Native cross-document morphs: project card images morph into case-study
   heroes. Falls back to the JS fade in browsers without support. */
@view-transition { navigation: auto; }
@media (prefers-reduced-motion: reduce) {
  @view-transition { navigation: none; }
}
::view-transition-old(root), ::view-transition-new(root) { animation-duration: 0.28s; }

/* ==================== */
/* COMMAND PALETTE       */
/* ==================== */
.cmdk {
  position: fixed; inset: 0; z-index: 10001; display: none;
  background: color-mix(in srgb, var(--text) 22%, transparent);
  align-items: flex-start; justify-content: center; padding: 14vh 1rem 0;
}
.cmdk.open { display: flex; }
.cmdk-panel {
  width: 100%; max-width: 520px; background: var(--bg);
  border: 1px solid var(--border); border-radius: 10px;
  box-shadow: 0 18px 60px rgba(0,0,0,0.28); overflow: hidden;
}
.cmdk-input {
  width: 100%; box-sizing: border-box; border: 0; border-bottom: 1px solid var(--border);
  background: transparent; color: var(--text); font-family: var(--font);
  font-size: 0.95rem; padding: 0.85rem 1rem; outline: none;
}
.cmdk-list { list-style: none; margin: 0; padding: 0.35rem; max-height: 46vh; overflow-y: auto; }
.cmdk-item {
  display: flex; justify-content: space-between; gap: 1rem; align-items: baseline;
  padding: 0.55rem 0.7rem; border-radius: 6px; font-size: 0.88rem;
  color: var(--text-body); cursor: pointer;
}
.cmdk-item.active { background: var(--soft); color: var(--text); }
.cmdk-hint {
  font-family: var(--font-display); font-size: 0.6rem; letter-spacing: 0.1em;
  text-transform: uppercase; color: var(--text-light); flex: none;
}
.cmdk-empty { padding: 0.9rem 0.7rem; font-size: 0.85rem; color: var(--text-light); }
.cmdk-foot {
  display: flex; gap: 1rem; justify-content: flex-end; margin: 0;
  padding: 0.5rem 0.9rem; border-top: 1px solid var(--border);
  font-size: 0.62rem; color: var(--text-light); font-family: var(--font-display); letter-spacing: 0.06em;
}

/* ==================== */
/* SECTION RAIL          */
/* ==================== */
/* Sits in the left gutter between the viewport edge and the 820px content
   column; labels hug the content. Hidden whenever the gutter is too narrow. */
.section-rail { position: fixed; top: 50%; transform: translateY(-50%); left: calc(50% - 690px); width: 210px; z-index: 50; }
.section-rail ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.3rem; }
.section-rail a {
  display: block; font-family: var(--font-display); font-size: 0.62rem; font-weight: 600;
  letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-light);
  text-decoration: none; padding: 0.2rem 0.7rem 0.2rem 0; border-right: 2px solid var(--border);
  text-align: right; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  transition: color var(--t-base), border-color var(--t-base);
}
.section-rail a.active { color: var(--text); border-right-color: var(--accent); }
@media (max-width: 1399px) { .section-rail { display: none; } }

/* ==================== */
/* DYSLEXIA-FRIENDLY TYPE */
/* ==================== */
.a11y-dyslexia * { font-family: Verdana, Tahoma, "Trebuchet MS", sans-serif !important; }
.a11y-dyslexia body { letter-spacing: 0.03em; word-spacing: 0.12em; }
.a11y-dyslexia h1 { letter-spacing: 0 !important; }

/* ==================== */
/* FORCED COLORS (Windows High Contrast) */
/* ==================== */
@media (forced-colors: active) {
  .cursor, .dot-field, .progress-bar, .reading-line { display: none !important; }
  .a11y-toggle { border: 1px solid ButtonText; }
  .a11y-toggle::after { background: ButtonText; }
  /* On/off must not rely on colour alone: knob position + a filled track */
  .a11y-toggle[aria-checked="true"] { background: Highlight; }
  .a11y-toggle[aria-checked="true"]::after { background: HighlightText; }
  .accent-dot { border: 1px solid ButtonText; }
  .accent-dot.active { outline: 2px solid Highlight; outline-offset: 2px; }
  .section-rail a { border-right-color: ButtonText; }
  /* Keep every focus ring visible in the system palette */
  :focus-visible { outline: 2px solid CanvasText !important; outline-offset: 2px; }
  /* Structure that relied on subtle fills now needs real borders */
  .project-card, .edu, .cv-link, .tldr-toggle, .jmap-stage, .cmdk-panel,
  .a11y-panel, .shortcuts-panel { border: 1px solid CanvasText; }
  .cv-link:hover, .tldr-toggle:hover { border-color: Highlight; }
}

/* ==================== */
/* REDUCED TRANSPARENCY  */
/* ==================== */
@media (prefers-reduced-transparency: reduce) {
  nav { backdrop-filter: none; -webkit-backdrop-filter: none; background: var(--bg); }
  .shortcuts-overlay { backdrop-filter: none; -webkit-backdrop-filter: none; background: rgba(0,0,0,0.85); }
  [data-theme="dark"] .shortcuts-overlay { background: rgba(0,0,0,0.92); }
}

/* ==================== */
/* IMAGE LIGHTBOX        */
/* ==================== */
.lightbox {
  position: fixed; inset: 0; z-index: 400;
  display: flex; align-items: center; justify-content: center;
  padding: 4vmin; background: rgba(10,10,10,0.9);
  opacity: 0; visibility: hidden;
  transition: opacity var(--t-base) ease, visibility 0s linear var(--t-base);
}
.lightbox.open { opacity: 1; visibility: visible; transition: opacity var(--t-base) ease; }
.lightbox img {
  max-width: 100%; max-height: 100%; border-radius: 6px; cursor: zoom-out;
  box-shadow: 0 24px 70px -20px rgba(0,0,0,0.7);
  transform: scale(0.96); transition: transform var(--t-slow) var(--ease);
}
.lightbox.open img { transform: scale(1); }
.lightbox-close {
  position: absolute; top: 1.1rem; right: 1.1rem; width: 42px; height: 42px;
  border-radius: 50%; border: 1px solid rgba(255,255,255,0.35);
  background: rgba(0,0,0,0.35); color: #fff; font-size: 1.4rem; line-height: 1;
  cursor: pointer; display: flex; align-items: center; justify-content: center;
  transition: background var(--t-base) ease, transform var(--t-base) var(--ease);
}
.lightbox-close:active { transform: scale(0.92); }
.lightbox-close:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
.img-zoomable { cursor: zoom-in; }
@media (prefers-reduced-motion: reduce) {
  .lightbox, .lightbox img, .lightbox-close { transition: none; }
}
@media (forced-colors: active) { .lightbox { background: Canvas; } .lightbox img { border: 1px solid CanvasText; } }

/* ==================== */
/* PRINT                 */
/* ==================== */



/* ==================== */
/* JOURNEY MAP (Pin)     */
/* ==================== */
/* One stage per card, whole journey visible without horizontal scrolling. */
.jmap { display: grid; gap: 0.7rem; margin: 1.6rem 0; }
.jmap-stage { min-width: 0; overflow-wrap: anywhere; border: 1px solid var(--border); border-radius: 10px; padding: 0.95rem 1.1rem; background: var(--soft); }
.jmap-head {
  /* WCAG 1.4.10 Reflow: the fixed 26px marker + title + right-aligned label could not
     compress at the 320px floor with Larger text, pushing the card past the viewport.
     Allow the row to wrap and its text to shrink. */
  flex-wrap: wrap; min-width: 0; display: flex; align-items: center; gap: 0.6rem; margin-bottom: 0.5rem; }
.jmap-n {
  flex: none; width: 26px; height: 26px; border-radius: 50%;
  background: var(--accent); color: var(--bg);
  font-family: var(--font-display); font-weight: 700; font-size: 0.85rem;
  display: flex; align-items: center; justify-content: center;
}
.jmap-t { min-width: 0; overflow-wrap: anywhere; font-family: var(--font-display); font-weight: 600; font-size: 1rem; color: var(--text); }
.jmap-feel {
  margin-left: auto; font-family: var(--font-display); font-size: 0.62rem; font-weight: 600;
  letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-light);
  border: 1px solid var(--border); border-radius: 999px; padding: 0.22rem 0.65rem; white-space: nowrap;
}
.jmap-doing { font-size: 0.92rem; font-weight: 300; color: var(--text-body); line-height: 1.55; margin: 0 0 0.35rem; }
.jmap-think { font-size: 0.9rem; font-style: italic; color: var(--text-light); line-height: 1.5; margin: 0 0 0.7rem; }
.jmap-eth {
  border-left: 3px solid var(--accent); background: var(--bg);
  border-radius: 0 8px 8px 0; padding: 0.6rem 0.85rem;
  font-size: 0.85rem; color: var(--text-body); line-height: 1.5; font-weight: 300;
}
.jmap-eth strong {
  display: block; font-family: var(--font-display); font-size: 0.62rem; font-weight: 700;
  letter-spacing: 0.12em; text-transform: uppercase; color: var(--accent); margin-bottom: 0.2rem;
}

/* ==================== */
/* DECISION MATRIX (no scroll) */
/* ==================== */
.mx-legend { font-size: 0.78rem; color: var(--text-light); line-height: 1.6; margin: 1.4rem 0 0.7rem; }
.mx-legend span { font-family: var(--font-display); font-size: 0.66rem; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-body); white-space: nowrap; display: inline-block; margin-right: 0.2em; }
.mx-legend span + span::before { content: "· "; color: var(--text-light); }
.mx { list-style: none; margin: 0 0 0.4rem; padding: 0; display: grid; gap: 0.45rem; }
.mx-row {
  display: flex; align-items: center; gap: 0.8rem; flex-wrap: wrap;
  border: 1px solid var(--border); border-radius: 8px; padding: 0.6rem 0.9rem; background: var(--soft);
}
.mx-row.pick { border-color: var(--accent); }
.mx-name { flex: 1 1 200px; font-family: var(--font-display); font-weight: 600; font-size: 0.88rem; color: var(--text); min-width: 0; }
.mx-row.pick .mx-name { color: var(--accent); }
.mx-chips { display: flex; gap: 0.3rem; flex: none; }
.mx-chips span {
  width: 26px; height: 26px; border-radius: 6px; display: flex; align-items: center; justify-content: center;
  font-family: var(--font-display); font-weight: 700; font-size: 0.78rem; color: var(--text);
  background: var(--bg); border: 1px solid var(--border);
}
/* Flat fallback FIRST: if color-mix() is unsupported the declaration is dropped, and without
   this the chip keeps `background: var(--bg)` (white) while `color:#fff` stays - invisible digits. */
/* s3 is the strongest score and carries WHITE text, so its background must be the SOLID accent,
   not a 55% tint. The tint composited to rgb(149,182,158) in the green scope - white on that is
   2.22:1, well under the 4.5:1 AA floor (measured). Solid also means the color-mix fallback and
   the modern path are identical, so nothing can diverge in older Safari/Firefox. */
.mx-chips .s3 { background: var(--accent); color: var(--bg); border-color: transparent; }
.mx-chips .s2 { background: var(--border); background: color-mix(in srgb, var(--accent) 28%, transparent); border-color: transparent; }
.mx-chips .s1 { background: var(--soft); background: color-mix(in srgb, var(--accent) 12%, transparent); border-color: transparent; }
.mx-chips .s0 { color: var(--text-light); }
.mx-total { flex: none; margin-left: auto; font-family: var(--font-display); font-weight: 700; font-size: 1.05rem; color: var(--text); font-variant-numeric: tabular-nums; }
.mx-row.pick .mx-total { color: var(--accent); }
@media (max-width: 480px) { .mx-row { padding: 0.55rem 0.7rem; } .mx-chips span { width: 23px; height: 23px; } }


/* ==================== */
/* TL;DR / RECRUITER MODE */
/* ==================== */
.tldr-toggle {
  display: inline-flex; align-items: center; gap: 0.45rem;
  margin: 0.4rem 0 0.9rem; padding: 0.45rem 0.95rem;
  background: transparent; border: 1.5px solid var(--border); border-radius: 999px;
  font-family: var(--font-display); font-size: 0.7rem; font-weight: 600;
  letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-body);
  cursor: pointer; transition: border-color var(--t-base), color var(--t-base);
}
.tldr-toggle::before { content: "\2192"; font-size: 0.8rem; color: var(--accent); transition: transform var(--t-slower) var(--ease); }
.tldr-toggle[aria-expanded="true"] { border-color: var(--accent); color: var(--text); }
.tldr-toggle[aria-expanded="true"]::before { transform: rotate(90deg); }

/* Micro-interactions — press feedback. The site already lifts controls on hover;
   this adds the missing "depress on click" half, plus mouse-hover feedback for the
   command palette (previously only the keyboard-selected item highlighted). */
.shortcuts-btn, .theme-toggle, .a11y-btn, .a11y-close, .tldr-toggle {
  transition-property: color, border-color, background, transform;
  transition-duration: var(--t-base);
  transition-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
}
.shortcuts-btn:active, .theme-toggle:active, .a11y-btn:active,
.a11y-close:active, .tldr-toggle:active { transform: scale(0.92); }
.cmdk-item { transition: background var(--t-fast) ease, color var(--t-fast) ease, transform var(--t-fast) ease; }
.cmdk-item:active { transform: scale(0.985); }
@media (prefers-reduced-motion: reduce) {
  .shortcuts-btn:active, .theme-toggle:active, .a11y-btn:active, .a11y-close:active,
  .tldr-toggle:active, .cmdk-item:active { transform: none; }
}
/* Toggle bar: sits below the hero image, above the first section */
.tldr-bar { margin: 2.4rem auto 0.6rem; text-align: left; }
.tldr-bar .tldr-toggle { margin: 0; }
/* The 30-second version expands IN PLACE below the button — nothing else moves or
   hides. Height animates via the grid 0fr->1fr technique (no JS measurement). */
.tldr-sec { padding: 0; margin: 0; }
.tldr-collapse { display: grid; grid-template-rows: 0fr; transition: grid-template-rows 0.5s var(--ease); }
.tldr-sec.open .tldr-collapse { grid-template-rows: 1fr; }
.tldr-collapse-inner {
  overflow: hidden; opacity: 0; visibility: hidden; transform: translateY(8px);
  transition: opacity var(--t-slow) ease, transform var(--t-slower) var(--ease), visibility 0s linear 0.5s;
}
.tldr-sec.open .tldr-collapse-inner {
  opacity: 1; visibility: visible; transform: none;
  transition: opacity var(--t-slower) ease var(--t-fast), transform var(--t-slower) var(--ease) 0.06s, visibility 0s;
}
.tldr-collapse-inner > .container { padding-block: 0.6rem 2.6rem; }
/* Skip-ahead link injected into the 30-second version for quick skimmers */
.tldr-next {
  display: inline-flex; align-items: center; gap: 0.5rem;
  margin-top: 1.8rem; padding-top: 1.3rem; width: 100%;
  border-top: 1px solid var(--border);
  font-family: var(--font-display); font-size: 0.82rem; font-weight: 600;
  letter-spacing: 0.02em; color: var(--text-light); text-decoration: none;
  transition: gap var(--t-base) var(--ease), color var(--t-base) ease;
}
.tldr-next strong { font-weight: 600; color: var(--text); }
.tldr-next span { transition: transform var(--t-base) var(--ease); }
@media (prefers-reduced-motion: reduce) {
  .tldr-collapse, .tldr-collapse-inner, .tldr-toggle::before { transition: none; }
  .tldr-next, .tldr-next span { transition: none; }
  .tldr-next:hover { gap: 0.5rem; }
  .tldr-next:hover span { transform: none; }
}
.tldr-list { margin: 0.4rem 0 0; padding-left: 1.2rem; }
.tldr-list li { font-size: 1rem; font-weight: 300; line-height: var(--lh-list); color: var(--text-body); margin-bottom: 0.55rem; }
.tldr-list li::marker { font-family: var(--font-display); font-weight: 700; color: var(--accent); }


/* ==================== */
/* BEFORE / AFTER SLIDER */
/* ==================== */
.ba-slider {
  position: relative; max-width: 300px; margin: 0 auto 0.4rem; user-select: none;
}
.ba-slider img {
  position: absolute; inset: 0; width: 100%; height: 100%;
  object-fit: contain; display: block; opacity: 1 !important; filter: none !important; transform: none !important;
}
.ba-slider .ba-top { clip-path: inset(0 calc(100% - var(--pos, 50%)) 0 0); }
.ba-slider .ba-divider {
  position: absolute; top: 0; bottom: 0; left: var(--pos, 50%); width: 2px;
  background: var(--accent); transform: translateX(-1px); pointer-events: none;
}
.ba-slider .ba-divider::after {
  content: "\2194"; position: absolute; top: 50%; left: 50%;
  transform: translate(-50%, -50%); width: 34px; height: 34px; border-radius: 50%;
  background: var(--accent); color: var(--bg); display: flex; align-items: center; justify-content: center;
  font-size: 1rem; font-family: var(--font-display); font-weight: 700;
}
.ba-slider input[type="range"] {
  position: absolute; inset: 0; width: 100%; height: 100%;
  opacity: 0; cursor: ew-resize; margin: 0;
}
/* The range input is transparent, so paint the focus ring on the slider itself */
.ba-slider:focus-within { outline: 3px solid var(--focus); outline-offset: 3px; border-radius: 3px; }
.a11y-focus .ba-slider:focus-within { outline: 4px solid var(--focus); outline-offset: 4px; }
.ba-caption { text-align: center; font-size: 0.78rem; color: var(--text-light); margin: 0.5rem 0 0; }

/* Labels live above the slider so they never fight the screens for contrast */
.ba-labels {
  display: flex; justify-content: space-between; max-width: 300px;
  margin: 1.4rem auto 0.35rem; font-family: var(--font-display);
  font-size: 0.66rem; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase;
}
/* opacity:0.55 removed: it composited --text-light down to 2.30:1 (light) / 2.43:1 (dark)
   against 4.5:1 required for this 10.56px bold label, and High Contrast did not rescue the
   light case (3.24:1). --text-light on its own is 5.74:1 / 5.20:1, and the active state is
   still distinguished by colour (--accent), so the fade was buying nothing but a WCAG fail. */
.ba-labels .bl { color: var(--text-light); transition: color var(--t-fast) ease; }
.ba-labels .bl.on { color: var(--accent); }

/* ==================== */
/* FOOTER LOCAL TIME     */
/* ==================== */
.footer-time { font-variant-numeric: tabular-nums; }


/* ==================== */
/* FEATURE + SCREEN ROWS */
/* ==================== */
.feat-split {
  display: grid; grid-template-columns: 1fr 218px; gap: 1.6rem;
  align-items: center; padding: 1.1rem 0; border-top: 1px solid var(--border);
}
.feat-split:first-of-type { border-top: 0; }
.feat-split-text h3 { font-size: 1rem; margin-bottom: 0.25rem; font-weight: 600; }
.feat-split-text p { font-size: 1rem; font-weight: 300; line-height: 1.7; margin: 0; }
.feat-split img { width: 100%; height: auto; display: block; }
@media (max-width: 640px) {
  .feat-split { grid-template-columns: 1fr; gap: 0.9rem; }
  .feat-split img { max-width: 230px; margin: 0 auto; }
}

/* ==================== */
/* HOVER (pointer-capable devices only)                                            */
/* ==================== */
/* On touch, an unguarded :hover STICKS after the tap - the card or link stays in its
   hover state until you tap elsewhere. These are the top-level hover rules, moved here
   verbatim and gated. Rules already nested inside a media query were left where they are
   so their breakpoint still applies. */
@media (hover: hover) and (pointer: fine) {
  /* Variable-axis weight. The fonts are requested as `wght@300..700`, so weight is a continuous
     axis rather than five fixed stops - which means it can be transitioned. Applied only to text
     that answers a deliberate hover (a card title, the next-project link), never ambiently.
     Block-level targets only, so the reflow from the width change can't shift a neighbour. */
  /* Targets chosen for HEADROOM: both sit at 600 against a 700 axis ceiling, so the weight can
     actually rise. `.project-card h3` is deliberately excluded - it is already 700, the top of
     the axis, so any hover value could only make it lighter, i.e. de-emphasise on focus. */
  .project-card .card-cta, .next a {
    transition: font-weight var(--t-base) var(--ease), gap var(--t-slow) ease;
  }
  .project-card:hover .card-cta,
  .next a:hover { font-weight: 700; }

  .scroll-top:hover { background: var(--border); color: var(--text); }
  .accent-dot:hover { transform: scale(1.3); }
  .accent-dot:hover::after { opacity: 1; }
  /* opacity:0.6 composited the logotype to 3.09:1 in the ochre light palette (needs 4.5:1);
     default light was a marginal 4.57:1. --text-light is an explicit, already-compliant token
     (5.74:1 light / 5.20:1 dark), so the hover now changes COLOUR rather than fading opacity. */
  .nav-name:hover { color: var(--text-light); }
  .nav-links a:hover { color: var(--text); }
  .nav-links a:hover::after { width: 100%; left: 0; }
  .shortcuts-btn:hover { color: var(--text); border-color: var(--text-light); }
  .theme-toggle:hover { color: var(--text); border-color: var(--text-light); }
  .project-card:hover .card-img.loaded { transform: scale(1.015); }
  .project-card:hover h3 { letter-spacing: 0.03em; }
  .project-card:hover .card-cta { gap: 0.7rem; }
  .project-card:hover .card-cta .cta-text::after { width: 100%; }
  /* The keyboard-focus state must NOT be faded. At opacity 0.55 the anchor's --text-light
     composited to 2.30:1 (needs 4.5:1) AND the 3px focus ring itself faded to ~2.33:1
     (SC 1.4.11 needs 3:1) — the focus indicator was the thing being made invisible.
     Hover keeps the soft 0.55 reveal; focus-visible is now fully opaque. */
  h2:hover > .h-anchor { opacity: 0.55; transform: none; }
  .h-anchor:focus-visible { opacity: 1; transform: none; }
  .h-anchor:hover { opacity: 1; color: var(--accent); }
  .highlight-box:hover { border-left-color: var(--accent); }
  .next a:hover { gap: 0.6rem; }
  .skill-col li:hover { color: var(--text); border-left-color: var(--accent); }
  .cv-link:hover { border-color: var(--accent); color: var(--text); transform: translateY(-1px); }
  .contact a:hover::after { transform: scaleX(1); transform-origin: left; }
  /* The 0.14em hover widening is for TEXT links; excluding the filled button in the SELECTOR
     lets it simply keep its own 0.08em. The previous approach - a .email-link:hover override
     restating an absolute -0.03em - had drifted from that 0.08em base, so hovering TIGHTENED
     the label by 0.11em, reflowing the button 17.3px narrower with an 8.7px sideways jump.
     Same restated-absolute trap as FIX-1. Nothing is restated now, so nothing can drift. */
  .contact a:not(.email-link):hover { letter-spacing: 0.14em; }
  .contact .email-link:hover { color: var(--accent); }
  footer a:hover { color: var(--text); }
  footer a:hover::after { width: 100%; left: 0; }
  .a11y-btn:hover { color: var(--text); border-color: var(--text-light); }
  .a11y-close:hover { color: var(--text); }
  .section-rail a:hover { color: var(--text); }
  .lightbox-close:hover { background: rgba(0,0,0,0.6); transform: scale(1.06); }
  .tldr-toggle:hover { border-color: var(--accent); color: var(--text); }
  .cmdk-item:hover { background: var(--soft); color: var(--text); }
  .tldr-next:hover { color: var(--text); gap: 0.7rem; }
  .tldr-next:hover span { transform: translateX(3px); }
}


/* ==================== */
/* PRINT                 */
/* ==================== */
/* One block, not two. There were previously two @media print blocks ~470 lines apart with
   conflicting values (`body{background:white;color:#1A1A1A}` vs `background:#fff;color:#000`,
   and `a{text-decoration:underline}` vs `a{color:#000}`) - the later one silently won on some
   properties and not others, so nobody had actually designed what came out of the printer.
   A hiring manager printing a case study is a real scenario; this makes it a deliberate artefact. */
@page {
  margin: 18mm 16mm 20mm;
}
@media print {
  /* Interface disappears - none of it means anything on paper. */
  nav, .cursor, .progress-bar, .scroll-top, .scroll-hint, .scroll-persist, .reading-line,
  .a11y-panel, .a11y-btn, .a11y-bubble, .shortcuts-overlay, .shortcuts-btn, .accent-picker,
  .theme-toggle, .section-rail, .cmdk, .dot-field, .tldr-toggle, .ba-slider input,
  .next, .skip-link { display: none !important; }

  html, body { background: #fff !important; color: #000 !important; }
  body { font-size: 11pt; line-height: 1.5; }

  /* Nothing arrives mid-animation on paper. */
  .reveal, .intro-text, .project-card .card-img,
  .project-card.reveal > * { opacity: 1 !important; transform: none !important; }

  /* Full-viewport sections collapse to their content. */
  .hero-full, .intro-full, .about, .contact { min-height: auto !important; padding: 1.2rem 0 !important; }
  .cs { padding: 1rem 0; }

  /* Headings keep their following text with them; blocks don't split across a page. */
  h1, h2, h3 { break-after: avoid-page; page-break-after: avoid; }
  .project-card, .stat-row, .highlight-box, .jmap-stage, .pq,
  figure, .img-desc, .bio-table tr { break-inside: avoid; page-break-inside: avoid; }
  img { max-width: 100% !important; }

  /* A printed link is dead unless the address comes with it - but only for real destinations,
     never for in-page anchors, and never twice for the same visible text. */
  a { color: #000 !important; text-decoration: underline; }
  a::after { display: none !important; }
  a[href^="http"]::after {
    display: inline !important;
    content: " (" attr(href) ")";
    font-size: 0.85em; font-weight: 400; word-break: break-all;
  }
  a[href^="#"]::after, a[href^="/"]::after, a[href^="mailto"]::after { display: none !important; }

  .accent-word { color: #000 !important; text-decoration: underline; }
  .accent-word::after { display: none !important; }
}

/* WCAG 1.4.10 Reflow backstop: at the 320px floor with Larger text + Reading guide +
   Dyslexia stacked, long unbroken labels could still establish a min-content width wider
   than the viewport in Firefox. Nothing in the page body may force sideways scrolling. */
@media (max-width: 400px) {
  main, main * { min-width: 0; }
  /* The next-project link keeps white-space:nowrap so its label and arrow stay together
     (see FIX-2). At the 320px reflow floor with Dyslexia type that single line is wider
     than the viewport, so allow it to wrap here — the arrow still cannot separate from
     the last word because the gap owns the spacing. */
  /* L1422 sets `.a11y-spacing .next a { white-space: nowrap !important }` (FIX-2, so the
     arrow never orphans). At the 320px floor that single line exceeds the viewport, so
     match that specificity and release it here only. The arrow still cannot separate:
     `gap` owns the spacing, not a whitespace text node. */
  .a11y-spacing .next a, .a11y-large .next a, .next a,
  .a11y-spacing .next a *, .next a * { white-space: normal !important; }
  main li, main a, main p, main h1, main h2, main h3 { overflow-wrap: anywhere; }
}
