:root {
  --onyx: #0a0a0a;
  --onyx-deep: #050505;
  --onyx-soft: #141414;
  --ink: #1a1a1a;
  --bone: #ffffff;
  --bone-dim: #d8d8d2;
  --bone-faint: #8a8a84;
  --accent: #38e0ff;
  --accent-dim: rgba(56, 224, 255, 0.5);
  --accent-faint: rgba(56, 224, 255, 0.1);
  --grid: rgba(245, 245, 240, 0.04);
  --line: rgba(245, 245, 240, 0.12);

  /* Glassmorphism system */
  --glass-bg: rgba(255, 255, 255, 0.03);
  --glass-border: rgba(255, 255, 255, 0.08);
  --glass-border-top: rgba(255, 255, 255, 0.15);
  --nav-bg: rgba(31, 31, 31, 0.4);

  --nav-link-color: rgba(255, 255, 255, 0.6);
  --nav-link-hover-color: #ffffff;
  --nav-link-glow: 0 0 10px rgba(56, 224, 255, 0.8), 0 0 20px rgba(56, 224, 255, 0.4);

  --font-display: 'Fraunces', Georgia, serif;
  --font-body: 'Outfit', -apple-system, sans-serif;
  --font-mono: 'JetBrains Mono', 'Courier New', monospace;

  /* ---- Motion system ----
     One vocabulary for every transition on the site so hovers, reveals and
     the splash all decelerate the same way. `--ease-out-expo` is the site's
     existing cubic-bezier(0.16, 1, 0.3, 1) given a name; `--ease-spring`
     overshoots slightly and is reserved for things that should feel
     physical (buttons, cards lifting). Durations are deliberately few —
     if a value isn't one of these four, it probably shouldn't be. */
  --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
  --ease-out-soft: cubic-bezier(0.22, 0.61, 0.36, 1);
  --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
  --dur-instant: 0.15s;
  --dur-fast: 0.25s;
  --dur-base: 0.4s;
  --dur-slow: 0.7s;

  /* Hover lift + glow, shared by every card-like surface */
  --hover-lift: -6px;
  --hover-glow: 0 18px 45px -12px rgba(0, 0, 0, 0.55), 0 0 0 1px var(--accent-faint);
}

:root[data-theme="light"] {
  --onyx: #f5f5f7;
  --onyx-deep: #ffffff;
  --onyx-soft: #e5e5ea;
  --ink: #f5f5f7;
  --bone: #1d1d1f;
  --bone-dim: #515154;
  --bone-faint: #8e8e93;
  --accent: #0066cc;
  --accent-dim: rgba(0, 102, 204, 0.5);
  --accent-faint: rgba(0, 102, 204, 0.1);
  --grid: rgba(0, 0, 0, 0.04);
  --line: rgba(0, 0, 0, 0.12);

  --glass-bg: rgba(255, 255, 255, 0.75);
  --glass-border: rgba(0, 0, 0, 0.08);
  --glass-border-top: rgba(0, 0, 0, 0.12);
  --nav-bg: rgba(255, 255, 255, 0.6);

  --nav-link-color: rgba(0, 0, 0, 0.55);
  --nav-link-hover-color: var(--accent);
  --nav-link-glow: 0 0 8px rgba(0, 102, 204, 0.5), 0 0 15px rgba(0, 102, 204, 0.25);

  /* Light theme needs a much softer shadow — the dark theme's near-black
     drop shadow reads as dirt on a white background. */
  --hover-glow: 0 18px 40px -14px rgba(0, 0, 0, 0.18), 0 0 0 1px var(--accent-faint);
}

:root[data-theme="light"] .nav-logo-img,
:root[data-theme="light"] .hero-logo {
  mix-blend-mode: normal;
  filter: invert(1) brightness(0);
}

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

/* Hide scrollbars across the entire website */
html, body, * {
  scrollbar-width: none !important;
  -ms-overflow-style: none !important;
}
html::-webkit-scrollbar,
body::-webkit-scrollbar,
*::-webkit-scrollbar {
  display: none !important;
  width: 0 !important;
  height: 0 !important;
}

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

.skip-link {
  position: absolute;
  top: -100%;
  left: 1rem;
  background: var(--accent);
  color: var(--onyx);
  padding: 0.5rem 1rem;
  font-family: var(--font-mono);
  font-size: 0.8rem;
  font-weight: 600;
  text-decoration: none;
  z-index: 999;
  border-radius: 2px;
}

.skip-link:focus { top: 1rem; }

/* Visually hidden but still announced — used for the loader's status text,
   since the animation itself is aria-hidden. */
.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;
}

html { scroll-behavior: auto; }

body {
  /* Ambient corner glow — a static radial-gradient background instead of
     the old animated, blurred "glass orb" divs. Same soft blue/purple
     depth behind the hero text, but painted once with no filter, no
     animation loop, and nothing for the compositor to recompute every
     frame — the cheapest way to get the look with zero flicker risk. */
  background:
    radial-gradient(circle 650px at -8% -12%, rgba(56, 224, 255, 0.12), transparent 70%),
    radial-gradient(circle 750px at 108% 112%, rgba(138, 43, 226, 0.11), transparent 70%),
    var(--onyx);
  background-attachment: fixed;
  color: var(--bone);
  font-family: var(--font-body);
  font-weight: 400;
  line-height: 1.65;
  overflow-x: hidden;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-rendering: optimizeLegibility;
  /* Universal fade-in on load — works in every browser, unlike the
     Chromium-only view-transition cross-fade below. In browsers that do
     support that, this just layers under it unnoticed; everywhere else
     it's the whole effect. Covered by the sitewide reduced-motion rule
     further down since it's a plain animation on a real element. */
  animation: pageFadeIn 0.4s ease both;
}

@keyframes pageFadeIn {
  from { opacity: 0; }
  to { opacity: 1; }
}

/* Selection */
::selection { background: var(--accent); color: var(--onyx); }

/* Custom cursor glow */
/* Positioned entirely by `transform` from common.js, which is why top/left stay
   at 0 and `will-change: transform` is honest.

   Two things were wrong here before. The glow is 400x400 with no centring
   offset, and the follower wrote `left`/`top` — so the element's top-left
   corner tracked the cursor and the visible centre sat 200px down-right of it.
   Writing `translate3d(x, y, 0) translate(-50%, -50%)` instead both centres it
   on the pointer and keeps the movement on the compositor; animating left/top
   forced a layout on every single frame. */
.cursor-glow {
  position: fixed;
  top: 0;
  left: 0;
  width: 400px;
  height: 400px;
  border-radius: 50%;
  background: radial-gradient(circle, rgba(0, 212, 255, 0.08) 0%, transparent 70%);
  pointer-events: none;
  z-index: 1;
  transition: opacity 0.3s ease;
  will-change: transform;
  /* Parked off-screen until the first mousemove, so it doesn't sit visibly in
     the top-left corner on load. */
  transform: translate3d(-50vw, -50vh, 0);
}

/* The fixed 80px `.grid-bg` that used to sit here is gone. Interior pages get
   `#pageGalaxy` instead (below) — the same galaxy renderer as the hero, dialled
   down, so the background is on-brand rather than a technical grid. */

/* Interior-page sky. Fixed and behind everything, at a fraction of the hero's
   intensity so body copy stays comfortable to read over it. Home doesn't use
   this — its hero canvas is the full-strength version. */
#pageGalaxy {
  position: fixed;
  inset: 0;
  width: 100%;
  height: 100%;
  z-index: 0;
  opacity: 0;
  pointer-events: none;
  animation: pageGalaxyIn 1.2s var(--ease-out-soft) 0.1s both;
}

@keyframes pageGalaxyIn {
  to { opacity: 0.38; }
}

/* A scatter of pale specks on white reads as dirt, not stars. */
:root[data-theme="light"] #pageGalaxy { display: none; }

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

/* Glass orbs — Home-only. Brought back at the user's request after the
   static body-gradient replacement; only index.html includes the orb-1/
   orb-2 divs, everywhere else keeps just the lighter static wash above.
   Still avoids the two things that actually caused the original flicker —
   no mix-blend-mode, and the floatOrb keyframe stays translate-only (no
   scale()) — but the blur radius itself was never the flicker culprit
   (a translate-only animation just composites a pre-blurred bitmap, cheap
   at any radius), so it's safe to push it up further here for a softer,
   more feathered edge than the immediate post-fix version had. */
.glass-orb {
  position: fixed;
  border-radius: 50%;
  filter: blur(80px);
  z-index: 0;
  pointer-events: none;
  animation: floatOrb 25s ease-in-out infinite alternate;
  will-change: transform;
  opacity: 0.4;
}
.orb-1 {
  width: 500px;
  height: 500px;
  background: radial-gradient(circle, rgba(56,224,255,0.4) 0%, rgba(138,43,226,0.1) 70%);
  top: -10%;
  left: -10%;
}
.orb-2 {
  width: 600px;
  height: 600px;
  background: radial-gradient(circle, rgba(138,43,226,0.3) 0%, rgba(56,224,255,0.1) 70%);
  bottom: -15%;
  right: -5%;
  animation-delay: -10s;
}

@keyframes floatOrb {
  0% { transform: translate(0, 0); }
  50% { transform: translate(100px, 150px); }
  100% { transform: translate(-50px, 80px); }
}

/* ============ NAV ============ */
.nav-container {
  position: fixed;
  top: 1.5rem;
  left: 50%;
  transform: translateX(-50%);
  z-index: 50;
  width: max-content;
  max-width: 95%;
}

nav {
  display: flex;
  justify-content: space-between;
  align-items: center;
  background: var(--nav-bg);
  backdrop-filter: blur(20px) saturate(180%);
  border-radius: 50px;
  padding: 0.5rem 0.5rem 0.5rem 0.5rem;
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
  gap: 3rem;
  border: 1px solid var(--glass-border);
}

.nav-logo {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 48px;
  height: 48px;
  text-decoration: none;
  transition: opacity 0.3s ease;
  flex-shrink: 0;
}

.nav-logo:hover { opacity: 0.85; }

.nav-logo-img {
  height: 24px;
  width: auto;
  display: block;
  transition: filter 0.3s ease;
}

.nav-logo.active .nav-logo-img,
.nav-logo:hover .nav-logo-img {
  filter: drop-shadow(0 0 8px var(--accent));
}

:root[data-theme="light"] .nav-logo.active .nav-logo-img,
:root[data-theme="light"] .nav-logo:hover .nav-logo-img {
  filter: invert(1) brightness(0) drop-shadow(0 0 6px var(--accent));
}

.nav-links {
  display: flex;
  gap: 2rem;
  list-style: none;
  font-family: var(--font-body);
  font-size: 0.95rem;
  font-weight: 400;
}

