/* =============================================================
   santiWeb - Santiago Olais
   Plain CSS · Dark + Light themes via [data-theme] on <html>
   ============================================================= */

/* ---------- TOKENS ---------- */
:root {
  --accent: #f5901c;
  --accent-fg: #0a0a0a;
  --accent-2: #ff4f1f;
  --accent-3: #ffd166;
  /* RGB triplet for use inside rgba() neon glows so opacity is tweakable */
  --accent-rgb: 245, 144, 28;

  --font-sans: "Inter", "Segoe UI Variable", "Segoe UI", system-ui, -apple-system, sans-serif;
  --font-display: "Space Grotesk", "Inter", system-ui, sans-serif;
  --font-mono: "JetBrains Mono", "Fira Code", ui-monospace, "SFMono-Regular", Menlo, monospace;

  --pad-section-y: clamp(4rem, 8vw, 9rem);
  --pad-page-x: clamp(1.2rem, 4vw, 4rem);
  --max-w: 1400px;
  --header-h: 64px;
  --header-h-condensed: 48px;
  /* Height of the `.scroll-progress` bar - referenced by the mobile
     drawer so it can sit BELOW the progress strip instead of covering
     it. Single source of truth for both rules. */
  --scroll-progress-h: 2px;

  --ease: cubic-bezier(0.2, 0.8, 0.2, 1);
  --ease-snap: cubic-bezier(0.7, 0, 0.2, 1);
}

:root[data-theme="dark"] {
  --bg: #0a0a0a;
  --bg-alt: #111111;
  --bg-panel: #161616;
  --bg-elev: #1c1c1c;
  --bg-inset: #1f1f1f;
  --fg: #f5f5f5;
  /* RGB triplet mirror of --fg (same pattern as --accent-rgb) so
     canvas/effect code can inject it into rgba() without a runtime
     hex->rgb parse. Inverse of --bg by definition, so effects that
     want "opposite of the theme" just read this. */
  --fg-rgb: 245, 245, 245;
  --fg-mute: #a3a3a3;
  --fg-dim: #6b6b6b;
  --line: #2a2a2a;
  --line-strong: #3d3d3d;
  --line-fg: #f5f5f5;
  /* hard brutalist offset - in dark mode the shadow uses the same fg
     off-white as the body text, mirroring the light-mode convention of a
     solid black offset on light bg */
  --shadow: 5px 5px 0 0 #f5f5f5;
  --grain-opacity: 0.06;
  color-scheme: dark;
}

:root[data-theme="light"] {
  --bg: #f9f8f5;
  --bg-alt: #f1f0eb;
  --bg-panel: #ffffff;
  --bg-elev: #ffffff;
  --bg-inset: #f4f2ed;
  --fg: #0a0a0a;
  /* Mirror of --fg for rgba() consumers — see dark theme note. */
  --fg-rgb: 10, 10, 10;
  --fg-mute: #3a3a3a;
  --fg-dim: #6b6b6b;
  --line: #0a0a0a;
  --line-strong: #0a0a0a;
  --line-fg: #0a0a0a;
  --shadow: 5px 5px 0 0 #0a0a0a;
  --grain-opacity: 0.04;
  color-scheme: light;
}

/* ---------- VIEW TRANSITIONS (theme swap) ----------
   Modern Chromium / Safari 18+ run the theme swap through the View
   Transitions API: the browser snapshots the page before/after the
   swap and cross-fades them on the GPU compositor in a single pass,
   so per-element color/border/shadow transitions don't all fire at
   once, and the cursor + DOOM iframe stay live during the fade.
   Engines without the API (mostly Firefox right now) fall back to an
   instant swap in JS - a snappy repaint reads cleaner than the global
   `*` color tween we used to run there. */
::view-transition-old(root),
::view-transition-new(root) {
  animation-duration: 380ms;
  animation-timing-function: var(--ease);
}
/* Live overlays opt out of being snapshot - they keep painting in
   real time on top of the cross-fade so the cursor doesn't freeze
   and the DOOM frame keeps drawing. */
.cursor-canvas,
.cursor-core,
.doom-modal {
  view-transition-name: none;
}

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

html {
  scroll-behavior: smooth;
  -webkit-text-size-adjust: 100%;
  background: var(--bg);
  scrollbar-width: thin;
  scrollbar-color: var(--line-strong) var(--bg);
  scroll-padding-top: calc(var(--header-h) + 16px);
}

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

body {
  min-height: 100vh;
  font-family: var(--font-sans);
  font-weight: 400;
  font-size: 16px;
  line-height: 1.6;
  background: transparent;
  color: var(--fg);
  overflow-x: hidden;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-rendering: optimizeLegibility;
  font-feature-settings: "ss01", "cv11";
}

/* Form controls (button, input, textarea, select) don't reliably
   inherit -webkit-font-smoothing / -moz-osx-font-smoothing from body
   in Chromium / Firefox, so re-state them here. Without this, button
   labels and input text can render slightly heavier than surrounding
   copy and look subtly out of place. */
button,
input,
textarea,
select {
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

/* hide native cursor on pointer:fine devices once custom cursor is up */
body.has-custom-cursor,
body.has-custom-cursor * {
  cursor: none !important;
}
@media (pointer: coarse) {
  body.has-custom-cursor,
  body.has-custom-cursor * {
    cursor: auto !important;
  }
}

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

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

button {
  font: inherit;
  color: inherit;
  background: none;
  border: 0;
  cursor: pointer;
}

input,
textarea {
  font: inherit;
  color: inherit;
}

ul,
ol {
  list-style: none;
}

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

/* keyboard focus - high-contrast accent ring on every focusable element.
   We rely on :focus-visible so it never appears for mouse clicks. */
:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
}
.skip:focus-visible,
.header__nav__link:focus-visible,
input:focus-visible,
textarea:focus-visible {
  outline: none;
}
.main:focus-visible {
  outline: none;
}

/* webkit scrollbar */
::-webkit-scrollbar {
  width: 10px;
  height: 10px;
}
::-webkit-scrollbar-track {
  background: var(--bg);
}
::-webkit-scrollbar-thumb {
  background: var(--line-strong);
  border: 2px solid var(--bg);
}
::-webkit-scrollbar-thumb:hover {
  background: var(--accent);
}

/* ---------- UTIL ---------- */
.brut-label {
  font-family: var(--font-mono);
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--accent);
}

.dot {
  display: inline-block;
  width: 8px;
  height: 8px;
  background: var(--fg-dim);
  flex-shrink: 0;
}
.dot--live {
  background: var(--accent);
  /* dual layered glow: an inner solid halo + the outward pulse ring */
  box-shadow:
    0 0 6px rgba(var(--accent-rgb), 0.85),
    0 0 14px rgba(var(--accent-rgb), 0.45),
    0 0 0 0 rgba(var(--accent-rgb), 0.7);
  animation: pulse 1.8s ease-out infinite;
}

@keyframes pulse {
  0% {
    box-shadow:
      0 0 6px rgba(var(--accent-rgb), 0.85),
      0 0 14px rgba(var(--accent-rgb), 0.45),
      0 0 0 0 rgba(var(--accent-rgb), 0.7);
  }
  70% {
    box-shadow:
      0 0 6px rgba(var(--accent-rgb), 0.85),
      0 0 14px rgba(var(--accent-rgb), 0.45),
      0 0 0 9px rgba(var(--accent-rgb), 0);
  }
  100% {
    box-shadow:
      0 0 6px rgba(var(--accent-rgb), 0.85),
      0 0 14px rgba(var(--accent-rgb), 0.45),
      0 0 0 0 rgba(var(--accent-rgb), 0);
  }
}

/* ---------- NEON ACCENT GLOW ----------
   A single breathing keyframe re-used across accent surfaces (hero name,
   section `//` prefix, work title underline) so the orange feels electric and
   alive without overwhelming the brutalist surfaces. */
@keyframes accentBreathe {
  0%, 100% {
    text-shadow:
      0 0 8px  rgba(var(--accent-rgb), 0.32),
      0 0 22px rgba(var(--accent-rgb), 0.18);
  }
  50% {
    text-shadow:
      0 0 14px rgba(var(--accent-rgb), 0.55),
      0 0 36px rgba(var(--accent-rgb), 0.32);
  }
}
/* Glow-pulse for the small orange cap blocks at the right edge of each
   section line. Same layered shadow recipe as `.dot--live` (a stable
   inner halo + an outward expanding ring that fades out) but stretched
   to a 3.2s cadence - the section caps are larger and more spread out
   across the page than the tiny live dot, so a slower rhythm reads as
   "breathing HUD accent" rather than "alarm light". */
@keyframes sectionCapPulse {
  0% {
    box-shadow:
      0 0 8px  rgba(var(--accent-rgb), 0.85),
      0 0 18px rgba(var(--accent-rgb), 0.45),
      0 0 0 0  rgba(var(--accent-rgb), 0.7);
  }
  70% {
    box-shadow:
      0 0 8px  rgba(var(--accent-rgb), 0.85),
      0 0 18px rgba(var(--accent-rgb), 0.45),
      0 0 0 10px rgba(var(--accent-rgb), 0);
  }
  100% {
    box-shadow:
      0 0 8px  rgba(var(--accent-rgb), 0.85),
      0 0 18px rgba(var(--accent-rgb), 0.45),
      0 0 0 0 rgba(var(--accent-rgb), 0);
  }
}
@media (prefers-reduced-motion: reduce) {
  /* keep glows but stop the breathing */
  .hero__title__line--accent > span,
  .section__title::before,
  .section__line::after,
  .work__item__title h3::after {
    animation: none !important;
  }
}

/* ---------- GRAIN OVERLAY ---------- */
.grain {
  position: fixed;
  inset: 0;
  pointer-events: none;
  z-index: 9998;
  opacity: var(--grain-opacity);
  mix-blend-mode: overlay;
  background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='200' height='200'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0   0 0 0 0 0   0 0 0 0 0   0 0 0 1 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>");
  background-size: 220px;
}

/* ---------- NUCLEUS ----------
   A scoped 3-blob warm gradient cluster injected behind specific titles
   (hero accent + every section title). Each blob orbits on a different
   prime-ish period and the whole cluster slowly rotates - the effect is
   a small, elegant "merging nucleus" of warm light, not a global wash.
   No blend modes: solid translucent gradients so it reads consistently
   on both dark and light themes. */
.nucleus {
  position: absolute;
  --n-size: 220px;
  width: var(--n-size);
  height: var(--n-size);
  pointer-events: none;
  z-index: -1;
  filter: saturate(1.7) brightness(1.2);
  opacity: 0.7;
}
.nucleus__core {
  position: absolute;
  inset: 0;
  animation: nucleusSpin 32s linear infinite;
  will-change: transform;
}
@keyframes nucleusSpin {
  to { transform: rotate(360deg); }
}
.nucleus__core > i {
  position: absolute;
  top: 22%;
  left: 22%;
  width: 56%;
  height: 56%;
  border-radius: 50%;
  /* heavy blur so the orbs lose all shape and read as a pure neon halo
     of warm light spilling outward - relative to nucleus size so it
     scales correctly between hero and section titles.
     Intentionally no `will-change: transform` here - the parent
     `.nucleus__core` is already promoted to its own compositor layer
     (for the 32s spin), and the three orbs share that layer instead of
     each getting their own. With ~6 title nuclei on the page that saved
     ~18 extra GPU layers (18 heavy blurs) with no visual difference. */
  filter: blur(calc(var(--n-size) * 0.32));
}
.nucleus__core > i:nth-child(1) {
  background: radial-gradient(circle, hsla(22, 100%, 62%, 1), hsla(22, 100%, 62%, 0) 70%);
  animation: nucleusOrbit1 7s ease-in-out var(--n-delay, 0s) infinite;
}
.nucleus__core > i:nth-child(2) {
  background: radial-gradient(circle, hsla(40, 100%, 64%, 0.95), hsla(40, 100%, 64%, 0) 70%);
  animation: nucleusOrbit2 11s ease-in-out var(--n-delay, 0s) infinite;
}
.nucleus__core > i:nth-child(3) {
  background: radial-gradient(circle, hsla(10, 100%, 58%, 0.95), hsla(10, 100%, 58%, 0) 70%);
  animation: nucleusOrbit3 13s ease-in-out var(--n-delay, 0s) infinite;
}
@keyframes nucleusOrbit1 {
  0%, 100% { transform: translate(-14%, -8%) scale(1); }
  33%      { transform: translate(8%, 6%)   scale(1.2); }
  66%      { transform: translate(2%, -10%) scale(0.85); }
}
@keyframes nucleusOrbit2 {
  0%, 100% { transform: translate(10%, 8%)  scale(1.05); }
  40%      { transform: translate(-8%, -6%) scale(0.9); }
  75%      { transform: translate(4%, 12%)  scale(1.25); }
}
@keyframes nucleusOrbit3 {
  0%, 100% { transform: translate(-4%, 10%) scale(0.95); }
  50%      { transform: translate(10%, -8%) scale(1.2); }
}
:root[data-theme="light"] .nucleus {
  opacity: 0.55;
  filter: saturate(1.5);
}
@media (prefers-reduced-motion: reduce) {
  .nucleus { display: none; }
}

/* placement variants - kept here so the JS only needs to inject one
   .nucleus span and the CSS handles per-target sizing & positioning */
.section__title { position: relative; isolation: isolate; }
.section__title .nucleus {
  --n-size: clamp(60px, 6vw, 100px);
  /* centered on the // glyph so the halo bleeds out from behind it */
  left: 0;
  top: 50%;
  transform: translate(-15%, -50%);
}
.hero__title { isolation: isolate; }
.hero__title .nucleus {
  --n-size: clamp(70px, 9vw, 130px);
  /* anchored over the SANTI! accent line, centered on the "S" */
  left: 5%;
  bottom: 12%;
  transform: translateX(-50%);
}

/* ---------- CUSTOM CURSOR ---------- */
.cursor-canvas {
  position: fixed;
  inset: 0;
  pointer-events: none;
  z-index: 9996;
  mix-blend-mode: screen;
  opacity: 1;
}
:root[data-theme="light"] .cursor-canvas {
  mix-blend-mode: multiply;
  opacity: 0.9;
}

.cursor-core {
  position: fixed;
  top: 0;
  left: 0;
  width: 26px;
  height: 26px;
  pointer-events: none;
  z-index: 9999;
  transform: translate3d(-100px, -100px, 0);
  will-change: transform;
  mix-blend-mode: difference;
}
.cursor-core__ring {
  position: absolute;
  inset: 0;
  border: 2px solid #fff;
  transform-origin: center;
}
.cursor-core__dot {
  position: absolute;
  top: 50%;
  left: 50%;
  width: 4px;
  height: 4px;
  background: #fff;
  transform: translate(-50%, -50%);
}
@media (pointer: coarse), (hover: none) {
  .cursor-canvas,
  .cursor-core {
    display: none;
  }
}

