/* ══ Fonts ═══════════════════════════════════════════ */
/* Self-hosted rather than fetched from Google: that cost two extra DNS
   lookups and TLS handshakes before any text could render, and left a
   visible swap on a slow connection. Latin subset only.

   These are variable fonts, so one file per family covers every weight
   the page uses — the range in font-weight is what tells the browser it
   may instance the file rather than reject it for weights that differ.

   Regenerate with scratchpad/fonts.py. */

@font-face {
  font-family:'Public Sans';
  font-style:normal;
  font-weight:100 900;
  font-display:swap;
  src:url('fonts/public-sans.woff2') format('woff2');
  unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family:'JetBrains Mono';
  font-style:normal;
  font-weight:100 800;
  font-display:swap;
  src:url('fonts/jetbrains-mono.woff2') format('woff2');
  unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

/* ══ Tokens ═══════════════════════════════════════════════ */
:root {
  --paper:#f6f5f3;
  --ink:#212327;
  --body:#33363b;
  --muted:#62666d;
  --faint:#7c8087;
  --rule:#212327;      /* the 2px structural rules */
  --hair:#d9d7d3;      /* 1px dividers */
  --field:#ecebe8;     /* image wells, chat bubbles */
  --accent:#2f6fed;

  --mono:'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,monospace;
  --sans:'Public Sans',system-ui,-apple-system,Segoe UI,sans-serif;

  /* Spacing scale. Every gap and pad below is one of these — mixing arbitrary
     values is what makes a layout's rhythm read as accidental. */
  --s1:4px;  --s2:8px;  --s3:12px; --s4:16px;
  --s5:24px; --s6:36px; --s7:56px; --s8:72px;

  --gut:24px;          /* the 12-column gutter */
  --shell:1180px;
  --pad:40px;
}

[data-theme="dark"] {
  --paper:#17181a;
  --ink:#eceae7;
  --body:#b9bcc1;
  --muted:#9a9ea5;
  --faint:#8b8f97;
  --rule:#eceae7;
  --hair:#33363b;
  --field:#212327;
}

/* ══ Base ═════════════════════════════════════════════════ */
*,*::before,*::after { box-sizing:border-box; }

html {
  scroll-behavior:smooth;
  background:var(--paper);
  -webkit-text-size-adjust:100%;
  text-size-adjust:100%;
  overflow-x:hidden;
}

body {
  margin:0;
  min-height:100vh;
  color:var(--ink);
  font:400 16px/1.5 var(--sans);
  -webkit-font-smoothing:antialiased;
  /* Three soft washes rather than a flat fill, fixed so they don't scroll
     away and leave the lower page bare. color-mix keeps them tied to the
     accent, so changing --accent restains the whole page. */
  background-color:var(--paper);
  background-image:
    radial-gradient(1100px 620px at 82% -8%, color-mix(in srgb, var(--accent) 14%, transparent), transparent 68%),
    radial-gradient(820px 560px at -6% 34%, color-mix(in srgb, var(--accent) 9%, transparent), transparent 70%),
    radial-gradient(760px 520px at 60% 88%, color-mix(in srgb, var(--ink) 7%, transparent), transparent 72%);
  background-attachment:fixed;
}

h1,h2,h3,p,ul,ol,dl,dd,figure { margin:0; }
ul,ol { list-style:none; padding:0; }
img { max-width:100%; display:block; }
h1,h2,h3,p,span,a,li { overflow-wrap:break-word; }
button { font-family:inherit; }

a { color:var(--ink); text-underline-offset:3px; text-decoration-thickness:1px; }
a:hover { color:var(--accent); }
::selection { background:var(--accent); color:#fff; }

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

.skip-link {
  position:absolute; left:-9999px; top:0; z-index:200;
  padding:10px 16px; background:var(--ink); color:var(--paper);
  font:600 13px/1 var(--sans); text-decoration:none;
}
.skip-link:focus { left:0; }

/* ══ Layout ═══════════════════════════════════════════════ */
.wrap { max-width:var(--shell); margin:0 auto; padding-inline:var(--pad); }

.g12 { display:grid; grid-template-columns:repeat(12,1fr); gap:var(--gut); }
.col-3 { grid-column:span 3; }
.col-4 { grid-column:span 4; }
.col-5 { grid-column:span 5; }
.col-6 { grid-column:span 6; }
.col-8 { grid-column:span 8; }
.col-9 { grid-column:span 9; }

/* Every numbered section carries a huge ghost numeral. It's clipped by the
   band rather than the page, so it can bleed past the shell without ever
   causing sideways scroll. */
.band { position:relative; overflow:hidden; }
.band-in { padding-block:var(--s7); }

.ghost {
  position:absolute; z-index:0;
  font:800 230px/1 var(--sans); letter-spacing:-0.07em;
  color:color-mix(in srgb, var(--ink) 5%, transparent);
  pointer-events:none; user-select:none;
}
.ghost-r { top:-26px; right:-14px; }
.ghost-l { top:-30px; left:-18px; }
.ghost-low { top:auto; bottom:-44px; }
.band > .wrap { position:relative; z-index:1; }

/* ══ Topbar ═══════════════════════════════════════════════ */
.topbar {
  position:sticky; top:0; z-index:50;
  background:color-mix(in srgb, var(--paper) 88%, transparent);
  backdrop-filter:blur(10px);
  border-bottom:2px solid var(--rule);
}
.topbar-in {
  max-width:var(--shell); margin:0 auto;
  padding:14px var(--pad);
  display:grid; grid-template-columns:1fr auto 1fr; align-items:center; gap:28px;
}
.topnav {
  grid-column:2;
  display:flex; gap:22px;
  font:400 12px/1 var(--mono); letter-spacing:0.06em;
}
.topnav a { color:var(--muted); text-decoration:none; transition:color .16s ease; }
.topnav a:hover { color:var(--ink); }
/* Set by the observer in script.js as each section passes under the bar. */
.topnav a.is-current { color:var(--accent); }

/* Hidden until the phone breakpoint, where the links become a dropdown. */
.nav-toggle {
  display:none; grid-column:1; justify-self:start;
  width:38px; height:38px; padding:0;
  place-items:center; gap:5px;
  background:transparent; border:1px solid var(--hair); cursor:pointer;
  transition:border-color .16s ease;
}
.nav-toggle:hover { border-color:var(--accent); }
.nav-toggle span {
  display:block; width:16px; height:1.5px; background:var(--ink);
  transition:transform .22s ease, opacity .18s ease;
}
/* Middle bar fades, outer two cross into an X. */
.nav-toggle[aria-expanded="true"] span:nth-child(1) { transform:translateY(6.5px) rotate(45deg); }
.nav-toggle[aria-expanded="true"] span:nth-child(2) { opacity:0; }
.nav-toggle[aria-expanded="true"] span:nth-child(3) { transform:translateY(-6.5px) rotate(-45deg); }

.mode-btn {
  grid-column:3; justify-self:end;
  font:400 12px/1 var(--mono); letter-spacing:0.06em;
  padding:6px 11px; background:transparent; color:var(--ink);
  border:1px solid var(--hair); cursor:pointer;
  transition:border-color .16s ease;
}
.mode-btn:hover { border-color:var(--accent); }

/* ══ Hero ═════════════════════════════════════════════════ */
.hero { padding-block:var(--s8) var(--s7); }

.status {
  display:flex; align-items:center; gap:10px;
  font:400 12px/1 var(--mono); letter-spacing:0.1em;
  color:var(--muted); margin-bottom:28px;
}
.status .sep { color:var(--faint); }
.blip {
  width:7px; height:7px; border-radius:50%; background:var(--accent);
  flex:none; animation:blip 2.4s ease-in-out infinite;
}
@keyframes blip { 0%,100% { opacity:.35; } 50% { opacity:1; } }

.hero h1 {
  font:800 86px/0.92 var(--sans); letter-spacing:-0.045em;
  margin-bottom:26px;
}
.lede { font:400 21px/1.45 var(--sans); max-width:46ch; color:var(--body); }
.hero .lede { margin-bottom:32px; }

.actions { display:flex; flex-wrap:wrap; gap:12px; align-items:center; }

.btn {
  display:inline-flex; align-items:center;
  font:600 15px/1 var(--sans);
  padding:13px 20px; text-decoration:none; cursor:pointer;
  border:1px solid transparent;
  transition:opacity .16s ease, border-color .16s ease, background-color .16s ease;
}
.btn-solid { background:var(--ink); color:var(--paper); }
.btn-solid:hover { opacity:.85; color:var(--paper); }
.btn-line { background:transparent; border-color:var(--ink); color:var(--ink); }
.btn-line:hover { border-color:var(--accent); color:var(--accent); }

.copy-btn {
  font:400 12px/1 var(--mono); color:var(--muted);
  background:transparent; border:0; cursor:pointer; padding:13px 4px;
  transition:color .16s ease;
}
.copy-btn:hover { color:var(--ink); }
.copy-btn.is-copied { color:var(--accent); }

.ats { font:400 12px/1.5 var(--mono); color:var(--faint); margin-top:var(--s3); }

.hero-side { display:flex; flex-direction:column; gap:var(--s4); }

/* Two stacked photos. Only the top one fades — cross-fading both dips through
   a transparent middle and shows the well behind them. */
.portrait {
  position:relative; isolation:isolate;
  aspect-ratio:3/4; overflow:hidden;
  background:var(--field); border:1px solid var(--hair);
  cursor:pointer;
}
.portrait .ph {
  position:absolute; inset:0;
  width:100%; height:100%; object-fit:cover; object-position:center top;
  transition:opacity .32s ease;
}
.portrait .ph-b { z-index:1; opacity:1; }
.portrait .ph-a { z-index:2; opacity:1; }
.portrait:hover .ph-a,
.portrait:focus-visible .ph-a,
.portrait.is-touched .ph-a { opacity:0; }
.portrait .ph.missing { display:none; }

.facts { display:grid; grid-template-columns:auto 1fr; gap:7px 16px; font-size:14px; }
.facts dt { font:400 12px/1 var(--mono); letter-spacing:0.08em; color:var(--faint); align-self:center; }
.facts dd { margin:0; }

/* ══ Section furniture ════════════════════════════════════ */
.kicker {
  font:500 12px/1 var(--mono); letter-spacing:0.12em;
  color:var(--muted); margin:0;
}
.big { font:400 18px/1.6 var(--sans); }
.body { font:400 17px/1.6 var(--sans); color:var(--body); max-width:62ch; }
.sub { font:400 17px/1.6 var(--sans); color:var(--body); max-width:52ch; margin-top:10px; }
.h-lg { font:700 34px/1.05 var(--sans); letter-spacing:-0.035em; }
.h-md { font:700 30px/1.1 var(--sans); letter-spacing:-0.035em; margin-bottom:var(--s4); }
.label { display:block; font:400 12px/1 var(--mono); letter-spacing:0.1em; color:var(--faint); margin-bottom:var(--s3); }
.label-gap { margin-top:var(--s6); }
.mono-link { font:400 12px/1 var(--mono); }

/* ══ Work ═════════════════════════════════════════════════ */
.work-head { padding-top:var(--s7); margin-bottom:var(--s6); }
.work-head .mono-link { display:block; margin-top:10px; }

.proj { padding-block:var(--s6); border-top:2px solid var(--rule); }
.proj:first-of-type { border-top:0; padding-top:var(--s2); }
.wip { border-bottom:2px solid var(--rule); }

.proj h3 { font:700 36px/1.05 var(--sans); letter-spacing:-0.035em; margin-bottom:var(--s4); }
.proj-sm h3 { font-size:32px; }
.proj h3 a { color:inherit; text-decoration:none; }
.proj h3 a:hover { color:var(--accent); }
.proj .body { margin-bottom:22px; }

.proj-meta { display:flex; flex-direction:column; align-items:flex-start; }
.tag { font:400 12px/1 var(--mono); letter-spacing:0.12em; color:var(--accent); margin-bottom:14px; }
.mono-tile {
  width:34px; height:34px; flex:none;
  display:grid; place-items:center;
  background:var(--lang,#888); color:#fff;
  font:600 13px/1 var(--mono); margin-bottom:14px;
}
.stack { font:400 12px/1.9 var(--mono); color:var(--muted); }
.when { font:400 12px/1.6 var(--mono); color:var(--faint); margin-top:12px; }
.repos { display:flex; flex-direction:column; gap:3px; margin-top:14px; font:400 12px/1.6 var(--mono); }

.feats {
  display:grid; grid-template-columns:repeat(3,1fr); gap:0 var(--gut);
  font-size:14px; padding-block:10px; margin-bottom:var(--s5);
  border-top:1px solid var(--hair); border-bottom:1px solid var(--hair);
}
.feats li { padding-block:4px; }

/* ── Screenshots ──────────────────────────────────────────
   Six columns, and every span is chosen so each row fills exactly: three
   phone captures at 2 columns each, or two landscape ones at 3. A row that
   comes up short leaves empty cells, which is a tall band of nothing you can
   still scroll through — the thing this replaced.

   object-fit is contain, not cover. The shots range from a 374×749 phone to
   a 243×703 receipt to a 1200×604 console; cover would crop each one
   differently and cut the tops off the wide ones. */
.shots { display:grid; grid-template-columns:repeat(6,1fr); gap:var(--s4); }

.shot {
  grid-column:span 3; aspect-ratio:2/1;
  padding:0; border:1px solid var(--hair); background:var(--field);
  cursor:pointer; overflow:hidden; line-height:0;
  transition:border-color .16s ease, transform .16s ease;
}
.shot:hover { border-color:var(--accent); transform:translateY(-2px); }
.shot img { width:100%; height:100%; object-fit:contain; }

/* Portrait captures: 2 columns, at their own ratio so they fit exactly. */
.shot-phone { grid-column:span 2; aspect-ratio:374/749; }

/* A lone screenshot shouldn't sit in a six-column grid with five cells of
   nothing beside it. */
.shots-solo { grid-template-columns:1fr; }
.shots-solo .shot { grid-column:span 1; }

/* script.js injects these for every multi-shot group, but they only mean
   anything once the grid becomes a scroller at the phone breakpoint. */
.shots-nav { display:none; }

/* ── In progress ────────────────────────────────────────── */
.wip-head { display:flex; align-items:center; gap:12px; margin-bottom:10px; flex-wrap:wrap; }
.wip-name { font:700 24px/1.15 var(--sans); letter-spacing:-0.03em; }
.wip-name a { color:inherit; text-decoration:none; }
.wip-name a:hover { color:var(--accent); }
.pill {
  font:400 11px/1 var(--mono); letter-spacing:0.1em;
  padding:4px 7px; background:var(--accent); color:#fff;
}
.wip .body { font-size:16px; margin-bottom:var(--s4); }

.steps { display:grid; font-size:15px; margin-bottom:14px; }
.steps li { display:grid; grid-template-columns:30px 1fr; padding-block:7px; border-top:1px solid var(--hair); }
.steps li:first-child { border-top:0; padding-top:4px; }
.steps span { font:400 12px/1.6 var(--mono); color:var(--accent); }
.stack-row { font:400 12px/1 var(--mono); color:var(--muted); }

/* ══ Design ═══════════════════════════════════════════════ */
/* Six links to the Behance galleries. Given a hairline each and a hover
   state so they read as somewhere to go — as plain text in two columns they
   looked like a list of words, and nobody clicks a word. */
.two-up {
  display:grid; grid-template-columns:1fr 1fr; gap:0 20px;
  font-size:15px; margin-block:var(--s4) 22px;
}
.two-up li { border-top:1px solid var(--hair); }
/* The first item of each column, so neither starts with a stray rule. */
.two-up li:nth-child(1), .two-up li:nth-child(2) { border-top:0; }
.two-up a {
  display:flex; align-items:baseline; justify-content:space-between; gap:8px;
  padding-block:8px; text-decoration:none; color:var(--ink);
  transition:color .16s ease, padding-inline-start .16s ease;
}
.two-up a span { font-size:12px; color:var(--faint); transition:color .16s ease; }
.two-up a:hover { color:var(--accent); padding-inline-start:4px; }
.two-up a:hover span { color:var(--accent); }

@media (max-width:640px) {
  /* One column: every row after the first needs its rule back. */
  .two-up { grid-template-columns:1fr; }
  .two-up li:nth-child(2) { border-top:1px solid var(--hair); }
}

.compare-fig { margin:0; }
.compare {
  position:relative; aspect-ratio:16/9; overflow:hidden;
  border:1px solid var(--hair); background:var(--field);
  cursor:ew-resize; touch-action:pan-y;
}
.compare .cmp { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; }
/* The top image is clipped to --pos, so the left slice is DAY and the right
   slice is the NIGHT image underneath. */
.cmp-over { clip-path:inset(0 calc(100% - var(--pos)) 0 0); }
.cmp-tag {
  position:absolute; top:10px;
  font:400 11px/1 var(--mono); letter-spacing:0.1em;
  padding:4px 6px; background:var(--paper); color:var(--ink);
}
.cmp-l { left:10px; }
.cmp-r { right:10px; }
/* A white hairline rather than an accent bar: it reads against both the lit
   and the dark half of the photo, where a coloured line only reads against
   one. The shadow is what keeps it visible on the pale side. */
.cmp-line {
  position:absolute; top:0; bottom:0; width:2px;
  left:var(--pos);
  background:rgba(255,255,255,.92);
  box-shadow:0 0 6px rgba(0,0,0,.25);
}

/* Centred by absolute offset, not by grid. place-items on a 2px-wide track
   doesn't reliably centre a 36px child — it hangs off one edge, which is
   why the handle sat to the right of the line. */
.cmp-grip {
  position:absolute; top:50%; left:50%;
  transform:translate(-50%,-50%);
  width:36px; height:36px; border-radius:50%;
  display:grid; place-items:center;
  background:rgba(255,255,255,.94);
  backdrop-filter:blur(4px);
  border:1px solid rgba(0,0,0,.10);
  box-shadow:0 4px 14px rgba(0,0,0,.18);
  font:500 13px/1 var(--mono); letter-spacing:.1em; color:#212327;
  pointer-events:none;   /* the range input underneath does the dragging */
}
.cmp-range {
  position:absolute; inset:0; width:100%; height:100%;
  opacity:0; margin:0; cursor:ew-resize;
  -webkit-appearance:none; appearance:none; background:transparent;
}
.compare-fig figcaption {
  font:400 12px/1.5 var(--mono); letter-spacing:0.06em;
  color:var(--faint); margin-top:10px;
}

/* ══ Background ═══════════════════════════════════════════ */
.entries { display:grid; }
.entry { padding-block:20px 24px; border-top:1px solid var(--hair); }
.entry:first-child { border-top:0; padding-top:0; }

/* ── Education row ────────────────────────────────────────
   Placed by explicit column rather than span, so it lands on the same lines
   as the two blocks above it: the Background section is kicker(3) +
   experience(5) + stack(4), so Senior High starts at 4 under Experience and
   the degree at 9 under Stack. */
.edu-rule {
  grid-column:4 / -1;
  border-top:1px solid var(--hair);
  margin-top:var(--s6);
}
.edu-label { grid-column:4 / -1; margin-top:var(--s5); margin-bottom:0; }
.edu-a { grid-column:4 / span 5; }
.edu-b { grid-column:9 / span 4; }
/* They're grid children now, not siblings in a stack, so the divider and
   top padding .entry carries would draw above each one. */
.edu-a, .edu-b { border-top:0; padding-top:0; }

/* Both entries stretch to the row's height, so pushing the date line down
   with an auto margin lands it on the same baseline in each. Without it the
   dates sit at different heights, because the degree's description wraps to
   two lines and the school's doesn't. */
.edu-a, .edu-b { display:flex; flex-direction:column; }
.edu-a .e-when, .edu-b .e-when { margin-top:auto; padding-top:6px; }
.e-title { display:block; font:700 18px/1.3 var(--sans); letter-spacing:-0.02em; }
.e-desc { display:block; font:400 15px/1.55 var(--sans); color:var(--body); margin-block:5px; }
.e-when { font:400 12px/1 var(--mono); color:var(--faint); }

.stack-list { display:grid; }
.stack-list dt {
  font:400 12px/1.5 var(--mono); letter-spacing:0.06em; color:var(--muted);
  grid-column:1; padding-block:9px;
}
.stack-list dd {
  font:400 15px/1.5 var(--sans); grid-column:2; padding-block:9px; margin:0;
}
.stack-list dt, .stack-list dd { border-top:1px solid var(--hair); }
.stack-list { grid-template-columns:92px 1fr; column-gap:14px; }
.stack-list dt:first-of-type, .stack-list dd:first-of-type { border-top:0; padding-top:5px; }

/* ══ Contact ══════════════════════════════════════════════ */
/* Fixed ink-on-paper rather than themed: this block is the page's full stop,
   and it should read the same in both themes. */
.contact { background:#212327; color:#f6f5f3; }
.contact .ghost { color:rgba(246,245,243,0.05); }
.contact-in { padding-block:var(--s8); position:relative; z-index:1; }
.contact .kicker { display:block; color:#8f939a; margin-bottom:26px; }
.contact h2 {
  font:800 64px/0.98 var(--sans); letter-spacing:-0.045em;
  max-width:16ch; margin-bottom:20px;
}
.contact .lede { font-size:18px; line-height:1.5; color:#c3c5c9; margin-bottom:32px; }
.contact .actions { margin-bottom:var(--s7); }
.btn-invert { background:#f6f5f3; color:#212327; }
.btn-invert:hover { opacity:.85; color:#212327; }
.btn-ghost { background:transparent; color:#f6f5f3; border-color:#4a4d53; }
.btn-ghost:hover { border-color:#f6f5f3; color:#f6f5f3; }

.elsewhere { display:grid; grid-template-columns:repeat(5,1fr); gap:20px; }
.elsewhere a { display:block; text-decoration:none; color:#f6f5f3; border-top:1px solid #3a3d43; padding-top:10px; }
.elsewhere a:hover { color:#fff; border-top-color:var(--accent); }
.l-name { display:block; font:700 16px/1.3 var(--sans); }
.l-sub { font:400 12px/1.6 var(--mono); color:#8f939a; }
.copyright { font:400 12px/1 var(--mono); letter-spacing:0.1em; color:#71757c; margin-top:var(--s7); }

/* ══ Screenshot viewer ════════════════════════════════════ */
/* `display` belongs on [open], NOT on .lb. A bare `display:grid` here beats
   the browser's own `dialog:not([open]) { display:none }` — author styles win
   over the UA sheet regardless of specificity — and the closed dialog then
   renders as a full-viewport block at the foot of the page: invisible at
   opacity 0, but a whole screen of scrollable nothing.

   `display` and `overlay` still need allow-discrete in the transition, or the
   dialog is removed the instant it closes and there's nothing left to fade. */
.lb {
  width:100vw; max-width:100vw; height:100vh; max-height:100vh;
  padding:26px 34px 34px; border:none; background:none;
  color:#f6f5f3;
  opacity:0;
  transition:opacity .22s ease, overlay .24s allow-discrete, display .24s allow-discrete;
}
.lb[open] {
  display:grid; grid-template-rows:auto 1fr auto; gap:22px;
  opacity:1;
}
.lb::backdrop { background:rgba(18,19,21,0); transition:background-color .24s ease; }
.lb[open]::backdrop { background:rgba(18,19,21,0.9); }
@starting-style {
  .lb[open] { opacity:0; }
  .lb[open]::backdrop { background:rgba(18,19,21,0); }
}

.lb-head { display:flex; align-items:baseline; gap:16px; }
.lb-project { font:400 12px/1 var(--mono); letter-spacing:0.1em; color:var(--accent); }
.lb-cap { font:600 16px/1.3 var(--sans); }
.lb-count { font:400 12px/1 var(--mono); letter-spacing:0.1em; color:#9a9ea5; margin-left:auto; }
.lb-x {
  font-size:22px; line-height:1; background:transparent; border:0;
  color:#f6f5f3; cursor:pointer; padding:0 4px;
}

.lb-body { display:grid; grid-template-columns:auto 1fr auto; align-items:center; gap:26px; min-height:0; }
.lb-arrow {
  width:46px; height:46px; flex:none;
  background:transparent; border:1px solid #4a4d53; color:#f6f5f3;
  font-size:18px; cursor:pointer;
  transition:border-color .16s ease, transform .16s ease;
}
.lb-arrow:hover { border-color:#f6f5f3; }
.lb-arrow:active { transform:scale(.9); }
.lb-arrow[hidden] { display:none; }

.lb-stage { height:100%; display:grid; place-items:center; min-height:0; }
.lb-img { max-width:100%; max-height:100%; width:auto; height:auto; object-fit:contain; border:1px solid #3a3d43; }
/* The class is set in the direction of travel, so the incoming shot enters
   from the side you came from. */
.lb-img.is-next { animation:lb-right .3s cubic-bezier(.2,.7,.2,1); }
.lb-img.is-prev { animation:lb-left  .3s cubic-bezier(.2,.7,.2,1); }
@keyframes lb-right { from { opacity:0; transform:translateX(46px) scale(.97); } to { opacity:1; transform:none; } }
@keyframes lb-left  { from { opacity:0; transform:translateX(-46px) scale(.97); } to { opacity:1; transform:none; } }

.lb-thumbs { display:flex; gap:10px; justify-content:center; }
.lb-thumbs button {
  width:52px; height:34px; padding:0;
  background:#26282c; border:1px solid #3a3d43; opacity:.5;
  color:#8f939a; font:400 11px/1 var(--mono); cursor:pointer;
  transition:opacity .16s ease, border-color .16s ease;
}
.lb-thumbs button.is-on { opacity:1; border-color:var(--accent); }

/* ══ Assistant ════════════════════════════════════════════ */
.chat {
  position:fixed; right:24px; bottom:80px; z-index:70;
  width:344px; max-height:62vh;
  display:flex; flex-direction:column;
  background:var(--paper); border:2px solid var(--rule);
  box-shadow:0 18px 50px rgba(33,35,39,0.22);
  animation:rise .2s ease both;
}
.chat[hidden] { display:none; }
.chat-head { display:flex; align-items:center; gap:9px; padding:12px 14px; border-bottom:2px solid var(--rule); }
.chat-mark { font:600 13px/1 var(--mono); color:var(--accent); }
.chat-title { font:400 12px/1 var(--mono); letter-spacing:0.08em; flex:1; }
.chat-x { font-size:18px; line-height:1; background:transparent; border:0; cursor:pointer; color:var(--ink); padding:0 4px; }
.chat-log { flex:1; overflow-y:auto; padding:14px; display:flex; flex-direction:column; gap:10px; }
.msg { font:400 14px/1.55 var(--sans); padding:9px 11px; max-width:92%; animation:rise .18s ease both; }
.msg-a { background:var(--field); color:var(--ink); align-self:flex-start; }
.msg-u { background:var(--ink); color:var(--paper); align-self:flex-end; }
.chat-suggest { padding:12px 14px; display:flex; flex-wrap:wrap; gap:6px; border-top:1px solid var(--hair); }
.chat-suggest button {
  font:400 12px/1.4 var(--mono); padding:5px 9px;
  background:transparent; border:1px solid var(--hair); color:var(--ink); cursor:pointer;
  transition:border-color .16s ease;
}
.chat-suggest button:hover { border-color:var(--accent); }

.chat-fab {
  position:fixed; right:24px; bottom:24px; z-index:70;
  display:flex; align-items:center; gap:8px;
  font:600 15px/1 var(--sans);
  padding:12px 18px; border-radius:18px;
  background:var(--ink); color:var(--paper); border:0; cursor:pointer;
  box-shadow:0 10px 28px rgba(33,35,39,0.25);
  transition:opacity .16s ease;
}
.chat-fab:hover { opacity:.9; }

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

/* ══ Back to top ══════════════════════════════════════════ */
/* Bottom-left, opposite the assistant. On phones the chat panel sits at
   bottom:80px and the fab at the right, so this stays clear of both without
   needing a breakpoint of its own.

   It stays in the layout when hidden and animates out rather than being
   display:none, so appearing and disappearing don't jump. pointer-events is
   what actually makes it unclickable while invisible. */
.to-top {
  position:fixed; left:24px; bottom:24px; z-index:70;
  width:44px; height:44px;
  display:grid; place-items:center;
  font:400 18px/1 var(--mono);
  background:var(--paper); color:var(--ink);
  border:1px solid var(--hair); cursor:pointer;
  box-shadow:0 8px 24px rgba(33,35,39,0.14);
  opacity:0; transform:translateY(10px); pointer-events:none;
  transition:opacity .22s ease, transform .22s ease,
             border-color .16s ease, color .16s ease;
}
.to-top.is-on { opacity:1; transform:none; pointer-events:auto; }
.to-top:hover { border-color:var(--accent); color:var(--accent); }
.to-top:active { transform:scale(.92); }

@media (max-width:640px) {
  .to-top { left:12px; bottom:20px; width:40px; height:40px; }
}

/* ══ Scroll reveal ════════════════════════════════════════ */
/* Both classes are applied by script.js, never in the markup. If the script
   fails or the browser has no IntersectionObserver, nothing is ever hidden —
   the page just renders. */
.reveal { opacity:0; transform:translateY(10px); }
.reveal.is-in { animation:rise .55s cubic-bezier(.2,.7,.2,1) both; }

/* ══ Theme switch ═════════════════════════════════════════ */
/* The incoming palette is revealed by a circle growing out of the toggle
   button — script.js animates the clip-path, because its origin and radius
   depend on where the button happens to be.

   Two things here are load-bearing. The NEW snapshot must sit on top: it is
   the one being clipped, so the old page shows through the part of the
   circle it hasn't covered yet. And both need `animation:none` to cancel the
   browser's default cross-fade, which would otherwise run underneath the
   wipe and wash the whole thing out. */
::view-transition-old(root),
::view-transition-new(root) { animation:none; mix-blend-mode:normal; }
::view-transition-old(root) { z-index:0; }
::view-transition-new(root) { z-index:1; }

/* Browsers without view transitions get a plain colour ramp instead — the
   palette still moves rather than snapping. */
html.theming, html.theming * {
  transition:background-color .5s ease, color .5s ease,
             border-color .5s ease, box-shadow .5s ease !important;
}

/* ══ Responsive ═══════════════════════════════════════════ */
@media (max-width:1080px) {
  .hero h1 { font-size:70px; }
  .contact h2 { font-size:52px; }
  .ghost { font-size:180px; }
}

@media (max-width:900px) {
  :root { --pad:28px; --gut:20px; }
  .col-8, .col-9 { grid-column:span 12; }
  .col-3, .col-4, .col-5, .col-6 { grid-column:span 6; }
  .hero .col-4 { grid-column:span 12; }
  .hero-side { flex-direction:row; align-items:flex-start; gap:var(--s5); }
  .portrait { flex:0 0 200px; }
  .facts { flex:1; }
  .work-head .col-9 { margin-top:var(--s4); }
  .elsewhere { grid-template-columns:repeat(3,1fr); }

  /* The kicker column is gone at this width, so education starts at 1. */
  .edu-rule, .edu-label { grid-column:1 / -1; }
  .edu-a { grid-column:1 / span 6; }
  .edu-b { grid-column:7 / span 6; }
}

@media (max-width:640px) {
  :root { --pad:20px; --s7:40px; --s8:52px; }

  /* ── Hamburger ──────────────────────────────────────────
     Five section links wouldn't fit across a phone, and scrolling them
     sideways hid half the page's navigation behind an edge nobody looks at.
     They become a dropdown instead. */
  .topbar-in { grid-template-columns:auto 1fr auto; gap:10px; }
  .nav-toggle { display:grid; }
  .mode-btn { grid-column:3; }
  .topnav {
    position:absolute; left:0; right:0; top:100%;
    grid-column:auto;
    display:none; flex-direction:column; gap:0;
    padding:0 var(--pad) var(--s3);
    background:var(--paper); border-bottom:2px solid var(--rule);
    box-shadow:0 14px 28px rgba(33,35,39,0.12);
  }
  .topnav.is-open { display:flex; }
  .topnav a {
    padding:14px 0; font-size:13px;
    border-top:1px solid var(--hair);
  }
  .topnav a:first-child { border-top:0; }

  /* The 01–05 numbering is wayfinding for a page you can take in at once. On
     a phone you only ever see one section, so it's noise in front of every
     label — and the oversized ghost numeral is worse, because at this width
     it sits behind the text rather than out in the margin. All of it goes. */
  .kicker .n, .topnav .n { display:none; }
  .ghost { display:none; }

  /* ── The assistant goes ─────────────────────────────────
     A floating panel over a 360px screen covers the page it is meant to
     answer questions about. The contact block still has the email. */
  .chat, .chat-fab { display:none !important; }
  .contact .actions [data-open-chat] { display:none; }

  .hero { padding-block:var(--s6) var(--s5); }
  .hero h1 { font-size:48px; letter-spacing:-0.04em; }
  .lede { font-size:18px; }
  .col-3, .col-4, .col-5, .col-6 { grid-column:span 12; }
  .hero-side { flex-direction:column; }
  .portrait { flex:none; max-width:260px; }

  /* Full-width, stacked, and the copy-email link goes with them — left as an
     inline button it sat orphaned under two block-level ones. */
  .actions { gap:var(--s2); }
  .actions .btn { flex:1 1 100%; justify-content:center; }
  .copy-btn { flex:1 1 100%; text-align:center; padding-block:8px; }

  /* Metadata moves BELOW the project. In one column it otherwise renders
     first — a colour tile, a four-line stack and two repo links before you
     learn what the project is. */
  .proj-meta {
    order:2;
    margin-top:var(--s5); padding-top:var(--s4);
    border-top:1px solid var(--hair);
    flex-direction:row; flex-wrap:wrap; align-items:flex-start;
    gap:var(--s3) var(--s5);
  }
  .proj-meta .tag,
  .proj-meta .mono-tile { margin-bottom:0; }
  .proj-meta .when,
  .proj-meta .repos { margin-top:0; }

  .proj h3, .proj-sm h3 { font-size:26px; }
  .proj { padding-block:var(--s5); }
  .feats { grid-template-columns:1fr; }
  .two-up { grid-template-columns:1fr; }

  /* One column: the degree drops below the school with its own divider. */
  .edu-a, .edu-b { grid-column:1 / -1; }
  .edu-b { border-top:1px solid var(--hair); padding-top:20px; margin-top:4px; }

  /* ── Screenshots become a carousel ──────────────────────
     One at a time, swipe or use the arrows. A grid of thumbnails at this
     width made every shot too small to read, and scroll-snap means a swipe
     lands on a shot rather than halfway between two.

     Fixed height with contain rather than per-shot aspect ratios: the group
     mixes 374x749 phones with 1200x604 consoles, and letting each set its
     own height makes the carousel jump as you move through it. */
  .shots {
    display:flex; grid-template-columns:none;
    overflow-x:auto; scroll-snap-type:x mandatory;
    gap:var(--s3); scrollbar-width:none;
    overscroll-behavior-x:contain;
  }
  .shots::-webkit-scrollbar { display:none; }
  .shot, .shot-phone {
    flex:0 0 100%; grid-column:auto;
    aspect-ratio:auto; height:260px;
    scroll-snap-align:center;
  }
  .shot:hover { transform:none; }   /* no hover on touch; the lift just sticks */
  /* The dialog is disabled here (see script.js), so don't offer a tap. */
  .shot { cursor:default; }

  /* Injected by script.js, so a group with one image gets no controls. */
  .shots-nav {
    display:flex; align-items:center; gap:var(--s3);
    margin-top:var(--s3);
  }
  .shots-nav button {
    width:38px; height:38px; flex:none;
    display:grid; place-items:center;
    background:transparent; border:1px solid var(--hair);
    color:var(--ink); font-size:17px; cursor:pointer;
    transition:border-color .16s ease, opacity .16s ease;
  }
  .shots-nav button:hover { border-color:var(--accent); }
  .shots-nav button[disabled] { opacity:.3; cursor:default; }
  .shots-count {
    font:400 12px/1 var(--mono); letter-spacing:.08em; color:var(--muted);
    margin-left:auto;
  }

  .h-lg { font-size:28px; }
  .h-md { font-size:26px; }
  .big, .sub { font-size:16px; }
  .body { font-size:16px; }
  .wip-name { font-size:20px; }

  .contact h2 { font-size:38px; max-width:none; }
  .elsewhere { grid-template-columns:1fr 1fr; gap:14px; }
  .ghost { font-size:120px; }

  /* Four items in a row crowd at this width; let the counter drop. */
  .lb-head { flex-wrap:wrap; gap:6px 12px; }
  .lb-count { margin-left:0; width:100%; order:3; }
  .lb-x { margin-left:auto; }

  .chat { right:12px; left:12px; width:auto; }
  .chat-fab { right:12px; }
  .lb { padding:16px 14px 22px; gap:14px; }
  .lb-body { gap:10px; }
  .lb-arrow { width:38px; height:38px; }
}

/* ── Phones ───────────────────────────────────────────────
   Most phones report 360–414px, which the 640 rules were never tuned for:
   at 20px padding that leaves ~320px of content, and several things were
   still laid out as if there were twice that. */
@media (max-width:430px) {
  :root { --pad:16px; }

  .hero h1 { font-size:38px; }
  .lede { font-size:17px; }
  .status { font-size:11px; gap:8px; flex-wrap:wrap; }
  .portrait { max-width:none; }

  .contact h2 { font-size:30px; }

  /* 92px of label left ~200px for values like "Supabase, PostgreSQL,
     Firebase, Python, MySQL" — three cramped lines. Label above value
     instead. */
  .stack-list { grid-template-columns:1fr; }
  .stack-list dt { padding-bottom:0; border-top:1px solid var(--hair); padding-top:10px; }
  .stack-list dd { padding-top:2px; border-top:0; }
  .stack-list dt:first-of-type { border-top:0; }

  /* Two columns put "OnlineJobs.ph ↗" in ~150px, which wrapped mid-label. */
  .elsewhere { grid-template-columns:1fr; gap:0; }
  .elsewhere a { padding-block:10px; }

  .feats, .two-up { font-size:14px; }
  .facts { font-size:13px; gap:6px 12px; }
  .proj h3, .proj-sm h3 { font-size:23px; }
  .shot, .shot-phone { height:220px; }

  /* Both corners are occupied down here; keep them from meeting. 42px rather
     than smaller — it's a thumb target, not a mouse one. */
  .to-top { width:42px; height:42px; }
  .chat-fab { font-size:14px; padding:10px 14px; }
}

/* ══ Reduced motion ═══════════════════════════════════════ */
@media (prefers-reduced-motion:reduce) {
  html { scroll-behavior:auto; }
  *,*::before,*::after { transition:none !important; animation:none !important; }
  /* The rule above removes the animation but not its start state, so these
     have to be reset explicitly or the page renders blank. */
  .reveal { opacity:1 !important; transform:none !important; }
  .chat, .msg { opacity:1 !important; transform:none !important; }
  .lb { opacity:1; }
}

/* ══ Print ════════════════════════════════════════════════ */
@media print {
  .topbar, .chat, .chat-fab, .skip-link, .lb, .ghost { display:none !important; }
  body { background:#fff; background-image:none; color:#000; }
  .wrap { max-width:none; padding:0; }
  .contact { background:none; color:#000; }
  .contact h2, .contact .lede, .copyright, .l-name, .l-sub { color:#000; }
  .reveal { opacity:1 !important; transform:none !important; }
}