.nav-links a {
  position: relative;
  color: var(--nav-link-color);
  text-decoration: none;
  transition: color 0.3s ease, text-shadow 0.3s ease;
}

/* `[aria-current="page"]` is the selector, not a separate `.active` class.
   The site is real URLs now, so which page you're on is a fact in the markup —
   driving the visual state off the accessibility attribute keeps one source of
   truth instead of a class that can drift out of sync with it. */
.nav-links a:hover,
.nav-links a[aria-current="page"] {
  color: var(--nav-link-hover-color);
  text-shadow: var(--nav-link-glow);
}

/* A hairline that grows out from the centre — on the current page it is simply
   already there. */
.nav-links a::after {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  bottom: -0.5rem;
  height: 1px;
  background: var(--accent);
  transform: scaleX(0);
  transition: transform var(--dur-base) var(--ease-out-expo);
}

.nav-links a[aria-current="page"]::after { transform: scaleX(1); }

/* Desktop keeps Contact as the pill on the right; this drawer-only duplicate
   stays out of the way. */
.nav-link-contact { display: none; }

@media (hover: hover) and (pointer: fine) {
  .nav-links a:hover::after { transform: scaleX(1); }
}

/* Was an inline style on the element; moved here now that seven pages share
   the same nav markup. */
.nav-right {
  display: flex;
  align-items: center;
  gap: 0.8rem;
}

.nav-email {
  background: #ffffff;
  color: #1a1a1a;
  text-decoration: none;
  padding: 0.7rem 1.2rem;
  border-radius: 30px;
  font-family: var(--font-body);
  font-weight: 500;
  font-size: 0.9rem;
  transition: background 0.3s ease;
  white-space: nowrap;
}

.nav-email:hover {
  background: #e0e0e0;
}

.theme-toggle {
  background: rgba(255, 255, 255, 0.05);
  border: 1px solid var(--glass-border);
  color: var(--bone);
  border-radius: 50%;
  width: 38px;
  height: 38px;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  transition: all 0.3s ease;
}
.theme-toggle:hover {
  background: rgba(255, 255, 255, 0.12);
  border-color: var(--accent);
  color: var(--accent);
  box-shadow: 0 0 10px var(--accent-dim);
}
:root[data-theme="light"] .theme-toggle {
  background: rgba(0, 0, 0, 0.05);
}
:root[data-theme="light"] .theme-toggle:hover {
  background: rgba(0, 0, 0, 0.08);
}
.theme-toggle svg {
  width: 18px;
  height: 18px;
}
:root[data-theme="light"] .nav-email {
  background: #1d1d1f;
  color: #ffffff;
}
:root[data-theme="light"] .nav-email:hover {
  background: #3a3a3c;
}
:root[data-theme="light"] nav {
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08);
}

.nav-toggle {
  display: none;
  background: none;
  border: none;
  color: var(--bone);
  cursor: pointer;
  padding: 0.5rem;
  margin-right: 0.5rem;
}

/* ============ HERO ============ */
.hero {
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  justify-content: center;
  position: relative;
  padding: 8rem 3rem 4rem;
  overflow: hidden;
}

/* Galaxy canvas */
.circuit-canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  z-index: 1;
  opacity: 0.85;
  transition: opacity 0.3s ease;
}

.hero-content {
  position: relative;
  z-index: 2;
  max-width: 1200px;
  margin: 0 auto;
  width: 100%;
}

/* ---- Hero entrance ----
   Every animation below is gated on `html.splash-done`, NOT on page load.
   This is a real bug that shipped: the splash covers the viewport for ~2.7s,
   while these delays run 0.1s–1.5s from load — so the whole hero entrance
   played out behind the curtain and had finished before anyone could see it.
   `.splash-done` is set by index.html's splash controller when the curtain
   starts lifting, and immediately in the <head> of every page that has no
   splash, so interior pages animate on arrival as normal. */
.hero-logo {
  max-width: 380px;
  width: 60%;
  height: auto;
  display: block;
  margin-bottom: 2.5rem;
  opacity: 0;
}
html.splash-done .hero-logo { animation: fadeUp 1s 0.1s forwards; }

.hero-tag {
  font-family: var(--font-mono);
  font-size: 0.8rem;
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.25em;
  color: var(--accent);
  margin-bottom: 2rem;
  display: flex;
  align-items: center;
  gap: 1rem;
  opacity: 0;
}
html.splash-done .hero-tag { animation: fadeUp 0.8s 0.2s forwards; }

.hero-tag::before {
  content: '';
  width: 30px;
  height: 1px;
  background: var(--accent);
}

.hero-headline {
  font-family: var(--font-display);
  font-size: clamp(2.5rem, 6.5vw, 5.8rem);
  font-weight: 300;
  line-height: 1.15;
  letter-spacing: -0.02em;
  margin-bottom: 2rem;
  color: var(--bone);
}

/* ---- Masked per-line reveal ----
   The site's signature text motion: each line sits below a clipped box and
   rises into place, one after another. This is what got deleted in e9dc5d3 —
   the `.line > span` markup and `@keyframes slideUp` both survived, but the
   `overflow: hidden`, the offset and the animation did not, so the structure
   was doing nothing.

   Now a reusable component. Two ways it fires:
     • `.lines-immediate` — plays as soon as the curtain clears. The hero, which
       is on screen before any scrolling happens.
     • plain `.lines`      — plays when scrolled to. common.js adds `.lines-in`.
   common.js also sets `--line-index` on each line, which is what staggers them;
   the hero's 0.3 / 0.45 / 0.6s cadence is `--line-base` plus that index.

   The padding/negative-margin pair is load-bearing: `overflow: hidden` would
   otherwise clip descenders and the accent line's glow. */
.lines .line {
  display: block;
  overflow: hidden;
  padding: 0.14em 0;
  margin: -0.14em 0;
}

.lines .line > span {
  display: inline-block;
  transform: translateY(110%);
  will-change: transform;
}

html.splash-done .lines.lines-immediate .line > span,
.lines.lines-in .line > span {
  animation: slideUp 1s var(--ease-out-expo) forwards;
  animation-delay: calc(var(--line-base, 0.3s) + var(--line-index, 0) * 0.15s);
}

.hero-headline em {
  font-style: italic;
  font-weight: 400;
  color: var(--accent);
  text-shadow: 0 0 25px var(--accent-dim);
}

/* ---- Hero rotator ----
   The tail of the headline cycles through phrases. Restored after being dropped
   in e9dc5d3 in favour of a static line.

   `inline-grid` with every word in the same cell replaces the original's
   `position: absolute` + `min-width: 260px`: the container now sizes itself to
   the widest phrase automatically, so there's no magic number to keep in sync
   with the copy and no width jump when the phrase changes. */
.hero-rotator {
  display: inline-grid;
  text-align: left;
}

.hero-rotator > .rotator-word {
  grid-area: 1 / 1;
  font-style: italic;
  font-weight: 400;
  color: var(--accent);
  text-shadow: 0 0 25px var(--accent-dim);
  white-space: nowrap;
  opacity: 0;
  /* Waiting below, so it rises into place. `.line`'s overflow:hidden crops it
     on the way, which is what makes it read as a masked slide rather than a
     drifting fade. */
  transform: translateY(0.42em);
  transition:
    opacity var(--dur-slow) var(--ease-out-expo),
    transform var(--dur-slow) var(--ease-out-expo);
}

.hero-rotator > .rotator-word.is-active {
  opacity: 1;
  transform: translateY(0);
}

/* Exits upward rather than sinking back the way it came, so the set reads as a
   conveyor moving one direction. The original just removed the active class,
   which made words fade downward on the way out. */
.hero-rotator > .rotator-word.is-leaving {
  opacity: 0;
  transform: translateY(-0.42em);
}

@media (prefers-reduced-motion: reduce) {
  /* No cycling (common.js never starts the timer); just show the first phrase
     at rest. */
  .hero-rotator > .rotator-word { transition: none; }
}

.hero-sub {
  font-size: clamp(1.05rem, 1.5vw, 1.3rem);
  color: var(--bone-dim);
  max-width: 620px;
  margin-bottom: 3rem;
  font-weight: 400;
  line-height: 1.7;
  opacity: 0;
}
html.splash-done .hero-sub { animation: fadeUp 0.8s 1s forwards; }

.hero-cta {
  display: flex;
  gap: 1.2rem;
  flex-wrap: wrap;
  opacity: 0;
}
html.splash-done .hero-cta { animation: fadeUp 0.8s 1.2s forwards; }

.btn {
  display: inline-flex;
  align-items: center;
  gap: 0.7rem;
  padding: 1rem 2rem;
  font-family: var(--font-mono);
  font-size: 0.82rem;
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.15em;
  text-decoration: none;
  border-radius: 50px;
  transition: all 0.4s ease;
  cursor: pointer;
  border: 1px solid rgba(255,255,255,0.1);
  position: relative;
  overflow: hidden;
  backdrop-filter: blur(10px) saturate(150%);
}

.btn-primary {
  background: rgba(255, 255, 255, 0.15);
  color: var(--bone);
  border-top: 1px solid rgba(255,255,255,0.3);
  box-shadow: 0 4px 15px rgba(0,0,0,0.2);
}

.btn-primary:hover {
  background: rgba(255, 255, 255, 0.25);
  box-shadow: 0 10px 40px rgba(0, 212, 255, 0.4);
  transform: translateY(-2px);
}

.btn-secondary {
  background: rgba(255, 255, 255, 0.05);
  color: var(--bone);
  border-color: rgba(255,255,255,0.08);
}

.btn-secondary:hover {
  border-color: rgba(56, 224, 255, 0.5);
  color: var(--accent);
  background: rgba(56, 224, 255, 0.1);
  box-shadow: 0 0 20px rgba(56, 224, 255, 0.2);
  transform: translateY(-2px);
}

.btn .arrow {
  transition: transform 0.3s ease;
}

.btn.is-dragging {
  transition: transform 0.1s ease, background 0.3s ease, border-color 0.3s ease, color 0.3s ease, box-shadow 0.3s ease;
}

.btn:hover .arrow { transform: translateX(4px); }

/* Scroll indicator */
.scroll-hint {
  position: absolute;
  bottom: 2.5rem;
  left: 50%;
  transform: translateX(-50%);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.6rem;
  opacity: 0;
  cursor: pointer;
  user-select: none;
}
html.splash-done .scroll-hint { animation: fadeUp 0.8s 1.5s forwards; }

.scroll-hint-label {
  font-family: var(--font-mono);
  font-size: 0.72rem;
  text-transform: uppercase;
  letter-spacing: 0.3em;
  color: var(--bone-dim);
}