/* ---------- BOOT LOADER ---------- */
.boot {
  position: fixed;
  inset: 0;
  z-index: 10000;
  background: var(--bg);
  display: grid;
  place-items: center;
  transition: opacity 500ms var(--ease), visibility 500ms var(--ease);
}
.boot.is-done {
  opacity: 0;
  visibility: hidden;
}
.boot__inner {
  width: min(360px, 80vw);
  text-align: left;
}
.boot__brand {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: clamp(2rem, 6vw, 3rem);
  letter-spacing: -0.04em;
  line-height: 1;
}
.boot__brand span {
  color: var(--accent);
}
.boot__bar {
  margin-top: 24px;
  height: 6px;
  background: var(--bg-inset);
  border: 1.5px solid var(--line-strong);
  position: relative;
  overflow: hidden;
}
.boot__bar span {
  position: absolute;
  inset: 0;
  background: var(--accent);
  transform: scaleX(0);
  transform-origin: left;
  transition: transform 80ms linear;
}
.boot__meta {
  margin-top: 12px;
  font-family: var(--font-mono);
  font-size: 11px;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--fg-mute);
  display: flex;
  justify-content: space-between;
}
.boot__meta .boot__pct {
  color: var(--accent);
}

/* ---------- SKIP LINK ----------
   Hidden off-screen by default, slides into view when tab-focused.
   Click sends the user past the header straight to the first content section. */
.skip {
  position: fixed;
  top: 12px;
  left: 12px;
  padding: 10px 16px;
  background: var(--accent);
  color: var(--accent-fg);
  font-family: var(--font-mono);
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  border: 2px solid var(--line-fg);
  box-shadow: 4px 4px 0 0 var(--line-fg);
  z-index: 10001;
  transform: translateY(calc(-100% - 24px));
  opacity: 0;
  transition:
    transform 360ms cubic-bezier(0.34, 1.56, 0.64, 1),
    opacity 220ms var(--ease);
}
.skip:focus-visible,
.skip:focus {
  transform: translateY(0);
  opacity: 1;
  outline: none;
}
.skip:hover {
  background: var(--fg);
  color: var(--bg);
}

/* ---------- SCROLL PROGRESS ---------- */
.scroll-progress {
  position: fixed;
  top: var(--header-h);
  left: 0;
  right: 0;
  height: var(--scroll-progress-h);
  background: var(--accent);
  transform: scaleX(0);
  transform-origin: left center;
  z-index: 99;
  pointer-events: none;
  will-change: transform;
  /* Follow the header as it shrinks/expands. The transition timing is
     tuned to match the header height transition below so the bar never
     visually detaches from the header bottom edge. */
  transition: top 320ms var(--ease);
}
:root.is-header-condensed .scroll-progress {
  top: var(--header-h-condensed);
}

/* ---------- HEADER ---------- */
.header {
  /* `sticky` keeps the header in the document flow (so the hero starts
     right below it instead of having to reserve `--header-h` of top
     padding), and pins it to the top of the viewport once you scroll
     past its natural position. */
  position: sticky;
  top: 0;
  height: var(--header-h);
  padding: 0 var(--pad-page-x);
  display: flex;
  align-items: center;
  gap: 32px;
  z-index: 100;
  background: var(--bg);
  border-bottom: 1.5px solid var(--line);
  /* Smoothly morph between full and condensed HUD states. Height,
     padding, background and border are all animated together. The
     timings stay short enough to feel snappy but long enough to read
     as a deliberate "mode change" rather than a flicker. */
  transition:
    height 320ms var(--ease),
    background-color 280ms var(--ease),
    border-color 200ms var(--ease),
    box-shadow 280ms var(--ease);
}
/* Faint hairline upgrade once the user has scrolled at all. Pre-existing
   behavior, kept so the header doesn't visually float at scrollY=0. */
.header.is-scrolled {
  border-bottom-color: var(--line-strong);
}

/* HUD reticle brackets at the far edges. Hidden offscreen and faded out
   in the resting state; they slide inward and fade up once the header
   condenses. Pure decoration — `aria-hidden` is implicit on pseudos. */
.header::before,
.header::after {
  content: "";
  position: absolute;
  top: 50%;
  width: 6px;
  height: 14px;
  border: 1.5px solid var(--accent);
  opacity: 0;
  pointer-events: none;
  transition:
    transform 360ms var(--ease-snap),
    opacity 240ms var(--ease);
}
.header::before {
  left: calc(var(--pad-page-x) - 16px);
  border-right: none;
  transform: translate(-12px, -50%);
}
.header::after {
  right: calc(var(--pad-page-x) - 16px);
  border-left: none;
  transform: translate(12px, -50%);
}
.header.is-condensed::before,
.header.is-condensed::after {
  opacity: 0.85;
  transform: translate(0, -50%);
}

/* ---- CONDENSED MODE ---------------------------------------------------
   When the user scrolls past the hero the header shifts into a tighter,
   translucent HUD readout: smaller height, glass background, an accent
   underline that gains a faint neon halo, and a section badge that
   reveals itself next to the (now collapsed) logo. */
.header.is-condensed {
  height: var(--header-h-condensed);
  background: var(--bg);
  /* Keep the hairline border in its usual subtle tone instead of
     switching to accent - the scroll-progress bar immediately under
     the header is the only accent-colored strip, which keeps it
     readable as the true scroll-progress indicator rather than a
     decorative HUD line. */
  border-bottom-color: var(--line-strong);
  box-shadow: 0 8px 24px -20px rgba(0, 0, 0, 0.45);
}

.header__logo {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 22px;
  letter-spacing: -0.04em;
  display: inline-flex;
  align-items: baseline;
  gap: 1px;
  user-select: none;
  position: relative;
}
.header__logo__a {
  display: inline-block;
  padding: 2px 6px;
  background: var(--fg);
  color: var(--bg);
  transition: transform 200ms var(--ease-snap), background 200ms;
}
.header__logo__b,
.header__logo__c {
  /* Both spans collapse smoothly when the header condenses. We animate
     `max-width` (with overflow hidden on the parent flex item) AND
     opacity so the morph reads as the WEB tag retracting into the
     boxed SO mark, like a HUD label dropping its descriptor. */
  display: inline-block;
  overflow: hidden;
  max-width: 4ch;
  opacity: 1;
  transition:
    max-width 320ms var(--ease),
    opacity 200ms var(--ease),
    margin 320ms var(--ease);
}
.header__logo__b {
  color: var(--accent);
  font-weight: 500;
  margin: 0 1px;
}
.header__logo__c {
  color: var(--fg);
  font-size: 14px;
  letter-spacing: 0.04em;
  font-weight: 600;
  max-width: 6ch;
}
.header.is-condensed .header__logo__b,
.header.is-condensed .header__logo__c {
  max-width: 0;
  opacity: 0;
  margin-left: 0;
  margin-right: 0;
}
.header__logo:hover .header__logo__a {
  transform: translate(-2px, -2px);
  background: var(--accent);
  color: var(--accent-fg);
}

/* Section readout badge - reveals next to the collapsed logo once the
   header condenses, acts as a live HUD indicator of which section the
   reader is currently in. Updated by the scroll-spy in scripts.js. */
.header__section {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  height: 22px;
  padding: 0 8px;
  border: 1px solid var(--line-strong);
  background: color-mix(in srgb, var(--bg-panel) 60%, transparent);
  font-family: var(--font-mono);
  font-size: 10px;
  font-weight: 700;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--fg);
  /* Hidden by default - slides in from the left and fades up when
     condensed. `max-width: 0` lets us animate the gap closure too. */
  max-width: 0;
  opacity: 0;
  overflow: hidden;
  white-space: nowrap;
  transform: translateX(-6px);
  margin-left: 0;
  transition:
    max-width 360ms var(--ease),
    opacity 220ms var(--ease),
    transform 360ms var(--ease-snap),
    margin-left 360ms var(--ease),
    border-color 200ms var(--ease),
    background 200ms var(--ease);
}
.header.is-condensed .header__section {
  /* Sized to comfortably fit the widest localized label ("EXPERIENCIA"
     in ES at 11 chars) PLUS caret + gap + padding. `ch` only measures
     glyph advance width, so we need generous headroom on top of the
     raw char count to cover the extra letter-spacing tracking. */
  max-width: 28ch;
  opacity: 1;
  transform: translateX(0);
  margin-left: 14px;
  border-color: var(--accent);
}
/* When the HUD has no active section (e.g. user scrolled back into the
   hero), collapse the pill entirely even in condensed mode - we don't
   want a lonely caret floating next to the logo with no label. */
.header.is-condensed .header__section.is-empty {
  max-width: 0;
  opacity: 0;
  transform: translateX(-6px);
  margin-left: 0;
  border-color: transparent;
}
.header__section__caret {
  /* Brutalist HUD data block - a solid accent rectangle that echoes
     the same motif as the section-line end caps and the PROFILE.SYS
     live dot. Uses a separate, dialed-back pulse (`sectionBadgePulse`)
     because the badge sits inside the tight condensed header and a
     full-strength pulse reads as agitated in such a small container.
     Sized 6x6 so it looks like a data marker next to the 10px readout
     text rather than a full-height block crowding the label.
     `flex-shrink: 0` keeps it from squashing when the label hits its
     `text-overflow: ellipsis` width cap. */
  display: inline-block;
  width: 6px;
  height: 6px;
  flex-shrink: 0;
  background: var(--accent);
  animation: sectionBadgePulse 3.6s ease-out infinite;
}
/* Toned-down twin of `sectionCapPulse` - smaller halos, a shorter ring
   throw (6px instead of 10px), and lower peak alphas. Reads as a calm
   breath rather than a flashing beacon, which is the right tone for a
   marker sitting inside the condensed-header chrome. */
@keyframes sectionBadgePulse {
  0% {
    box-shadow:
      0 0 4px  rgba(var(--accent-rgb), 0.55),
      0 0 10px rgba(var(--accent-rgb), 0.25),
      0 0 0 0  rgba(var(--accent-rgb), 0.4);
  }
  70% {
    box-shadow:
      0 0 4px  rgba(var(--accent-rgb), 0.55),
      0 0 10px rgba(var(--accent-rgb), 0.25),
      0 0 0 6px rgba(var(--accent-rgb), 0);
  }
  100% {
    box-shadow:
      0 0 4px  rgba(var(--accent-rgb), 0.55),
      0 0 10px rgba(var(--accent-rgb), 0.25),
      0 0 0 0  rgba(var(--accent-rgb), 0);
  }
}
@media (prefers-reduced-motion: reduce) {
  .header__section__caret { animation: none; }
}
.header__section__name {
  display: inline-block;
  overflow: hidden;
  text-overflow: ellipsis;
  /* Smooth content-swap animation when the active section changes. */
  transition: opacity 180ms var(--ease), transform 180ms var(--ease);
}
.header__section.is-changing .header__section__name {
  opacity: 0;
  transform: translateY(-2px);
}

.header__nav {
  display: flex;
  align-items: center;
  gap: 28px;
  margin-left: auto;
  /* Animate the inter-link gap so the nav tightens slightly in
     condensed mode, reinforcing the "compressed HUD" feel. */
  transition: gap 280ms var(--ease);
}
.header.is-condensed .header__nav {
  gap: 22px;
}
.header__nav__link {
  font-family: var(--font-mono);
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  position: relative;
  padding: 6px 0;
  color: var(--fg-mute);
  transition:
    color 180ms var(--ease),
    font-size 280ms var(--ease),
    letter-spacing 280ms var(--ease);
}
.header.is-condensed .header__nav__link {
  font-size: 10px;
  letter-spacing: 0.14em;
}
.header__nav__link::after {
  content: "";
  position: absolute;
  left: 0;
  bottom: 0;
  width: 100%;
  height: 2px;
  background: var(--accent);
  transform: scaleX(0);
  transform-origin: left;
  transition: transform 220ms var(--ease);
}
.header__nav__link:hover,
.header__nav__link:focus-visible,
.header__nav__link.is-active {
  color: var(--fg);
  outline: none;
}
.header__nav__link:hover::after,
.header__nav__link:focus-visible::after,
.header__nav__link.is-active::after {
  transform: scaleX(1);
}

.header__actions {
  display: flex;
  align-items: stretch;
  gap: 12px;
}

.lang-toggle {
  font-family: var(--font-mono);
  font-size: 11px;
  font-weight: 700;
  line-height: 1;
  letter-spacing: 0.16em;
  display: inline-flex;
  align-items: center;
  gap: 4px;
  padding: 6px 10px;
  border: 1.5px solid var(--line-strong);
  background: transparent;
  color: var(--fg-mute);
  transition: border-color 180ms;
}
.lang-toggle:hover {
  border-color: var(--accent);
}
.lang-toggle__opt {
  transition: color 180ms;
}
.lang-toggle__opt.is-active {
  color: var(--accent);
}
.lang-toggle__sep {
  color: var(--fg-dim);
}

.theme-toggle {
  width: 36px;
  height: 36px;
  border: 1.5px solid var(--line-strong);
  display: grid;
  place-items: center;
  position: relative;
  overflow: hidden;
  transition: border-color 180ms, background 180ms;
}
.theme-toggle:hover {
  border-color: var(--accent);
  background: var(--bg-elev);
}
/* the icons live inside this wrapper so we can spin just them
   (not the whole brutalist button frame) on theme swap */
.theme-toggle__icons {
  position: absolute;
  inset: 0;
  display: block;
  transform-origin: 50% 50%;
}
.theme-toggle.is-spinning .theme-toggle__icons {
  animation: themeToggleSpin 520ms var(--ease-snap);
}
@keyframes themeToggleSpin {
  from { transform: rotate(0deg); }
  to   { transform: rotate(360deg); }
}
.theme-toggle svg {
  width: 16px;
  height: 16px;
  position: absolute;
  inset: 0;
  margin: auto;
  transition: transform 350ms var(--ease-snap), opacity 220ms;
}
:root[data-theme="dark"] .theme-toggle__sun {
  transform: translateY(120%) rotate(90deg);
  opacity: 0;
}
:root[data-theme="dark"] .theme-toggle__moon {
  transform: translateY(0) rotate(0);
  opacity: 1;
}
:root[data-theme="light"] .theme-toggle__sun {
  transform: translateY(0) rotate(0);
  opacity: 1;
}
:root[data-theme="light"] .theme-toggle__moon {
  transform: translateY(-120%) rotate(-90deg);
  opacity: 0;
}

