/* Menlo — self-hosted from the user's licensed copy in fonts/
   (packaged LICENSE.txt marks it MIT). Only the weights actually used
   download; declared italics stay dormant unless applied. */
@font-face {
  font-family: 'Menlo';
  src: url('fonts/Menlo-Regular.ttf') format('truetype');
  font-weight: 400; font-style: normal; font-display: swap;
}
@font-face {
  font-family: 'Menlo';
  src: url('fonts/Menlo-Bold.ttf') format('truetype');
  font-weight: 700; font-style: normal; font-display: swap;
}
@font-face {
  font-family: 'Menlo';
  src: url('fonts/Menlo-Italic.ttf') format('truetype');
  font-weight: 400; font-style: italic; font-display: swap;
}
@font-face {
  font-family: 'Menlo';
  src: url('fonts/Menlo-BoldItalic.ttf') format('truetype');
  font-weight: 700; font-style: italic; font-display: swap;
}

/* Anton (display) and Montserrat (body) — self-hosted latin subsets pulled
   from Google Fonts (both Open Font License). Montserrat's file is variable,
   so one woff2 covers weights 400–600. Non-latin text (e.g. CJK 陈渐荣) falls
   through to the system stack in --sans/--display, which is correct: these
   faces carry no CJK glyphs. */
@font-face {
  font-family: 'Anton';
  src: url('fonts/anton-latin.woff2') format('woff2');
  font-weight: 400; font-style: normal; font-display: swap;
}
@font-face {
  font-family: 'Montserrat';
  src: url('fonts/montserrat-latin.woff2') format('woff2');
  font-weight: 400 600; font-style: normal; font-display: swap;
}

:root {
  /* Depth ladder. Previously --bg and --bg-alt were 3 points apart, so
     every surface read as the same plane. These are now far enough
     apart to establish near/far. */
  --bg-deep: #050705;   /* recessed: what you see "behind" the page */
  --bg: #0a0c0a;        /* base content plane */
  --bg-alt: #0f130e;    /* raised plane */
  --bg-hi: #151b14;     /* nearest plane */

  /* Single light source, above. Surfaces catch it on their top edge
     and drop shade below: this is what makes a slab read as a slab. */
  --edge-lit: rgba(190, 255, 220, .06);
  --edge-shade: rgba(0, 0, 0, .6);

  --line: #223328;
  --accent: #52e896;
  /* secondary accent: the amber CRT phosphor. Reserved for present-tense
     status ("now"/live) and hard numbers, so it never competes with
     green as the interactive/identity colour. */
  --amber: #f2a03c;
  --amber-dim: rgba(242, 160, 60, .08);
  --text: #cdd6cd;
  --text-bright: #e8efe8;
  --text-soft: #aeb9b1;
  --text-dim: #8a978e;
  --text-faint: #5e6b62;

  --mono: 'Menlo', ui-monospace, 'SF Mono', Monaco, Consolas, monospace;
  --sans: 'Montserrat', system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
  --display: 'Anton', 'Arial Narrow', 'Helvetica Neue Condensed', Impact, sans-serif;

  --pad-x: 4vw;
  --max: 1400px;

  --ease: cubic-bezier(.22, 1, .36, 1);

  /* z-index scale, so nothing ever needs a 9999 */
  --z-grain: 80;
  --z-nav: 90;
  --z-progress: 95;
  --z-drop: 96;
  --z-cursor: 100;
}

* { box-sizing: border-box; }

html {
  scroll-behavior: smooth;
  /* anchor jumps clear the fixed nav instead of tucking the section title
     underneath it */
  scroll-padding-top: 72px;
}

body {
  margin: 0;
  background: var(--bg-deep);
  color: var(--text);
  font-family: var(--sans);
  -webkit-font-smoothing: antialiased;
  overflow-x: hidden;
}

/* ------------------------------------------------------------
   DEPTH LAYER 1: ambient light
   A fixed bloom behind everything, so the page is lit rather than
   printed. Fixed + pointer-events:none, so it never repaints on scroll.
   ------------------------------------------------------------ */
.bloom {
  position: fixed;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  background:
    radial-gradient(70% 55% at 78% 12%, rgba(82, 232, 150, .10), transparent 68%),
    radial-gradient(60% 50% at 12% 88%, rgba(82, 232, 150, .06), transparent 70%);
}

/* ------------------------------------------------------------
   DEPTH LAYER 2: sections as stacked slabs
   Each band gets a lit top edge and drops shade onto the band below,
   so the page reads as sheets in a stack rather than one flat sheet.
   ------------------------------------------------------------ */
.hero,
.about,
.section {
  position: relative;
  z-index: 1;
  background: var(--bg);
  /* one tight shadow, not a 60px blur across five full-width slabs:
     large-radius shadows on huge elements are expensive to repaint */
  box-shadow:
    inset 0 1px 0 var(--edge-lit),
    0 -18px 34px -26px var(--edge-shade);
}

/* ------------------------------------------------------------
   DEPTH LAYER 3: ghost numerals
   Outlined section numbers at display scale, sitting behind content
   and bleeding off the edge. Gives every section a background plane
   for the foreground to sit in front of.
   ------------------------------------------------------------ */
.ghost {
  position: absolute;
  right: -3vw;
  top: -2vh;
  z-index: 0;
  /* monospace, not the sporty condensed display — reads as terminal
     line numbers rather than jersey numerals */
  font-family: var(--mono);
  font-weight: 700;
  font-size: clamp(160px, 26vw, 440px);
  line-height: .72;
  letter-spacing: -.02em;
  color: transparent;
  -webkit-text-stroke: 1px rgba(242, 160, 60, .14);
  pointer-events: none;
  user-select: none;
  /* slight blur = atmospheric perspective; far things are softer */
  filter: blur(1px);
}

.ghost--left { right: auto; left: -3vw; }

/* ------------------------------------------------------------
   DEPTH LAYER 4: cursor spotlight
   Surfaces catch a highlight under the pointer, so they read as lit
   material instead of printed rectangles.
   ------------------------------------------------------------ */
.spot { position: relative; }

.spot::before {
  content: '';
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  opacity: 0;
  background: radial-gradient(340px circle at var(--mx, 50%) var(--my, 50%), rgba(82, 232, 150, .11), transparent 62%);
  transition: opacity .4s var(--ease);
}

.spot:hover::before { opacity: 1; }

/* lift content above the spotlight wash */
.spot > * { position: relative; z-index: 1; }