.scroll-hint-arrow {
  width: 14px;
  height: 22px;
  color: var(--accent);
  filter: drop-shadow(0 0 6px rgba(56, 224, 255, 0.5));
  animation: scrollBounce 1.8s ease-in-out infinite;
}

/* ============ SECTIONS ============ */
section {
  padding: 8rem 3rem;
  position: relative;
  z-index: 2;
}

.section-inner {
  max-width: 1200px;
  margin: 0 auto;
}

.section-label {
  font-family: var(--font-mono);
  font-size: 0.78rem;
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.25em;
  color: var(--accent);
  margin-bottom: 1.5rem;
  display: flex;
  align-items: center;
  gap: 1rem;
}

.section-label::before {
  content: '';
  width: 30px;
  height: 1px;
  background: var(--accent);
}

.section-title {
  font-family: var(--font-display);
  font-size: clamp(2.2rem, 5vw, 4rem);
  font-weight: 400;
  line-height: 1.05;
  letter-spacing: -0.02em;
  margin-bottom: 1.5rem;
  font-variation-settings: "opsz" 144;
  max-width: 800px;
  color: var(--bone);
}

.section-title em {
  font-style: italic;
  color: var(--accent);
}

.section-intro {
  font-size: 1.15rem;
  color: var(--bone-dim);
  max-width: 620px;
  margin-bottom: 5rem;
  font-weight: 400;
  line-height: 1.7;
}

/* Reveal on scroll */
.reveal {
  opacity: 0;
  transform: translateY(40px);
  transition: opacity 1s cubic-bezier(0.16, 1, 0.3, 1), transform 1s cubic-bezier(0.16, 1, 0.3, 1);
}

.reveal.visible {
  opacity: 1;
  transform: translateY(0);
}

/* Staggered reveal groups — a `.reveal-group` container (e.g. a grid, or a
   label/title/intro block) turns its direct children into a one-after-
   another entrance instead of the flat all-at-once fade `.reveal` gives a
   single block. common.js assigns each child the `.reveal` class (so it
   still uses the fade+rise transition and IntersectionObserver above — this
   re-triggers correctly for below-the-fold content) plus a `--reveal-index`
   custom property, which this rule turns into an incremental transition
   delay. Same 1s cubic-bezier(0.16, 1, 0.3, 1) as `.reveal` itself, so it
   reads as the same motion language as the hero's slideUp/fadeUp — just
   applied per-item instead of per-block. Capped with min() so grids with
   many items don't end up with a multi-second tail. */
.reveal-group > .reveal {
  transition-delay: min(calc(var(--reveal-index, 0) * 0.1s), 0.6s);
}

/* ============ SERVICES ============ */
.services {
  background: transparent;
  border: none;
}

.services-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
  gap: 2rem;
}

.service-card {
  background: var(--glass-bg);
  backdrop-filter: blur(10px) saturate(150%);
  border: 1px solid var(--glass-border);
  border-top: 1px solid var(--glass-border-top);
  box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.2);
  padding: 3rem 2.5rem;
  position: relative;
  transition: all 0.5s ease;
  cursor: pointer;
  overflow: hidden;
  border-radius: 16px;
  contain: layout paint style;
}

.service-card::before {
  content: '';
  position: absolute;
  inset: 0;
  background: radial-gradient(circle at var(--mx, 50%) var(--my, 50%), var(--accent-faint), transparent 50%);
  opacity: 0;
  transition: opacity 0.4s ease;
  pointer-events: none;
}

.service-card:hover::before { opacity: 1; }

.service-card:hover {
  background: var(--glass-bg);
  border-color: var(--accent);
  transform: translateY(-5px);
  box-shadow: 0 0 25px var(--accent-dim), 0 8px 32px rgba(0, 0, 0, 0.25);
}

.service-num {
  font-family: var(--font-mono);
  font-size: 0.75rem;
  color: var(--bone-faint);
  letter-spacing: 0.2em;
  margin-bottom: 2.5rem;
  display: flex;
  align-items: center;
  gap: 0.5rem;
}

.service-icon {
  width: 48px;
  height: 48px;
  margin-bottom: 1.8rem;
  color: var(--accent);
  transition: transform 0.4s ease;
}

.service-card:hover .service-icon {
  transform: scale(1.1) rotate(-5deg);
}

.service-name {
  font-family: var(--font-display);
  font-size: 1.6rem;
  font-weight: 400;
  margin-bottom: 0.8rem;
  letter-spacing: -0.01em;
}

.service-desc {
  color: var(--bone-dim);
  font-size: 1rem;
  font-weight: 400;
  line-height: 1.7;
  margin-bottom: 1.5rem;
}

.service-tags {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
}

.service-tag {
  font-family: var(--font-mono);
  font-size: 0.72rem;
  font-weight: 500;
  padding: 0.35rem 0.75rem;
  border: 1px solid var(--line);
  border-radius: 2px;
  color: var(--bone-dim);
  text-transform: uppercase;
  letter-spacing: 0.1em;
  transition: border-color 0.2s ease, color 0.2s ease;
}

.service-tag:hover {
  border-color: var(--accent-dim);
  color: var(--accent);
}

/* ============ WHY ONYXX ============ */
.why {
  position: relative;
}

.why-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
  gap: 3rem;
  margin-top: 4rem;
}

.why-item {
  position: relative;
  padding-left: 2rem;
  border-left: 1px solid var(--line);
  /* opacity/transform are here (not just border-left-color) because this
     element also gets `.reveal` when it's a .reveal-group child (why.html) —
     a `transition:` shorthand completely replaces any other rule's
     transition-property list of equal specificity, so without these two
     .reveal's fade+rise would silently never fire and the item would just
     snap into view instead of animating. */
  transition: border-left-color 0.3s ease, opacity 1s cubic-bezier(0.16, 1, 0.3, 1), transform 1s cubic-bezier(0.16, 1, 0.3, 1);
}

.why-item:hover {
  border-left-color: var(--accent-dim);
}

.why-item::before {
  content: '';
  position: absolute;
  left: -1px;
  top: 0;
  width: 1px;
  height: 40px;
  background: var(--accent);
}

.why-num {
  font-family: var(--font-mono);
  font-size: 0.75rem;
  color: var(--accent);
  letter-spacing: 0.2em;
  margin-bottom: 1rem;
}

.why-title {
  font-family: var(--font-display);
  font-size: 1.5rem;
  font-weight: 400;
  margin-bottom: 1rem;
  letter-spacing: -0.01em;
}

.why-desc {
  color: var(--bone-dim);
  font-size: 1rem;
  font-weight: 400;
  line-height: 1.7;
}

/* ============ WORK ============ */
.work {
  background: transparent;
  border: none;
}

/* The `.work-list` / `.work-item` / `.work-num` / `.work-title` / `.work-meta` /
   `.work-stack` block that used to live here was a leftover from the first
   multi-page era — a row-list layout for the Work page that was replaced by
   `.projects-grid` and matched no markup in any file. Removed rather than
   carried forward, so it can't collide with the new work.html. */

/* ============ PROCESS ============ */
.process-steps {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  gap: 2rem;
  margin-top: 4rem;
  position: relative;
}

.process-steps::before {
  content: '';
  position: absolute;
  top: 30px;
  left: 0;
  right: 0;
  height: 1px;
  background: linear-gradient(90deg, transparent, var(--line) 10%, var(--line) 90%, transparent);
  z-index: 0;
}

.process-step {
  position: relative;
  z-index: 1;
}

.process-dot {
  width: 60px;
  height: 60px;
  border-radius: 50%;
  background: var(--onyx);
  border: 1px solid var(--line);
  display: flex;
  align-items: center;
  justify-content: center;
  font-family: var(--font-mono);
  font-size: 0.8rem;
  color: var(--accent);
  margin-bottom: 1.5rem;
  position: relative;
}

.process-dot::after {
  content: '';
  position: absolute;
  inset: -6px;
  border: 1px solid var(--accent-faint);
  border-radius: 50%;
  opacity: 0;
  transition: opacity 0.3s ease;
}

.process-step:hover .process-dot::after { opacity: 1; }

.process-name {
  font-family: var(--font-display);
  font-size: 1.3rem;
  font-weight: 400;
  margin-bottom: 0.5rem;
}

.process-desc {
  color: var(--bone-dim);
  font-size: 0.95rem;
  font-weight: 400;
  line-height: 1.65;
}

/* ============ FOUNDERS ============ */
.founders {
  background: transparent;
  border: none;
}

.founders-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
  gap: 2rem;
  margin-top: 4rem;
}

.founder-card {
  background: rgba(255, 255, 255, 0.03);
  backdrop-filter: blur(10px) saturate(150%);
  border: 1px solid rgba(255, 255, 255, 0.08);
  border-top: 1px solid rgba(255, 255, 255, 0.15);
  box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.2);
  padding: 3rem 2.5rem;
  transition: all 0.5s ease;
  position: relative;
  overflow: hidden;
  border-radius: 16px;
  contain: layout paint style;
}

.founder-card::before {
  content: '';
  position: absolute;
  top: 0;
  left: 0;
  width: 0;
  height: 1px;
  background: var(--accent);
  transition: width 0.5s ease;
}

.founder-card:hover::before { width: 100%; }

.founder-card:hover {
  background: rgba(255, 255, 255, 0.06);
  transform: translateY(-5px);
  box-shadow: 0 12px 40px 0 rgba(0, 0, 0, 0.4);
}

.founder-avatar {
  width: 130px;
  height: 130px;
  border-radius: 50%;
  overflow: hidden;
  margin-bottom: 1.5rem;
  border: 1px solid var(--accent-dim);
}

.founder-avatar img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

.founder-name {
  font-family: var(--font-display);
  font-size: 1.8rem;
  font-weight: 400;
  letter-spacing: -0.01em;
  margin-bottom: 0.3rem;
}

.founder-role {
  font-family: var(--font-mono);
  font-size: 0.75rem;
  text-transform: uppercase;
  letter-spacing: 0.2em;
  color: var(--accent);
  margin-bottom: 1.5rem;
}

.founder-contact {
  display: flex;
  flex-direction: column;
  gap: 0.7rem;
  font-family: var(--font-mono);
  font-size: 0.9rem;
  font-weight: 400;
  color: var(--bone-dim);
}

.founder-contact a {
  color: var(--bone);
  text-decoration: none;
  transition: color 0.3s ease;
  display: flex;
  align-items: center;
  gap: 0.6rem;
}

.founder-contact a:hover { color: var(--accent); }

.founder-contact svg {
  width: 14px;
  height: 14px;
  flex-shrink: 0;
}

/* ============ CONTACT ============ */
.contact {
  text-align: center;
  padding: 10rem 3rem;
  position: relative;
}

.contact-inner {
  max-width: 800px;
  margin: 0 auto;
}