.burger {
  display: none;
  width: 36px;
  height: 36px;
  border: 1.5px solid var(--line-strong);
  position: relative;
}
/* All three bars are anchored to the exact vertical center of the
   button (top: 50%, then `margin-top: -1px` pulls them up by half their
   own 2px height). The closed state shifts bars 1 and 3 up/down with a
   transform; the open state simply removes that shift and rotates -
   both rotating bars share the SAME center of rotation, so the X is
   symmetric by construction (no translate math to get wrong). */
.burger span {
  position: absolute;
  left: 8px;
  right: 8px;
  top: 50%;
  height: 2px;
  margin-top: -1px;
  background: var(--fg);
  transition: transform 220ms var(--ease-snap), opacity 220ms;
}
.burger span:nth-child(1) { transform: translateY(-7px); }
.burger span:nth-child(2) { transform: translateY(0); }
.burger span:nth-child(3) { transform: translateY(7px); }
.burger.is-open span:nth-child(1) { transform: rotate(45deg); }
.burger.is-open span:nth-child(2) { opacity: 0; }
.burger.is-open span:nth-child(3) { transform: rotate(-45deg); }

/* ---------- HERO ---------- */
.hero {
  position: relative;
  /* Hero fills exactly the remaining viewport below the (sticky, in-flow)
     header so the bottom-anchored marquee always lands on the fold.
     `100vh` is the fallback for engines without dynamic viewport units;
     `100dvh` wins in modern browsers (and on mobile it shrinks/grows
     with the URL bar instead of overflowing). */
  height: calc(100vh - var(--header-h));
  height: calc(100dvh - var(--header-h));
  padding: 80px var(--pad-page-x) 120px;
  display: flex;
  flex-direction: column;
  justify-content: center;
  overflow: hidden;
  max-width: var(--max-w);
  margin: 0 auto;
  width: 100%;
}