a { color: var(--accent); text-decoration: none; }

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

.accent { color: var(--accent); }
.amber { color: var(--amber); }

/* tabular figures anywhere numbers need to line up */
.num { font-variant-numeric: tabular-nums; }

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

/* visible focus for keyboard users, suppressed for mouse */
:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
}

.skip {
  position: absolute;
  left: -9999px;
  top: 0;
  z-index: 200;
  background: var(--accent);
  color: var(--bg);
  font-family: var(--mono);
  font-size: 13px;
  padding: 12px 20px;
}

.skip:focus { left: 0; }

/* ============================================================
   TEXTURE: grain + halftone
   ============================================================ */

/* Global film grain. Fixed and pointer-events:none so it never
   participates in scroll repaints. */
.grain {
  position: fixed;
  inset: 0;
  z-index: var(--z-grain);
  pointer-events: none;
  opacity: .045;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='3'/%3E%3C/filter%3E%3Crect width='140' height='140' filter='url(%23n)'/%3E%3C/svg%3E");
}

/* Halftone dot screen.
   The image is crushed to high-contrast greyscale, tinted green by a
   blended wash, then a tiled dot grid is punched through it. Dot pitch
   varies per surface so large and small frames do not share one flat
   pattern. Scroll and hover both dissolve it (see .is-clear). */
.ht { position: relative; }
/* Isolate so the green colour-blend below only recolours the image,
   never the page behind it. (.shot nests its img one level deeper.) */
.ht:not(.shot), .shot__frame { isolation: isolate; }

/* The image keeps its real colours — the green is a separate wash on top,
   so the original colour shows through it rather than being replaced. */
.ht img {
  filter: contrast(1.05) brightness(.95);
  transition: filter .3s var(--ease), transform .8s var(--ease);
}

/* Green tint: a colour-blend at 75%, so ~25% of the real colour stays
   visible. Removed on hover / when scrolled to centre. */
.ht:not(.shot)::before,
.shot__frame::before {
  content: '';
  position: absolute;
  inset: 0;
  z-index: 2;
  pointer-events: none;
  background: var(--accent);
  mix-blend-mode: color;
  opacity: .5;
  transition: opacity .3s var(--ease);
}

/* dot screen */
.ht::after,
.ht .shot__frame::after {
  content: '';
  position: absolute;
  inset: 0;
  z-index: 3;
  pointer-events: none;
  background-image: radial-gradient(circle at center, var(--bg) 32%, transparent 33%);
  background-size: var(--ht-pitch, 5px) var(--ht-pitch, 5px);
  opacity: var(--ht-strength, .85);
  transition: opacity .5s var(--ease);
}

.ht--fine   { --ht-pitch: 4px; }
.ht--mid    { --ht-pitch: 6px; }
.ht--coarse { --ht-pitch: 9px; }

/* Resolved state: tint + dots fall away, full colour. Driven by scroll
   (JS adds .is-clear) and by hover. */
.ht:not(.shot):hover::before,
.shot:hover .shot__frame::before,
.ht:not(.shot).is-clear::before,
.shot.is-clear .shot__frame::before { opacity: 0; }

/* In focus (hover / scrolled to centre) the dots drop right back so the
   exposed frame reads clearly, leaving only a faint trace of texture */
.ht.is-clear::after,
.ht.is-clear .shot__frame::after,
.ht:hover::after,
.ht:hover .shot__frame::after { opacity: .07; }

.ht.is-clear img,
.ht:hover img { filter: none; }

/* ------------------------------------------------------------
   HALFTONE TYPE
   The same dot screen as the images, masked through the glyphs so
   display type reads as screen-print rather than vector.

   Only applied to large display sizes: the dot pitch has to stay
   well under the stroke width or the text turns to mud. Anything
   at body scale keeps solid fill. Toggle with `dots` in the shell.
   ------------------------------------------------------------ */