.contact-title {
  font-family: var(--font-display);
  font-size: clamp(2.5rem, 6vw, 5rem);
  font-weight: 400;
  line-height: 1;
  letter-spacing: -0.03em;
  margin-bottom: 2rem;
  font-variation-settings: "opsz" 144;
  color: var(--bone);
}

.contact-title em {
  font-style: italic;
  font-weight: 500;
  color: var(--accent);
  text-shadow: 0 0 40px rgba(56, 224, 255, 0.4);
}

.contact-sub {
  color: var(--bone-dim);
  font-size: 1.15rem;
  font-weight: 400;
  line-height: 1.7;
  margin-bottom: 3rem;
  max-width: 520px;
  margin-left: auto;
  margin-right: auto;
}

.contact-email {
  display: inline-block;
  font-family: var(--font-display);
  font-size: clamp(1.4rem, 3vw, 2.2rem);
  font-style: italic;
  color: var(--accent);
  text-decoration: none;
  position: relative;
  margin-bottom: 3rem;
  /* transform stays a quick 0.3s ease (the hover-lift feel); opacity gets
     its own entry so .reveal's fade still fires on this element — see the
     .why-item comment above for why omitting it silently breaks the reveal
     animation instead of just falling back to it. */
  transition: transform 0.3s ease, opacity 1s cubic-bezier(0.16, 1, 0.3, 1);
}

.contact-email::after {
  content: '';
  position: absolute;
  bottom: -4px;
  left: 0;
  width: 100%;
  height: 1px;
  background: var(--accent);
  transform: scaleX(0.3);
  transform-origin: left;
  transition: transform 0.4s ease;
}

.contact-email:hover { transform: translateY(-2px); }
.contact-email:hover::after { transform: scaleX(1); }

.contact-channels {
  display: flex;
  gap: 1rem;
  justify-content: center;
  flex-wrap: wrap;
}

/* ============ FOOTER ============ */
/* Real structure rather than two centred lines: with seven pages the footer is
   also the site's secondary navigation, so it carries the full page list and
   both direct contacts. */
footer {
  position: relative;
  z-index: 2;
  border-top: 1px solid var(--line);
  padding: 4rem 3rem 2.5rem;
  font-family: var(--font-mono);
  font-size: 0.78rem;
  font-weight: 500;
  color: var(--bone-dim);
  letter-spacing: 0.15em;
  text-transform: uppercase;
}

.footer-inner {
  max-width: 1200px;
  margin: 0 auto;
  display: grid;
  grid-template-columns: 1.6fr 1fr 1fr;
  gap: 3rem;
  align-items: start;
}

.footer-brand-mark {
  height: 28px;
  width: auto;
  display: block;
  margin-bottom: 1.2rem;
}

:root[data-theme="light"] .footer-brand-mark {
  filter: invert(1) brightness(0);
}

.footer-tagline {
  text-transform: none;
  letter-spacing: 0.02em;
  font-family: var(--font-body);
  font-size: 0.92rem;
  line-height: 1.7;
  color: var(--bone-dim);
  max-width: 30ch;
}

.footer-col-title {
  color: var(--bone);
  letter-spacing: 0.22em;
  font-size: 0.68rem;
  margin-bottom: 1.1rem;
}

.footer-links {
  list-style: none;
  display: flex;
  flex-direction: column;
  gap: 0.7rem;
}

.footer-links a,
.footer-contact a {
  text-transform: none;
  letter-spacing: 0.02em;
  font-family: var(--font-body);
  font-size: 0.92rem;
}

.footer-contact {
  display: flex;
  flex-direction: column;
  gap: 0.7rem;
}

.footer-bottom {
  max-width: 1200px;
  margin: 3.5rem auto 0;
  padding-top: 1.8rem;
  border-top: 1px solid var(--line);
  display: flex;
  justify-content: space-between;
  align-items: center;
  flex-wrap: wrap;
  gap: 1rem;
  font-size: 0.72rem;
}

footer a {
  color: var(--bone-dim);
  text-decoration: none;
  transition: color 0.3s ease;
}

footer a:hover { color: var(--accent); }

@media (max-width: 768px) {
  footer { padding: 3rem 1.5rem 2rem; }
  .footer-inner {
    grid-template-columns: 1fr;
    gap: 2.4rem;
  }
  .footer-bottom {
    margin-top: 2.4rem;
    flex-direction: column;
    align-items: flex-start;
  }
}

/* ============ ANIMATIONS ============ */
@keyframes fadeUp {
  to { opacity: 1; transform: translateY(0); }
}

@keyframes slideUp {
  from { transform: translateY(110%); opacity: 0; }
  to { transform: translateY(0); opacity: 1; }
}

@keyframes scrollBounce {
  0%, 100% { transform: translateY(0); opacity: 1; }
  50% { transform: translateY(6px); opacity: 0.4; }
}

@keyframes pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.4; }
}

.pulse-dot {
  display: inline-block;
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--accent);
  animation: pulse 2s ease-in-out infinite;
  box-shadow: 0 0 10px var(--accent);
}

/* ============ RESPONSIVE ============ */
@media (max-width: 1024px) {
  .projects-grid {
    grid-template-columns: repeat(2, 1fr);
  }

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

@media (max-width: 768px) {
  .nav-container { width: 92%; top: 0.8rem; }
  nav { padding: 0.5rem 0.8rem; gap: 0.75rem; position: relative; }
  /* The Contact pill doesn't fit the mobile bar, so it moves into the drawer
     instead of disappearing. It used to be reachable by scrolling to #contact;
     now that Contact is its own page, dropping the pill without a replacement
     would leave it with no mobile entry point in the nav at all. */
  .nav-email { display: none; }
  .nav-link-contact { display: list-item; }
  .nav-toggle { display: flex; }

  .nav-links {
    display: flex;
    position: absolute;
    top: calc(100% + 10px);
    left: 0;
    right: 0;
    flex-direction: column;
    background: var(--onyx-soft);
    padding: 1.25rem;
    border-radius: 16px;
    border: 1px solid var(--glass-border);
    backdrop-filter: blur(24px);
    -webkit-backdrop-filter: blur(24px);
    box-shadow: 0 20px 40px rgba(0, 0, 0, 0.4);
    gap: 0.5rem;
    opacity: 0;
    transform: translateY(-10px) scale(0.98);
    pointer-events: none;
    /* Same as the modal overlay: without this the CLOSED drawer keeps its six
       links in the tab order, so a mobile keyboard user traverses six
       invisible stops before reaching the page content. */
    visibility: hidden;
    transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.3s ease, visibility 0.3s, background 0.3s ease, border-color 0.3s ease;
    z-index: 1000;
  }

  .nav-links.open {
    opacity: 1;
    transform: translateY(0) scale(1);
    pointer-events: auto;
    visibility: visible;
  }

  .nav-links a {
    color: var(--bone);
    font-size: 1rem;
    font-weight: 400;
    padding: 0.75rem 1rem;
    border-radius: 8px;
    transition: all 0.2s ease;
    display: block;
    width: 100%;
  }

  .nav-links a:hover,
  .nav-links a[aria-current="page"] {
    color: var(--accent);
    background: var(--accent-faint);
  }

  /* In the drawer each link is a full-width block, so the centred hairline
     underline reads as a stray rule. The tinted background carries the state. */
  .nav-links a::after { display: none; }

  :root[data-theme="light"] .nav-links {
    background: rgba(255, 255, 255, 0.96);
    border: 1px solid rgba(0, 0, 0, 0.12);
    box-shadow: 0 16px 40px rgba(0, 0, 0, 0.15);
  }

  :root[data-theme="light"] .nav-links a {
    color: #1d1d1f;
  }

  :root[data-theme="light"] .nav-links a:hover,
  :root[data-theme="light"] .nav-links a[aria-current="page"] {
    color: var(--accent);
    background: rgba(0, 102, 204, 0.08);
  }

  .hero { padding: 7rem 1.5rem 4rem; }
  .hero-logo { max-width: 240px; width: 70%; margin-bottom: 1.5rem; }
  .hero-headline { font-size: clamp(2rem, 9vw, 2.6rem); }
  /* Block-level grid so the rotator drops to its own line — the longest phrase
     plus "for " does not fit beside it on a narrow screen. */
  .hero-rotator { display: grid; }
  .hero-sub { font-size: 0.95rem; line-height: 1.6; margin-bottom: 2rem; }
  .hero-cta { flex-direction: column; align-items: stretch; gap: 0.75rem; width: 100%; }
  .btn { width: 100%; justify-content: center; text-align: center; }

  .nav-logo-img { height: 20px; }

  section { padding: 4.5rem 1.5rem; }
  .contact { padding: 5rem 1.5rem; }

  .founders-grid { grid-template-columns: 1fr; gap: 1.5rem; }

  .founder-card { padding: 2rem 1.5rem; }
  .founder-avatar { width: 96px; height: 96px; }
  .founder-name { font-size: 1.4rem; }
  .founder-contact { font-size: 0.8rem; }
  .founder-contact a { width: 100%; justify-content: center; }

  .process-steps::before { display: none; }
  .process-steps { grid-template-columns: 1fr; gap: 1.5rem; }
  .process-dot { width: 44px; height: 44px; font-size: 0.75rem; }

  footer {
    flex-direction: column;
    text-align: center;
    padding: 2rem 1.5rem;
    gap: 1rem;
  }

  .cursor-glow { display: none; }
}

@media (max-width: 390px) {
  .hero-headline { font-size: 1.85rem; }
  .hero-logo { max-width: 200px; width: 65%; }
}

/* Touch active feedback for mobile UI/UX */
@media (hover: none) and (pointer: coarse) {
  .btn:active,
  .service-card:active,
  .project-card:active,
  .founder-card:active,
  .theme-toggle:active,
  .nav-toggle:active {
    transform: scale(0.97);
    transition: transform 0.1s ease;
  }
}

/* Reduce motion */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
    /* Also zero out delays, not just durations: without this, the
       `.reveal-group` stagger above (and the hero's per-line animation-delay)
       would still wait out their staggered delay before snapping into place
       near-instantly — visible sequencing, which reduced-motion users are
       asking us not to do. Zeroing the delay means every reveal-group child
       (and every hero element) appears together, immediately. */
    transition-delay: 0ms !important;
    animation-delay: 0ms !important;
  }

  /* The masked line reveal starts at translateY(110%) — entirely outside its
     clipped box. Zeroing the duration is not enough here: if the animation
     never gets applied at all (`.lines-in` not yet added, or `.splash-done`
     late) the text would sit permanently hidden. Force the resting state. */
  .lines .line > span {
    animation: none !important;
    transform: none !important;
  }
}