.hero__grid {
  position: absolute;
  inset: 0;
  pointer-events: none;
  background-image:
    linear-gradient(to right, var(--line) 1px, transparent 1px),
    linear-gradient(to bottom, var(--line) 1px, transparent 1px);
  background-size: 80px 80px;
  background-position: center;
  mask-image: radial-gradient(ellipse at 30% 50%, #000 0%, transparent 70%);
  -webkit-mask-image: radial-gradient(ellipse at 30% 50%, #000 0%, transparent 70%);
  /* per-theme opacity - dark theme gets a softer grid since the dark bg
     already creates contrast; light theme gets a stronger grid because
     light bg + low-contrast lines wash out otherwise */
  opacity: var(--hero-grid-opacity, 0.5);
  animation: gridDrift 60s linear infinite;
}
/* dark needs both higher opacity AND a lighter line color since the
   default --line is so close to the bg it disappears at low opacity. */
:root[data-theme="dark"] .hero__grid {
  --hero-grid-opacity: 0.6;
  background-image:
    linear-gradient(to right, var(--line-strong) 1px, transparent 1px),
    linear-gradient(to bottom, var(--line-strong) 1px, transparent 1px);
}
:root[data-theme="light"] .hero__grid { --hero-grid-opacity: 0.1; }
/* Seamless drift: both endpoints are tile-aligned (the pattern repeats
   every 80px), so iteration N ends pixel-identically to where
   iteration N+1 begins - no snap on loop. Declaring an explicit `from`
   is critical; without it CSS fills the implicit start from the
   static `background-position: center`, which resolves to an arbitrary
   pixel offset tied to the hero's current size and won't line up with
   the `to` endpoint. Negative offsets drift the pattern up-left;
   using 10 tiles (-800px) over 30s preserves the original visible
   pace while still landing on an exact tile boundary for the loop. */
@keyframes gridDrift {
  from { background-position: 0 0; }
  to { background-position: -800px -800px; }
}

/* ---- ANIMATED HERO CELLS ----
   JS sprinkles small accent shapes at random snapped 80px grid cells, each
   playing one of several brutalist micro-animations. Lives in its own layer
   (sibling of .hero__grid) so cell opacity isn't multiplied by the line
   layer's per-theme opacity. */
.hero__cells {
  position: absolute;
  inset: 0;
  pointer-events: none;
  overflow: hidden;
  mask-image: radial-gradient(ellipse at 30% 50%, #000 0%, transparent 75%);
  -webkit-mask-image: radial-gradient(ellipse at 30% 50%, #000 0%, transparent 75%);
  z-index: 0;
}
.hero__cells__cell {
  position: absolute;
  width: 80px;
  height: 80px;
  pointer-events: none;
  perspective: 320px;
  will-change: transform, opacity;
}
/* All five twinkle variants share the same on/off rhythm (idle, peak at
   44%, back to idle by 88%, idle through 100%). Previously that lived
   as five near-duplicate @keyframes rules; consolidated here into a
   single `heroCellTick` keyframe that reads the per-variant peak
   transform and peak opacity from CSS custom properties. Each variant
   below just sets `--cell-rest`, `--cell-peak` and `--cell-op`. JS
   already drives `--cell-dur` and `--cell-delay` per cell via
   inline styles, so the timing story is unchanged. */
.hero__cells__cell::before {
  content: "";
  position: absolute;
  inset: 28px;
  transform-origin: 50% 50%;
  opacity: 0;
  animation: heroCellTick var(--cell-dur, 5s) ease-in-out var(--cell-delay, 0s) infinite;
  will-change: transform, opacity, background-color;
}
@keyframes heroCellTick {
  0%, 88%, 100% {
    transform: var(--cell-rest, none);
    opacity: 0;
  }
  44% {
    transform: var(--cell-peak, none);
    opacity: var(--cell-op, 0.3);
  }
}

/* variant 1: orange fill flash (no transform, opacity-only) */
.hero__cells__cell--fill::before {
  inset: 8px;
  background: var(--accent);
  --cell-op: 0.22;
}

/* variant 2: small dot scales up and out */
.hero__cells__cell--pulse::before {
  inset: 32px;
  background: var(--fg);
  border-radius: 0;
  --cell-rest: scale(0.4);
  --cell-peak: scale(1.2);
  --cell-op: 0.28;
}

/* variant 3: outlined accent square spins 180deg */
.hero__cells__cell--spin::before {
  inset: 18px;
  border: 1.5px solid var(--accent);
  background: transparent;
  --cell-rest: rotate(0deg);
  --cell-peak: rotate(180deg);
  --cell-op: 0.4;
}

/* variant 4: 3D Y-axis flip - outlined fg */
.hero__cells__cell--flipy::before {
  inset: 14px;
  border: 1.5px solid var(--fg);
  background: transparent;
  --cell-rest: rotateY(0deg);
  --cell-peak: rotateY(360deg);
  --cell-op: 0.32;
}

/* variant 5: 3D X-axis flip - solid accent */
.hero__cells__cell--flipx::before {
  inset: 22px;
  background: var(--accent);
  --cell-rest: rotateX(0deg);
  --cell-peak: rotateX(360deg);
  --cell-op: 0.28;
}

/* respect reduced motion - drop the cell layer entirely */
@media (prefers-reduced-motion: reduce) {
  .hero__cells { display: none; }
}

/* ---- HERO RIFT (easter egg) ----
   A Half-Life-meets-cyberpunk reality tear triggered by clicking a
   "suspicious" grid cell. Two pieces:

     .hero__rift          - full-hero overlay <canvas>, paints nothing
                            until the rift fires, then runs a rAF loop
                            for ~2.5s (jagged vertical tear + sparks +
                            shockwave rings + flicker-hex text), then
                            goes dormant again. pointer-events: none so
                            the CTAs underneath stay clickable.
     .hero__rift__hint    - a small <button> snapped to an 80px grid
                            cell in the empty right-side area of the
                            hero. Dashed brutalist outline, flickers
                            subtly so it reads as "something is off
                            here", brightens on hover + cursor:crosshair,
                            and fades in a small crosshair glyph on hover
                            to signal it's a hit target. Keyboard-
                            accessible since it's a real button.

   Both are created in HTML but left hidden/empty; initHeroRift() in
   js/scripts.js sizes the canvas, positions the hint on a random
   grid cell, wires the trigger, and removes everything entirely for
   prefers-reduced-motion users. */

/* ---- the rift canvas ---- */
.hero__rift {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
  /* Above the grid / cells layers but below the title's stacking
     context so SANTI! always reads on top of a stray spark. */
  z-index: 1;
  /* Zero paint cost while idle: the canvas is transparent and the
     rAF loop is parked. `contain: paint` confines invalidations to
     the canvas rect during the 2.5s active window. */
  contain: paint;
  /* fade-in on activation is a single quick frame of alpha easing
     so the flash burst doesn't pop hard-cut */
  opacity: 0;
  transition: opacity 120ms var(--ease);
}
.hero__rift.is-active { opacity: 1; }

/* ---- the suspicious grid hint ---- */
.hero__rift__hint {
  position: absolute;
  /* Button is 96x96 but the VISIBLE border (drawn by ::before at
     inset:8px) is 80x80 centered on the 80px hero grid cell. That
     gives us an 8px clickable margin on all sides around the
     visible border, which is what kills the "gotta click many
     times" bug: the drop-shadow RGB-split ghosts and the sway's
     3D-rotated corners both visually bleed a few pixels past the
     border, and with inset:0 users were clicking on those pixels
     (visually part of the cell, but outside the hit box). JS
     offsets left/top by -8px so the visible 80x80 still snaps to
     the grid. */
  width: 96px;
  height: 96px;
  padding: 0;
  margin: 0;
  background: transparent;
  border: 0;
  cursor: crosshair;
  /* z-index must be STRICTLY greater than every other positioned
     hero child (meta/title/sub/cta, all at z:2) so the hint's hit
     box always wins pointer-event routing - even when the hint
     lands inside the title's bounding box. That was the root of
     the "grid isn't clickable sometimes" bug: .hero__title is a
     block-level <h1> that stretches full hero width at z:2, and
     because it comes AFTER the hint in the DOM, equal z-indices
     meant the title always captured clicks over its (mostly
     empty, right-of-SANTI!) box. Bumped to 5 so we're above
     every z:2 sibling with margin to spare. Canvas stays at z:1
     so rift particles still draw BEHIND the title text, matching
     the original "SANTI! reads on top of stray sparks" intent. */
  z-index: 5;
  /* Create a stacking context for the filter/shadow stack on
     ::before without CLIPPING like contain:paint does. We can't
     use contain:paint here because the sway animation rotates
     ::before with amplitudes (up to 32° rotateY + 12° rotateZ +
     14° rotateX + a scale pulse) where corner displacement far
     exceeds the 8px hit margin at peak - contain:paint would
     visibly crop the rotated border. isolation:isolate is enough
     for paint grouping (stacking context) without the bounding-
     box clip. */
  isolation: isolate;
  /* reset default <button> focus outline; replaced with a theme-aware
     one below so keyboard users can still see where focus lives */
  outline: none;
  -webkit-tap-highlight-color: transparent;
  transform-origin: 50% 50%;
  /* Entry transition (reappear after teleport / respawn / initial
     load reveal). Gentle symmetric pair with easeOutCubic on
     BOTH opacity and transform:
       - 1600ms for the arrival - slightly slower than the OUT
         (1400ms, below) so "arrives softly, leaves calmly" -
         a subtle asymmetry rather than the old "reluctantly
         leaves / snaps back" which had dramatically different
         curves in each direction and read as two different
         moods instead of one smooth breath. Both durations
         were bumped from the previous 980/760ms calibration
         (which felt abrupt for a "suspicious, maybe not there"
         cell) so the cell now fades for a full 1.4-1.6s in
         each direction - enough runway to actually register
         as a materializing/dematerializing easter egg.
       - easeOutCubic spreads the change evenly - no front-
         loaded snap-pop (expo-out) and no accelerating rush
         (the old OUT curve). The cell just... appears,
         smoothly, and settles into place.
     Paired with the OUT transition below, the two directions
     now share the same character: a soft scale pulse (0.96 ->
     1.0 / 1.0 -> 0.96) plus an even opacity fade. Scale delta
     was softened from 0.94 to 0.96 so the shrink is barely
     visible but still present - a whisper of "contracting
     inward" rather than a visible shrink. Feels like the same
     entity materializing and dematerializing, rather than two
     different animations bolted together. */
  transition:
    opacity 1600ms cubic-bezier(0.33, 1, 0.68, 1),
    transform 1600ms cubic-bezier(0.33, 1, 0.68, 1);
  /* No halo / box-shadow pulse on the button - and none on the
     ::before either. The earlier two-layer breathing halo (tight
     inner + 26px ambient) plus the static outer box-shadow on
     ::before stacked into a ghost-duplicate rectangle behind the
     neon border - too much visual noise around an already-glowing
     outline. Both were stripped; the only outward reaction now is
     the chromatic-aberration glitch on hover (via filter, not
     box-shadow). The idle look is a clean accent border with a
     tight inset neon glow and nothing around it. */

  /* Idle positional wander. Two layered idle motions total:
       - riftHintDrift on the BUTTON (this rule) - an 11s
         translate-based wander, ±12-14px across x/y so the
         whole cell visibly drifts around its grid slot between
         the 16s teleports. Uses the individual `translate:`
         property (NOT `transform:`) so it composes cleanly
         with the `transform:` that .is-teleporting sets for
         the 3D flip - the two properties never stomp each
         other in the composed matrix.
       - riftHintSway on the ::before pseudo (the visible 80x80
         border) - rotates and scale-pulses the border in place,
         also 11s. Drift is on the BUTTON and sway is on the
         ::before CHILD, so even though both are 11s they drive
         different elements with different transform-origins -
         the eye reads them as two independent motions, not
         one synced loop. */
  animation: riftHintDrift 11s ease-in-out infinite;
}
.hero__rift__hint[hidden] { display: none; }
/* Teleport exit state: opacity fades to 0 and the cell shrinks
   subtly to scale(0.96). Held invisible for a JS-driven
   randomized 3000-4500ms dwell before it's repositioned + the
   class is removed (total absence per teleport: OUT 1400ms +
   dwell 3-4.5s + IN 1600ms = 6-7.5s, long enough that the cell
   genuinely feels "gone for a bit" before it reappears).
   Dropped the old rotateY(90deg) edge-on flip - that was way too
   dramatic for what should be a subtle "it's leaving" moment.
   A tiny scale(0.96) shrink paired with the opacity fade reads
   as a gentle dematerialization without the snap of a 3D flip.
   Matches the IN transition above (same easeOutCubic curve, both
   properties) so the two directions feel like one coherent
   breath - only difference is the duration (1400ms out vs 1600ms
   in) so leaving feels a touch more decisive than arriving. */
.hero__rift__hint.is-teleporting {
  opacity: 0;
  transform: scale(0.96);
  pointer-events: none;
  transition:
    opacity 1400ms cubic-bezier(0.33, 1, 0.68, 1),
    transform 1400ms cubic-bezier(0.33, 1, 0.68, 1);
}

/* Solid accent-neon frame with an inner-only neon glow.
   Two visual tiers at rest:
     1. Solid 1.5px accent-colored border  - the "tube" itself
     2. inset box-shadow                   - inner neon bleed
   Previously a third layer existed - an outer (non-inset)
   box-shadow that drew a soft halo around the border. Combined
   with the button's ambient halo animation it read as a ghost
   "shadow of itself" behind the square. Both were removed;
   the border now sits flush against the hero grid with only its
   inner light bleed, reading as a clean neon outline.
   On hover the filter-based chromatic aberration (riftHintGlitch)
   takes over - that's the only outside visual effect then. */
.hero__rift__hint::before {
  content: "";
  position: absolute;
  /* inset:8px = 80x80 visible border centered in the 96x96 button.
     The extra 8px around it is transparent but clickable, so sway
     can bleed slightly past the border without landing outside
     the hit box. */
  inset: 8px;
  border: 1.5px solid var(--accent);
  /* Barely-there inner glow: just enough to sell the neon-tube
     feel on the border without pushing a colored haze into the
     middle of the cell. Earlier iterations used 8-12px blur at
     0.3-0.55 alpha, which read as a pink fog hanging in the
     center of the box (especially on hover). 3px blur at 0.2
     alpha stays pinned to the border line and leaves the cell
     interior clean, so the crosshair, sway, and sparks below
     aren't fighting a background wash. */
  box-shadow: inset 0 0 3px rgba(var(--accent-rgb), 0.2);
  opacity: 0.78;
  transform-origin: 50% 50%;
  pointer-events: none;
  /* Idle state is intentionally free of filters - clean solid
     neon border only, no chromatic aberration. The RGB-split
     drop-shadows are reserved for hover (riftHintGlitch), so the
     aberration reads as a reaction to user attention rather than
     ambient noise. Transition filter here too so when the hover
     animation releases, any residual filter state eases back to
     none instead of snapping off. */
  filter: none;
  transition:
    opacity 220ms var(--ease),
    border-color 220ms var(--ease),
    box-shadow 260ms var(--ease),
    filter 220ms var(--ease);
  /* Sole idle animation: rotation + occasional scale pulses on
     transform. No filter/aberration animation - those are gated
     to hover only. Period tuned so a casual viewer sees visible
     motion at any moment: slower than 4-5s (would feel busy) but
     much shorter than 12s (reads as static because amplitudes
     peak past the observation window). */
  animation: riftHintSway 11s ease-in-out infinite;
}
/* Center crosshair glyph — tiny four-tick crosshair drawn with
   two crossed linear-gradients so we get a crisp "+" without
   loading a font or an SVG. Hidden idle, fades in on hover/focus
   to signal the cell is a hit target. pointer-events: none so it
   never eats clicks meant for the button. */
.hero__rift__hint::after {
  content: "";
  position: absolute;
  top: 50%;
  left: 50%;
  width: 10px;
  height: 10px;
  transform: translate(-50%, -50%);
  background:
    linear-gradient(var(--accent), var(--accent)) center / 2px 10px no-repeat,
    linear-gradient(var(--accent), var(--accent)) center / 10px 2px no-repeat;
  opacity: 0;
  transition: opacity 220ms var(--ease);
  pointer-events: none;
  /* faint phosphor glow so when it appears it reads as "signal from
     elsewhere" - CRT monitor vibes */
  filter: drop-shadow(0 0 3px rgba(var(--accent-rgb), 0.7));
}
.hero__rift__hint:hover::before,
.hero__rift__hint:focus-visible::before {
  opacity: 1;
  /* Hover ramps the neon without escalating the inset glow.
     Earlier versions pushed the inset blur from 8px/0.3 up to
     12px/0.55 here, which is what produced the hazy fog in the
     middle of the cell - the user specifically didn't want that.
     Now the inset stays at the same barely-there 3px/0.2 from
     idle, and the border picks up the interaction via the
     chromatic-aberration glitch + spark burst only. That keeps
     the cell interior clean on hover and concentrates the
     reaction on the edges where sparks fire. */
  border-color: var(--accent);
  box-shadow: inset 0 0 3px rgba(var(--accent-rgb), 0.28);
  /* Stepped glitch drives transform + filter together for the
     theme-colored chromatic aberration. Steps(6) gives discrete
     per-frame jumps (digital corruption) rather than interpolation. */
  animation: riftHintGlitch 260ms steps(6) infinite;
}
.hero__rift__hint:hover::after,
.hero__rift__hint:focus-visible::after {
  opacity: 1;
}

/* Spark embers that pop outward from the border on hover/focus.
   Six child <span>s inside the button. Each is pinned at the
   button's center via top/left 50% + transform-origin 50% 50%,
   then the keyframe rotates around that origin with --dir before
   translating outward. That way every spark shares the same
   riftHintSpark keyframe but aims in its own direction (per
   nth-child --dir) and starts at its own phase in the loop (per
   nth-child --spark-delay). Two size + color variants for more
   visual variety - accent-colored sparks read as "hot" and
   fg-colored sparks read as the theme's neutral ember.
   Idle: opacity 0, no animation (no CPU cost when not hovered).
   Hover/focus: animation kicks in, sparks fire continuously. */
.hero__rift__hint__spark {
  position: absolute;
  top: 50%;
  left: 50%;
  width: var(--spark-size, 3px);
  height: var(--spark-size, 3px);
  background: var(--spark-color, var(--accent));
  box-shadow: 0 0 4px 0.5px rgba(var(--spark-rgb, var(--accent-rgb)), 0.85);
  opacity: 0;
  pointer-events: none;
  transform-origin: 50% 50%;
  /* Keeping the translate axis simple (+X after rotate) means
     all sparks use the same keyframe - the per-element aim comes
     purely from --dir. will-change hints the browser to upload
     the 6 layers to the compositor so the burst doesn't repaint
     the whole hint every frame. */
  will-change: transform, opacity;
  /* When hover/focus ends the animation goes away, which would
     otherwise snap in-flight sparks from opacity:1 back to their
     base opacity:0 in a single frame. This transition catches
     that return-to-base so remaining sparks soft-fade out instead
     of blinking off - reads as sparks settling down instead of
     being abruptly cut. Animation takes priority while active, so
     this only applies on the exit path. */
  transition: opacity 260ms ease-out;
}
.hero__rift__hint__spark:nth-child(1) { --dir:  24deg; --spark-delay: 0s;     --spark-size: 3px; --spark-color: var(--accent); --spark-rgb: var(--accent-rgb); }
.hero__rift__hint__spark:nth-child(2) { --dir:  82deg; --spark-delay: 0.16s;  --spark-size: 2px; --spark-color: var(--fg);     --spark-rgb: var(--fg-rgb); }
.hero__rift__hint__spark:nth-child(3) { --dir: 148deg; --spark-delay: 0.32s;  --spark-size: 3px; --spark-color: var(--accent); --spark-rgb: var(--accent-rgb); }
.hero__rift__hint__spark:nth-child(4) { --dir: 212deg; --spark-delay: 0.48s;  --spark-size: 2px; --spark-color: var(--fg);     --spark-rgb: var(--fg-rgb); }
.hero__rift__hint__spark:nth-child(5) { --dir: 278deg; --spark-delay: 0.64s;  --spark-size: 3px; --spark-color: var(--accent); --spark-rgb: var(--accent-rgb); }
.hero__rift__hint__spark:nth-child(6) { --dir: 332deg; --spark-delay: 0.8s;   --spark-size: 2px; --spark-color: var(--fg);     --spark-rgb: var(--fg-rgb); }

.hero__rift__hint:hover .hero__rift__hint__spark,
.hero__rift__hint:focus-visible .hero__rift__hint__spark {
  animation: riftHintSpark 1.05s ease-out var(--spark-delay, 0s) infinite;
}

@keyframes riftHintSpark {
  /* Emit-from-border trajectory. The button is 96x96, so its
     center is at (48,48) from the box origin; the visible border
     sits at inset:8 (80x80), i.e. ~40px out from center. Each
     keyframe uses `rotate(var(--dir)) translate(...)`, which
     rotates the translation axis first and then moves the spark
     along its own aimed direction.
       0%:   born at ~36px from center (just inside the border)
             still invisible - this is the "loading" beat
       12%:  crossed the border, at ~42px, full opacity - peak
             brightness, pops out as visible spark
       50%:  mid-flight at ~50px, softening
       100%: at ~58px, faded out + shrunk - ember dies
     Total ~20-22px of travel beyond the border before fade,
     enough to feel like sparks are flying off without straying
     so far they look like separate elements. */
  0% {
    transform: translate(-50%, -50%) rotate(var(--dir)) translate(36px, 0) scale(0.6);
    opacity: 0;
  }
  12% {
    transform: translate(-50%, -50%) rotate(var(--dir)) translate(42px, 0) scale(1);
    opacity: 1;
  }
  50% {
    transform: translate(-50%, -50%) rotate(var(--dir)) translate(50px, 0) scale(0.85);
    opacity: 0.7;
  }
  100% {
    transform: translate(-50%, -50%) rotate(var(--dir)) translate(58px, 0) scale(0.25);
    opacity: 0;
  }
}
.hero__rift__hint:focus-visible {
  /* Keyboard focus ring: outline instead of box-shadow so it
     can't be mistaken for a re-introduced outer halo. */
  outline: 1.5px solid var(--accent);
  outline-offset: 3px;
}
.hero__rift__hint:active::before {
  /* Brief "committed" flash the moment the rift fires: the neon
     tube snaps from accent-orange to cold cyan. Only the border
     color changes - the inset blur stays matched to the idle/
     hover states so we don't flash a hazy blue fog into the
     middle of the cell at the click instant. The canvas rift
     animation that follows carries the rest of the impact. */
  border-color: #9af0ff;
  box-shadow: inset 0 0 3px rgba(154, 240, 255, 0.35);
  opacity: 1;
}

@keyframes riftHintSway {
  /* Multi-axis idle drift - amplitudes are a family step above
     the other hero grid cells (which do clean 180°/360° flips
     and simple scale pulses) so the rift cell reads as "the
     same family, but weirder / more restless". Continuous
     rotation on all three axes with bigger swings (±32° on Y,
     ±14° on X, ±12° on Z - was ±20°/±8°/±7°) + two scale
     "breath" moments (a swell at 34% and a shrink at 72%) with
     the rest of the cycle held at scale(1). The larger Y-axis
     range in particular sells the "2D/3D rotation" brief - at
     32° the cell tips far enough past its grid plane to read as
     clearly 3D instead of a flat icon wiggling. Twelve keyframes
     (was ten) so the extra detail doesn't interpolate through
     simple straight lines between peaks; each adjacent pair
     defines a distinct micro-motion. Scale peaks still neighbor
     each other as scale(1) -> peak -> scale(1) so interpolation
     stays localized and reads as "resizes sometimes, not all
     the time". 11s period is slow enough to feel suspicious but
     fast enough to always read as moving. */
  0%   { transform: perspective(560px) rotateZ(0deg)    rotateY(0deg)    rotateX(0deg)    scale(1); }
  10%  { transform: perspective(560px) rotateZ(6deg)    rotateY(22deg)   rotateX(-6deg)   scale(1); }
  20%  { transform: perspective(560px) rotateZ(8deg)    rotateY(30deg)   rotateX(-2deg)   scale(1); }
  28%  { transform: perspective(560px) rotateZ(-5deg)   rotateY(-24deg)  rotateX(10deg)   scale(1); }
  34%  { transform: perspective(560px) rotateZ(-2deg)   rotateY(-10deg)  rotateX(6deg)    scale(1.12); }
  42%  { transform: perspective(560px) rotateZ(5deg)    rotateY(14deg)   rotateX(4deg)    scale(1); }
  52%  { transform: perspective(560px) rotateZ(11deg)   rotateY(20deg)   rotateX(8deg)    scale(1); }
  60%  { transform: perspective(560px) rotateZ(7deg)    rotateY(6deg)    rotateX(12deg)   scale(1); }
  68%  { transform: perspective(560px) rotateZ(-10deg)  rotateY(-32deg)  rotateX(-4deg)   scale(1); }
  72%  { transform: perspective(560px) rotateZ(-7deg)   rotateY(-20deg)  rotateX(3deg)    scale(0.84); }
  84%  { transform: perspective(560px) rotateZ(4deg)    rotateY(12deg)   rotateX(7deg)    scale(1); }
  100% { transform: perspective(560px) rotateZ(0deg)    rotateY(0deg)    rotateX(0deg)    scale(1); }
}

@keyframes riftHintDrift {
  /* Idle positional wander for the whole hint button.
     Amplitude sizing: ±22px horizontally, ±18px vertically
     (was ±14/±12). The earlier range was readable but the
     cell still felt tethered to its grid slot; bumping to
     ±18-22px gives it genuine wander - you can see the cell
     drift the better part of a cell-width in each direction
     over the 11s cycle, which is what sells the "has its own
     mind" brief. The cell still stays close enough to its
     grid slot that it reads as "the same cell, drifting"
     rather than "a new cell appeared", but now the wander
     is unambiguous. 11s period stays offset from the sway's
     11s because the two animations drive different elements
     (button vs ::before) with different transform-origins -
     same period, different kinematics, so the eye reads them
     as independent motions not a single synced loop.

     Uses the individual `translate:` CSS property (NOT
     `transform:`) on purpose so it never conflicts with:
       - `transform:` on .is-teleporting, which sets the
         scale(0.96) shrink during teleport; `translate:`
         composes on top of `transform:` in the final matrix
         so the drift just adds an offset to the teleport
         animation instead of fighting it.
       - the ::before sway, which rotates/scales the visible
         border in place and has its own transform-origin.
     Asymmetric keyframe spacing (peaks at 14/28/46/60/76/88%,
     never simple halves) prevents the wander from reading as
     a predictable back-and-forth rhythm. Added an extra
     keyframe (7 peaks vs 5) so the path traces a more
     irregular figure-eight-ish shape instead of a smoother
     ellipse. */
  0%   { translate:   0     0;    }
  14%  { translate:  16px  -9px;  }
  28%  { translate:   6px  14px;  }
  46%  { translate: -13px  18px;  }
  60%  { translate: -22px  -5px;  }
  76%  { translate:  10px -16px;  }
  88%  { translate:  19px   4px;  }
  100% { translate:   0     0;    }
}

@keyframes riftHintGlitch {
  /* Hover glitch - classic RGB-split chromatic aberration tuned
     for elegance, not drama. This is the "back-of-camera" kind
     of CA you'd see on a cheap lens, not the aggressive stylized
     kind - the ghost channels are deliberately translucent so
     they read as subtly smeared RGB rather than visible colored
     echoes of the border.
       - cyan channel  (rgba(70, 200, 255)) - cooler side
       - magenta chan. (rgba(255, 80, 150)) - warmer side
     Both triplets match the CYAN_RGB / MAGENTA_RGB constants the
     rift particle system uses for tear sparks, so the hint's
     hover ghosts and the explosion sparks read as the same
     "corruption signature" - not two unrelated palettes.
     Three design rules keep this professional instead of
     cartoonish:
       1. Offsets stay inside ±2.2px. Any wider and the ghosts
          separate into distinct rectangles instead of fused
          smears. Real camera CA is sub-pixel - ±2.2px is
          stylized but stays in the "this lens has aberration"
          reading rather than "this UI is broken".
       2. Alphas are capped at ~0.55. Earlier iterations used
          0.85-1.0 alpha and the ghosts screamed at you. Dropping
          to 0.35-0.55 range makes them whisper - they're there,
          they jitter, but they don't dominate the border. This
          is the single biggest lever between "amateur neon
          effect" and "pro camera-lens CA".
       3. Ghost offsets NEVER fully converge to 0 on any frame.
          If they did, that frame would read as "the glitch
          paused" and break the vibration. Minimum ~1px kept
          throughout so the CA stays continuous.
     Each keyframe simultaneously jitters:
       - transform (always-on ±1-2px sub-pixel shake)
       - filter    (cyan + magenta offsets, always non-zero,
                    asymmetric alphas between frames so the
                    ghosts breathe slightly instead of staying
                    flat - small detail that sells "motion")
     steps(6) timing gives discrete per-frame jumps (digital
     corruption feel) rather than smooth interpolation. 260ms
     loop fires ~4x/second. */
  0% {
    transform: translate(0.5px, -0.5px);
    filter:
      drop-shadow(-1.4px 0.3px 0 rgba(70, 200, 255, 0.42))
      drop-shadow(1.4px -0.3px 0 rgba(255, 80, 150, 0.42));
  }
  16% {
    transform: translate(-1.5px, 0.5px);
    filter:
      drop-shadow(-2.2px 0.6px 0 rgba(70, 200, 255, 0.55))
      drop-shadow(1.8px -0.4px 0 rgba(255, 80, 150, 0.48));
  }
  33% {
    transform: translate(1px, -1px);
    filter:
      drop-shadow(-1px -0.6px 0 rgba(70, 200, 255, 0.38))
      drop-shadow(2.1px 0.4px 0 rgba(255, 80, 150, 0.5));
  }
  50% {
    transform: translate(-0.5px, 1.2px);
    filter:
      drop-shadow(-2.2px 0.2px 0 rgba(70, 200, 255, 0.52))
      drop-shadow(1.2px -0.8px 0 rgba(255, 80, 150, 0.4));
  }
  66% {
    transform: translate(1.5px, 0.5px);
    filter:
      drop-shadow(-0.9px 0.4px 0 rgba(70, 200, 255, 0.38))
      drop-shadow(2px -0.2px 0 rgba(255, 80, 150, 0.45));
  }
  83% {
    transform: translate(-1px, -1px);
    filter:
      drop-shadow(-1.8px -0.5px 0 rgba(70, 200, 255, 0.5))
      drop-shadow(1.5px 0.8px 0 rgba(255, 80, 150, 0.48));
  }
  100% {
    transform: translate(0.5px, -0.5px);
    filter:
      drop-shadow(-1.4px 0.3px 0 rgba(70, 200, 255, 0.42))
      drop-shadow(1.4px -0.3px 0 rgba(255, 80, 150, 0.42));
  }
}
/* No viewport-width gate on the rift: the effect now runs on
   mobile + tablet too. JS picks viewport-appropriate safe zones
   (top strip + above-marquee row on narrow screens, where no
   content lives) so the hint never lands on the centered CTAs /
   title, and the canvas's `contain: paint` clips any tear that
   would have run past the hero edge - so the occasional
   half-cropped seam at a narrow viewport reads as an intentional
   "reality tearing off the screen" rather than a bug. Only the
   reduced-motion rule below still removes both. */

/* Touch devices don't have :hover, so the `riftHintGlitch` +
   hover-sparks rules never fire for them and touch users would
   otherwise never see the chromatic-aberration signature that's
   a core part of the Half-Life / brutalist aesthetic. This
   passive cycle layers ON TOP of the idle `riftHintSway`
   (riftHintSway drives `transform`, the passive anim drives
   `filter` only - CSS composes them cleanly, no property
   conflict), and flickers CA briefly in a ~0.8s window every
   ~4.8s. The "mostly off, occasionally glitching" rhythm reads
   as "reality occasionally destabilizing around this cell"
   rather than a constant drone that would turn into visual
   noise. Colors + alphas match the hover riftHintGlitch so
   desktop hover and touch idle share one coherent CA palette.
   `:focus-visible` and `:hover` selectors still win over this
   when they match (for keyboards-attached-to-tablets, for
   instance), because those rules use the `animation` shorthand
   which REPLACES both entries at once with the full-intensity
   riftHintGlitch. */
@media (hover: none) {
  .hero__rift__hint::before {
    animation:
      riftHintSway 11s ease-in-out infinite,
      riftHintGlitchPassive 4.8s steps(8) infinite;
  }
}

@keyframes riftHintGlitchPassive {
  /* Long quiet stretch (0-78%, ~3.75s) then a brief stutter
     (78-89%, ~530ms) then back to clean. Three discrete CA
     frames in the stutter window give a "stutter-reboot" feel
     through steps(8) interpolation. Alphas held lower than the
     hover version (0.3-0.42 vs 0.35-0.55) so passive users get
     a hint of the CA rather than the full hover-intensity
     burst - the effect reads as ambient rather than a
     reaction-to-interaction. */
  0%, 78%, 100% { filter: none; }
  80% {
    filter:
      drop-shadow(-0.9px 0.2px 0 rgba(70, 200, 255, 0.38))
      drop-shadow(0.9px -0.2px 0 rgba(255, 80, 150, 0.38));
  }
  83% {
    filter:
      drop-shadow(1.1px -0.3px 0 rgba(70, 200, 255, 0.42))
      drop-shadow(-1.1px 0.3px 0 rgba(255, 80, 150, 0.42));
  }
  86% {
    filter:
      drop-shadow(-0.7px -0.2px 0 rgba(70, 200, 255, 0.3))
      drop-shadow(0.7px 0.2px 0 rgba(255, 80, 150, 0.3));
  }
  89% { filter: none; }
}

@media (prefers-reduced-motion: reduce) {
  .hero__rift__hint,
  .hero__rift__hint:hover,
  .hero__rift__hint:focus-visible,
  .hero__rift__hint::before,
  .hero__rift__hint:hover::before,
  .hero__rift__hint:focus-visible::before,
  .hero__rift__hint:hover .hero__rift__hint__spark,
  .hero__rift__hint:focus-visible .hero__rift__hint__spark { animation: none; }
}

.hero__meta {
  display: inline-flex;
  align-items: center;
  gap: 10px;
  font-family: var(--font-mono);
  font-size: 11px;
  font-weight: 700;
  line-height: 1;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--fg-mute);
  padding: 6px 12px;
  border: 1.5px solid var(--line-strong);
  background: var(--bg-panel);
  align-self: flex-start;
  position: relative;
  z-index: 2;
  animation: fadeInUp 600ms var(--ease) both;
}
.hero__title {
  margin-top: 28px;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: clamp(3.5rem, 13vw, 11rem);
  line-height: 0.92;
  letter-spacing: -0.05em;
  color: var(--fg);
  position: relative;
  z-index: 2;
}
.hero__title__line {
  display: block;
  position: relative;
  /* `overflow: hidden` is needed so the titleRise animation can slide the
     text up from below without it leaking over the line above. With
     line-height: 0.92 the line box has no room for descenders, so the
     comma in "HI," gets clipped at the bottom. The padding-bottom adds
     descender room to the clip area, and the matching negative
     margin-bottom keeps the visual gap to the next line unchanged. */
  overflow: hidden;
  padding-bottom: 0.18em;
  margin-bottom: -0.18em;
}
.hero__title__line > span {
  display: inline-block;
  animation: titleRise 900ms var(--ease) both;
}

.hero__title__line--accent > span {
  color: var(--accent);
  /* layered breathing neon: tight inner glow over the wide ambient halo */
  text-shadow:
    0 0 8px  rgba(var(--accent-rgb), 0.45),
    0 0 24px rgba(var(--accent-rgb), 0.3),
    0 0 60px rgba(var(--accent-rgb), 0.28);
  animation:
    titleRise 900ms var(--ease) both,
    accentBreathe 5.5s ease-in-out 1.2s infinite;
}
.hero__title__line:nth-child(2) > span {
  animation:
    titleRise 900ms var(--ease) 120ms both,
    accentBreathe 5.5s ease-in-out 1.2s infinite;
}
:root[data-theme="light"] .hero__title__line--accent > span {
  text-shadow:
    0 0 6px  rgba(var(--accent-rgb), 0.55),
    0 0 22px rgba(var(--accent-rgb), 0.4),
    0 0 50px rgba(var(--accent-rgb), 0.45);
}

@keyframes titleRise {
  from {
    /* `110%` clears the line-box. The extra `+ 0.2em` clears the
       padding-bottom we added to .hero__title__line for descenders, so
       the span is still fully off-screen at frame 0. */
    transform: translateY(calc(110% + 0.2em)) rotate(2deg);
    opacity: 0;
  }
  to {
    transform: translateY(0) rotate(0);
    opacity: 1;
  }
}
@keyframes fadeInUp {
  from { transform: translateY(20px); opacity: 0; }
  to { transform: translateY(0); opacity: 1; }
}

.hero__sub {
  margin-top: 32px;
  max-width: 60ch;
  font-size: clamp(1rem, 1.2vw, 1.15rem);
  color: var(--fg-mute);
  position: relative;
  z-index: 2;
  animation: fadeInUp 700ms var(--ease) 400ms both;
}

.hero__cta {
  margin-top: 36px;
  display: flex;
  flex-wrap: wrap;
  gap: 14px;
  position: relative;
  z-index: 2;
  animation: fadeInUp 700ms var(--ease) 550ms both;
}

.btn {
  display: inline-flex;
  align-items: center;
  gap: 10px;
  padding: 14px 22px;
  font-family: var(--font-mono);
  font-size: 12px;
  font-weight: 700;
  line-height: 1;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  border: 2px solid var(--line-fg);
  background: transparent;
  color: var(--fg);
  position: relative;
  transition: transform 180ms var(--ease-snap), background 180ms, color 180ms, box-shadow 180ms;
}
.btn svg {
  width: 14px;
  height: 14px;
  transition: transform 220ms var(--ease-snap);
}
.btn--primary {
  background: var(--accent);
  color: var(--accent-fg);
  border-color: var(--accent);
  /* brutalist offset + subtle warm neon halo bleeding downward, like the
     button is casting light onto the surface beneath it. negative spread
     keeps the glow concentrated below the button instead of haloing all
     four sides */
  box-shadow:
    var(--shadow),
    0 14px 28px -10px rgba(var(--accent-rgb), 0.5),
    0 26px 52px -14px rgba(var(--accent-rgb), 0.28);
}
.btn--primary:hover {
  transform: translate(-3px, -3px);
  /* hard brutalist offset shadow + soft neon halo radiating from the button +
     the same downward warm bleed from the rest state, so the button still
     looks like it's casting light onto the floor when lifted */
  box-shadow:
    7px 7px 0 0 #f5f5f5,
    0 0 18px rgba(var(--accent-rgb), 0.55),
    0 0 42px rgba(var(--accent-rgb), 0.32),
    0 14px 28px -10px rgba(var(--accent-rgb), 0.5),
    0 26px 52px -14px rgba(var(--accent-rgb), 0.28);
}
:root[data-theme="light"] .btn--primary:hover {
  box-shadow:
    7px 7px 0 0 #0a0a0a,
    0 0 18px rgba(var(--accent-rgb), 0.55),
    0 0 42px rgba(var(--accent-rgb), 0.32),
    0 14px 28px -10px rgba(var(--accent-rgb), 0.5),
    0 26px 52px -14px rgba(var(--accent-rgb), 0.28);
}
.btn--primary:hover svg {
  transform: translateX(4px);
}
.btn--ghost {
  background: transparent;
  color: var(--fg);
  border-color: var(--line-strong);
}
.btn--ghost:hover {
  background: var(--fg);
  color: var(--bg);
  border-color: var(--fg);
  transform: translate(-2px, -2px);
}
.btn--sm {
  padding: 10px 16px;
  font-size: 11px;
}

.hero__scroll {
  position: absolute;
  left: var(--pad-page-x);
  bottom: 80px;
  display: inline-flex;
  align-items: center;
  flex-direction: row-reverse;
  gap: 14px;
  padding: 4px;
  font-family: var(--font-mono);
  font-size: 10px;
  font-weight: 700;
  line-height: 1;
  letter-spacing: 0.3em;
  text-transform: uppercase;
  color: var(--fg-dim);
  z-index: 2;
  transition: color 200ms var(--ease);
  text-decoration: none;
}
.hero__scroll:hover,
.hero__scroll:focus-visible {
  color: var(--fg);
  outline: none;
}
.hero__scroll__line {
  width: 60px;
  height: 1.5px;
  background: var(--accent);
  position: relative;
  overflow: hidden;
  box-shadow:
    0 0 6px  rgba(var(--accent-rgb), 0.55),
    0 0 14px rgba(var(--accent-rgb), 0.3);
}
.hero__scroll__line::after {
  content: "";
  position: absolute;
  inset: 0;
  background: var(--bg);
  animation: scrollLine 2.4s var(--ease) infinite;
}
@keyframes scrollLine {
  0% { transform: translateX(-100%); }
  60% { transform: translateX(100%); }
  100% { transform: translateX(100%); }
}

/* small brutalist square button with a down arrow - bobs gently to invite a click */
.hero__scroll__arrow {
  display: inline-grid;
  place-items: center;
  width: 30px;
  height: 30px;
  border: 1.5px solid var(--line-strong);
  /* slightly off-white in light mode so the button doesn't punch out as
     pure #fff against the warm cream page bg */
  background: var(--bg-panel);
  color: var(--fg);
  transition:
    background 200ms var(--ease),
    color 200ms var(--ease),
    border-color 200ms var(--ease),
    transform 220ms var(--ease-snap),
    box-shadow 220ms var(--ease-snap);
  animation: scrollArrowBob 2.2s ease-in-out infinite;
  will-change: transform;
}
.hero__scroll__arrow svg {
  width: 14px;
  height: 14px;
}
:root[data-theme="light"] .hero__scroll__arrow {
  background: #fbf8ed;
}
.hero__scroll:hover .hero__scroll__arrow,
.hero__scroll:focus-visible .hero__scroll__arrow {
  background: var(--accent);
  color: var(--accent-fg);
  border-color: var(--accent);
  transform: translateY(2px);
  /* keep just the warm neon halo on hover - the hard offset shadow read
     too heavy on such a small button */
  box-shadow:
    0 0 14px rgba(var(--accent-rgb), 0.55),
    0 0 32px rgba(var(--accent-rgb), 0.3);
  animation-play-state: paused;
}
@keyframes scrollArrowBob {
  0%, 100% { transform: translateY(0); }
  50% { transform: translateY(4px); }
}

/* ---------- BACK-TO-TOP CUE ----------
   Bookends the page by mirroring the hero "SCROLL" cue. Sits in a thin
   band between the last section and the footer, anchored to the right
   edge so it visually rhymes with the hero cue's bottom-left anchor. */
.back-to-top-bar {
  /* Outer band spans the full viewport like the footer chrome. */
  padding: 40px var(--pad-page-x);
  background: var(--bg);
  width: 100%;
}
.back-to-top-bar__inner {
  /* Inner track mirrors `.footer__top` / `.footer__bottom` so the cue
     aligns with the right edge of the footer's content column on any
     viewport. */
  max-width: var(--max-w);
  margin: 0 auto;
  display: flex;
  justify-content: flex-end;
}
.back-to-top {
  display: inline-flex;
  align-items: center;
  gap: 14px;
  padding: 4px;
  font-family: var(--font-mono);
  font-size: 10px;
  font-weight: 700;
  line-height: 1;
  letter-spacing: 0.3em;
  text-transform: uppercase;
  color: var(--fg-dim);
  transition: color 200ms var(--ease);
  text-decoration: none;
}
.back-to-top:hover,
.back-to-top:focus-visible {
  color: var(--fg);
  outline: none;
}
.back-to-top__line {
  width: 60px;
  height: 1.5px;
  background: var(--accent);
  position: relative;
  overflow: hidden;
  box-shadow:
    0 0 6px  rgba(var(--accent-rgb), 0.55),
    0 0 14px rgba(var(--accent-rgb), 0.3);
}
.back-to-top__line::after {
  content: "";
  position: absolute;
  inset: 0;
  background: var(--bg);
  /* swept opposite direction from the hero cue's line so the bookend
     reads as a mirror, not a copy */
  animation: scrollLineReverse 2.4s var(--ease) infinite;
}
@keyframes scrollLineReverse {
  0%   { transform: translateX(100%); }
  60%  { transform: translateX(-100%); }
  100% { transform: translateX(-100%); }
}
.back-to-top__arrow {
  display: inline-grid;
  place-items: center;
  width: 30px;
  height: 30px;
  border: 1.5px solid var(--line-strong);
  background: var(--bg-panel);
  color: var(--fg);
  transition:
    background 200ms var(--ease),
    color 200ms var(--ease),
    border-color 200ms var(--ease),
    transform 220ms var(--ease-snap),
    box-shadow 220ms var(--ease-snap);
  /* hero cue bobs DOWN, this one bobs UP to reinforce the direction */
  animation: backToTopArrowBob 2.2s ease-in-out infinite;
  will-change: transform;
}
.back-to-top__arrow svg {
  width: 14px;
  height: 14px;
}
:root[data-theme="light"] .back-to-top__arrow {
  background: #fbf8ed;
}
.back-to-top:hover .back-to-top__arrow,
.back-to-top:focus-visible .back-to-top__arrow {
  background: var(--accent);
  color: var(--accent-fg);
  border-color: var(--accent);
  transform: translateY(-2px);
  box-shadow:
    0 0 14px rgba(var(--accent-rgb), 0.55),
    0 0 32px rgba(var(--accent-rgb), 0.3);
  animation-play-state: paused;
}
@keyframes backToTopArrowBob {
  0%, 100% { transform: translateY(0); }
  50% { transform: translateY(-4px); }
}

/* ---------- MARQUEE ---------- */
.marquee {
  position: relative;
  overflow: hidden;
  border-block: 1.5px solid var(--line-strong);
  background: var(--bg-alt);
  padding: 16px 0;
  cursor: grab;
  /* allow vertical page scroll to pass through, capture horizontal touch */
  touch-action: pan-y;
  user-select: none;
  -webkit-user-select: none;
  /* isolate the marquee's layout & paint from the rest of the page so
     reflows elsewhere (theme transitions, reveals, work grid relayout)
     don't dirty the marquee or affect its compositor-driven animation */
  contain: layout paint;
  /* fade content (and borders) to transparent at the horizontal edges
     so the band reads as scrolling infinitely off-screen */
  --marquee-fade: clamp(48px, 9%, 140px);
  -webkit-mask-image: linear-gradient(
    to right,
    transparent 0,
    #000 var(--marquee-fade),
    #000 calc(100% - var(--marquee-fade)),
    transparent 100%
  );
  mask-image: linear-gradient(
    to right,
    transparent 0,
    #000 var(--marquee-fade),
    #000 calc(100% - var(--marquee-fade)),
    transparent 100%
  );
}
.marquee--hero {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
}
.marquee__track {
  display: inline-flex;
  align-items: center;
  white-space: nowrap;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: clamp(1.4rem, 3vw, 2.2rem);
  letter-spacing: -0.02em;
  color: var(--fg);
  /* duration is set by JS (initMarquee) so the visual scroll speed is
     the same regardless of viewport / total content width. Falls back
     to 50s if JS hasn't run yet. */
  animation: marquee var(--marquee-duration, 50s) linear infinite;
  /* paused until JS measures with the final webfont widths and adds
     `.is-ready` to the marquee. Avoids a font-swap mid-animation snap. */
  animation-play-state: paused;
  will-change: transform;
  pointer-events: none;
}
.marquee.is-ready .marquee__track {
  animation-play-state: running;
}
/* While the user is dragging, the JS removes the animation entirely so
   the inline `transform` style takes effect (CSS animations otherwise
   override inline transforms on the same property). On release the JS
   restores the animation with the right `animation-delay` so it picks
   up seamlessly from where the user let go. */
.marquee.is-paused .marquee__track {
  animation: none;
}
.marquee.is-dragging {
  cursor: grabbing;
}
.marquee__track > span {
  display: inline-block;
  /* Use padding-inline (not flex `gap`) for inter-item spacing so the
     duplicated content makes scrollWidth/2 EXACTLY equal to one full
     cycle. With `gap` the wrap distance would be off by half a gap and
     the CSS translateX(-50%) loop would snap on every cycle. */
  padding-inline: 16px;
}
.marquee__track > span:nth-child(odd) {
  color: var(--fg);
}
/* Brutalist marquee separator - a small solid accent diamond (square
   rotated 45deg) between tech names. `em`-sized so it scales with the
   marquee's responsive font-size - stays proportional to the tech
   names at every viewport. A rotated rectangle (not a plain square)
   keeps a clear visual distinction from the solid-rectangle indicators
   used elsewhere (`.dot--live`, section-line caps, header badge),
   so the user reads "separator" not "status light". */
/* Use `.marquee__track > span.marquee__sep` (specificity 0,2,1) so the
   `display` here beats `.marquee__track > span { display: inline-block }`
   - an inline-block span would still render the ::before but its layout
   metrics make centering the rotated mark awkward. */
.marquee__track > span.marquee__sep {
  display: inline-flex;
  align-items: center;
  justify-content: center;
}
/* `display: inline-block` is required on the pseudo itself - without
   it the default `inline` display ignores width/height and the diamond
   collapses to 0x0. */
.marquee__sep::before {
  content: "";
  display: inline-block;
  width: 0.35em;
  height: 0.35em;
  background: var(--accent);
  transform: rotate(45deg);
}
@keyframes marquee {
  from { transform: translate3d(0, 0, 0); }
  to { transform: translate3d(-50%, 0, 0); }
}
@media (prefers-reduced-motion: reduce) {
  .marquee__track {
    animation: none;
  }
}

/* ---------- SECTIONS ---------- */
.section {
  padding: var(--pad-section-y) var(--pad-page-x);
  max-width: var(--max-w);
  margin: 0 auto;
  width: 100%;
  position: relative;
}

.section__head {
  display: flex;
  align-items: center;
  gap: 18px;
  margin-bottom: 48px;
}
.section__title {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: clamp(2rem, 4.5vw, 3.4rem);
  letter-spacing: -0.04em;
  line-height: 1;
  color: var(--fg);
}
/* orange `//` prefix on every section title - kept in CSS so translations
   stay clean and the accent never has to be repeated in the dictionary. */
.section__title::before {
  content: "// ";
  color: var(--accent);
  text-shadow:
    0 0 8px  rgba(var(--accent-rgb), 0.4),
    0 0 22px rgba(var(--accent-rgb), 0.22);
  animation: accentBreathe 6s ease-in-out infinite;
}
/* slightly stagger each section's breath so they don't pulse in sync */
.section:nth-of-type(2) .section__title::before { animation-delay: -1.2s; }
.section:nth-of-type(3) .section__title::before { animation-delay: -2.4s; }
.section:nth-of-type(4) .section__title::before { animation-delay: -3.6s; }
.section:nth-of-type(5) .section__title::before { animation-delay: -4.8s; }
.section__line {
  flex: 1;
  height: 1.5px;
  background: var(--line-strong);
  position: relative;
}
.section__line::after {
  content: "";
  position: absolute;
  right: 0;
  top: -3px;
  bottom: -3px;
  width: 12px;
  background: var(--accent);
  animation: sectionCapPulse 3.2s ease-out infinite;
}
/* Stagger each section's pulse so they don't all fire in unison -
   avoids the page feeling "strobey". Offsets are spread across the
   3.2s cycle (~0.6-0.65s apart) instead of matching the breathing
   delays from the `//` prefixes, because this pulse is phase-based
   (ring expansion) rather than amplitude-based (halo fade), so a
   simple even spread reads better. */
.section:nth-of-type(2) .section__line::after { animation-delay: -0.6s; }
.section:nth-of-type(3) .section__line::after { animation-delay: -1.2s; }
.section:nth-of-type(4) .section__line::after { animation-delay: -1.9s; }
.section:nth-of-type(5) .section__line::after { animation-delay: -2.5s; }
.section__lede {
  max-width: 60ch;
  font-size: clamp(1rem, 1.2vw, 1.1rem);
  color: var(--fg-mute);
  margin-bottom: 36px;
}

/* ---------- ABOUT ---------- */
.about__grid {
  display: grid;
  grid-template-columns: 1.4fr 1fr;
  gap: 48px;
  align-items: start;
}
.about__bio p {
  font-size: clamp(1.05rem, 1.4vw, 1.3rem);
  color: var(--fg-mute);
  line-height: 1.65;
  margin-bottom: 1.2em;
}
.about__bio p strong {
  color: var(--fg);
  font-weight: 600;
}

.about__card {
  background: var(--bg-panel);
  border: 1.5px solid var(--line-strong);
  box-shadow: var(--shadow);
  padding: 24px;
  /* tilt + transition + 3D context come from the shared [data-tilt] rule */
}
.about__card__head {
  display: flex;
  justify-content: space-between;
  align-items: center;
  line-height: 1;
  padding-bottom: 16px;
  border-bottom: 1.5px solid var(--line);
}
.about__card__list {
  display: flex;
  flex-direction: column;
  gap: 14px;
  margin-top: 18px;
}
.about__card__list > div {
  display: grid;
  grid-template-columns: 100px 1fr;
  gap: 14px;
  align-items: baseline;
}
.about__card__list dt {
  font-family: var(--font-mono);
  font-size: 10px;
  font-weight: 700;
  letter-spacing: 0.18em;
  color: var(--fg-dim);
  text-transform: uppercase;
}
.about__card__list dd {
  font-size: 14px;
  color: var(--fg);
}
.about__card__foot {
  margin-top: 24px;
  padding-top: 18px;
  border-top: 1.5px solid var(--line);
}
.about__card__link {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  font-family: var(--font-mono);
  font-size: 11px;
  font-weight: 700;
  line-height: 1;
  letter-spacing: 0.18em;
  color: var(--accent);
  transition: gap 180ms var(--ease);
}
.about__card__link svg {
  width: 14px;
  height: 14px;
}
.about__card__link:hover {
  gap: 14px;
}

/* ---------- SKILLS ---------- */
.skills__grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 24px;
}
.skills__col {
  background: var(--bg-panel);
  border: 1.5px solid var(--line-strong);
  padding: 24px;
  /* tilt + transition + 3D context come from the shared [data-tilt] rule */
}
.skills__col:hover {
  box-shadow: var(--shadow);
}
:root[data-theme="light"] .skills__col:hover {
  box-shadow: var(--shadow);
}
.skills__col__head {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  padding-bottom: 14px;
  border-bottom: 1.5px solid var(--line);
}
.skills__col__head--mt {
  margin-top: 28px;
}
.skills__col__count {
  font-family: var(--font-mono);
  font-size: 11px;
  color: var(--fg-dim);
  font-weight: 700;
}
.skills__list {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin-top: 16px;
}
.skills__list li {
  font-family: var(--font-mono);
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  padding: 6px 10px;
  border: 1.5px solid var(--line);
  color: var(--fg);
  background: var(--bg-inset);
  transition: background 160ms, color 160ms, border-color 160ms;
}
.skills__list li:hover {
  background: var(--accent);
  color: var(--accent-fg);
  border-color: var(--accent);
}

/* ---------- WORK ---------- */
.work__grid {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 48px;
}
.work__item {
  display: flex;
  flex-direction: column;
  gap: 20px;
  position: relative;
  transform-style: preserve-3d;
}
.work__item__frame {
  position: relative;
  display: block;
  background: var(--bg-panel);
  border: 1.5px solid var(--line-strong);
  box-shadow: var(--shadow);
  aspect-ratio: 16 / 10;
  overflow: hidden;
  /* tilt + transition + 3D context come from the shared [data-tilt] rule.
     overflow:hidden is kept locally because the frame clips its own
     image scale-on-hover. */
}
.work__item__frame:hover {
  box-shadow: 12px 12px 0 0 rgba(0, 0, 0, 0.85);
}
:root[data-theme="light"] .work__item__frame:hover {
  box-shadow: 12px 12px 0 0 #0a0a0a;
}
.work__item__browser {
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 8px 14px;
  background: var(--bg-inset);
  border-bottom: 1.5px solid var(--line);
  height: 30px;
}
.work__item__browser span {
  width: 8px;
  height: 8px;
  background: var(--line-strong);
  display: inline-block;
}
.work__item__browser em {
  margin-left: 12px;
  font-style: normal;
  font-family: var(--font-mono);
  font-size: 10px;
  letter-spacing: 0.08em;
  color: var(--fg-dim);
  text-transform: lowercase;
}
/* static screenshot replaces the heavy live iframe preview - same slot,
   filled with object-cover so the captured viewport tiles the area
   without distortion regardless of the source aspect ratio. */
.work__item__frame img {
  width: 100%;
  height: calc(100% - 30px);
  object-fit: cover;
  object-position: top center;
  display: block;
  background: var(--bg);
  transition: transform 480ms var(--ease), filter 240ms var(--ease);
  filter: saturate(0.92);
}
.work__item__frame:hover img,
.work__item__frame:focus-visible img {
  transform: scale(1.03);
  filter: saturate(1.05) brightness(1.02);
}

/* ---------- Theme-aware work shots ----------
   Some work items ship two screenshots - one for dark theme and one for
   light. They live as two stacked <img> elements inside
   .work__item__shots so the browser can lazy-load BOTH when the work
   item enters the viewport (no `display: none` because that would defer
   loading the hidden one). The active variant is driven entirely by
   :root[data-theme="..."], and the cross-fade rides along with the
   site's existing theme-swap tween / view transition. Ships WebP only -
   WebP is supported by every target browser (~97% global in 2026), and
   the old PNG fallbacks inside <picture><source> were never actually
   downloaded by real visitors while still occupying ~475KB in the
   deploy. */
.work__item__shots {
  position: absolute;
  /* sits below the .work__item__browser strip (height: 30px) */
  top: 30px;
  left: 0;
  right: 0;
  bottom: 0;
}
.work__item__shot {
  /* `.work__item__shot` now lives directly on the <img> (one element
     instead of <picture><source><img></picture>). Both the positioning
     (absolute fill) + the sizing/filter rules are merged here since
     there's no longer a wrapper to split concerns across. The
     `.work__item__frame:hover img` rule above still matches because
     the shot is itself an <img>. */
  position: absolute;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: top center;
  background: var(--bg);
  filter: saturate(0.92);
  transition:
    opacity 380ms var(--ease),
    transform 480ms var(--ease),
    filter 240ms var(--ease);
}
/* Dark is the default (site is dark-first); light variant fades in
   when the user toggles the page to light mode. */
.work__item__shot--light { opacity: 0; }
:root[data-theme="light"] .work__item__shot--dark { opacity: 0; }
:root[data-theme="light"] .work__item__shot--light { opacity: 1; }
.work__item__overlay {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 12px;
  background: color-mix(in srgb, var(--bg) 70%, transparent);
  color: var(--accent);
  font-family: var(--font-mono);
  font-size: 14px;
  font-weight: 700;
  letter-spacing: 0.2em;
  opacity: 0;
  transition: opacity 220ms var(--ease);
  z-index: 2;
}
.work__item__overlay svg {
  width: 18px;
  height: 18px;
}
.work__item__frame:hover .work__item__overlay,
.work__item__frame:focus-visible .work__item__overlay {
  opacity: 1;
}
.work__item__meta {
  display: flex;
  flex-direction: column;
  gap: 8px;
}
.work__item__title {
  display: flex;
  align-items: baseline;
  gap: 12px;
  margin-bottom: 14px;
}
.work__item__title h3 {
  position: relative;
  display: inline-block;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: clamp(1.3rem, 2vw, 1.7rem);
  letter-spacing: -0.02em;
  color: var(--fg);
}
/* small brutalist orange bar under each work title - separated from the text */
.work__item__title h3::after {
  content: "";
  position: absolute;
  left: 0;
  bottom: -10px;
  width: 36px;
  height: 3px;
  background: var(--accent);
  /* baseline neon halo - lifts on hover for an electric ramp */
  box-shadow:
    0 0 6px  rgba(var(--accent-rgb), 0.35),
    0 0 14px rgba(var(--accent-rgb), 0.2);
  transition:
    width 280ms var(--ease-snap),
    box-shadow 280ms var(--ease);
}
.work__item:hover .work__item__title h3::after,
.work__item:focus-within .work__item__title h3::after {
  width: 64px;
  box-shadow:
    0 0 10px rgba(var(--accent-rgb), 0.7),
    0 0 24px rgba(var(--accent-rgb), 0.45),
    0 0 44px rgba(var(--accent-rgb), 0.25);
}
.work__item__meta p {
  color: var(--fg-mute);
  font-size: 0.95rem;
  max-width: 50ch;
}
.work__item__tags {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin-top: 4px;
}
.work__item__tags li {
  font-family: var(--font-mono);
  font-size: 10px;
  font-weight: 700;
  letter-spacing: 0.16em;
  padding: 3px 8px;
  border: 1.5px solid var(--line);
  color: var(--fg-mute);
}

.work__more {
  display: inline-flex;
  align-items: center;
  gap: 12px;
  margin-top: 56px;
  padding: 18px 24px;
  border: 2px solid var(--line-strong);
  font-family: var(--font-mono);
  font-size: 12px;
  font-weight: 700;
  line-height: 1;
  letter-spacing: 0.18em;
  color: var(--fg);
  background: transparent;
  transition: background 200ms, color 200ms, transform 200ms var(--ease-snap);
}
.work__more svg {
  width: 14px;
  height: 14px;
  transition: transform 200ms var(--ease-snap);
}
.work__more:hover {
  background: var(--accent);
  color: var(--accent-fg);
  border-color: var(--accent);
  transform: translate(-3px, -3px);
}
.work__more:hover svg {
  transform: translateX(4px) translateY(-4px);
}

/* ---------- EXPERIENCE ---------- */
.exp__list {
  display: flex;
  flex-direction: column;
  gap: 0;
}
.exp__item {
  display: grid;
  grid-template-columns: 80px 1fr;
  gap: 24px;
  padding-bottom: 32px;
}
.exp__item:last-child {
  padding-bottom: 0;
}
.exp__item__rail {
  position: relative;
  display: flex;
  justify-content: center;
  padding-top: 30px;
}
.exp__item__rail::before {
  content: "";
  position: absolute;
  top: 0;
  bottom: -32px;
  left: 50%;
  width: 2px;
  background: var(--line-strong);
  transform: translateX(-50%);
}
.exp__item:last-child .exp__item__rail::before {
  bottom: 50%;
}
.exp__item__node {
  width: 16px;
  height: 16px;
  background: var(--accent);
  border: 2px solid var(--line-fg);
  position: relative;
  z-index: 1;
  transition: transform 220ms var(--ease-snap);
}
.exp__item:hover .exp__item__node {
  transform: rotate(45deg) scale(1.2);
}
.exp__item__body {
  background: var(--bg-panel);
  border: 1.5px solid var(--line-strong);
  padding: 24px;
  /* tilt + transition + 3D context come from the shared [data-tilt] rule */
}
.exp__item__body:hover {
  box-shadow: var(--shadow);
}
.exp__item__head {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  flex-wrap: wrap;
  gap: 12px;
}
.exp__item__head h3 {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: clamp(1.15rem, 1.8vw, 1.4rem);
  letter-spacing: -0.02em;
  color: var(--fg);
}
.exp__item__period {
  font-family: var(--font-mono);
  font-size: 10px;
  font-weight: 700;
  letter-spacing: 0.16em;
  color: var(--fg-dim);
}
.exp__item__company {
  display: inline-block;
  margin-top: 4px;
  font-family: var(--font-mono);
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.16em;
  color: var(--accent);
}
.exp__item__body p {
  margin-top: 12px;
  color: var(--fg-mute);
  font-size: 0.95rem;
}
.exp__item__tags {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin-top: 14px;
}
.exp__item__tags li {
  font-family: var(--font-mono);
  font-size: 9px;
  font-weight: 700;
  letter-spacing: 0.16em;
  padding: 2px 7px;
  border: 1.5px solid var(--line);
  color: var(--fg-mute);
}

/* ---------- CONTACT ---------- */
.contact__grid {
  display: grid;
  grid-template-columns: 1fr 1.1fr;
  gap: 56px;
}

.contact__pitch h3 {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: clamp(1.4rem, 2.4vw, 2rem);
  letter-spacing: -0.02em;
  line-height: 1.2;
  color: var(--fg);
}
.contact__pitch > p {
  margin-top: 14px;
  color: var(--fg-mute);
  max-width: 38ch;
}
.contact__links {
  margin-top: 32px;
  display: flex;
  flex-direction: column;
  gap: 12px;
}
.contact__links a {
  display: grid;
  grid-template-columns: 100px 1fr auto;
  gap: 16px;
  align-items: center;
  line-height: 1;
  padding: 18px 20px;
  background: var(--bg-panel);
  border: 1.5px solid var(--line-strong);
  font-family: var(--font-mono);
  transition: background 200ms, transform 200ms var(--ease-snap), border-color 200ms;
}
.contact__links a:hover {
  background: var(--accent);
  color: var(--accent-fg);
  border-color: var(--accent);
  transform: translate(-3px, -3px);
}
.contact__links a:hover .brut-label {
  color: var(--accent-fg);
}
.contact__links__arrow {
  width: 20px;
  height: 20px;
  flex-shrink: 0;
  transition: transform 220ms var(--ease-snap);
}
/* per-direction hover nudge - arrows lean the way they point */
.contact__links a:hover .brut-arrow--right { transform: translateX(4px); }
.contact__links a:hover .brut-arrow--upright { transform: translate(4px, -4px); }
.contact__links__cv {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 12px;
}
.contact__links__cv a {
  grid-template-columns: auto 1fr auto;
}

.contact__form {
  background: var(--bg-panel);
  border: 1.5px solid var(--line-strong);
  padding: 28px;
  display: flex;
  flex-direction: column;
  gap: 20px;
  position: relative;
  box-shadow: var(--shadow);
  /* tilt + transition + 3D context come from the shared [data-tilt] rule */
}
.contact__form__bot {
  position: absolute;
  left: -9999px;
  width: 1px;
  height: 1px;
  overflow: hidden;
}
.contact__form__row label {
  display: flex;
  flex-direction: column;
  gap: 8px;
}
.contact__form input,
.contact__form textarea {
  width: 100%;
  padding: 14px;
  background: var(--bg-inset);
  border: 1.5px solid var(--line);
  color: var(--fg);
  font-family: var(--font-mono);
  font-size: 14px;
  transition: border-color 180ms, background 180ms;
}
.contact__form textarea {
  resize: none;
  min-height: 160px;
  overflow-y: auto;
  line-height: 1.5;
}
.contact__form input::placeholder,
.contact__form textarea::placeholder {
  color: var(--fg-dim);
  font-style: italic;
}
.contact__form input:focus,
.contact__form textarea:focus {
  outline: none;
  border-color: var(--accent);
  background: var(--bg-elev);
}
.contact__form__actions {
  display: flex;
  justify-content: flex-end;
  gap: 12px;
  margin-top: 6px;
}

/* ---------- FOOTER ---------- */
.footer {
  border-top: 1.5px solid var(--line-strong);
  background: var(--bg-alt);
  padding: 48px var(--pad-page-x) 24px;
  /* The .back-to-top-bar that sits directly above provides the
     breathing room from the previous section, so no margin needed
     here. */
  margin-top: 0;
}
.footer__top {
  max-width: var(--max-w);
  margin: 0 auto;
  display: grid;
  grid-template-columns: auto 1fr auto;
  gap: 32px;
  align-items: center;
  padding-bottom: 32px;
  border-bottom: 1.5px solid var(--line);
}
.footer__brand {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 22px;
  letter-spacing: -0.04em;
  display: inline-flex;
  align-items: baseline;
  gap: 1px;
}
.footer__brand__a {
  display: inline-block;
  padding: 2px 6px;
  background: var(--fg);
  color: var(--bg);
}
.footer__brand__b {
  color: var(--accent);
  font-weight: 600;
  font-size: 14px;
}
.footer__nav {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 24px;
}
.footer__nav a {
  font-family: var(--font-mono);
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.18em;
  color: var(--fg-mute);
  position: relative;
  transition: color 180ms;
}
.footer__nav a::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: -4px;
  height: 1.5px;
  background: var(--accent);
  transform: scaleX(0);
  transform-origin: left;
  transition: transform 200ms var(--ease);
}
.footer__nav a:hover {
  color: var(--fg);
}
.footer__nav a:hover::after {
  transform: scaleX(1);
}
.footer__socials {
  display: flex;
  gap: 10px;
}
.footer__socials a {
  width: 38px;
  height: 38px;
  display: grid;
  place-items: center;
  border: 1.5px solid var(--line-strong);
  color: var(--fg);
  transition: background 180ms, color 180ms, border-color 180ms, transform 180ms var(--ease-snap);
}
.footer__socials a svg {
  width: 16px;
  height: 16px;
}
.footer__socials a:hover {
  background: var(--accent);
  color: var(--accent-fg);
  border-color: var(--accent);
  transform: translate(-2px, -2px);
}
.footer__bottom {
  max-width: var(--max-w);
  margin: 0 auto;
  padding-top: 24px;
  display: flex;
  justify-content: space-between;
  align-items: center;
  flex-wrap: wrap;
  gap: 12px;
  font-family: var(--font-mono);
  font-size: 10px;
  font-weight: 700;
  letter-spacing: 0.18em;
  color: var(--fg-dim);
  text-transform: uppercase;
}

/* ---------- REVEAL ANIMATION ---------- */
.reveal {
  opacity: 0;
  transform: translateY(30px);
  transition: opacity 700ms var(--ease), transform 700ms var(--ease);
  will-change: opacity, transform;
}
.reveal.is-visible {
  opacity: 1;
  transform: translateY(0);
}
/* Once the entrance animation has played, drop `transform` from the
   transition list - but ONLY for [data-tilt] elements. Otherwise
   skills cols, about card, contact form (which are both .reveal AND
   [data-tilt]) keep a permanent 700ms transform interpolation that
   fights the per-frame inline transform set by initTilt - making the
   tilt feel laggy, sticky, or visually "snap" between angles.
   We deliberately do NOT touch reveal elements that lack [data-tilt]
   (like .work__item, whose tilt lives on a child .work__item__frame),
   because clearing their will-change / dropping the transform
   transition on a 3D-context parent can demote its compositor layer
   and break the child's perspective tilt rendering.
   JS adds .reveal-done shortly after the entrance completes
   (see initReveal in scripts.js). */
.reveal.reveal-done[data-tilt] {
  transition: opacity 700ms var(--ease);
  will-change: opacity;
}

/* ---------- TILT (shared base for every [data-tilt] element) ----------
   Single source of truth so every card with a JS tilt feels identical.
   Contract:
   - transform-style: preserve-3d so the perspective() in the inline
     transform set by initTilt actually renders in 3D.
   - will-change: transform tells the compositor to keep this element
     on its own GPU layer, which is what makes the per-frame inline
     transform writes cheap and stutter-free.
   - isolation: isolate creates a stacking context so the box-shadow
     paints relative to this card and doesn't bleed into siblings
     when the card is rotated.
   - transition is restricted to box-shadow (NO `transform`). Any
     `transition: transform` here would fight the inline transform
     written every animation frame by initTilt - the symptom is the
     "sometimes works, sometimes sticks" laggy tilt that bit us
     before on .skills__col, .exp__item__body and .work__item__frame.
   Per-element rules below set their own background / border / shadow,
   but they must NOT add `transform` to their transition list and
   must NOT set a `transform` on :hover / :focus / :focus-within. */
[data-tilt] {
  transform-style: preserve-3d;
  will-change: transform;
  isolation: isolate;
  transition: box-shadow 220ms var(--ease);
}

/* stagger inside grids */
.skills__grid > .reveal:nth-child(2).is-visible { transition-delay: 80ms; }
.skills__grid > .reveal:nth-child(3).is-visible { transition-delay: 160ms; }
.work__grid > .reveal:nth-child(2).is-visible { transition-delay: 80ms; }
.work__grid > .reveal:nth-child(3).is-visible { transition-delay: 140ms; }
.work__grid > .reveal:nth-child(4).is-visible { transition-delay: 200ms; }
.work__grid > .reveal:nth-child(5).is-visible { transition-delay: 260ms; }
.work__grid > .reveal:nth-child(6).is-visible { transition-delay: 320ms; }
.exp__list > .exp__item.reveal:nth-child(2).is-visible { transition-delay: 80ms; }
.exp__list > .exp__item.reveal:nth-child(3).is-visible { transition-delay: 160ms; }
.exp__list > .exp__item.reveal:nth-child(4).is-visible { transition-delay: 240ms; }

/* ---------- 404 PAGE EXTRAS ---------- */
.error-page {
  /* Fill the viewport area below the sticky header without overflowing. */
  min-height: calc(100vh - var(--header-h));
  min-height: calc(100dvh - var(--header-h));
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: flex-start;
  padding: 0 var(--pad-page-x) 80px;
  max-width: var(--max-w);
  margin: 0 auto;
  position: relative;
}
.error-page__code {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: clamp(7rem, 25vw, 22rem);
  line-height: 0.85;
  letter-spacing: -0.06em;
  color: transparent;
  -webkit-text-stroke: 3px var(--fg);
  position: relative;
}
.error-page__code span {
  color: var(--accent);
  -webkit-text-stroke: 0;
}
.error-page__msg {
  margin-top: 20px;
  max-width: 50ch;
  color: var(--fg-mute);
  font-size: clamp(1rem, 1.4vw, 1.2rem);
}
.error-page__actions {
  margin-top: 36px;
  display: flex;
  gap: 14px;
  flex-wrap: wrap;
}

/* ---------- DOOM EASTER EGG ---------- */
.doom-trigger {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 52px;
  height: 52px;
  padding: 8px;
  border: 1.5px solid var(--line);
  background: transparent;
  color: inherit;
  cursor: pointer;
  position: relative;
  isolation: isolate;
  transition:
    border-color 200ms var(--ease),
    transform 220ms var(--ease-snap),
    background 200ms var(--ease),
    box-shadow 200ms var(--ease);
}
.doom-trigger__face {
  width: 100%;
  height: 100%;
  display: block;
  image-rendering: pixelated;
  image-rendering: crisp-edges;
  filter: saturate(0.55) brightness(0.92);
  transition: filter 240ms var(--ease);
}
.doom-trigger:hover,
.doom-trigger:focus-visible {
  border-color: var(--accent);
  transform: translate(-1px, -1px);
  box-shadow:
    3px 3px 0 0 var(--accent),
    0 0 14px rgba(var(--accent-rgb), 0.35);
}
.doom-trigger:hover .doom-trigger__face,
.doom-trigger:focus-visible .doom-trigger__face {
  filter: saturate(1.25) brightness(1.05);
  animation: doomFaceTwitch 1.6s steps(2, end) infinite;
}
.doom-trigger:active {
  transform: translate(1px, 1px);
  box-shadow: 1px 1px 0 0 var(--accent);
}
@keyframes doomFaceTwitch {
  0%, 70%, 100% { transform: translate(0, 0); }
  72% { transform: translate(-0.5px, 0.5px); }
  74% { transform: translate(0.5px, -0.5px); }
  76% { transform: translate(0, 0); }
}

/* The modal + footer styles for this easter egg live in js/doom.css
   and are fetched on demand the first time the user clicks the
   trigger (see initDoomLazy in js/scripts.js). The ~15KB of modal
   CSS never lands on any visitor that doesn't open the game.

   BUT the modal markup itself is already in index.html on first
   paint (the big pixel-face portrait, key-chip legend, loader bar,
   sliders, etc). Without any CSS those children render as unstyled
   blocks right below the footer, which looked like DOOM assets had
   scattered all over the page. So we keep just enough CSS here to
   replicate the default *closed* state from doom.css - position it
   fixed so it's out of flow, make it fully invisible and non-
   interactive. When doom.css lazy-loads it takes over with the full
   grid layout + transitions and the `.is-open` class animates it in.
*/
.doom-modal {
  position: fixed;
  inset: 0;
  z-index: 1000;
  visibility: hidden;
  opacity: 0;
  pointer-events: none;
}

/* ---------- RESPONSIVE ---------- */
@media (max-width: 1024px) {
  .about__grid { grid-template-columns: 1fr; gap: 32px; }
  .skills__grid { grid-template-columns: 1fr 1fr; }
  .work__grid { grid-template-columns: 1fr; gap: 56px; }
  .contact__grid { grid-template-columns: 1fr; gap: 40px; }
}

/* hide the mobile scrim element completely on desktop */
.nav-scrim { display: none; }

/* ---------- RESIZE GUARD ----------
   While the window is actively being resized, `initResizeGuard` (in
   scripts.js) puts `data-resizing` on <html>. Kill transitions on any
   element whose *ruleset itself* switches across the mobile breakpoint
   - otherwise crossing 760px animates the drawer's `transform` /
     `visibility` / `background` from their desktop values to their
     closed-mobile values, which reads as the drawer briefly sliding
     open and closed. Scoped to the specific offenders so unrelated
     transitions (hover, scroll-spy, theme switch) stay smooth. */
html[data-resizing] .header,
html[data-resizing] .header__nav,
html[data-resizing] .header__nav__link,
html[data-resizing] .nav-scrim,
html[data-resizing] .scroll-progress,
html[data-resizing] .header__logo__a,
html[data-resizing] .header__logo__b,
html[data-resizing] .header__logo__c,
html[data-resizing] .header__section,
html[data-resizing] .header::before,
html[data-resizing] .header::after {
  transition: none !important;
}

@media (max-width: 760px) {
  :root { --header-h: 56px; }
  .nav-scrim { display: block; }

  .header { gap: 12px; }
  /* The mobile nav is sized with viewport units (dvh/vw) so it doesn't
     depend on the header being a normal containing block - keeps the
     drawer's geometry decoupled from the header's own layout. */
  .header__nav {
    position: fixed;
    /* Offset by `--scroll-progress-h` (2px) below the header so the
       scroll-progress bar stays visible above the drawer when the
       user opens the nav mid-scroll. Without this the drawer's
       solid background paints right over the progress strip. */
    top: calc(var(--header-h) + var(--scroll-progress-h));
    right: 0;
    left: auto;
    width: min(82vw, 360px);
    height: calc(100dvh - var(--header-h) - var(--scroll-progress-h));
    max-height: calc(100dvh - var(--header-h) - var(--scroll-progress-h));
    transition:
      transform 320ms var(--ease-snap),
      visibility 0s linear 320ms,
      top 320ms var(--ease),
      height 320ms var(--ease),
      max-height 320ms var(--ease);
    background: var(--bg);
    border-left: 1px solid var(--line-strong);
    flex-direction: column;
    align-items: stretch;
    justify-content: flex-start;
    gap: 0;
    padding: 0;
    overflow-y: auto;
    overscroll-behavior: contain;
    z-index: 99;
    transform: translateX(calc(100% + 4px));
    visibility: hidden;
    margin-left: 0;
  }
  /* Drawer follows the condensed header height, preserving the same
     `--scroll-progress-h` breathing room so the progress bar stays
     visible in both states. */
  :root.is-header-condensed .header__nav {
    top: calc(var(--header-h-condensed) + var(--scroll-progress-h));
    height: calc(100dvh - var(--header-h-condensed) - var(--scroll-progress-h));
    max-height: calc(100dvh - var(--header-h-condensed) - var(--scroll-progress-h));
  }
  /* Kill the condensed-header inter-link `gap` on mobile - in the
     desktop horizontal nav the gap sits between pill-shaped links, but
     in the vertical drawer it becomes a stripe of empty space above
     each row (most visible above an `.is-active` row, where you see a
     band between the previous border and the active background/accent
     bar starting). Specificity has to match `.header.is-condensed
     .header__nav` since media queries don't add specificity. */
  .header .header__nav,
  .header.is-condensed .header__nav {
    gap: 0;
  }
  .header__nav.is-open {
    transform: translateX(0);
    visibility: visible;
    transition:
      transform 320ms var(--ease-snap),
      visibility 0s linear 0s;
  }
  /* Rows sit at their natural height, stacked at the top of the
     drawer (the parent's `justify-content: flex-start` + no flex-grow
     keeps them packed at the top, with empty space below the last
     row). `display: flex; align-items: center` is used purely to
     vertically center the label inside its row - it doesn't make the
     row itself grow. `min-height` just guarantees a comfortable tap
     target. */
  .header__nav__link {
    display: flex;
    align-items: center;
    flex: 0 0 auto;
    min-height: 56px;
    padding: 0 var(--pad-page-x);
    border-bottom: 1px solid var(--line);
    font-size: 13px;
    color: var(--fg);
  }
  .header__nav__link.is-active {
    background: var(--bg-panel);
  }
  .header__nav__link.is-active::before {
    content: "";
    position: absolute;
    left: 0;
    top: 0;
    bottom: 0;
    width: 3px;
    background: var(--accent);
  }

  /* dark scrim that dims the page behind the open mobile menu and serves
     as a tap-target for click-outside-to-close. */
  .nav-scrim {
    position: fixed;
    inset: var(--header-h) 0 0 0;
    /* Fully transparent. The drawer is opaque and sits flush with the
       header background, so it already reads as a clearly separated
       surface from the page. The scrim exists purely as a
       click-outside-to-close target. */
    background: transparent;
    opacity: 0;
    visibility: hidden;
    z-index: 98;
    transition:
      opacity 280ms var(--ease),
      visibility 0s linear 280ms,
      top 320ms var(--ease);
  }
  .nav-scrim.is-open {
    opacity: 1;
    visibility: visible;
    transition:
      opacity 280ms var(--ease),
      visibility 0s linear 0s,
      top 320ms var(--ease);
  }
  /* Scrim tracks the condensed header so there's no gap between the
     bottom edge of the header and the top of the dim. */
  :root.is-header-condensed .nav-scrim {
    top: var(--header-h-condensed);
  }
  .header__nav__link::after { display: none; }
  .header__actions {
    margin-left: auto;
  }
  .burger { display: block; }

  /* On mobile the badge sits next to the (collapsed) logo and we cap
     it so it can't crowd the action cluster on the right. The header
     still condenses but the brackets are dropped - they fight the lang
     toggle visually at narrow widths. The `ch` value has to be much
     larger than the actual glyph count because `ch` doesn't account
     for letter-spacing; at 9px mono font with 0.16em tracking we need
     ~6.8px per char, so 20ch (~108px) gives us room for the full
     "EXPERIENCE"/"EXPERIENCIA" label + caret + gap + padding. Below
     a very narrow viewport the flex parent will still shrink us, and
     text-overflow ellipsis on the inner name span catches that case. */
  .header.is-condensed .header__section { max-width: 20ch; margin-left: 10px; }
  .header__section { font-size: 9px; letter-spacing: 0.16em; height: 20px; padding: 0 6px; }
  .header::before,
  .header::after { display: none; }

  /* Mobile inherits the desktop `calc(100dvh - var(--header-h))` height
     lock; this just trims the inner padding so the title has more room. */
  .hero { padding-top: 40px; padding-bottom: 80px; }
  .hero__title { font-size: clamp(3rem, 18vw, 6rem); }
  /* keep the scroll indicator on mobile, just lift it above the marquee
     and slim down the connecting line so it fits narrow viewports */
  .hero__scroll {
    bottom: 90px;
    gap: 10px;
    font-size: 9px;
    letter-spacing: 0.26em;
  }
  .hero__scroll__line { width: 32px; }
  .hero__scroll__arrow { width: 26px; height: 26px; }
  .hero__scroll__arrow svg { width: 12px; height: 12px; }
  .back-to-top-bar { padding: 28px var(--pad-page-x); }
  .back-to-top {
    gap: 10px;
    font-size: 9px;
    letter-spacing: 0.26em;
  }
  .back-to-top__line { width: 32px; }
  .back-to-top__arrow { width: 26px; height: 26px; }
  .back-to-top__arrow svg { width: 12px; height: 12px; }
  .marquee--hero { padding: 12px 0; }

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

  .section__head { gap: 12px; flex-wrap: wrap; }
  .section__line { display: none; }

  .exp__item { grid-template-columns: 36px 1fr; gap: 16px; }
  .exp__item__rail { padding-top: 26px; }

  .footer__top {
    grid-template-columns: 1fr;
    text-align: center;
  }
  .footer__nav { justify-content: center; }
  .footer__socials { justify-content: center; }
  .footer__bottom { justify-content: center; text-align: center; }


  .contact__links a { grid-template-columns: auto 1fr auto; }
  .contact__links__cv { grid-template-columns: 1fr; }

  .about__card__list > div { grid-template-columns: 90px 1fr; }
}

@media (max-width: 380px) {
  .hero__title { font-size: clamp(2.4rem, 16vw, 4rem); }
}