.type-dots .hero__title,
.type-dots .display--lg,
.type-dots .display--xl {
  -webkit-mask-image: radial-gradient(circle at center, #000 43%, transparent 44%);
  mask-image: radial-gradient(circle at center, #000 43%, transparent 44%);
  -webkit-mask-size: 5px 5px;
  mask-size: 5px 5px;
  transition: -webkit-mask-size .5s var(--ease), mask-size .5s var(--ease);
}

/* finer pitch on the smaller display sizes so strokes survive */
.type-dots .display--md,
.type-dots .card__title {
  -webkit-mask-image: radial-gradient(circle at center, #000 45%, transparent 46%);
  mask-image: radial-gradient(circle at center, #000 45%, transparent 46%);
  -webkit-mask-size: 3.5px 3.5px;
  mask-size: 3.5px 3.5px;
  transition: -webkit-mask-size .5s var(--ease), mask-size .5s var(--ease);
}

/* hover keeps the original dissolve — the mask pitch tightens and the
   type fades toward vanishing — but stops at a faded ghost instead of
   disappearing completely. 2px went fully sub-pixel; 2.6px holds a trace. */
.type-dots .hero__title:hover,
.type-dots .display--lg:hover,
.type-dots .display--xl:hover,
.type-dots .display--md:hover,
.type-dots .card__title:hover {
  -webkit-mask-size: 2.6px 2.6px;
  mask-size: 2.6px 2.6px;
}

/* ------------------------------------------------------------
   MONO TYPE MODE
   Body copy in Menlo. Mono runs optically larger and wider than
   Montserrat, so sizes step down and measures widen to keep the same
   line rhythm. Toggle with `mono` in the shell.
   ------------------------------------------------------------ */

.type-mono body { font-family: var(--mono); }

.type-mono .prose {
  font-size: 14.5px;
  line-height: 1.85;
  letter-spacing: -.005em;
  max-width: 68ch;
}

.type-mono .row__title {
  font-family: var(--mono);
  font-size: 17px;
  font-weight: 500;
  letter-spacing: -.015em;
}

.type-mono .row__list { font-size: 13px; line-height: 1.8; max-width: 78ch; }
.type-mono .row__note { font-size: 13px; }
.type-mono .card__desc { font-size: 13.5px; line-height: 1.85; max-width: 64ch; }
.type-mono .contact__blurb { font-size: 15px; line-height: 1.8; max-width: 58ch; }
.type-mono .section__sub { font-size: 14px; line-height: 1.8; max-width: 58ch; }
.type-mono .skills__note { font-family: var(--mono); font-size: 13px; }
.type-mono .facts dd { font-size: 13px; }

/* ============================================================
   CHROME: progress, nav, cursor
   ============================================================ */

.progress {
  position: fixed;
  top: 0;
  left: 0;
  height: 3px;
  width: 0;
  /* scroll position is state, not an action, so it wears amber */
  background: var(--amber);
  z-index: var(--z-progress);
}

.nav {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: var(--z-nav);
  display: flex;
  align-items: stretch;
  justify-content: space-between;
  /* was backdrop-filter: blur(). A fixed blurred layer re-samples what
     is behind it on every scroll frame, which is the single most
     expensive thing a sticky nav can do. Opaque suits the hairline
     language better anyway. */
  background: var(--bg);
  border-bottom: 1px solid var(--line);
  font-family: var(--mono);
  font-size: 12px;
}

.nav__brand {
  display: flex;
  align-items: center;
  padding: 16px 24px;
  color: var(--accent);
  font-weight: 700;
  letter-spacing: .08em;
  border-right: 1px solid var(--line);
}

.nav__links { display: flex; align-items: stretch; }

.nav__links a {
  display: flex;
  align-items: center;
  padding: 0 20px;
  color: var(--text-dim);
  border-left: 1px solid var(--line);
  transition: color .2s, background .2s;
}

.nav__cta { color: var(--bg) !important; background: var(--accent); font-weight: 700; }

.nav a:hover { background: var(--accent); color: var(--bg) !important; }

/* current section, set by the scroll spy */
.nav__links a.is-current { color: var(--accent); }
.nav__links a.is-current.nav__cta { color: var(--bg) !important; }

/* ---- crosshair cursor ---- */

.cursor__core,
.cursor__ring { display: none; }

@media (hover: hover) and (pointer: fine) {
  body.has-cursor,
  body.has-cursor a,
  body.has-cursor button { cursor: none; }

  body.has-cursor .cursor__core,
  body.has-cursor .cursor__ring {
    display: block;
    position: fixed;
    top: 0;
    left: 0;
    z-index: var(--z-cursor);
    pointer-events: none;
    will-change: transform;
    opacity: 0;
    transition: opacity .2s ease;
  }

  body.has-cursor.cursor-live .cursor__core,
  body.has-cursor.cursor-live .cursor__ring { opacity: 1; }

  .cursor__core { width: 26px; height: 26px; margin: -13px 0 0 -13px; }

  .cursor__core::before,
  .cursor__core::after {
    content: '';
    position: absolute;
    background: var(--accent);
    transition: transform .25s var(--ease);
  }

  .cursor__core::before { top: 50%; left: 0; width: 100%; height: 1px; margin-top: -.5px; }
  .cursor__core::after { left: 50%; top: 0; height: 100%; width: 1px; margin-left: -.5px; }

  .cursor__ring {
    width: 30px;
    height: 30px;
    margin: -15px 0 0 -15px;
    border: 1px solid rgba(82, 232, 150, .38);
    transition: opacity .2s ease, border-color .25s ease;
  }

  .cursor__ring::after {
    content: '';
    position: absolute;
    top: 50%;
    left: 50%;
    width: 5px;
    height: 5px;
    margin: -2.5px 0 0 -2.5px;
    background: var(--accent);
    transition: transform .25s var(--ease);
  }

  body.cursor-lock .cursor__ring { border-color: var(--accent); }
  body.cursor-lock .cursor__ring::after { transform: scale(2); }
  body.cursor-lock .cursor__core::before { transform: scaleX(1.7); }
  body.cursor-lock .cursor__core::after { transform: scaleY(1.7); }
  body.cursor-down .cursor__ring::after { transform: scale(.6); }
}

/* ============================================================
   HERO
   ============================================================ */

/* Pinned: the hero holds while About slides up over it, so the very
   first scroll establishes z-order. JS dims and scales it as it is
   covered, which is what sells the layering. */
.hero {
  min-height: 100dvh;
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  position: sticky;
  top: 0;
  z-index: 0;
  overflow: hidden;
  border-bottom: 1px solid var(--line);
  padding-top: 80px;
  will-change: transform, opacity;
  transform-origin: 50% 35%;
}

/* everything after the hero rides above it */
.about { z-index: 2; }

.hero__meta {
  display: flex;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: 12px;
  padding: 24px var(--pad-x) 0;
  margin-bottom: auto;
  font-family: var(--mono);
  font-size: 12.5px;
  color: var(--text-faint);
  position: relative;
  z-index: 2;
}

/* live status: the amber secondary's most visible moment, top of page */
.hero__status {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  color: var(--amber);
}
.status-dot {
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--amber);
  box-shadow: 0 0 8px var(--amber);
  animation: statusPulse 2.4s ease-in-out infinite;
}
@keyframes statusPulse {
  0%, 100% { opacity: 1; box-shadow: 0 0 8px var(--amber); }
  50% { opacity: .55; box-shadow: 0 0 3px var(--amber); }
}

/* Portrait sits flush to the right edge; the wordmark overlaps it.
   right:0 keeps it put on any width (the old -4vw drifted on big screens).
   clamp holds 460px on large screens and shrinks it on narrower ones. */
.hero__portrait {
  position: absolute;
  right: 0;
  bottom: 90px;
  width: clamp(200px, 32vw, 460px);
  z-index: 1;
  will-change: transform;
  -webkit-mask-image: linear-gradient(to bottom, transparent, #000 18%, #000 82%, transparent);
  mask-image: linear-gradient(to bottom, transparent, #000 18%, #000 82%, transparent);
}

.hero__portrait img { width: 100%; aspect-ratio: 3 / 4; object-fit: cover; }

/* The body sits above the portrait, so its box was swallowing hover over
   the lower half of the image. Make the body pass pointer events through,
   and switch them back on only for the things you actually click. */
.hero__body { padding: 6vh var(--pad-x) 0; position: relative; z-index: 2; pointer-events: none; }
.hero__body :is(a, button, input, .term) { pointer-events: auto; }

.hero__prompt {
  display: block;
  font-family: var(--mono);
  font-size: 14px;
  color: var(--accent);
  margin: 0 0 8px;
  padding: 0;
  background: none;
  border: none;
  border-bottom: 1px dashed transparent;
  transition: border-color .2s;
}

.hero__prompt:hover { border-bottom-color: var(--accent); }

.hero__title {
  font-family: var(--display);
  font-weight: 400;
  font-size: clamp(56px, 13vw, 200px);
  line-height: .88;
  margin: 0;
  color: var(--text-bright);
  letter-spacing: -.01em;
  text-transform: uppercase;
  text-wrap: balance;
}

/* the blend only exists to interact with the portrait, which is hidden
   below 860px, so scope it and save a composited layer on mobile */
@media (min-width: 861px) {
  .hero__title { mix-blend-mode: difference; }
}

.hero__row {
  display: flex;
  justify-content: space-between;
  align-items: flex-end;
  flex-wrap: wrap;
  gap: 20px;
  margin: 28px 0 40px;
}

.hero__typed {
  font-family: var(--mono);
  font-size: clamp(14px, 1.6vw, 18px);
  color: var(--text-dim);
  margin: 0;
  min-height: 1.5em;
}

.caret {
  display: inline-block;
  width: .55em;
  height: 1.1em;
  background: var(--accent);
  vertical-align: text-bottom;
  animation: blink 1.1s step-end infinite;
}

@keyframes blink { 0%, 49% { opacity: 1 } 50%, 100% { opacity: 0 } }

/* ---- hero terminal ---- */

.term {
  font-family: var(--mono);
  font-size: 13px;
  line-height: 1.7;
  border: 1px solid var(--line);
  background: rgba(13, 16, 13, .92);
  padding: 14px 16px;
  margin: 0 0 16px;
  max-width: 620px;
}

.term__out { color: var(--text-dim); white-space: pre-wrap; margin: 0; }
.term__out b { color: var(--accent); font-weight: 400; }
.term__in { display: flex; align-items: center; gap: 8px; }
.term__ps1 { color: var(--accent); }

.term__field {
  flex: 1;
  background: none;
  border: none;
  outline: none;
  color: var(--text-bright);
  font: inherit;
  padding: 4px 0;
  caret-color: var(--accent);
}

/* ---- marquee ---- */

.marquee {
  border-top: 1px solid var(--line);
  background: var(--bg-alt);
  overflow: hidden;
  padding: 12px 0;
  white-space: nowrap;
  position: relative;
  z-index: 2;
}

.marquee__track {
  display: inline-flex;
  animation: marquee 28s linear infinite;
  font-family: var(--mono);
  font-size: 13px;
  color: var(--text-faint);
  letter-spacing: .12em;
}

.marquee__track span { padding-right: 48px; }

@keyframes marquee { from { transform: translateX(0) } to { transform: translateX(-50%) } }

/* ============================================================
   BUTTONS
   ============================================================ */

.btn-group { display: flex; font-family: var(--mono); font-size: 13px; }
.btn-group--wrap { flex-wrap: wrap; font-size: 14px; }

.btn {
  display: inline-block;
  padding: 12px 20px;
  border: 1px solid var(--accent);
  color: var(--accent);
  /* reset so a <button> renders identically to the anchor buttons */
  background: none;
  font: inherit;
  cursor: pointer;
  transition: background .2s var(--ease), color .2s var(--ease), transform .12s var(--ease);
}

.btn-group .btn + .btn { border-left: none; }

.btn--solid { background: var(--accent); color: var(--bg); font-weight: 700; }
.btn--sm { padding: 6px 12px; font-size: 12.5px; }
.btn--lg { padding: 16px 28px; }
.btn--muted { border-color: var(--line); color: var(--text-dim); }

.btn:hover { background: var(--accent); color: var(--bg); border-color: var(--accent); }
.btn--solid:hover { background: transparent; color: var(--accent); }
.btn:active { transform: translateY(1px); }

/* not a link yet, so it does not pretend to be one */
.btn--dead {
  border-color: var(--line);
  color: var(--text-faint);
  cursor: not-allowed;
}

.btn--dead:hover { background: none; color: var(--text-faint); border-color: var(--line); }

/* ---- inline SVG icons (github / linkedin) ---- */
.svg-sprite { position: absolute; width: 0; height: 0; overflow: hidden; }

.ico {
  width: 1.1em;
  height: 1.1em;
  fill: currentColor;   /* follows the element's colour, incl. hover */
  flex: 0 0 auto;
}

/* buttons that carry an icon lay it out beside the label */
.btn--ico { display: inline-flex; align-items: center; gap: 9px; }

/* phone renders as a canvas (see main.js) so the number is never DOM text */
.btn--phone { display: inline-flex; align-items: center; line-height: 0; }
.phone-canvas { display: block; }

/* "more on GitHub" link under Selected builds: quiet, greys then greens */
.ghlink {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  margin-top: 22px;
  font-family: var(--mono);
  font-size: 13px;
  color: var(--text-dim);
  transition: color .2s var(--ease);
}
.ghlink:hover { color: var(--accent); }

/* "Copied" bubble — spawned at the pointer, lifts and fades */
.copied-bubble {
  position: fixed;
  z-index: 120;
  pointer-events: none;
  padding: 5px 10px;
  background: var(--accent);
  color: var(--bg);
  font-family: var(--mono);
  font-size: 11px;
  font-weight: 700;
  letter-spacing: .06em;
  white-space: nowrap;
  opacity: 0;
  transform: translate(-50%, -120%) scale(.85);
  transition: opacity .16s ease, transform .28s var(--ease);
}
.copied-bubble.is-show {
  opacity: 1;
  transform: translate(-50%, -165%) scale(1);
}
.copied-bubble::after {
  content: '';
  position: absolute;
  left: 50%;
  top: 100%;
  transform: translateX(-50%);
  border: 5px solid transparent;
  border-top-color: var(--accent);
}

/* ============================================================
   SECTION FURNITURE
   ============================================================ */

.container { max-width: var(--max); margin: 0 auto; padding: 0 var(--pad-x); }

.section { border-bottom: 1px solid var(--line); padding-top: 96px; }

.section__head {
  display: flex;
  justify-content: space-between;
  align-items: flex-end;
  flex-wrap: wrap;
  gap: 20px;
  margin-bottom: 48px;
}

.section__intro { padding-bottom: 48px; }

.eyebrow {
  font-family: var(--mono);
  font-size: 13px;
  color: var(--accent);
  margin: 0 0 12px;
  letter-spacing: .06em;
}

.section__sub {
  color: var(--text-dim);
  font-size: 16px;
  line-height: 1.7;
  margin: 20px 0 0;
  max-width: 52ch;
  text-wrap: pretty;
}

.display {
  font-family: var(--display);
  font-weight: 400;
  margin: 0;
  color: var(--text-bright);
  text-transform: uppercase;
  line-height: .93;
  letter-spacing: -.015em;
  text-wrap: balance;
}

.display--md { font-size: clamp(36px, 4.5vw, 64px); line-height: 1; margin-bottom: 28px; }
.display--lg { font-size: clamp(40px, 7vw, 104px); }
.display--xl { font-size: clamp(48px, 10vw, 154px); line-height: .9; margin-bottom: 28px; }

/* ============================================================
   ABOUT
   ============================================================ */

.about { border-bottom: 1px solid var(--line); }

.about__grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
  max-width: var(--max);
  margin: 0 auto;
}

/* extra right padding keeps the text clear of the shot, which bleeds 72px
   into this column (see .about__shot margin) */
.about__prose { padding: 96px max(var(--pad-x), 92px) 96px var(--pad-x); border-right: 1px solid var(--line); }
.about .eyebrow { margin-bottom: 20px; }

.prose { font-size: 16.5px; line-height: 1.8; color: var(--text-soft); max-width: 60ch; text-wrap: pretty; }
.prose p { margin: 0 0 16px; }
.prose p:last-child { margin-bottom: 0; }

.about__side { display: flex; flex-direction: column; }

/* Breaks left across the column divider and casts onto the prose,
   so the two columns occlude instead of sitting side by side. */
.about__shot {
  margin: 0 0 0 -72px;
  border: 1px solid var(--line);
  border-right: none;
  overflow: hidden;
  will-change: transform;
  box-shadow: -30px 30px 60px -30px rgba(0, 0, 0, .85);
}

.about__shot img { width: 100%; aspect-ratio: 3 / 2; object-fit: cover; }

/* code-style label pinned to the top-left of the shot (sits above the
   halftone tint + dot layers, z 2/3) */
.about__tag {
  position: absolute;
  top: 12px;
  left: 12px;
  z-index: 4;
  font-family: var(--mono);
  font-size: 11px;
  letter-spacing: .05em;
  color: var(--text-bright);
  background: rgba(10, 12, 10, .6);
  border: 1px solid var(--line);
  padding: 5px 9px;
  pointer-events: none;
}

.facts {
  display: flex;
  flex-direction: column;
  margin: 0;
  font-family: var(--mono);
  font-size: 13.5px;
  flex: 1;
}

.facts__row {
  display: flex;
  justify-content: space-between;
  gap: 16px;
  padding: 24px var(--pad-x);
  border-bottom: 1px solid var(--line);
}

.facts__row:last-child { border-bottom: none; }
.facts dt { color: var(--text-faint); }
.facts dd { margin: 0; color: var(--text); text-align: right; }

/* ============================================================
   EXPERIENCE
   ============================================================ */

/* Two columns: the left one pins while the right scrolls past it. */
.timeline {
  display: grid;
  grid-template-columns: minmax(300px, 33%) 1fr;
  max-width: var(--max);
  margin: 0 auto;
  border-top: 1px solid var(--line);
}

.timeline__aside { border-right: 1px solid var(--line); }

.timeline__sticky {
  position: sticky;
  top: 108px;
  padding: 44px var(--pad-x) 60px;
}

.timeline__note {
  color: var(--text-faint);
  font-size: 14px;
  line-height: 1.7;
  margin: 0 0 28px;
  max-width: 34ch;
}

/* ---- halftone progress ---- */

.tprog { margin: 32px 0 28px; }

.tprog__track {
  position: relative;
  height: 18px;
  border: 1px solid var(--line);
  background-color: var(--bg-alt);
  /* unfilled dots, so the track reads as the same screen as the images */
  background-image: radial-gradient(circle at center, rgba(82, 232, 150, .16) 42%, transparent 43%);
  background-size: 5px 5px;
  overflow: hidden;
}

.tprog__fill {
  position: absolute;
  top: 0;
  left: 0;
  bottom: 0;
  width: 0;
  /* fill loads in amber over the green dotted track, matching the top
     scroll bar; the track (background) stays green */
  background: var(--amber);
  /* the fill is the halftone: solid bar masked down to dots */
  -webkit-mask-image: radial-gradient(circle at center, #000 42%, transparent 43%);
  mask-image: radial-gradient(circle at center, #000 42%, transparent 43%);
  -webkit-mask-size: 5px 5px;
  mask-size: 5px 5px;
}

.tprog__meta {
  display: flex;
  justify-content: space-between;
  font-family: var(--mono);
  font-size: 11.5px;
  letter-spacing: .08em;
  color: var(--text-faint);
  margin-top: 10px;
}

.tprog__meta span:first-child { color: var(--amber); }

.rows { border-top: none; }

.row { border-bottom: 1px solid var(--line); transition: background .25s var(--ease); }
.row:hover { background: rgba(82, 232, 150, .04); }
.row--edu { background: var(--bg-alt); }
.row--edu:hover { background: var(--bg-alt); }

/* narrower column now, so the date drops under the title instead of
   competing for horizontal room */
.row__inner {
  padding: 30px clamp(20px, 3vw, 44px);
  display: grid;
  grid-template-columns: 56px 1fr;
  gap: 6px 18px;
  align-items: baseline;
}

.row__inner > div { grid-column: 2; }
.row__date { grid-column: 2; margin-top: 12px; }

.row__idx { font-family: var(--mono); font-size: 13px; color: var(--amber); }

.row__title { margin: 0 0 4px; font-size: 20px; font-weight: 600; color: var(--text-bright); }

.row__list {
  margin: 10px 0 0;
  padding-left: 18px;
  color: var(--text-dim);
  line-height: 1.7;
  font-size: 14.5px;
  max-width: 72ch;
}

.row__note { margin: 8px 0 0; color: var(--text-dim); font-size: 14.5px; }

.row__date { font-family: var(--mono); font-size: 12px; color: var(--text-faint); white-space: nowrap; }
.row__date--now { color: var(--amber); }

/* ============================================================
   HORIZONTAL PHOTO STRIP
   Scrolls sideways in response to vertical scroll.
   ============================================================ */

/* Centered 3D "coverflow" fan. Pure CSS, so it looks identical on every
   screen size — no scroll-driven geometry to go flat when the row fits, to
   leave a right-side gap, or to get stuck after a resize. The outer frames
   turn away and sit back; the centre faces forward; hover pulls a frame flat
   and forward so it's always on top and hoverable. */
.strip {
  overflow: hidden;
  border-bottom: 1px solid var(--line);
  background: var(--bg-deep);
  padding: 72px 4vw;
  display: flex;
  justify-content: center;
}

/* Row on a shallow carousel arc. main.js pans it sideways and lifts each
   cell toward the crest as it nears centre, so it scrolls along a curve
   rather than a flat line. Easing is done in JS to keep the pan and the
   per-cell curve in lockstep. */
.strip__rail {
  display: flex;
  justify-content: center;
  align-items: center;
  gap: 16px;
  will-change: transform;
}

.strip__cell { will-change: transform; }

.strip__cell {
  position: relative;
  flex: 0 0 auto;
  /* smaller than the viewport-fill so the 01 and 05 frames always peek at the
     edges — a visual cue that the row continues and can be panned */
  width: clamp(160px, 19vw, 310px);
  background: var(--bg);
  border: 1px solid var(--line);
  box-shadow: 0 30px 60px -34px rgba(0, 0, 0, .9);
  transition: box-shadow .35s var(--ease), border-color .25s var(--ease);
}

/* No transform change on hover: moving the cell shifted it out from under
   the cursor, which dropped :hover instantly and stopped it lighting up.
   It now untints in place (see .ht:hover) and just brightens its frame. */
.strip__cell:hover {
  border-color: var(--accent);
  box-shadow: 0 46px 88px -40px rgba(0, 0, 0, .95);
}

.strip__cell img { width: 100%; aspect-ratio: 4 / 3; object-fit: cover; }

.strip__cell figcaption {
  font-family: var(--mono);
  font-size: 11px;
  letter-spacing: .08em;
  color: var(--text-faint);
  padding: 12px 16px 14px;
  position: relative;
  z-index: 4;
}

.strip__cell:hover figcaption { color: var(--accent); }

/* ============================================================
   PROJECT CARDS
   ============================================================ */

.cards {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
  border-top: 1px solid var(--line);
}

.card { display: flex; flex-direction: column; border-right: 1px solid var(--line); }
.card:last-child { border-right: none; }

/* offset the second card so the pair is not one flat band */
.cards .card:nth-child(2) { margin-top: 72px; }

.card__bar {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 18px var(--pad-x);
  border-bottom: 1px solid var(--line);
  font-family: var(--mono);
  font-size: 12.5px;
}

.card__idx { color: var(--amber); }
.card__links { display: flex; }
.card__links .btn + .btn { border-left: none; }

.card__shot {
  height: 260px;
  border-bottom: 1px solid var(--line);
  overflow: hidden;
  background: var(--bg-alt);
  /* wipe is driven by scroll progress from JS */
  clip-path: inset(0 var(--wipe, 0%) 0 0);
}

.card__shot img { width: 100%; height: 100%; object-fit: cover; }

.card__body { padding: 32px var(--pad-x) 38px; flex: 1; display: flex; flex-direction: column; }

.card__title {
  margin: 0 0 14px;
  font-family: var(--display);
  font-weight: 400;
  font-size: 36px;
  color: var(--text-bright);
  text-transform: uppercase;
  letter-spacing: .01em;
}

.card__desc {
  color: var(--text-soft);
  font-size: 15px;
  line-height: 1.75;
  margin: 0 0 20px;
  flex: 1;
  max-width: 58ch;
  text-wrap: pretty;
}

.card__stack { font-family: var(--mono); font-size: 12px; color: var(--text-faint); margin: 0; letter-spacing: .05em; }

/* ============================================================
   SKILLS: asymmetric grid, not three equal towers
   ============================================================ */

.skills {
  display: grid;
  grid-template-columns: repeat(12, 1fr);
  gap: 1px;
  background: var(--line);
  border-top: 1px solid var(--line);
  font-family: var(--mono);
}

.skills__cell {
  background: var(--bg);
  padding: 34px var(--pad-x) 42px;
  grid-column: span 4;
}

.skills__cell--lead { grid-column: span 7; }
.skills__cell--tall { grid-column: span 5; }
.skills__cell--wide { grid-column: span 8; }
.skills__cell--stat {
  grid-column: span 3;
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  background: var(--bg-alt);
}

.skills__cmd { font-size: 12px; color: var(--accent); margin: 0 0 18px; letter-spacing: .1em; }
.skills__cell--stat .skills__cmd { color: var(--text-faint); margin: 8px 0 0; letter-spacing: .05em; }

.skills__list { font-size: 14px; line-height: 2.2; color: var(--text-soft); margin: 0; }

.skills__note {
  font-family: var(--sans);
  font-size: 14.5px;
  line-height: 1.7;
  color: var(--text-faint);
  margin: 20px 0 0;
  max-width: 46ch;
}

.skills__stat {
  font-family: var(--display);
  font-size: clamp(40px, 5vw, 68px);
  line-height: 1;
  color: var(--amber);
  margin: 0;
}

/* ---- logo chips (companies + tools) ----
   Monospace monograms by default; each auto-swaps to real art when a
   matching file lands at images/logos/<slug>.svg (see the logo loader
   in main.js). Until then these read as intentional badges, not gaps. */
.logo {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 30px;
  height: 30px;
  flex: 0 0 auto;
  border: 1px solid var(--line);
  background-color: var(--bg-alt);
  background-size: 66%;
  background-position: center;
  background-repeat: no-repeat;
  font-family: var(--mono);
  font-size: 9px;
  font-weight: 500;
  color: var(--text-dim);
  transition: border-color .2s, color .2s;
}
.logo.has-logo { color: transparent; }  /* real art covers the monogram */
/* devicon marks sit transparent on the chip (no tile); the inset size
   just gives the logo a little breathing room */
.logo--art { background-size: 80%; }
.tool .logo--art { background-size: 86%; }
/* local company/brand art (NUS, CPF, Next.js, Flask, ...) shown as-is, sized
   larger to fill the chip. Monochrome marks are pre-whitened in the SVG, so no
   CSS filter is needed — a filter would also invert the chip's dark tile. */
.logo--file { background-size: 90%; }
/* per-logo file / size / badge overrides come from content.js (see main.js) */

.row__title .logo {
  width: 46px;
  height: 46px;
  font-size: 11px;
  vertical-align: middle;   /* box centre line aligns with the title's centre line */
  margin-right: 13px;
  border: none;             /* no tile in the work log — the mark floats by the title */
  background-color: transparent;
  background-size: contain; /* fill the space; nothing to touch now */
}
.row:hover .logo { border-color: var(--amber); color: var(--text); }
/* once real art is loaded the monogram must stay hidden, even on row hover,
   or the "NUS" / "CPF" text paints back over the logo */
.row:hover .logo.has-logo { color: transparent; }

.tools { display: flex; flex-wrap: wrap; gap: 9px; margin: 0; }
.tool {
  display: inline-flex;
  align-items: center;
  gap: 9px;
  padding: 6px 13px 6px 6px;
  border: 1px solid var(--line);
  background: var(--bg-alt);
  font-family: var(--mono);
  font-size: 12px;
  color: var(--text-soft);
  transition: border-color .2s, color .2s, transform .12s var(--ease);
}
.tool .logo { width: 24px; height: 24px; font-size: 8px; }
.tool:hover { border-color: var(--accent); color: var(--text-bright); transform: translateY(-2px); }
.tool:hover .logo { border-color: var(--accent); }

/* ============================================================
   LIFE GALLERY
   ============================================================ */

/* A collage, not a grid. Frames are placed on overlapping tracks so
   they genuinely occlude each other; hovering one brings it forward and
   pushes the rest back. */
.gallery {
  display: grid;
  grid-template-columns: repeat(12, 1fr);
  padding: 0 var(--pad-x) 40px;
  max-width: var(--max);
  margin: 0 auto;
  align-items: start;
  /* pointer-driven look-around, values written by main.js */
  transform: perspective(1500px) rotateY(var(--rot, 0deg)) translateX(var(--pan, 0px));
  transition: transform .45s var(--ease);
  will-change: transform;
}

.shot {
  margin: 0;
  background: var(--bg);
  position: relative;
}

/* overlapping placement */
.shot--a { grid-column: 1 / 8;  grid-row: 1; z-index: 2; }
.shot--b { grid-column: 6 / 12; grid-row: 1; margin-top: 150px; z-index: 4; }
.shot--c { grid-column: 2 / 7;  grid-row: 2; margin-top: -90px; z-index: 3; }
.shot--d { grid-column: 7 / 13; grid-row: 2; margin-top: 60px; z-index: 5; }
.shot--e { grid-column: 3 / 11; grid-row: 3; margin-top: -70px; z-index: 6; }

/* The lift wrapper owns hover motion; .shot owns the parallax
   transform. Separating them stops the two from overwriting each other. */
.shot__lift {
  position: relative;
  background: var(--bg);
  border: 1px solid var(--line);
  box-shadow: 0 30px 60px -34px rgba(0, 0, 0, .9);
  transition:
    transform .55s var(--ease),
    box-shadow .55s var(--ease),
    opacity .45s var(--ease),
    border-color .45s var(--ease);
}

.shot__frame { position: relative; aspect-ratio: 4 / 3; overflow: hidden; background: var(--bg-alt); }
.shot__frame img { width: 100%; height: 100%; object-fit: cover; }

/* hovered frame comes forward and floats */
.gallery .shot:hover { z-index: 20; }

.shot:hover .shot__lift {
  border-color: var(--accent);
  box-shadow: 0 60px 110px -40px rgba(0, 0, 0, .95);
  animation: float 3.4s ease-in-out infinite;
}

@keyframes float {
  0%, 100% { transform: translateY(-18px) scale(1.045); }
  50%      { transform: translateY(-30px) scale(1.045); }
}

/* everything else recedes */
.gallery:hover .shot:not(:hover) .shot__lift {
  opacity: .38;
  transform: scale(.975);
}

.shot__cap {
  display: flex;
  justify-content: space-between;
  gap: 16px;
  padding: 14px var(--pad-x) 20px;
  font-family: var(--mono);
  font-size: 11.5px;
  letter-spacing: .06em;
  color: var(--text-faint);
}

.shot.is-clear .shot__cap span:first-child,
.shot:hover .shot__cap span:first-child { color: var(--accent); }

/* ============================================================
   CAT RAIN
   ============================================================ */

.cat-drop {
  position: fixed;
  top: -40px;
  z-index: var(--z-drop);
  font-family: var(--mono);
  font-size: 20px;
  color: var(--accent);
  pointer-events: none;
  user-select: none;
}

/* ============================================================
   CONTACT + FOOTER
   ============================================================ */

/* Isolated stacking context for the whole closing sequence. Because of
   `isolation: isolate`, the z-index values below (life 1, contact 2,
   footer 3) are scoped entirely to this box and can never reorder the
   sections above it. `.closer` itself is just one plain block in the
   page flow. */
.closer {
  position: relative;
  isolation: isolate;
}

/* The gallery holds by its BOTTOM: once the last images reach the foot
   of the viewport they stay there while the contact card scrolls up and
   covers them. */
#life {
  position: sticky;
  bottom: 0;
  z-index: 1;
}

/* Contact is a raised card. Its top edge casts a shadow down onto the
   held gallery as it rises, it pins at the top, then the footer covers
   it in turn. JS scales and dims it as that happens, the same recede
   move that opens the page at the hero. */
.contact {
  min-height: 100dvh;
  display: flex;
  flex-direction: column;
  justify-content: center;
  padding: 120px var(--pad-x);
  max-width: none;
  margin: 0;
  position: sticky;
  top: 0;
  z-index: 2;
  background: var(--bg);
  border-top: 1px solid var(--line);
  box-shadow:
    inset 0 1px 0 var(--edge-lit),
    0 -50px 80px -30px rgba(0, 0, 0, .9);
  will-change: transform, opacity;
  transform-origin: 50% 40%;
}

.contact > * { max-width: var(--max); margin: 0 auto; width: 100%; }

.contact__blurb {
  color: var(--text-dim);
  font-size: 17px;
  line-height: 1.75;
  margin: 0 0 36px;
  max-width: 52ch;
  text-wrap: pretty;
}

/* the closing plate that slides over the pinned contact panel */
.footer {
  position: relative;
  z-index: 3;
  border-top: 1px solid var(--line);
  display: flex;
  justify-content: space-between;
  align-items: flex-end;
  flex-wrap: wrap;
  gap: 10px;
  min-height: 46vh;
  padding: 60px var(--pad-x) 32px;
  background: var(--bg-deep);
  font-family: var(--mono);
  font-size: 12px;
  color: var(--text-faint);
  box-shadow: 0 -30px 60px -30px rgba(0, 0, 0, .95);
}

/* ============================================================
   SCROLL REVEALS (reversible)
   ============================================================ */

[data-reveal] {
  opacity: 1;
  transform: none;
  transition: opacity .65s ease, transform .75s var(--ease);
}

.js [data-reveal].is-hidden { opacity: 0; will-change: transform, opacity; }

/* Entry and exit are mirror images, so scrolling back up rewinds. */
.js [data-reveal="up"].is-hidden.is-below { transform: translateY(34px); }
.js [data-reveal="up"].is-hidden.is-above { transform: translateY(-34px); }
.js [data-reveal="row"].is-hidden.is-below { transform: translateX(-28px); }
.js [data-reveal="row"].is-hidden.is-above { transform: translateX(-28px); }

/* ============================================================
   RESPONSIVE
   ============================================================ */

@media (max-width: 1000px) {
  .skills__cell,
  .skills__cell--lead,
  .skills__cell--tall,
  .skills__cell--wide { grid-column: span 6; }
  .skills__cell--stat { grid-column: span 6; }
}

@media (max-width: 860px) {
  /* display type shrinks here, so the dot pitch would start eating
     strokes. Solid fill below this width. */
  .type-dots .hero__title,
  .type-dots .display--md,
  .type-dots .display--lg,
  .type-dots .display--xl,
  .type-dots .card__title {
    -webkit-mask-image: none;
    mask-image: none;
  }

  .nav { font-size: 11px; }
  .nav__brand { padding: 14px 16px; }
  .nav__links a { padding: 0 12px; }

  /* keep the halftone portrait on phones, but as a dimmed backdrop behind
     the name instead of a side-by-side element that fights the wordmark.
     The bright title sits on top via z-index; the base mask already fades
     the top/bottom edges so the surrounding copy stays legible. */
  .hero__portrait {
    display: block;
    top: auto;
    bottom: 6%;
    right: -8%;
    width: min(82vw, 430px);
    opacity: .5;
    z-index: 1;
  }

  /* offsets that create depth on desktop just cause gaps in a single
     column, and a pinned hero eats the whole viewport on a phone */
  .hero { position: relative; will-change: auto; }

  /* Stop the sideways drift on iOS. body already hides overflow-x, but the
     viewport scrolls on <html> there, so decorative bleed (e.g. .ghost at
     right:-3vw) drags the page. No sticky survives at this width, so
     clipping the root is safe. */
  html { overflow-x: hidden; scroll-padding-top: 60px; }

  /* space-between wrapped the meta row into lopsided gaps; pack it left */
  .hero__meta { justify-content: flex-start; gap: 6px 16px; font-size: 11px; }
  .gallery .shot:nth-child(2),
  .gallery .shot:nth-child(3),
  .gallery .shot:nth-child(4),
  .gallery .shot:nth-child(5) { margin-top: 0; }
  .cards .card:nth-child(2) { margin-top: 0; }
  .about__shot { margin-left: 0; border-right: 1px solid var(--line); }
  .ghost { font-size: clamp(140px, 40vw, 260px); }

  .about__prose { border-right: none; border-bottom: 1px solid var(--line); padding: 70px var(--pad-x); }
  .card { border-right: none; border-bottom: 1px solid var(--line); }

  .row__inner { grid-template-columns: 56px 1fr; }
  .row__date { grid-column: 2; }

  .gallery { grid-template-columns: repeat(2, 1fr); }
  .shot--wide { grid-column: span 2; }
  .shot__cap { flex-direction: column; gap: 4px; }

  .contact { padding: 80px var(--pad-x); }
  .section { padding-top: 70px; }
}

@media (max-width: 560px) {
  .nav__links a:not(.nav__cta) { padding: 0 10px; font-size: 10px; }
  /* six links is more than a phone holds; let them scroll */
  .nav__links { overflow-x: auto; scrollbar-width: none; }
  .nav__links::-webkit-scrollbar { display: none; }
  .nav__links a { flex: 0 0 auto; }

  .skills__cell,
  .skills__cell--lead,
  .skills__cell--tall,
  .skills__cell--wide,
  .skills__cell--stat { grid-column: span 12; }

  .gallery { grid-template-columns: 1fr; }
  .shot, .shot--wide { grid-column: span 1; }
}

@media (max-width: 860px) {
  /* flatten the fan into a plain horizontal scroller on small screens */
  .strip { perspective: none; padding: 30px 0; justify-content: flex-start; overflow-x: auto; }
  .strip__rail { justify-content: flex-start; gap: 12px; padding: 0 4vw; transform: none !important; }
  .strip__cell { transform: none !important; z-index: auto !important; width: clamp(200px, 60vw, 300px); }

  /* single column: no room to pin a sidebar or overlap a collage */
  .timeline { grid-template-columns: 1fr; }
  .timeline__aside { border-right: none; border-bottom: 1px solid var(--line); }
  .timeline__sticky { position: static; }

  .gallery { display: flex; flex-direction: column; gap: 28px; transform: none; }
  .shot, .shot--a, .shot--b, .shot--c, .shot--d, .shot--e {
    grid-column: auto;
    grid-row: auto;
    margin-top: 0;
  }
  .shot:hover .shot__lift { animation: none; transform: none; }
  .gallery:hover .shot:not(:hover) .shot__lift { opacity: 1; transform: none; }

  /* no pinning on a phone: gallery scrolls, contact follows in flow */
  #life { position: relative; bottom: auto; }
  .contact { position: relative; min-height: 0; padding: 80px var(--pad-x); box-shadow: inset 0 1px 0 var(--edge-lit); }
  .footer { min-height: 0; padding: 40px var(--pad-x) 32px; }
}

@media (prefers-reduced-motion: reduce) {
  .strip { perspective: none; justify-content: flex-start; overflow-x: auto; }
  .strip__rail { justify-content: flex-start; gap: 12px; }
  .strip__cell { transform: none !important; z-index: auto !important; }
  #life { position: relative; bottom: auto; }
  .contact { position: relative; transform: none !important; opacity: 1 !important; }
  .shot:hover .shot__lift { animation: none; }
  html { scroll-behavior: auto; }
  .marquee__track, .caret, .status-dot { animation: none; }
  [data-reveal], .ht img, .ht::after, .ht::before { transition: none; }
  .hero__portrait, .about__shot, .shot, .strip__rail, .gallery { transform: none !important; }
  .hero { position: relative; transform: none !important; opacity: 1 !important; }
  .gallery .shot, .cards .card, .about__shot { margin-top: 0; margin-left: 0; }
}