/* ============ PAGE TRANSITIONS ============ */
/* Cross-fades between pages via the View Transitions API's cross-document
   navigation. No JS: browsers that support it (Chromium-based, as of 2026)
   animate the swap; browsers that don't just keep the current instant
   navigation — there's no unsupported/broken state, only enhanced or not. */
@view-transition {
  navigation: auto;
}

::view-transition-old(root),
::view-transition-new(root) {
  animation-duration: 0.35s;
  animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
}

/* The nav logo is the one element that genuinely persists between pages, so it
   gets its own transition name and is morphed rather than cross-faded with
   everything else. Cheap, and it makes navigation feel like one continuous
   surface instead of seven separate documents. */
.nav-logo-img {
  view-transition-name: onyx-logo;
}

/* The universal-selector reduced-motion rule above doesn't reach these
   browser-generated pseudo-elements, so they need their own override. */
@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation-duration: 0.01ms !important;
  }
}

/* ============ PAGE HEADER (interior pages) ============ */
/* The old multi-page version dropped straight into a section with no title
   treatment, which is the main reason those pages read as thin. Every interior
   page now opens with the same three-part header — eyebrow, masked per-line
   title, intro — so the homepage's text motion is the site's language rather
   than a homepage trick. */
.page-header {
  position: relative;
  z-index: 2;
  padding: 11rem 3rem 4rem;
  max-width: 1200px;
  margin: 0 auto;
  width: 100%;
}

.page-header .section-label { margin-bottom: 1.6rem; }

.page-title {
  font-family: var(--font-display);
  font-size: clamp(2.4rem, 6vw, 4.6rem);
  font-weight: 300;
  line-height: 1.12;
  letter-spacing: -0.02em;
  color: var(--bone);
  margin-bottom: 1.6rem;
}

.page-title em {
  font-style: italic;
  font-weight: 400;
  color: var(--accent);
  text-shadow: 0 0 25px var(--accent-dim);
}

.page-intro {
  font-size: clamp(1.02rem, 1.4vw, 1.25rem);
  color: var(--bone-dim);
  max-width: 640px;
  line-height: 1.7;
}

/* Interior pages have a header instead of a full-viewport hero, so their first
   section shouldn't repeat the hero's top padding. */
.page-header + main .section-inner,
.page-body .section-inner { padding-top: 2rem; }

@media (max-width: 768px) {
  .page-header { padding: 8.5rem 1.5rem 2.5rem; }
}

/* ============ SECTION FOOT LINK ============ */
/* "See all work →" under a teaser grid on the homepage. */
.section-more {
  display: flex;
  justify-content: center;
  margin-top: 3.5rem;
}

/* ============ HOME CTA BAND ============ */
.home-cta {
  position: relative;
  z-index: 2;
  text-align: center;
  padding: 7rem 2rem 8rem;
}

.home-cta .contact-title { margin-bottom: 1.2rem; }

.home-cta-actions {
  display: flex;
  gap: 1.2rem;
  justify-content: center;
  flex-wrap: wrap;
  margin-top: 2.4rem;
}

/* ============ SPLASH SCREEN ============ */
/* A first-paint curtain built out of the brand mark, over the site's own sky.

   The OX monogram is a circuit-trace glyph — an O and an X wired together
   through six node vias — so the splash treats loading as the mark powering
   up: the vias ignite, a bright head runs the outline like current finding a
   route, the silhouette floods in, and the curtain lifts.

   The plate is the hero's galaxy, painted on the splash's own canvas by the
   controller in index.html with the spiral core at the hero's exact 78%/32%.
   That is the point: when the curtain goes, the star field behind it is
   already in the same place, so the load resolves into the page instead of
   cutting to it.

   Geometry is inline SVG in index.html, traced from
   images/onyxx_logo_transparent.png and partitioned into three subpath groups
   (traces / ring / cross) so the draw-on can be staggered. Every group
   carries pathLength="100", so each dash figure below is a percentage of that
   group's own outline — nothing here depends on measured lengths.

   Timing lives in this file; the controller owns only the lifecycle. The
   entrance fully resolves at ~2.58s, which is what MIN_MS (2700) there is
   protecting — change one and re-check the other. Every duration and delay in
   this block is one 1.6x-scaled vocabulary; retiming means scaling all of them
   together, not nudging one.

   There is no progress bar by design. The galaxy is live, so the wait has
   motion without a rail claiming byte-level progress a static page cannot
   honestly measure. */
/* Repeat visits within a session skip the curtain entirely. index.html's <head>
   decides and stamps `data-splash` before first paint, so the splash is never
   briefly visible on a page that isn't going to show it. A 2.7s brand moment is
   right once per session; on every navigation it would be a toll booth. */
:root[data-splash="skip"] #splashScreen { display: none !important; }

#splashScreen {
  position: fixed;
  inset: 0;
  z-index: 99999;
  background-color: var(--onyx-deep);
  /* A base wash under the canvas, so the very first frame — before the galaxy
     has painted once — is already the right sky rather than flat black. */
  background-image:
    radial-gradient(circle 620px at 4% 2%, rgba(56, 224, 255, 0.10), transparent 68%),
    radial-gradient(circle 720px at 98% 100%, rgba(138, 43, 226, 0.12), transparent 70%);
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
  /* Deliberately NOT faded in. This is a curtain over content that is already
     in the DOM behind it, so any entrance opacity below 1 shows the hero
     bleeding through on the first frames. It is opaque from first paint and
     only ever animates on the way out. */
  transition:
    opacity 0.72s var(--ease-out-expo),
    transform 0.72s var(--ease-out-expo),
    filter 0.72s ease,
    visibility 0.72s ease;
}

:root[data-theme="light"] #splashScreen {
  background-color: #ffffff;
  background-image:
    radial-gradient(circle 620px at 4% 2%, rgba(0, 102, 204, 0.10), transparent 68%),
    radial-gradient(circle 720px at 98% 100%, rgba(124, 58, 237, 0.07), transparent 70%);
}

.splash-galaxy {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
  /* 0.85 to match .circuit-canvas exactly. The hero's galaxy sits at 0.85, so
     any other value here would make the star field visibly change brightness
     at the moment the curtain lifts — the one seam this design exists to
     avoid. */
  opacity: 0;
  /* Fast, and effectively undelayed. The galaxy is the backdrop, not a reveal —
     at the slower entrance pace a long fade left the first half-second looking
     like an empty black plate before anything arrived. */
  animation: splashGalaxyIn 0.9s var(--ease-out-soft) both;
}

@keyframes splashGalaxyIn {
  to { opacity: 0.85; }
}

/* The galaxy is a dark-sky object. On the light theme it would be a scatter of
   grey specks on white, which reads as dirt, so the plate stays clean. */
:root[data-theme="light"] .splash-galaxy { display: none; }

/* Soft scrim centred on the lockup: keeps the wordmark and tagline crisp over
   a field of stars without darkening the corners where the galaxy lives.
   Wide and gently stepped on purpose — a tighter falloff renders as a
   visible dark oval floating over the sky instead of as depth. */
.splash-vignette {
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: radial-gradient(ellipse 1120px 800px at 50% 50%,
    rgba(5, 5, 5, 0.66) 0%,
    rgba(5, 5, 5, 0.5) 30%,
    rgba(5, 5, 5, 0.26) 55%,
    rgba(5, 5, 5, 0.08) 75%,
    transparent 92%);
}

:root[data-theme="light"] .splash-vignette { display: none; }

/* Not an even three-item stack: the wordmark and tagline belong together as
   one lockup, so they sit close and the mark gets the larger interval above
   them. A uniform gap made the tagline read as a third, unrelated line. */
.splash-content {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0;
  text-align: center;
  position: relative;
  z-index: 2;
  padding: 2rem;
  transition: opacity 0.34s ease, transform 0.46s var(--ease-out-expo);
}

.splash-word { margin-top: 2.3rem; }
.splash-tagline { margin-top: 1.05rem; }

/* ---- The mark ---- */
.splash-mark {
  position: relative;
  width: 210px;
  height: 210px;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: transform 0.5s var(--ease-spring), filter 0.5s ease;
}

.splash-ox {
  position: relative;
  z-index: 1;
  width: 100%;
  height: 100%;
  overflow: visible;
}

/* Tight halo seating the mark against the star field. */
.splash-halo {
  position: absolute;
  inset: -78%;
  z-index: 0;
  border-radius: 50%;
  background: radial-gradient(circle, var(--accent-faint) 0%, transparent 62%);
  pointer-events: none;
  animation: splashHalo 4.6s ease-in-out 0.9s infinite alternate;
}

@keyframes splashHalo {
  from { opacity: 0.5; transform: scale(0.94); }
  to   { opacity: 0.9; transform: scale(1.05); }
}

/* Two rings pinging outward, timed with the via ignition so the power-up has
   a pulse the eye can follow out to the edges. Kept faint on purpose: at full
   accent it reads as a hard drawn circle competing with the mark. */
.splash-ping {
  position: absolute;
  inset: 6%;
  z-index: 0;
  border-radius: 50%;
  border: 1px solid var(--accent-dim);
  opacity: 0;
  pointer-events: none;
  animation: splashPing 3.8s var(--ease-out-soft) infinite;
}
.splash-ping.p2 { animation-delay: 1.9s; }

@keyframes splashPing {
  0%   { transform: scale(0.72); opacity: 0; }
  12%  { opacity: 0.26; }
  100% { transform: scale(1.85); opacity: 0; }
}

/* Fires once, on exit. Sits under the mark so it flares out from behind the
   glyph instead of washing it out. */
.splash-bloom {
  position: absolute;
  inset: 12%;
  z-index: 0;
  border-radius: 50%;
  background: radial-gradient(circle, rgba(56, 224, 255, 0.5) 0%, transparent 64%);
  opacity: 0;
  transform: scale(0.5);
  pointer-events: none;
  transition: opacity 0.55s ease, transform 0.65s var(--ease-out-expo);
}

:root[data-theme="light"] .splash-bloom {
  background: radial-gradient(circle, rgba(0, 102, 204, 0.32) 0%, transparent 64%);
}

/* 1 — the route being laid down. Dash 100 with pathLength="100" means one dash
   spanning the whole group; the offset walks it on. */
.ox-draws path {
  fill: none;
  stroke: var(--accent);
  stroke-width: 0.85;
  stroke-linecap: round;
  stroke-linejoin: round;
  stroke-dasharray: 100;
  stroke-dashoffset: 100;
  opacity: 0.9;
  animation:
    oxDraw var(--dd, 0.84s) var(--ease-out-soft) var(--delay, 0s) forwards,
    oxDrawFade 0.58s ease 1.44s forwards;
}
.ox-draws .d1 { --delay: 0.32s; --dd: 0.84s; }
.ox-draws .d2 { --delay: 0.53s; --dd: 0.84s; }
.ox-draws .d3 { --delay: 0.69s; --dd: 0.80s; }

@keyframes oxDraw {
  to { stroke-dashoffset: 0; }
}

/* The wireframe doesn't vanish — it stays as a faint edge under the fill,
   which keeps the blueprint reading after the mark lands. */
@keyframes oxDrawFade {
  to { opacity: 0.22; }
}

/* 2 — a short bright dash riding ahead of the draw. One dash of 4% and a gap
   of 96% over a 100-unit path: the offset carries it exactly once along the
   whole outline. */
.ox-comets path {
  fill: none;
  stroke: #ffffff;
  stroke-width: 1.35;
  stroke-linecap: round;
  stroke-dasharray: 4 96;
  stroke-dashoffset: 100;
  opacity: 0;
  filter: drop-shadow(0 0 4px var(--accent));
  animation: oxComet var(--cd, 0.74s) linear var(--delay, 0s) forwards;
}
.ox-comets .c1 { --delay: 0.32s; --cd: 0.74s; }
.ox-comets .c2 { --delay: 0.53s; --cd: 0.74s; }
.ox-comets .c3 { --delay: 0.69s; --cd: 0.70s; }

@keyframes oxComet {
  0%   { stroke-dashoffset: 100; opacity: 0; }
  10%  { opacity: 1; }
  85%  { opacity: 1; }
  100% { stroke-dashoffset: 0; opacity: 0; }
}

/* 3 — the mark itself. --bone so it inverts with the theme, matching how the
   nav and hero logos are handled in light mode. */
.ox-solid path {
  fill: var(--bone);
  stroke: none;
  opacity: 0;
  transform-origin: 50% 50%;
  animation: oxSolid 0.58s var(--ease-out-soft) 1.38s forwards;
}

@keyframes oxSolid {
  from { opacity: 0; transform: scale(0.965); }
  to   { opacity: 1; transform: scale(1); }
}

/* 4 — current running through the silhouette. Loops, so a slow load (up to the
   4.5s cap) keeps a live mark rather than a frozen one. */
.ox-sweepwrap {
  opacity: 0;
  animation: splashFade 0.6s ease 2s forwards;
}

.ox-sweep {
  animation: oxSweep 3.2s var(--ease-out-soft) 2s infinite;
}

/* 130px carries the 24-unit band from just off the left edge to just past the
   right, so nearly the whole cycle is spent crossing the mark rather than
   travelling empty space beside it. */
@keyframes oxSweep {
  0%   { transform: translateX(0); }
  100% { transform: translateX(130px); }
}

/* 5 — node vias. These ignite before anything is drawn: the board gets power,
   then the traces route. Symmetric pairs share an --i so the mark stays
   balanced as it lights. */
.ox-vias circle {
  fill: var(--accent);
  opacity: 0;
  transform-origin: center;
  transform-box: fill-box;
  animation:
    oxVia 0.6s var(--ease-spring) calc(0.13s + var(--i, 0) * 0.105s) both,
    oxViaBreathe 3.4s ease-in-out 2.1s infinite alternate;
}

@keyframes oxVia {
  0%   { opacity: 0; transform: scale(0.2); }
  55%  { opacity: 1; transform: scale(1.5); }
  100% { opacity: 0.95; transform: scale(1); }
}

@keyframes oxViaBreathe {
  from { opacity: 0.6; }
  to   { opacity: 1; }
}

/* ---- Lockup ----
   Matches the banner: geometric sans, ONYXX heavy against TECH light. This is
   --font-body (Outfit) rather than the display serif on purpose — the brand's
   own wordmark is a sans, and setting it in Fraunces made the splash disagree
   with every other place the logo appears. */
.splash-word {
  display: flex;
  align-items: baseline;
  gap: 0.34em;
  font-family: var(--font-body);
  font-size: clamp(2.1rem, 6vw, 3rem);
  line-height: 1;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--bone);
  overflow: hidden;
}

.splash-word .sw-bold  { font-weight: 700; }
.splash-word .sw-light { font-weight: 300; letter-spacing: 0.1em; }

/* Per-character rise. index.html splits each [data-splitchars] into
   `<span class="splash-char" style="--i:n">`, indexing continuously across
   both words so the stagger reads as a single sweep. */
.splash-char {
  display: inline-block;
  white-space: pre;
  animation: splashChar 0.8s var(--ease-out-expo) both;
  animation-delay: calc(1.18s + var(--i, 0) * 0.052s);
}

@keyframes splashChar {
  0%   { transform: translateY(105%); opacity: 0; }
  100% { transform: translateY(0); opacity: 1; }
}

/* Muted rather than accent-coloured: the mark's cyan is the only accent on
   screen, which reads richer than three competing highlights. */
.splash-tagline {
  font-family: var(--font-mono);
  font-size: 0.8rem;
  font-weight: 400;
  /* 0.26em rather than 0.32em: at this size the wider tracking made the
     strapline measure broader than the wordmark above it, which reads as the
     supporting line overpowering the name. */
  letter-spacing: 0.26em;
  color: var(--bone-dim);
  text-transform: uppercase;
  text-indent: 0.26em;
  opacity: 0;
  animation: splashFadeUp 0.8s var(--ease-out-expo) 1.78s both;
}

@keyframes splashFadeUp {
  0%   { transform: translateY(10px); opacity: 0; }
  100% { transform: translateY(0); opacity: 0.92; }
}

@keyframes splashFade {
  to { opacity: 1; }
}

/* ---- Exit ---- */
/* Stage one: the mark flares. The controller adds .is-leaving 260ms before
   .fade-out, so the brand pushes forward and blooms while the plate is still
   opaque — then the curtain clears under it. */
#splashScreen.is-leaving .splash-mark {
  transform: scale(1.1);
  filter: brightness(1.35) drop-shadow(0 0 32px var(--accent));
}

#splashScreen.is-leaving .splash-bloom {
  opacity: 0.85;
  transform: scale(2);
}

#splashScreen.is-leaving .splash-ping { animation: none; opacity: 0; }

/* Stage two: the curtain. Scale and a light blur so it lifts away from the
   hero rather than cross-fading flat into it. */
#splashScreen.fade-out {
  opacity: 0;
  transform: scale(1.05);
  filter: blur(9px);
  visibility: hidden;
  pointer-events: none;
}

/* Content leaves fractionally ahead of the curtain. */
#splashScreen.fade-out .splash-content {
  opacity: 0;
  transform: translateY(-20px) scale(0.975);
}

@media (max-width: 600px) {
  .splash-mark { width: 168px; height: 168px; }
  .splash-content { padding: 1.5rem; }
  .splash-word { margin-top: 1.8rem; }
  .splash-tagline { margin-top: 0.9rem; }
  /* Tighter tracking and a smaller floor so the lockup and the tagline both
     stay on one line down to a 320px viewport. */
  .splash-word { font-size: clamp(1.5rem, 7.2vw, 2rem); letter-spacing: 0.04em; }
  .splash-word .sw-light { letter-spacing: 0.07em; }
  .splash-tagline { font-size: 0.58rem; letter-spacing: 0.17em; text-indent: 0.17em; }
  .splash-vignette {
    background: radial-gradient(ellipse 520px 460px at 50% 50%,
      rgba(5, 5, 5, 0.78) 0%, rgba(5, 5, 5, 0.5) 45%, transparent 78%);
  }
}

/* Reduced motion: keep the splash — it still marks the load — but drop the
   whole power-up. The mark is simply present, filled, at rest, over a static
   sky (the controller draws a single galaxy frame and never starts its rAF
   loop). The global reduced-motion rule collapses durations and delays; these
   turn the *looping* decoration off entirely and force the end state, which a
   duration override alone can't do — a 0.01ms oxDraw would still leave the
   stroke mid-dash on some engines. */
@media (prefers-reduced-motion: reduce) {
  .splash-halo,
  .splash-ping,
  .ox-sweep,
  .ox-sweepwrap,
  .ox-vias circle { animation: none !important; }

  .splash-ping { display: none; }
  .ox-draws,
  .ox-comets,
  .ox-sweepwrap { display: none; }

  .ox-vias circle { opacity: 0.95; }

  .ox-solid path {
    animation: none !important;
    opacity: 1;
    transform: none;
  }

  .splash-char,
  .splash-tagline,
  .splash-galaxy,
  .splash-halo {
    animation: none !important;
    opacity: 1;
    transform: none;
  }
  .splash-tagline { opacity: 0.92; }
  .splash-halo { opacity: 0.6; }
  .splash-galaxy { opacity: 0.85; }

  #splashScreen.is-leaving .splash-mark { transform: none; filter: none; }
  #splashScreen.is-leaving .splash-bloom { opacity: 0; }
}

/* ============ LOTTIE LOADER (shared) ============ */
/* One loading indicator for the whole site: every Supabase-backed grid, the
   project modal, form submits and the admin tool all render this same block
   via `window.lottieLoader()` in common.js.

   Not the splash. The splash animates the brand mark itself, and a second
   spinner next to a logo that is already visibly doing something read as two
   competing loaders.

   The <dotlottie-player> element comes from a CDN module, so it can fail or
   simply not have upgraded yet. `.lottie-fallback` is a pure-CSS ring shown
   until `documentElement` gets `.lottie-ready` (set by common.js from
   customElements.whenDefined) — so a blocked CDN degrades to a spinner
   instead of an empty hole. */
.lottie-loader {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 0.85rem;
  padding: 3rem 1rem;
  width: 100%;
  grid-column: 1 / -1;
  animation: loaderIn var(--dur-base) var(--ease-out-expo) both;
}

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

.lottie-loader.is-inline {
  flex-direction: row;
  padding: 0;
  width: auto;
  gap: 0.6rem;
  --loader-size: 22px;
}

.lottie-loader dotlottie-player {
  width: var(--loader-size, 84px);
  height: var(--loader-size, 84px);
  display: block;
}

.lottie-loader-label {
  font-family: var(--font-mono);
  font-size: 0.65rem;
  letter-spacing: 0.28em;
  text-transform: uppercase;
  color: var(--bone-faint);
  text-indent: 0.28em;
  animation: loaderLabelPulse 1.9s ease-in-out infinite;
}

@keyframes loaderLabelPulse {
  0%, 100% { opacity: 1; }
  50%      { opacity: 0.45; }
}

/* CSS fallback ring */
.lottie-fallback {
  width: var(--loader-size, 84px);
  height: var(--loader-size, 84px);
  border-radius: 50%;
  border: 2px solid var(--line);
  border-top-color: var(--accent);
  animation: lottieFallbackSpin 0.9s linear infinite;
}

@keyframes lottieFallbackSpin {
  to { transform: rotate(360deg); }
}

/* Exactly one of the two is ever visible. */
:root:not(.lottie-ready) .lottie-loader dotlottie-player { display: none; }
:root.lottie-ready .lottie-fallback { display: none; }

/* Full-surface overlay variant — used while the project modal loads. */
.lottie-overlay {
  position: absolute;
  inset: 0;
  z-index: 5;
  display: flex;
  align-items: center;
  justify-content: center;
  /* Plain rgba first so browsers without color-mix() still get a scrim. */
  background: rgba(10, 10, 10, 0.72);
  background: color-mix(in srgb, var(--onyx) 72%, transparent);
  backdrop-filter: blur(6px);
  border-radius: inherit;
  transition: opacity var(--dur-fast) ease;
}

.lottie-overlay.is-hidden {
  opacity: 0;
  pointer-events: none;
}

/* ============ PROJECT CARDS ============ */
.projects-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 2rem;
  margin-top: 3rem;
}

.project-card {
  background: var(--glass-bg);
  backdrop-filter: blur(10px) saturate(150%);
  border: 1px solid var(--glass-border);
  border-top: 1px solid var(--glass-border-top);
  box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.2);
  cursor: pointer;
  overflow: hidden;
  transition: all 0.5s ease;
  display: flex;
  flex-direction: column;
  border-radius: 16px;
  contain: layout paint style;
}

/* The homepage's teaser cards are real <a> elements so they work without JS and
   get the browser's own affordances. Without this they render with the UA's link
   styling — blue text, plus an underline that propagates into every descendant,
   so the title and the description both pick it up. `.project-card` already
   supplies display:flex, so only the text needs resetting. */
.project-card--link,
.project-card--link:visited {
  color: inherit;
  text-decoration: none;
}

/* /work's cards open a modal instead of navigating, so they are <button>s —
   the only element that is focusable and Enter/Space-activated for free. The UA
   button styling has to go: it would otherwise override the card's background
   and border, centre the text, and shrink the font to the system default.
   `inherit` on font and `left` on text-align are the two that actually bite. */
button.project-card {
  appearance: none;
  -webkit-appearance: none;
  background: none;
  border: 1px solid var(--glass-border);
  font: inherit;
  color: inherit;
  text-align: left;
  width: 100%;
  cursor: pointer;
}

/* The whole card is the control, so give keyboard focus a ring that follows the
   card's own 16px radius instead of the global 2px one. */
.project-card--link:focus-visible,
button.project-card:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 4px;
  border-radius: 16px;
}

.project-card:hover {
  background: var(--glass-bg);
  border-color: var(--accent);
  transform: translateY(-5px);
  box-shadow: 0 0 25px var(--accent-dim), 0 8px 32px rgba(0, 0, 0, 0.25);
}

.project-card.is-tilting { transition: transform 0.1s ease, background 0.4s ease; }

.project-thumb {
  position: relative;
  width: 100%;
  aspect-ratio: 16/9;
  overflow: hidden;
}

.project-thumb img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  transition: transform 0.5s ease;
}

.project-card:hover .project-thumb img { transform: scale(1.04); }

.project-thumb--contain img { object-fit: contain; }

.project-thumb-bg {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
}

.project-thumb-bg::after {
  content: '';
  position: absolute;
  inset: 0;
  background-image:
    linear-gradient(rgba(245,245,240,0.04) 1px, transparent 1px),
    linear-gradient(90deg, rgba(245,245,240,0.04) 1px, transparent 1px);
  background-size: 40px 40px;
}

.project-thumb-label {
  font-family: var(--font-mono);
  font-size: 0.7rem;
  letter-spacing: 0.3em;
  text-transform: uppercase;
  color: rgba(255,255,255,0.18);
  position: relative;
  z-index: 1;
}

.project-body {
  padding: 1.8rem 2rem;
  flex: 1;
  display: flex;
  flex-direction: column;
}

.project-category {
  font-family: var(--font-mono);
  font-size: 0.68rem;
  text-transform: uppercase;
  letter-spacing: 0.2em;
  color: var(--accent);
  margin-bottom: 0.5rem;
}

.project-name {
  font-family: var(--font-display);
  font-size: 1.35rem;
  font-weight: 400;
  letter-spacing: -0.01em;
  margin-bottom: 0.35rem;
  transition: color 0.3s ease;
  line-height: 1.2;
}

.project-card:hover .project-name { color: var(--accent); }

.project-client {
  font-family: var(--font-mono);
  font-size: 0.7rem;
  color: var(--bone-faint);
  margin-bottom: 0.9rem;
}

.project-desc {
  color: var(--bone-dim);
  font-size: 0.88rem;
  line-height: 1.65;
  margin-bottom: 1.25rem;
  flex: 1;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.project-footer {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.75rem;
  flex-wrap: wrap;
}

.project-tags {
  display: flex;
  flex-wrap: wrap;
  gap: 0.35rem;
}

.project-tag {
  font-family: var(--font-mono);
  font-size: 0.62rem;
  padding: 0.2rem 0.5rem;
  border: 1px solid var(--line);
  color: var(--bone-faint);
  text-transform: uppercase;
  letter-spacing: 0.08em;
}

.project-more {
  font-family: var(--font-mono);
  font-size: 0.7rem;
  color: var(--bone-faint);
  white-space: nowrap;
  transition: color 0.3s ease;
  flex-shrink: 0;
}

.project-card:hover .project-more { color: var(--accent); }

/* ============ MODAL ============ */
.modal-overlay {
  position: fixed;
  inset: 0;
  background: rgba(5,5,5,0.7);
  backdrop-filter: blur(12px);
  z-index: 300;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 2rem;
  opacity: 0;
  pointer-events: none;
  /* pointer-events stops the MOUSE, but the closed dialog stayed in the tab
     order: on /work, tab stop 13 landed on the invisible close button, then
     "View on GitHub", then "Start a Similar Project" — three focus stops with
     nothing on screen. visibility:hidden removes it from the tab order and
     from the accessibility tree, so the permanently-present
     role="dialog" aria-modal="true" stops being announced too.
     It still animates: visibility is transitionable to/from hidden. */
  visibility: hidden;
  transition: all 0.3s ease;
}

:root[data-theme="light"] .modal-overlay {
  background: rgba(0,0,0,0.3);
}

.modal-overlay.open {
  opacity: 1;
  pointer-events: all;
  visibility: visible;
}

.modal {
  background: var(--glass-bg);
  backdrop-filter: blur(24px) saturate(200%);
  border: 1px solid var(--glass-border);
  border-top: 1px solid var(--glass-border-top);
  box-shadow: 0 12px 64px 0 rgba(0, 0, 0, 0.25);
  border-radius: 20px;
  max-width: 680px;
  width: 100%;
  max-height: 88vh;
  overflow-y: auto;
  position: relative;
  transform: translateY(24px);
  transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1), background 0.3s ease, border-color 0.3s ease;
}

.modal-overlay.open .modal { transform: translateY(0); }

.modal-thumb {
  position: relative;
  width: 100%;
  aspect-ratio: 16/9;
  overflow: hidden;
}

.modal-thumb img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

.modal-thumb-bg {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
}

.modal-thumb-bg::after {
  content: '';
  position: absolute;
  inset: 0;
  background-image:
    linear-gradient(rgba(245,245,240,0.04) 1px, transparent 1px),
    linear-gradient(90deg, rgba(245,245,240,0.04) 1px, transparent 1px);
  background-size: 40px 40px;
}

.modal-thumb-label {
  font-family: var(--font-mono);
  font-size: 0.8rem;
  letter-spacing: 0.3em;
  text-transform: uppercase;
  color: rgba(255,255,255,0.15);
  position: relative;
  z-index: 1;
}

.modal-close {
  position: absolute;
  top: 1rem;
  right: 1rem;
  background: var(--onyx-soft);
  border: 1px solid var(--glass-border);
  color: var(--bone);
  width: 36px;
  height: 36px;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  font-family: var(--font-mono);
  font-size: 1.1rem;
  line-height: 1;
  border-radius: 50%;
  transition: all 0.2s ease;
  z-index: 10;
}

.modal-close:hover { border-color: var(--accent); color: var(--accent); }

.modal-body { padding: 2.5rem; }

.modal-category {
  font-family: var(--font-mono);
  font-size: 0.7rem;
  text-transform: uppercase;
  letter-spacing: 0.2em;
  color: var(--accent);
  margin-bottom: 0.5rem;
}

.modal-title {
  font-family: var(--font-display);
  font-size: 2rem;
  font-weight: 400;
  letter-spacing: -0.02em;
  margin-bottom: 0.4rem;
  color: var(--bone);
}

.modal-client {
  font-family: var(--font-mono);
  font-size: 0.75rem;
  color: var(--bone-faint);
  margin-bottom: 1.5rem;
}

.modal-desc {
  color: var(--bone-dim);
  font-size: 1rem;
  line-height: 1.75;
  margin-bottom: 2rem;
}

.modal-label {
  font-family: var(--font-mono);
  font-size: 0.7rem;
  text-transform: uppercase;
  letter-spacing: 0.2em;
  color: var(--bone-faint);
  margin-bottom: 0.75rem;
}

.modal-stack {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  margin-bottom: 2rem;
}

.modal-stack span {
  font-family: var(--font-mono);
  font-size: 0.7rem;
  padding: 0.3rem 0.75rem;
  border: 1px solid var(--accent-dim);
  color: var(--accent);
  text-transform: uppercase;
  letter-spacing: 0.1em;
}

.modal-actions { display: flex; gap: 1rem; flex-wrap: wrap; }

@media (max-width: 1024px) {
  .projects-grid { grid-template-columns: repeat(2, 1fr); }
}

@media (max-width: 768px) {
  .projects-grid { grid-template-columns: 1fr; }
  .modal-overlay { padding: 1rem; align-items: flex-end; }
  .modal { max-height: 92vh; }
  .modal-body { padding: 1.5rem; }
  .modal-title { font-size: 1.6rem; }
}

/* ==================================================================== */
/* ============ SITEWIDE MOTION LAYER ============                      */
/* Loaded last on purpose. Everything above defines what the site looks  */
/* like; this defines how it *moves* — one easing vocabulary, one hover  */
/* grammar, one set of entrance variants.                               */
/*                                                                      */
/* Two rules kept it from breaking anything above:                      */
/*  1. Never use `transition: all` or a bare `transition:` shorthand on  */
/*     an element that can also carry `.reveal` — the shorthand replaces */
/*     .reveal's opacity/transform entry and the element would snap in   */
/*     instead of fading. Longhands (`transition-timing-function`,       */
/*     `transition-duration`) are used instead where a whole element's   */
/*     feel is being retuned.                                           */
/*  2. Every hover effect is inside `@media (hover: hover)` so a tap on  */
/*     a phone doesn't leave a card stuck in its hover state.           */
/* ==================================================================== */

/* ---- Retune existing transitions to the shared easing ----
   These elements already had `transition: ... ease` declarations above.
   Overriding only the timing function and duration (never the property
   list) upgrades them to the site's easing without touching what they
   animate — so .reveal keeps working on the ones that also reveal. */
a,
button,
.btn,
.nav-links a,
.nav-email,
.nav-logo,
.nav-logo-img,
.theme-toggle,
.service-card,
.project-card,
.founder-card,
.why-item,
.process-step,
.contact-email,
.modal-close,
.service-tag,
.project-tag {
  transition-timing-function: var(--ease-out-expo);
}

/* Anything without its own transition still gets a sane default. */
a, button, input, textarea, select, summary {
  transition-property: color, background-color, border-color, box-shadow, opacity, transform;
  transition-duration: var(--dur-fast);
  transition-timing-function: var(--ease-out-expo);
}

/* Smooth in-page scrolling for the one-page nav. `scroll-padding-top`
   keeps section headings clear of the fixed pill nav on arrival. */
html {
  scroll-behavior: smooth;
  scroll-padding-top: 7rem;
}

@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
}

/* ---- Hover grammar ---- */
@media (hover: hover) and (pointer: fine) {

  /* Card surfaces: lift, glow, and warm the border. */
  .service-card:hover,
  .founder-card:hover {
    transform: translateY(var(--hover-lift));
    box-shadow: var(--hover-glow);
    border-color: var(--accent-dim);
  }

  /* Project cards get the same glow, but not the transform — their JS
     3D-tilt handler writes an inline `transform` that would override
     anything set here. The lift is folded into that tilt instead
     (see setupProjectCard in index.html). */
  .project-card:hover {
    box-shadow: var(--hover-glow);
    border-color: var(--accent-dim);
  }

  /* Thumbnails inside a hovered card push in slightly — the parallax
     between card and image is what sells the lift as depth. */
  .project-card .project-thumb img,
  .project-card .project-thumb-bg {
    transition: transform var(--dur-slow) var(--ease-out-expo);
  }
  .project-card:hover .project-thumb img,
  .project-card:hover .project-thumb-bg {
    transform: scale(1.06);
  }

  /* Service number + icon lead the card's motion by a hair. */
  .service-card .service-num,
  .service-card .service-icon {
    transition: transform var(--dur-base) var(--ease-spring), color var(--dur-fast) ease;
  }
  .service-card:hover .service-num { transform: translateX(4px); color: var(--accent); }
  .service-card:hover .service-icon { transform: translateY(-4px) scale(1.08); }

  /* Founder avatar: rise out of the card and pick up an accent ring. */
  .founder-avatar {
    transition: transform var(--dur-base) var(--ease-spring), box-shadow var(--dur-base) ease, filter var(--dur-base) ease;
  }
  .founder-card:hover .founder-avatar {
    transform: translateY(-6px) scale(1.05);
    box-shadow: 0 12px 30px -10px rgba(0, 0, 0, 0.5), 0 0 0 2px var(--accent-dim);
  }

  /* "Details →" on a project card leads the card's hover. The arrow is a
     literal character inside `.project-more`, not an `.arrow` span, so the
     whole label travels. */
  .project-more {
    display: inline-block;
    transition: transform var(--dur-base) var(--ease-spring), color var(--dur-fast) ease;
  }
  .project-card:hover .project-more {
    transform: translateX(5px);
    color: var(--accent);
  }

  /* Numbered process steps: the dot is the affordance. */
  .process-step .process-dot {
    transition: transform var(--dur-base) var(--ease-spring), box-shadow var(--dur-base) ease, background-color var(--dur-fast) ease;
  }
  .process-step:hover .process-dot {
    transform: scale(1.12);
    box-shadow: 0 0 22px var(--accent-dim);
  }

  /* Why items: the left rule fills in rather than just changing colour. */
  .why-item::before {
    content: '';
    position: absolute;
    left: -1px;
    top: 0;
    width: 2px;
    height: 100%;
    background: var(--accent);
    transform: scaleY(0);
    transform-origin: top;
    transition: transform var(--dur-base) var(--ease-out-expo);
  }
  .why-item { position: relative; }
  .why-item:hover::before { transform: scaleY(1); }

  /* The nav-link underline used to be redefined here, which silently overrode
     the canonical rule near `.nav-links a` (this block comes later in the file,
     so it won). It now lives in one place only, alongside the
     `[aria-current="page"]` state it shares geometry with. */

  /* Pill CTA in the nav lifts with the rest of the button family. */
  .nav-email:hover {
    transform: translateY(-2px);
    box-shadow: 0 8px 20px -8px rgba(0, 0, 0, 0.6);
  }

  .theme-toggle:hover { transform: rotate(15deg) scale(1.08); }

  /* Tags/chips: fill on hover. */
  .service-tag,
  .project-tag,
  .modal-stack span {
    transition: background-color var(--dur-fast) var(--ease-out-expo),
                color var(--dur-fast) var(--ease-out-expo),
                border-color var(--dur-fast) var(--ease-out-expo),
                transform var(--dur-fast) var(--ease-out-expo);
  }
  .service-tag:hover,
  .project-tag:hover,
  .modal-stack span:hover {
    background: var(--accent-faint);
    border-color: var(--accent);
    color: var(--accent);
    transform: translateY(-2px);
  }

  /* Arrows inside CTAs travel on hover. Scoped away from
     #btnToggleShowcase: its arrow is a ↓ whose transform is driven by JS
     (rotate(180deg) in "Show Less" state), and a sideways nudge would both
     read wrong on a vertical glyph and lose to that inline style anyway. */
  .btn .arrow {
    display: inline-block;
    transition: transform var(--dur-base) var(--ease-spring);
  }
  .btn:not(#btnToggleShowcase):hover .arrow {
    transform: translateX(6px);
  }

  .modal-close:hover { transform: rotate(90deg); color: var(--accent); }
}

/* ---- Press feedback ----
   `.btn` is deliberately absent: common.js's magnetic-button effect writes an
   inline `transform` while the cursor is over the button, and an inline style
   beats any rule here — so a `.btn:active` scale would simply never apply on
   a fine pointer. The touch-device `:active` block further up covers `.btn`
   on phones, where magnetism doesn't run at all. */
.nav-email:active,
.theme-toggle:active,
.modal-close:active {
  transform: scale(0.96);
  transition-duration: var(--dur-instant);
}

/* Fallback message when a Supabase-backed grid has nothing to show. */
.grid-empty {
  grid-column: 1 / -1;
  text-align: center;
  padding: 3rem 1rem;
  font-family: var(--font-mono);
  font-size: 0.8rem;
  letter-spacing: 0.05em;
  color: var(--bone-faint);
}

/* ---- Entrance variants ----
   `.reveal` (fade + rise) already exists. These are drop-in siblings for
   places where a plain rise reads wrong: a grid that should scale in, a
   sidebar-ish block that should arrive from the side. They share
   .reveal's `.visible` toggle so common.js's IntersectionObserver drives
   them with no extra JS. */
.reveal-scale {
  opacity: 0;
  transform: scale(0.94);
  transition: opacity var(--dur-slow) var(--ease-out-expo), transform var(--dur-slow) var(--ease-out-expo);
}
.reveal-scale.visible { opacity: 1; transform: scale(1); }

.reveal-left {
  opacity: 0;
  transform: translateX(-32px);
  transition: opacity var(--dur-slow) var(--ease-out-expo), transform var(--dur-slow) var(--ease-out-expo);
}
.reveal-left.visible { opacity: 1; transform: translateX(0); }

.reveal-right {
  opacity: 0;
  transform: translateX(32px);
  transition: opacity var(--dur-slow) var(--ease-out-expo), transform var(--dur-slow) var(--ease-out-expo);
}
.reveal-right.visible { opacity: 1; transform: translateX(0); }

.reveal-blur {
  opacity: 0;
  filter: blur(10px);
  transform: translateY(20px);
  transition: opacity var(--dur-slow) var(--ease-out-expo),
              filter var(--dur-slow) var(--ease-out-expo),
              transform var(--dur-slow) var(--ease-out-expo);
}
.reveal-blur.visible { opacity: 1; filter: blur(0); transform: translateY(0); }

/* Stagger applies to every variant, not just `.reveal`. */
.reveal-group > .reveal-scale,
.reveal-group > .reveal-left,
.reveal-group > .reveal-right,
.reveal-group > .reveal-blur {
  transition-delay: min(calc(var(--reveal-index, 0) * 0.1s), 0.6s);
}

/* Content swapped in by JS (Supabase grids, modal body) fades rather than
   popping. Applied by common.js via `window.fadeSwap()`. */
.fade-swap-in {
  animation: fadeSwapIn var(--dur-base) var(--ease-out-expo) both;
}

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

/* Images arrive rather than blink in. common.js adds `.is-loaded` on the
   image's own load event (and immediately for cached ones). */
img[data-fade] {
  opacity: 0;
  transform: scale(1.02);
  transition: opacity var(--dur-slow) var(--ease-out-expo), transform var(--dur-slow) var(--ease-out-expo);
}
img[data-fade].is-loaded { opacity: 1; transform: scale(1); }

/* Theme flip: cross-fade the whole surface instead of hard-cutting between
   palettes. Scoped to a class common.js adds for the length of the swap,
   so it never taxes normal scrolling. */
:root.theme-transition,
:root.theme-transition body,
:root.theme-transition nav,
:root.theme-transition .service-card,
:root.theme-transition .project-card,
:root.theme-transition .founder-card,
:root.theme-transition .modal {
  transition: background-color 0.45s var(--ease-out-soft),
              color 0.45s var(--ease-out-soft),
              border-color 0.45s var(--ease-out-soft) !important;
}

/* Reduced motion: strip the decorative layer entirely. The global rule
   near the top already collapses durations; this removes the transforms
   themselves so nothing shifts position at all. */
@media (prefers-reduced-motion: reduce) {
  .service-card:hover,
  .project-card:hover,
  .founder-card:hover,
  .nav-email:hover,
  .theme-toggle:hover,
  .btn:active,
  .project-card:hover .project-thumb img,
  .project-card:hover .project-thumb-bg,
  .founder-card:hover .founder-avatar,
  .service-card:hover .service-num,
  .service-card:hover .service-icon,
  .process-step:hover .process-dot,
  .btn:hover .arrow,
  .modal-close:hover {
    transform: none !important;
  }

  .reveal-scale, .reveal-left, .reveal-right, .reveal-blur {
    opacity: 1;
    transform: none;
    filter: none;
  }

  .lottie-loader-label { animation: none !important; }
}
