/* ============================================================
   Design system — Arjun Manoj portfolio
   Dark theme is the default; [data-theme=light] flips the vars.
   ============================================================ */

:root {
  --bg: #0b0c0f;
  --dot: rgba(150,170,255,.22);
  --surface: #101218;
  --glass: linear-gradient(150deg, rgba(255,255,255,.08), rgba(255,255,255,.02));
  /* The top bars only: the same glass over a dark base, so they read a shade
     heavier than the tiles they float over and the nav stays legible against a
     bright hero. */
  --glassChrome: linear-gradient(150deg, rgba(255,255,255,.08), rgba(255,255,255,.02)), rgba(7,8,11,.42);
  --glassHi: linear-gradient(150deg, rgba(255,255,255,.13), rgba(255,255,255,.04));
  --chip: rgba(255,255,255,.05);
  --border: rgba(255,255,255,.13);
  --borderHi: rgba(255,255,255,.26);
  --text: #eef0f4;
  --muted: #9aa0ad;
  --faint: #6d7382;
  --dim: #565c68;
  --chrome: linear-gradient(160deg, #f6f7f9, #c9cdd7);
  --chromeText: #0e1014;
  --accent: #8fb0ff;
  --accentBorder: rgba(140,170,255,.35);
  --glow: rgba(110,140,255,.25);
  --inset: inset 0 1px 0 rgba(255,255,255,.16);
  --btnTint: rgba(125,155,255,.16);   /* accent wash over solid buttons on hover */
  --btnHover: rgba(125,155,255,.11);  /* accent fill for outline/icon buttons on hover */
  --heroGrad: radial-gradient(ellipse 90% 60% at 50% -10%, #1a1e27, transparent 60%);
  --stripe: repeating-linear-gradient(45deg, rgba(255,255,255,.05) 0 12px, rgba(255,255,255,.02) 12px 24px);
  --nameGrad: linear-gradient(115deg, #ffffff 25%, #aab0be 50%, #f2f4f7 75%);
  --termBg: rgba(10,11,14,.88);
  --focusHalo: rgba(110,140,255,.28);
  /* Deliberately not --accent: the cycling phrase needs to read as distinct
     from every other accented thing on the page. */
  --cycleAccent: #f2c879;
  --cycleAccentBorder: rgba(242,200,121,.42);
  --cycleAccentBg: rgba(242,200,121,.10);
  /* One source of truth for the swap timing: the pill morph (CSS transition)
     and the phrase slide (Web Animations) both read these, so they cannot
     drift apart and leave the text arriving before the box has finished. */
  --cycleDur: 620ms;
  --cycleEase: cubic-bezier(.66,0,.2,1);
  --cycleGlass: rgba(255,255,255,.06);
  --cycleGlassBorder: rgba(255,255,255,.16);
  /* Buttons are a flat pill that only changes fill on hover. The old treatment
     grew its shadow and blue glow on hover, which read as the button swelling. */
  --btnFill: #ffffff;
  --btnFillHover: #d9dce2;
  --btnFillActive: #c7cbd3;
  --btnText: #0e1014;
  --fontDisplay: 'Geist', 'Helvetica Neue', Helvetica, Arial, sans-serif;
}

[data-theme=light] {
  --bg: #f4f5f8;
  --dot: rgba(30,38,64,.15);
  --surface: #ffffff;
  --glass: linear-gradient(150deg, rgba(255,255,255,.8), rgba(255,255,255,.45));
  --glassChrome: linear-gradient(150deg, rgba(255,255,255,.8), rgba(255,255,255,.45)), rgba(18,22,34,.10);
  --glassHi: linear-gradient(150deg, rgba(255,255,255,.95), rgba(255,255,255,.6));
  --chip: rgba(255,255,255,.55);
  --border: rgba(20,24,34,.12);
  --borderHi: rgba(20,24,34,.22);
  --text: #15171c;
  --muted: #5a5f6b;
  --faint: #8d93a1;
  --dim: #a4aab6;
  --chrome: linear-gradient(160deg, #2a2e38, #15171c);
  --chromeText: #f4f5f8;
  --accent: #4664dc;
  --accentBorder: rgba(70,100,220,.35);
  --glow: rgba(70,100,220,.2);
  --inset: inset 0 1px 0 rgba(255,255,255,.9);
  --btnTint: rgba(70,100,220,.14);
  --btnHover: rgba(70,100,220,.08);
  --heroGrad: radial-gradient(ellipse 90% 60% at 50% -10%, #ffffff, transparent 60%);
  --stripe: repeating-linear-gradient(45deg, rgba(20,24,34,.05) 0 12px, rgba(20,24,34,.02) 12px 24px);
  --nameGrad: linear-gradient(115deg, #15171c 25%, #5a6070 50%, #22242c 75%);
  --termBg: rgba(255,255,255,.92);
  --focusHalo: rgba(70,100,220,.22);
  --cycleAccent: #77490a;
  --cycleAccentBorder: rgba(119,73,10,.38);
  --cycleAccentBg: rgba(119,73,10,.07);
  --cycleGlass: rgba(255,255,255,.5);
  --cycleGlassBorder: rgba(20,24,34,.14);
  /* Light theme inverts: a white pill would vanish on a white page, so the
     primary is dark and hover lightens its inside instead. */
  --btnFill: #15171c;
  --btnFillHover: #383c45;
  --btnFillActive: #4a4f59;
  --btnText: #f4f5f8;
}

/* ---------- display face ----------
   Geist, self-hosted. It is the sans sibling of Geist Mono, which this
   stylesheet already uses in thirty-odd places, so the two read as one family
   rather than an unrelated pairing. Variable 100-900, which is what lets the
   hero subtitle sit at a genuinely light weight without a second file.

   Self-hosted rather than pulled from Google Fonts: it is above the fold, so
   it should not wait on a third-party connection, and it keeps working where
   that CDN is blocked. Two subsets, 45KB total.

   To swap in a licensed face later (Helvetica Now, say), drop the files
   alongside these and change --fontDisplay; nothing else refers to it. */
@font-face {
  font-family: 'Geist';
  src: url('../assets/fonts/geist-latin.woff2') format('woff2');
  font-weight: 100 900; font-style: normal; font-display: swap;
  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: 'Geist';
  src: url('../assets/fonts/geist-latin-ext.woff2') format('woff2');
  font-weight: 100 900; font-style: normal; font-display: swap;
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* Galaxie Copernicus Book, self-hosted for the one serif tile in About. Book is
   the only weight shipped, so the tile asks for 400 and nothing synthesises.
   Swap this file and the family name below to change it; nothing else refers
   to the serif. The woff is there for older Safari, which never sees the woff2. */
@font-face {
  font-family: 'Galaxie Copernicus';
  src: url('../assets/fonts/GalaxieCopernicus-Book.woff2') format('woff2'),
       url('../assets/fonts/GalaxieCopernicus-Book.woff') format('woff');
  font-weight: 400; font-style: normal; font-display: swap;
}

/* ---------- base ---------- */
* { box-sizing: border-box; }
html { scroll-behavior: auto; }
body {
  margin: 0;
  /* The dot grid used to be two full-screen <canvas> layers painted in JS. One
     repeating radial-gradient draws the same grid, and because it lives in the
     page's own background instead of a stacked fixed layer, nothing has to be
     re-blended over it while the page scrolls. Personal mode resets the
     shorthand in creative.css, so it gets its paper texture instead. */
  background: var(--bg);
  background-image: radial-gradient(circle at center, var(--dot) 1.1px, transparent 1.2px);
  background-size: 30px 30px;
  color: var(--text);
  font-family: 'Instrument Sans', system-ui, sans-serif;
  -webkit-font-smoothing: antialiased;
  overflow-x: hidden;
  transition: background .5s;
}
a { color: inherit; text-decoration: none; }
a:hover { color: var(--accent); }
::selection { background: rgba(110,140,255,.3); }
img { max-width: 100%; }

/* ---------- interactive controls ----------
   The dock pills, filter chips, skill nodes and theme/mode toggles are real
   <button>s so they are focusable, announced as buttons, and fire on both
   Enter and Space for free. This strips the UA styling so they still look
   like the pills they are. */
.dock__logo, .dock__item, .dock__mode, .dock__theme, .chip--filter, .skill-node, .term__close {
  font: inherit; color: inherit; background: none; border: 0; margin: 0;
  text-align: inherit; appearance: none;
}

/* ---------- focus ----------
   Previously only creative mode styled focus, so keyboard users had nothing
   but the UA outline on top of glass — effectively invisible. Two rings: a
   dark inner and a light outer, so it stays visible on any backdrop in
   either theme. */
:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
  border-radius: 6px;
}
.dock__item:focus-visible, .dock__logo:focus-visible, .dock__mode:focus-visible,
.dock__theme:focus-visible, .chip:focus-visible, .skill-node:focus-visible,
.btn:focus-visible, .pill-link:focus-visible, .social-btn:focus-visible {
  outline-offset: 2px;
  box-shadow: 0 0 0 5px var(--focusHalo);
}
/* Windows high-contrast draws its own focus indicator; don't fight it. */
@media (forced-colors: active) {
  :focus-visible { outline: 3px solid Highlight; }
}

.skip-link {
  position: fixed; top: 10px; left: 50%; transform: translate(-50%, -160%);
  z-index: 3000; padding: 12px 22px; border-radius: 100px;
  background: var(--chrome); color: var(--chromeText);
  font-size: 14px; font-weight: 600;
  box-shadow: 0 10px 30px rgba(0,0,0,.45);
  transition: transform .25s cubic-bezier(.3,1,.4,1);
}
.skip-link:focus { transform: translate(-50%, 0); color: var(--chromeText); }

.mono { font-family: 'Geist Mono', monospace; }
.doto { font-family: 'Doto', monospace; }

/* custom cursors */
body {
  cursor: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'><path d='M6 2.5 L6 19.8 L10.1 16.1 L12.5 21.8 L15.4 20.5 L12.9 14.9 L18.4 14.9 Z' fill='black' stroke='white' stroke-width='1.5' stroke-linejoin='round'/></svg>") 5 3, auto;
}
a, button, [data-cursor] {
  cursor: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='26' height='26' viewBox='0 0 26 26'><g stroke='black' stroke-width='1.1' stroke-linejoin='round' fill='white'><path d='M9.2 5.2 C9.2 3.9 11.2 3.9 11.2 5.2 L11.2 12.1 L12.1 12.1 L12.1 6.9 C12.1 5.7 14 5.7 14 6.9 L14 12.2 L14.9 12.2 L14.9 7.9 C14.9 6.8 16.7 6.8 16.7 7.9 L16.7 12.5 L17.5 12.5 L17.5 9.6 C17.5 8.5 19.3 8.5 19.3 9.6 L19.3 15.9 C19.3 19.6 17.1 22 13.6 22 C10.5 22 9.2 20.6 7.6 17.6 C6.8 16.1 5.9 14.5 5.4 13.5 C4.8 12.4 6.2 11.4 7.1 12.3 L9.2 14.6 Z'/></g></svg>") 11 4, pointer;
}
input, textarea { cursor: text; }
/* A custom cursor image ignores the OS pointer size and contrast settings that
   low-vision users rely on. High-contrast mode is the one signal we can read,
   so hand the system cursor back there. */
@media (forced-colors: active) {
  body, a, button, [data-cursor] { cursor: auto; }
  a, button, [data-cursor] { cursor: pointer; }
}

/* ---------- animations ---------- */
@keyframes floaty { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-8px); } }
@keyframes pulseDot { 0%,100% { opacity: 1; } 50% { opacity: .35; } }
@keyframes blink { 0%,49% { opacity: 1; } 50%,100% { opacity: 0; } }
/* Translate-only: scaling or rotating a 70-90px-blurred, 70vw-wide layer forces
   the compositor to re-rasterize that whole blur every frame. Pure translation
   just slides the already-rasterized layer — the drift stays, the cost drops. */
@keyframes auroraA { 0%,100% { transform: translate(-10%,-6%); } 50% { transform: translate(10%,9%); } }
@keyframes auroraB { 0%,100% { transform: translate(6%,4%); } 50% { transform: translate(-13%,-9%); } }
@keyframes auroraC { 0%,100% { transform: translate(0,8%); } 50% { transform: translate(-9%,-11%); } }
@media (prefers-reduced-motion: reduce) {
  .aurora__blob, .floaty, .pulse, .blink { animation: none !important; }
}

/* Reveal items start hidden, but only once the observer that reveals them is
   actually watching: setupReveals stamps data-reveals on <html>, so with no JS,
   with reduced motion, or if the script never runs, everything renders plainly
   visible. Without this the card was painted, then blanked, then faded back in,
   which is the content showing up before its own entrance. */
:root[data-reveals] [data-reveal]:not(.revealed) { opacity: 0; }

/* ---------- aurora background ---------- */
.aurora { position: fixed; inset: 0; z-index: 0; pointer-events: none; overflow: hidden; }
/* Four soft blobs drift behind everything. The blur is what makes them read as
   ambient light rather than discs — a radial-gradient alone ends at a definite
   radius, and on a narrow viewport (where 72vw is only ~290px) that boundary
   is plainly visible as a circle. Removing it was tried and reverted; see the
   note in .aurora--paused below. */
.aurora__blob { position: absolute; border-radius: 50%; will-change: transform; }
.aurora__blob:nth-child(1) { left: -22%; top: -28%; width: 72vw; height: 72vw; background: radial-gradient(circle at 32% 32%, rgba(110,140,255,.38), transparent 64%); filter: blur(70px); animation: auroraA 26s ease-in-out infinite; }
.aurora__blob:nth-child(2) { right: -26%; top: 18%; width: 82vw; height: 82vw; background: radial-gradient(circle at 60% 40%, rgba(170,190,230,.26), transparent 64%); filter: blur(80px); animation: auroraB 34s ease-in-out infinite; }
.aurora__blob:nth-child(3) { left: 8%; bottom: -34%; width: 78vw; height: 78vw; background: radial-gradient(circle at 50% 50%, rgba(150,115,255,.28), transparent 66%); filter: blur(90px); animation: auroraC 42s ease-in-out infinite; }
/* Faintest blob (.14 alpha) is now static — one fewer animated blurred layer. */
.aurora__blob:nth-child(4) { left: 30%; top: 35%; width: 55vw; height: 55vw; background: radial-gradient(circle at 50% 50%, rgba(90,200,235,.14), transparent 62%); filter: blur(85px); }
/* Nothing is on screen while the tab is backgrounded — stop animating it.
   (This is the only part of the blur-removal experiment worth keeping: it costs
   nothing visually. The blur itself measured expensive only under the software
   rasterizer in a headless container; real devices composite it on the GPU.) */
.aurora--paused .aurora__blob { animation-play-state: paused; }
[data-theme=light] .aurora { opacity: .5; }
/* The blobs are sized in vw, so a phone held upright gets a 280px wash in one
   corner and reads as flat near-black, while a 1440px window gets a 1040px one
   that covers the whole screen and turns the page navy. Same CSS, different
   result. Turning it down on wide screens is what makes a desktop match the
   phone: the light stays as a hint of depth behind the glass instead of a
   colour cast over the page. */
@media (min-width: 901px) {
  /* The dimming sits on the blobs, not on the wrapper. A wrapper below 1 turns
     the group into one render surface that has to be re-blended every time the
     ribbon redraws a frame, while per-blob opacity rides along on each blob's
     own compositor layer for free. Measured on a full-page scroll: this was
     worth about 2ms of the 64ms-to-17ms drop the whole pass bought. */
  .aurora__blob { opacity: .15; }
  /* Same move for the light theme, whose own wrapper rule above would otherwise
     put the render surface straight back. */
  [data-theme=light] .aurora { opacity: 1; }
  [data-theme=light] .aurora__blob { opacity: .5; }
}

/* ---------- ribbon thread ---------- */
/* overflow:visible on purpose: setupRibbon parks the thread's straight runs
   past the window edge, so the path is drawn wider than this box. */
.ribbon { position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: 0; pointer-events: none; overflow: visible; }

/* ---------- loader ---------- */
.loader {
  position: fixed; inset: 0; z-index: 2000; background: #0b0c0f;
  display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 26px;
}
.loader__row { display: flex; align-items: center; gap: 18px; }
.loader__mark { font-family: 'Doto', monospace; font-weight: 900; font-size: 44px; color: #eef0f4; letter-spacing: -.04em; }
.loader__pct { font-family: 'Doto', monospace; font-weight: 700; font-size: 14px; letter-spacing: .3em; color: #565c68; }

/* ---------- top chrome: nav dock + display actions ---------- */
/* The wrapper owns the fixed positioning and the hide-on-scroll, so both bars
   move as one piece; each bar keeps its own pill. */
.chrome {
  position: fixed; top: 22px; left: 50%; transform: translateX(-50%); z-index: 900;
  display: flex; align-items: center; gap: 10px;
  transition: transform .5s cubic-bezier(.3,1,.4,1);
}
.chrome--hidden { transform: translateX(-50%) translateY(-160%); }
.dock { min-width: 0; }   /* lets the nav shrink and scroll rather than push the actions off-screen */

.dock__inner, .actionbar {
  display: flex; align-items: center; gap: 2px; padding: 7px 9px; border-radius: 100px;
  background: var(--glassChrome); border: 1px solid var(--border); backdrop-filter: blur(12px);
  box-shadow: 0 18px 50px rgba(0,0,0,.45), var(--inset);
}
.actionbar { flex: 0 0 auto; }

.dock__logo { font-family: 'Doto', monospace; font-weight: 900; font-size: 14px; color: var(--text); padding: 7px 11px; border-radius: 100px; }
.dock__item {
  white-space: nowrap; font-size: 13px; color: var(--muted); padding: 7px 14px; border-radius: 100px;
  transition: color .22s ease, background .22s ease, transform .22s cubic-bezier(.34,1.56,.64,1);
}
.dock__item:hover:not(.dock__item--active) { color: var(--text); background: var(--chip); transform: translateY(-1px); }
.dock__item:active { transform: translateY(0); }
@media (prefers-reduced-motion: reduce) { .dock__item:hover:not(.dock__item--active) { transform: none; } }
.dock__item--active {
  font-weight: 500; color: var(--chromeText); background: var(--chrome);
  box-shadow: inset 0 1px 0 rgba(255,255,255,.8);
}
.dock__theme { font-size: 14px; color: var(--muted); padding: 7px 11px; border-radius: 100px; }

@media (max-width: 720px) {
  /* The nav sizes to its own content and scrolls sideways only when it truly
     overflows, so a short section list never leaves a stretched bar with empty
     space on the right. flex: 0 1 auto = shrink to fit, never grow. */
  .chrome { max-width: 96vw; gap: 8px; }
  .dock { flex: 0 1 auto; }
  .dock__inner { max-width: 100%; overflow-x: auto; scrollbar-width: none; -webkit-overflow-scrolling: touch; }
  .dock__inner::-webkit-scrollbar { display: none; }
  .dock__item { padding: 7px 10px; font-size: 12.5px; }
}

/* ---------- layout ---------- */
main { position: relative; z-index: 2; }
.container { width: min(1240px, 92vw); margin: 0 auto; }
.container--narrow { width: min(1000px, 92vw); }
.container--mid { width: min(1100px, 92vw); }
.section { padding: clamp(80px, 12vh, 140px) 0; }
.section--tight { padding: clamp(60px, 9vh, 100px) 0; }
/* The vh-based gap is right on a desktop and far too much on a phone, where a
   tall viewport buys 112px of nothing at both ends of every section. */
@media (max-width: 720px) {
  .section { padding: 72px 0; }
  .section--tight { padding: 56px 0; }
}

/* A tight glass chip, not loose text: it gives the section label its own plate
   so nothing passing behind it competes with the words. Padding is asymmetric
   because the letter-spacing already adds a gap after the last letter. */
.eyebrow {
  display: inline-flex; align-items: center;
  padding: 5px 8px 5px 11px; border-radius: 999px;
  background: var(--glass); border: 1px solid var(--border); backdrop-filter: blur(9px);
  font-family: 'Doto', monospace; font-weight: 700; font-size: 13px; letter-spacing: .32em; color: var(--muted);
}
.heading { margin: 16px 0 0; font-size: clamp(36px, 4.5vw, 56px); font-weight: 600; letter-spacing: -.025em; line-height: 1.06; }
.heading--md { font-size: clamp(30px, 3.6vw, 44px); }
/* A container, not a <p> — bodies can be multiple paragraphs or a list, and
   nesting those inside a <p> is invalid and gets split by the parser. */
/* 66ch, not a pixel cap: wide enough that a one-sentence lead stays on one
   line, still short enough to read comfortably. */
.lead { color: var(--muted); font-size: 16px; max-width: 66ch; line-height: 1.6; }
.lead p { margin: 0 0 12px; }
.lead p:last-child, .lead ul:last-child { margin-bottom: 0; }
.lead ul { margin: 0 0 12px; padding-left: 20px; }
.lead--skills { margin-bottom: 40px; }
/* Wide enough that this one-sentence lead stays one line on a laptop, and
   clear of the heading rather than tucked under its descenders. */
.lead--pubs { margin: 6px 0 30px; max-width: 74ch; }

/* ---------- buttons & pills ---------- */
.btn {
  display: inline-flex; align-items: center; gap: 8px; white-space: nowrap;
  font-size: 14.5px; font-weight: 600; color: var(--btnText); padding: 10px 22px; border-radius: 100px;
  background: var(--btnFill); border: 0;
  /* Nothing grows on hover — only the fill changes. */
  transition: background .22s ease, color .22s ease, border-color .22s ease;
  font-family: 'Instrument Sans', sans-serif;
}
.btn:hover { background: var(--btnFillHover); color: var(--btnText); }
.btn:active { background: var(--btnFillActive); }
.btn--ghost {
  font-weight: 500; color: var(--text); background: transparent;
  border: 1px solid var(--borderHi);
}
.btn--ghost:hover { background: var(--chip); color: var(--text); border-color: var(--borderHi); }
.btn--ghost:active { background: var(--glass); }
.btn--plain { font-weight: 400; color: var(--muted); background: transparent; border: 1px solid var(--border); padding: 10px 18px; }
.btn--plain:hover { color: var(--text); background: var(--chip); }
.btn--sm { font-size: 13.5px; padding: 8px 16px; }
.badge-pill {
  font-family: 'Geist Mono', monospace; font-size: 11px; color: var(--muted);
  border: 1px solid var(--border); border-radius: 100px; padding: 4px 10px; white-space: nowrap;
}
.badge-pill--hi { color: var(--accent); border-color: var(--accentBorder); padding: 5px 12px; }
.note { font-family: 'Geist Mono', monospace; font-size: 12px; color: var(--dim); padding: 13px 4px; }
.tag { font-family: 'Geist Mono', monospace; font-size: 11px; color: var(--muted); padding: 5px 11px; border-radius: 100px; border: 1px solid var(--border); }
.tags { display: flex; gap: 8px; margin-top: 16px; flex-wrap: wrap; }

/* ---------- glass card ---------- */
.card {
  background: var(--glass); border: 1px solid var(--border); border-radius: 20px;
  backdrop-filter: blur(10px); box-shadow: var(--inset);
  transition: all .3s cubic-bezier(.2,1.2,.4,1);
}
.card--hover:hover { transform: translateY(-3px); border-color: var(--borderHi); box-shadow: 0 18px 44px rgba(0,0,0,.4); }

/* ---------- striped placeholder ---------- */
.ph {
  border-radius: 20px; border: 1px solid var(--border); background: var(--stripe);
  display: flex; align-items: center; justify-content: center; box-shadow: var(--inset);
  font-family: 'Geist Mono', monospace; font-size: 12px; color: var(--faint);
  text-align: center; line-height: 1.8; white-space: pre-line;
}

/* ---------- hero ---------- */
.hero { min-height: 100vh; position: relative; display: flex; align-items: center; background: var(--heroGrad); }
.hero__inner { display: flex; flex-wrap: wrap; gap: 56px; align-items: center; justify-content: space-between; padding: 120px 0 80px; }
.hero__copy { flex: 1 1 520px; min-width: min(520px, 90vw); }
.hero__eyebrow { font-size: 14px; }
.hero__greeting { margin: 16px 0 0; font-size: clamp(18px, 2vw, 22px); font-weight: 500; color: var(--text); opacity: .92; letter-spacing: .01em; }
.hero__title {
  margin: 6px 0 0; font-size: clamp(52px, 8vw, 108px); font-weight: 600; letter-spacing: -.035em; line-height: 1.06;
  width: fit-content; outline: none;
}
/* The gradient lives on the inner name span so hovering can swap it for a solid
   highlight, and so the font can cycle without disturbing the gradient fill. */
.hero__name {
  background: var(--nameGrad); -webkit-background-clip: text; background-clip: text;
  color: transparent; -webkit-text-fill-color: transparent;
  /* Tight horizontal breathing room for the highlight, pulled back with a
     negative margin so the name still sits flush against the left edge. The
     highlight covers the glyph box only (not the tall line-box), so it can
     never bleed onto the greeting above or the subtitle below. */
  border-radius: 12px; cursor: default; padding: 0 .1em; margin: 0 -.1em;
  transition: color .2s ease, background-color .2s ease;
}
.hero__title:hover .hero__name, .hero__title:focus-visible .hero__name {
  background: rgba(125,155,255,.22); color: var(--text); -webkit-text-fill-color: var(--text);
}
:root[data-theme=light] .hero__title:hover .hero__name,
:root[data-theme=light] .hero__title:focus-visible .hero__name {
  background: rgba(70,100,220,.15);
}
/* ---------- hero subtitle + cycling pill ----------
   Helvetica Now is a licensed Monotype face, so it cannot be fetched from a
   CDN — it only applies once you drop licensed files into assets/fonts/ and
   uncomment the @font-face block above. Until then this falls back to
   Helvetica Neue and Arial, which are metrically close enough that the layout
   does not shift when the real face arrives. */
.hero__sub {
  /* Real breathing room under the name — at mobile sizes the title is huge
     and a small fixed margin read as no gap at all. */
  margin-top: clamp(28px, 2.6vw, 34px);
  font-size: clamp(19px, 2.4vw, 27px);
  /* Geist is variable, so the subtitle can sit at a genuinely light weight.
     300 keeps it elegant without going fragile against the aurora. */
  font-weight: 300;
  color: var(--text); letter-spacing: -.005em;
  font-family: var(--fontDisplay);
}
.hero__sub-line {
  display: inline-flex; align-items: center; flex-wrap: wrap; gap: .42em;
  /* Both runs of text must share a line-height, or centring the boxes still
     leaves the glyphs sitting on different lines. */
  line-height: 1.25;
}
/* The softening lives on the prefix rather than the whole line: applied to the
   parent it also diluted the pill, which left the accent phrase looking washed
   out instead of deliberate. */
.hero__sub-prefix { white-space: nowrap; opacity: .78; }

/* Below this width the longest phrase cannot sit beside the prefix, so the
   line wrapped for long phrases and unwrapped for short ones — the subtitle
   flipped between 71px and 37px tall on every swap and dragged everything
   under it up and down. Giving the prefix the whole row puts the pill on its
   own line permanently: two lines always, one height, nothing moves. */
@media (max-width: 640px) {
  .hero__sub-prefix { flex: 0 0 100%; }
  .cycler { margin-top: .1em; }
}

/* The pill sizes itself from JS, which measures each phrase and transitions
   width and height — that is what makes it appear to morph between lengths
   rather than snap. Phrases are stacked on top of each other and crossfaded. */
.cycler {
  position: relative; display: inline-block; vertical-align: middle;
  /* Padding lives on the phrase, not here: the phrases are absolutely
     positioned to stack for the crossfade, and an offset-from-padding-box box
     does not have the same usable width as the in-flow one that was measured.
     Text then wraps out of a pill sized for a single line. Same box both
     ways = measurement and render always agree. */
  padding: 0;
  /* Fully rounded, matching the pill language the dock, chips and buttons
     already use — a smaller radius reads as a text box rather than a token. */
  border-radius: 999px;
  /* Frosted glass: a translucent fill plus a real backdrop blur, so the aurora
     behind it shows through softened rather than being covered. Saturation is
     nudged up because blurring alone washes the colour out. No shadow — the
     border alone defines the edge. */
  background: var(--cycleGlass);
  border: 1px solid var(--cycleGlassBorder);
  backdrop-filter: blur(12px) saturate(160%);
  -webkit-backdrop-filter: blur(12px) saturate(160%);
  transition: width var(--cycleDur) var(--cycleEase),
              height var(--cycleDur) var(--cycleEase),
              color .45s ease, border-color .45s ease, background .45s ease;
  overflow: hidden; max-width: 100%;
  /* The pill is a flex item, and flex items shrink by default — so the width
     set from measurement was being squeezed back down by the line, wrapping
     text out of a box sized not to wrap. Never shrink; wrap to the next line
     instead when there genuinely isn't room. */
  flex: 0 0 auto;
  will-change: width, height;
}
/* Exactly one phrase is in the document flow at a time. An earlier version
   stacked them absolutely for a crossfade, which meant the pill was measured
   in one layout mode and rendered in another — inline vs blockified, padding
   counted vs not — and the two never reliably agreed. Sizing is exact by
   construction this way: what gets measured is literally what gets shown. */
/* nowrap is deliberate. While the pill is morphing between two widths the
   text would otherwise reflow to two or three lines and snap back as the pill
   caught up — visible as text spilling out of the pill mid-transition. Keeping
   every phrase on one line makes the morph purely horizontal. The subtitle's
   clamp() already scales the type down on narrow screens, so the phrases still
   fit; max-width on the pill stops it ever exceeding its container. */
/* Vertical padding is deliberately small. The pill only has to clear the
   glyphs, and .22em left it noticeably taller than the prefix text beside it,
   which read as a text box rather than a token. .1em keeps a hair of breathing
   room above the ascenders while letting the pill hug the line. Horizontal
   padding stays generous so the shape still reads as a pill, not a stripe. */
.cycler__item { display: none; padding: .1em .72em; white-space: nowrap; line-height: 1.25; }
.cycler__item--on {
  display: block; opacity: 1;
  transition: opacity .2s ease;
}
.cycler__ghost {
  position: absolute; left: 0; top: 0; width: 100%;
  /* Must match .cycler__item exactly — the ghost is the outgoing phrase
     sliding out, and any padding difference shows as a jolt mid-swap. */
  padding: .1em .72em; white-space: nowrap; pointer-events: none;
}
.cycler--accent { color: var(--cycleAccent); border-color: var(--cycleAccentBorder); background: var(--cycleAccentBg); }

/* No JS, or reduced motion: the first phrase simply sits in the pill. */
.cycler--static .cycler__item { position: static; }
.cycler--static .cycler__item:not(.cycler__item--on) { display: none; }

.sr-only {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
.hero__text { margin: 16px 0 0; font-size: 17px; line-height: 1.6; color: var(--muted); max-width: 540px; }
.hero__cta { display: flex; gap: 18px; margin-top: clamp(44px, 5vw, 84px); align-items: center; flex-wrap: wrap; }
/* Sits beside the primary button without competing with it: no fill, no
   border, just a legible link. Present for anyone who came looking for the
   PDF, invisible as an "ask" to everyone else. */
.hero__quiet {
  font-size: 15px; color: var(--muted);
  text-decoration: underline; text-underline-offset: 4px;
  text-decoration-color: var(--border);
  transition: color .24s ease, text-decoration-color .24s ease;
}
.hero__quiet:hover { color: var(--text); text-decoration-color: var(--accentBorder); }
/* Social icons ride in the same row as the buttons now, grouped so they read
   as a cluster and not as three more competing calls to action. A hairline to
   their left sets them apart from the résumé button beside them. */
.hero__socials { display: inline-flex; gap: 10px; align-items: center; padding-left: 6px; }
.hero__socials::before { content: ''; width: 1px; align-self: stretch; margin: 4px 8px 4px 0; background: var(--border); }
.btn--resume svg { width: 16px; height: 16px; }
.socials { display: flex; gap: 10px; margin-top: 22px; }
.social-btn {
  width: 44px; height: 44px; border-radius: 50%; border: 1px solid var(--border);
  background: var(--chip); backdrop-filter: blur(10px);
  display: inline-flex; align-items: center; justify-content: center;
  font-size: 15px; color: var(--muted); transition: background .22s ease, border-color .22s ease, color .22s ease;
}
.social-btn:hover { border-color: var(--accentBorder); background: var(--btnHover); color: var(--accent); }
.social-btn:active { filter: brightness(.95); transition-duration: .07s; }
.social-btn svg { width: 18px; height: 18px; }
.in-box {
  width: 17px; height: 17px; border-radius: 4px; border: 1.5px solid var(--muted);
  display: inline-flex; align-items: center; justify-content: center;
  font-weight: 700; font-size: 9.5px; color: var(--muted); line-height: 1;
}
.photo-card-wrap { flex: 0 0 280px; animation: floaty 5.5s ease-in-out infinite; }

/* ---------- hero on mobile: stack and centre ----------
   Below the width where the photo drops under the copy, everything in the hero
   centres — name, greeting, subtitle, actions and the photo card all sit on the
   axis rather than ragged-left against a full-width column. */
@media (max-width: 820px) {
  .hero__inner { justify-content: center; text-align: center; padding: 96px 0 64px; gap: 40px; }
  .hero__copy { flex-basis: 100%; text-align: center; }
  .hero__title { margin-inline: auto; }
  /* Drop the negative side margin (it exists only to keep the name flush-left);
     centred, it would nudge the name off-axis. Keep the highlight padding. */
  .hero__name { margin: 0; }
  .hero__sub { text-align: center; }
  .hero__sub-line { justify-content: center; }
  .hero__cta { justify-content: center; margin-top: 40px; }
  .hero__socials { justify-content: center; }
  .photo-card-wrap { margin-inline: auto; }
}

/* ---------- mobile / tablet performance ----------
   The two costliest GPU effects on weaker devices are (1) backdrop-blur on the
   many glass tiles and cards that move under the finger while scrolling, and
   (2) the large, animated, heavily-blurred aurora layers. Below tablet width
   both are dropped: the aurora holds still and the glass surfaces render on
   their translucent fills without a live blur. The look softens slightly; the
   scroll goes from stuttering to smooth. The dock keeps its blur (one fixed
   element, cheap, and it needs the frost to stay legible over content). */
@media (max-width: 900px) {
  .aurora__blob { animation: none !important; }
  .bento__cell, .tl-face, .card, .cert-card, .contact-card, .li-card,
  .social-btn, .skill-node, .chip, .pill-link, .eyebrow {
    backdrop-filter: none !important; -webkit-backdrop-filter: none !important;
  }
}
.photo-card {
  width: 280px; border-radius: 24px; background: var(--glassHi); border: 1px solid var(--borderHi);
  backdrop-filter: blur(10px); box-shadow: 0 30px 70px rgba(0,0,0,.5), var(--inset); padding: 10px;
}
.photo-card img { width: 100%; height: 345px; object-fit: cover; border-radius: 16px; display: block; filter: saturate(.85) contrast(1.02); }
#hero-status.typing::after { content: '_'; margin-left: 2px; color: var(--accent); animation: blink 1.1s step-end infinite; }
.photo-card__meta { padding: 12px 8px 4px; display: flex; justify-content: space-between; align-items: center; }
.photo-card__loc { font-family: 'Geist Mono', monospace; font-size: 11px; color: var(--muted); }
.open-badge { font-family: 'Geist Mono', monospace; font-size: 11px; color: var(--accent); display: inline-flex; align-items: center; gap: 6px; }
.open-badge i { width: 6px; height: 6px; border-radius: 50%; background: var(--accent); animation: pulseDot 2.4s ease-in-out infinite; }
.scroll-hint {
  position: absolute; bottom: 24px; left: 50%; transform: translateX(-50%);
  font-family: 'Geist Mono', monospace; font-size: 11px; color: var(--dim); letter-spacing: .22em;
}

/* shared stat pair — used by the project cards */
.stat__num { font-family: 'Geist Mono', monospace; font-size: 30px; font-weight: 600; color: var(--text); }
.stat__cap { font-size: 12.5px; color: var(--faint); margin-top: 2px; }

/* ---------- about: bento grid ----------
   Six columns, and a tile's `size:` picks how many it takes. Nothing here is
   auto-fit: the whole point of the section is that the tiles are deliberately
   unequal, so the spans are authored and the grid places them in order.

   The cells are not links and have no hover state. Every other card on this
   page reacts to the cursor because it leads somewhere; these lead nowhere,
   and a card that lifts under the pointer but does nothing on click is a lie. */
.bento { display: grid; grid-template-columns: repeat(6, 1fr); gap: 16px; margin-top: 44px; }
.bento__lead { margin-top: 20px; }

.bento__cell {
  grid-column: span 2;
  background: var(--tile, var(--glass)); color: var(--tileInk, inherit);
  border: 1px solid var(--border); border-radius: 20px;
  backdrop-filter: blur(10px); box-shadow: var(--inset);
  display: flex; min-width: 0; position: relative;
}
.bento__cell--feature { grid-column: span 4; grid-row: span 2; }
.bento__cell--tall    { grid-column: span 2; grid-row: span 2; }
.bento__cell--wide    { grid-column: span 4; }
.bento__cell--half    { grid-column: span 3; }
.bento__cell--full    { grid-column: 1 / -1; }

/* The body is the flex column, not the cell, so `note:` can be pushed to the
   bottom edge of a tall tile with margin-top:auto and still keep its padding. */
.bento__body { display: flex; flex-direction: column; flex: 1; padding: 26px 28px; min-width: 0; }
.bento__cell--feature .bento__body { padding: clamp(28px, 3vw, 40px); }

/* Pinned to the corner rather than stacked above the kicker: in flow it pushed
   the title down by its own height and the tile fell out of line with its
   neighbours in the same row, which is the one thing a grid cannot absorb. */
.bento__logo {
  position: absolute; top: 22px; right: 22px;
  width: 28px; height: 28px; border-radius: 8px;
  background: var(--chip); border: 1px solid var(--border); overflow: hidden;
}
.bento__body:has(.bento__logo) .bento__kicker,
.bento__body:has(.bento__logo) .bento__title { padding-right: 42px; }
.bento__logo img { width: 100%; height: 100%; object-fit: contain; display: block; }
.bento__kicker {
  font-family: 'Geist Mono', monospace; font-size: 10.5px; letter-spacing: .18em;
  color: var(--faint); text-transform: uppercase; line-height: 1.4;
}
.bento__title {
  margin: 12px 0 0; font-size: 19px; font-weight: 600; line-height: 1.3; letter-spacing: -.015em;
  text-wrap: balance;
}
.bento__cell--feature .bento__title {
  font-family: var(--fontDisplay);
  font-size: clamp(24px, 2.5vw, 33px); line-height: 1.14; letter-spacing: -.025em; margin-top: 16px;
}
/* The second half of a title, in the title's own face one size down. A minor
   subject sits under a degree the way it does on the certificate, so the two
   read as one line of study rather than a heading and an unrelated label. */
.bento__subtitle {
  margin-top: 6px; font-size: 15.5px; font-weight: 600; line-height: 1.3;
  letter-spacing: -.015em; opacity: .82;
}
.bento__cell--filled .bento__subtitle { color: var(--tileInk); }
.bento__body:has(.bento__logo) .bento__subtitle { padding-right: 42px; }
.bento__text { margin-top: 10px; font-size: 14.5px; line-height: 1.65; color: var(--muted); }
.bento__cell--feature .bento__text { margin-top: 16px; font-size: 16px; max-width: 60ch; }
.bento__text p { margin: 0 0 10px; }
.bento__text p:last-child { margin-bottom: 0; }
/* Sits on the bottom edge, and the gap above it collapses to whatever is
   left — on a short tile it stays a normal footnote, on a tall one it
   becomes the thing that anchors the whitespace. */
.bento__note {
  margin-top: auto; padding-top: 22px;
  font-family: 'Geist Mono', monospace; font-size: 11.5px; color: var(--dim);
}

/* An award: the credential worth reading on its own. Outlined rather than
   filled, so it reads as a mark on the tile instead of a button asking to be
   clicked. On a filled tile the outline borrows the tile's own ink. */
.bento__chips { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 18px; }
.bento__chip {
  display: inline-flex; align-items: center; gap: 7px;
  font-size: 12.5px; line-height: 1.2; padding: 7px 13px 7px 11px; border-radius: 100px;
  border: 1px solid var(--borderHi); background: var(--chip); color: var(--text);
}
.bento__chip svg { flex: none; opacity: .85; }
.bento__cell--filled .bento__chip {
  color: var(--tileInk); border-color: currentColor; background: none;
}

/* The one typographic moment in the section: no title, no rules, just the
   sentence set large and light with room around it. Used once. */
.bento__cell--quote .bento__body { justify-content: center; }
.bento__cell--quote .bento__text {
  margin-top: 22px;
  font-family: var(--fontDisplay); font-weight: 300;
  font-size: clamp(17px, 1.5vw, 20px); line-height: 1.5; letter-spacing: -.01em;
  color: var(--text);
}

/* Matches the dashed placeholder language the project media panels use, so an
   unwritten tile reads as a to-do rather than as a finished empty card. */
.bento__cell--draft {
  border-style: dashed; border-color: var(--borderHi); background: var(--chip); box-shadow: none;
}
.bento__cell--draft .bento__body { padding: 20px 24px; }
.bento__cell--draft .bento__text {
  font-family: 'Geist Mono', monospace; font-size: 12.5px; color: var(--faint); line-height: 1.7;
}

/* ---------- about: brand tiles ----------
   A tile can wear a brand: `color:` fills it, `ink:` sets the text, `accent:`
   tints the kicker. A `link:` tile is a real anchor and the only kind that
   reacts to the cursor. A filled tile that leads nowhere stays perfectly still,
   because a card that lifts under the pointer but does nothing is a lie. */
.bento__cell--filled {
  border: 0; backdrop-filter: none;
  box-shadow: 0 22px 55px rgba(0,0,0,.30), var(--inset);
}
.bento__cell--filled .bento__kicker { color: var(--tileAccent, var(--tileInk)); opacity: .82; }
.bento__cell--filled .bento__title,
.bento__cell--filled .bento__text { color: var(--tileInk); }
.bento__cell--filled .bento__note { color: var(--tileInk); opacity: .62; }

/* A wordmark set in the body on a fill that matches its own artwork, so the
   image reads as set type rather than a pasted-in logo box. */
.bento__brandmark { max-width: min(74%, 240px); height: auto; display: block; margin-bottom: 14px; }

/* Feature tile wears its brand mark large in the bottom-right instead of the
   small corner chip. The mark's own background matches the tile fill, so only
   the glyph shows and the two big top tiles stop reading as identical blocks. */
.bento__cell--feature .bento__logo {
  top: auto; right: 18px; bottom: 18px;
  width: clamp(92px, 23%, 172px); height: auto; aspect-ratio: 1;
  background: none; border: 0; border-radius: 18px;
}
.bento__cell--feature .bento__logo img { object-fit: contain; }
.bento__cell--feature .bento__body { position: relative; z-index: 1; }

/* A filled brand tile carries its raster mark large in the top-right, with the
   chip backing removed so the logo's own artwork sits flush on the fill
   (e.g. MathCo's coral asterisk on its indigo). */
.bento__cell--filled:not(.bento__cell--serif) .bento__logo {
  width: 52px; height: 52px; top: 18px; right: 18px;
  background: none; border: 0; border-radius: 12px; overflow: hidden;
}
.bento__cell--filled:not(.bento__cell--serif) .bento__body:has(.bento__logo) .bento__kicker,
.bento__cell--filled:not(.bento__cell--serif) .bento__body:has(.bento__logo) .bento__title { padding-right: 64px; }

/* Big display number (research tile): the count leads, the label sits under. */
.bento__big {
  font-family: var(--fontDisplay); font-size: clamp(46px, 7vw, 76px);
  font-weight: 600; line-height: .9; letter-spacing: -.03em; margin-top: 6px;
}
.bento__cell--filled .bento__big { color: var(--tileInk); }
.bento__stat { display: block; font: inherit; margin: 0; }
.bento__stat .bento__big, .bento__stat .bento__title { display: block; }
.bento__cell--filled .bento__big + .bento__title,
.bento__cell--filled .bento__stat .bento__title { font-weight: 500; font-size: 15px; margin-top: 8px; opacity: .9; }

/* Photo tile caption: a small kicker over a larger place name. */
.bento__cell--photo .bento__kicker { color: rgba(255,255,255,.82); }
.bento__cell--photo .bento__title { font-size: clamp(22px, 2.6vw, 30px); margin-top: 2px; }

/* The one serif tile: a single statement set in Galaxie Copernicus, so it
   reads as a quiet voice next to the mono and sans everywhere else. */
.bento__cell--serif .bento__body { justify-content: center; }
/* The statement itself is bold. Galaxie Copernicus ships only its Book weight
   here, and asking for 700 makes the browser smear Book into a fake bold that
   thickens the round strokes and leaves the thin ones alone. A hairline stroke
   in the same colour adds weight evenly instead, which is what a real bold cut
   does. Drop this rule and set font-weight if a Bold file is ever added. */
.bento__cell--serif .bento__title {
  font-family: 'Galaxie Copernicus', Georgia, 'Times New Roman', serif; font-weight: 400;
  font-size: clamp(25px, 2.8vw, 35px); line-height: 1.22; letter-spacing: -.01em;
  -webkit-text-stroke: .6px currentColor; paint-order: stroke fill;
}
/* The Claude mark sits bare in the corner, aligned with the top of the text,
   rather than in the chip box the other tiles use for a logo. */
.bento__cell--serif .bento__logo {
  background: none; border: 0; border-radius: 0;
  width: 30px; height: 30px; top: 26px; right: 26px;
}
/* The second line of the statement, set in the same serif as the first so the
   two read as one voice — logo in the corner, both lines beneath it. */
.bento__cell--serif .bento__text {
  margin-top: 8px; color: var(--tileInk); opacity: .92;
  font-family: 'Galaxie Copernicus', Georgia, 'Times New Roman', serif; font-weight: 400;
  font-size: clamp(20px, 2.1vw, 28px); line-height: 1.25; letter-spacing: -.01em;
}

/* Stack marks: brand icons from simpleicons, sized to sit on one line. */
.bento__marks { display: flex; flex-wrap: wrap; align-items: center; gap: 15px; margin-top: 18px; }
.bento__marks img { width: 26px; height: 26px; display: block; }

/* Publisher discs. The disc stays light whatever the tile is filled with, so a
   brand mark reads at its own colour instead of vanishing into the fill. A disc
   with no mark to show falls back to the publisher's name set small.
   The mark is capped against the circle's inscribed square rather than its
   width, because a wide wordmark laid across the full diameter would run past
   the curve at top and bottom. 70% is that square with a little air. */
.bento__badges { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-top: 18px; }
.bento__badge {
  flex: none; width: 54px; height: 54px; border-radius: 50%; overflow: hidden;
  display: inline-flex; align-items: center; justify-content: center;
  background: #fff; box-shadow: 0 1px 3px rgba(0, 0, 0, .28);
}
.bento__badge img { max-width: 70%; max-height: 70%; width: auto; height: auto; display: block; object-fit: contain; }
.bento__badge-word {
  font-family: 'Geist Mono', monospace; font-size: 9px; line-height: 1;
  color: #14161a; text-align: center; padding: 0 3px; letter-spacing: .01em;
}

/* Stack marquee: two rows of marks scrolling opposite ways. Each track carries
   the marks twice, so animating one full set-width (-50%) loops seamlessly. A
   mask fades both ends so icons enter and leave rather than pop. */
.bento__marquee { margin-top: 14px; display: flex; flex-direction: column; gap: 12px; }
.bento__mq-row {
  overflow: hidden;
  -webkit-mask: linear-gradient(90deg, transparent, #000 6%, #000 94%, transparent);
          mask: linear-gradient(90deg, transparent, #000 6%, #000 94%, transparent);
}
.bento__mq-track { display: flex; gap: 0; width: max-content; }
.mq-chip { display: inline-flex; align-items: center; gap: 9px; padding: 0 16px; white-space: nowrap; color: var(--tileInk); flex: none; }
.mq-chip img { width: 24px; height: 24px; display: block; flex: none; }
.mq-chip span { font-family: 'Geist Mono', monospace; font-size: 12.5px; letter-spacing: .02em; opacity: .92; }
.bento__mq-row--l .bento__mq-track { animation: mqScroll 34s linear infinite; }
.bento__mq-row--r .bento__mq-track { animation: mqScroll 34s linear infinite reverse; }
@keyframes mqScroll { from { transform: translateX(0); } to { transform: translateX(-50%); } }
@media (prefers-reduced-motion: reduce) {
  .bento__mq-row--l .bento__mq-track, .bento__mq-row--r .bento__mq-track { animation: none; }
  .bento__mq-row { -webkit-mask: none; mask: none; }
  .bento__mq-track { flex-wrap: wrap; }
}

/* Photo tile: the image is the fill, text pinned to the bottom over a gradient. */
.bento__cell--photo { overflow: hidden; min-height: 168px; }
.bento__photo { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; z-index: 0; filter: saturate(.95); }
.bento__cell--photo::after {
  content: ''; position: absolute; inset: 0; z-index: 1;
  background: linear-gradient(180deg, rgba(8,10,20,.05) 35%, rgba(8,10,20,.78) 100%);
}
.bento__cell--photo .bento__body { position: relative; z-index: 2; justify-content: flex-end; }
.bento__cell--photo .bento__title { color: #fff; }

/* Link tiles are the only tiles that move, because they are the only ones that
   go anywhere. A corner arrow marks them. */
a.bento__cell { text-decoration: none; color: inherit; }
.bento__cell--link { cursor: pointer; transition: transform .22s ease, box-shadow .22s ease, filter .22s ease; }
.bento__cell--link:hover { transform: translateY(-3px); filter: brightness(1.08); }
.bento__cell--link:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
.bento__arrow { position: absolute; right: 20px; bottom: 20px; z-index: 3; color: var(--tileInk, var(--text)); opacity: .55; transition: opacity .22s ease, transform .22s ease; }
.bento__cell--link:hover .bento__arrow { opacity: 1; transform: translate(2px, -2px); }
@media (prefers-reduced-motion: reduce) {
  .bento__cell--link { transition: none; }
  .bento__cell--link:hover { transform: none; }
  .bento__cell--link:hover .bento__arrow { transform: none; }
}

/* Four columns: the feature keeps the full width as a banner and everything
   below it pairs up. Row spans are dropped here — a double-height quote beside
   two stacked tiles leaves a half-empty card, and empty space that isn't
   deliberate just reads as a rendering fault. */
@media (max-width: 1100px) {
  .bento { grid-template-columns: repeat(4, 1fr); }
  .bento__cell--feature { grid-column: span 4; }
  .bento__cell--half { grid-column: span 2; }
  .bento__cell--feature, .bento__cell--tall { grid-row: span 1; }
}
@media (max-width: 700px) {
  .bento { grid-template-columns: 1fr; gap: 12px; }
  .bento__cell, .bento__cell--feature, .bento__cell--tall,
  .bento__cell--wide, .bento__cell--half, .bento__cell--full { grid-column: 1 / -1; grid-row: auto; }
  .bento__body { padding: 22px 24px; }
  .bento__cell--photo { min-height: 200px; }
  .bento__cell--quote .bento__text { margin-top: 16px; }
}

/* ---------- experience cards ---------- */
.xp-list { display: flex; flex-direction: column; gap: 20px; margin-top: 44px; }
.xp-card { padding: 26px 30px; }
.xp-head { display: flex; justify-content: space-between; gap: 16px; flex-wrap: wrap; align-items: center; }
.xp-id { display: flex; align-items: center; gap: 14px; }
.xp-logo {
  width: 42px; height: 42px; border-radius: 11px; background: var(--chip); border: 1px solid var(--border);
  display: flex; align-items: center; justify-content: center; overflow: hidden;
  font-family: 'Geist Mono', monospace; font-size: 10px; color: var(--faint);
}
.xp-logo img { width: 100%; height: 100%; object-fit: contain; }
.xp-org { font-size: 19px; font-weight: 600; }
.xp-date { font-family: 'Geist Mono', monospace; font-size: 12px; color: var(--faint); margin-top: 3px; }
.xp-text { margin-top: 16px; font-size: 15px; color: var(--muted); line-height: 1.65; max-width: 820px; }

/* ---------- experience timeline ----------
   A centre thread; each role's card sits in one half and its date in the other,
   and clicking the card flips it in 3D to reveal the detail. The logo is a
   flex:none square so the fixed-height flip face can never crush it. Collapses
   to a single left rail below 720px. */
.timeline { position: relative; list-style: none; margin: 52px 0 0; padding: 0; }
.timeline::before {
  content: ''; position: absolute; left: 50%; top: 8px; bottom: 8px; width: 2px;
  transform: translateX(-50%); background: linear-gradient(var(--borderHi), var(--border));
}
/* Two halves with the thread between them. The card goes in one, the date in
   the other; nth-child swaps which side per role. */
.tl-item { position: relative; display: grid; grid-template-columns: 1fr 1fr; column-gap: 56px; align-items: start; padding-bottom: 30px; }
.tl-item:last-child { padding-bottom: 0; }
.tl-item:nth-child(odd)  .tl-card     { grid-column: 1; }
.tl-item:nth-child(odd)  .tl-dateside { grid-column: 2; justify-self: start; text-align: left; }
.tl-item:nth-child(even) .tl-card     { grid-column: 2; }
.tl-item:nth-child(even) .tl-dateside { grid-column: 1; justify-self: end; text-align: right; }
.tl-dot {
  position: absolute; left: 50%; top: 30px; transform: translateX(-50%);
  width: 15px; height: 15px; border-radius: 50%; z-index: 2;
  background: var(--accent); box-shadow: 0 0 0 4px var(--bg), 0 0 16px var(--glow);
}
.tl-dateside { align-self: start; padding-top: 30px; }
.tl-date { font-family: 'Geist Mono', monospace; font-size: 12.5px; color: var(--faint); }

/* Flip card: perspective on the button, the inner element rotates, the two
   faces are stacked and hide their own backs. */
.tl-card { display: block; width: 100%; text-align: left; background: none; border: 0; padding: 0; perspective: 1500px; cursor: pointer; }
.tl-inner {
  display: block; position: relative; transform-style: preserve-3d; min-height: 188px;
  transition: transform .6s cubic-bezier(.4,.2,.2,1);
}
.tl-card[aria-pressed="true"] .tl-inner { transform: rotateY(180deg); }
.tl-face {
  position: absolute; inset: 0; display: flex; padding: 22px 24px;
  border-radius: 18px; border: 1px solid var(--border);
  background: var(--glass); backdrop-filter: blur(9px); box-shadow: var(--inset);
  -webkit-backface-visibility: hidden; backface-visibility: hidden;
  /* backface-visibility alone is not enough here. A backdrop-filter promotes
     the face to its own render surface, and a surface is composited without
     backface culling, so the reverse side paints straight through the front:
     every card showed its skills list stamped over its own header. Hiding the
     idle face outright is the fix that does not cost the glass. The delay puts
     the swap at the halfway point of the flip, where the card is edge-on and
     nothing is visible to swap. */
  visibility: hidden; transition: visibility 0s linear .3s;
}
.tl-face--front { visibility: visible; }
.tl-card[aria-pressed="true"] .tl-face--front { visibility: hidden; }
.tl-card[aria-pressed="true"] .tl-face--back { visibility: visible; }
@media (prefers-reduced-motion: reduce) { .tl-face { transition: none; } }
.tl-card:hover .tl-face { border-color: var(--borderHi); }
.tl-card:focus-visible .tl-face { outline: 2px solid var(--accent); outline-offset: 2px; }
.tl-face--front { flex-direction: row; align-items: center; gap: 18px; }
/* Full-colour square logos that fill the badge like an app icon — flex:none so
   the fixed-height face never shrinks them into a thin band. */
.tl-logo {
  width: 72px; height: 72px; border-radius: 16px; border: 1px solid var(--border);
  overflow: hidden; flex: none;
}
.tl-logo img { width: 100%; height: 100%; object-fit: cover; display: block; }
/* The flip hint is absolutely placed top-right, so the text column keeps a gap
   its width. Without it the org name wraps straight under the hint on any card
   narrower than about 500px, which is every card on a tablet or phone. */
.tl-front-text { flex: 1; min-width: 0; display: flex; flex-direction: column; padding-right: 118px; }
.tl-org { font-family: var(--fontDisplay); font-size: 21px; font-weight: 600; line-height: 1.15; color: var(--text); letter-spacing: -.02em; }
.tl-role { margin-top: 4px; font-size: 14px; font-weight: 500; color: var(--accent); }
/* Employment type (Intern / On-site) as a quiet meta line — kept apart from the
   skills so the repetitive badges don't make every card read the same. */
.tl-type { margin-top: 5px; font-family: 'Geist Mono', monospace; font-size: 11px; color: var(--dim); }
/* Small caps label so the two sides of the flip are explicitly SKILLS / DETAILS. */
.tl-eyebrow { margin-top: 13px; font-family: 'Geist Mono', monospace; font-size: 9.5px; font-weight: 600; letter-spacing: .2em; text-transform: uppercase; color: var(--faint); }
.tl-skills { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-top: 7px; }
.tl-flip-hint {
  position: absolute; top: 18px; right: 20px; display: inline-flex; align-items: center; gap: 5px;
  font-family: 'Geist Mono', monospace; font-size: 10px; letter-spacing: .1em; text-transform: uppercase; color: var(--faint);
  transition: color .2s ease;
}
.tl-flip-hint__t { font-style: normal; }
.tl-card:hover .tl-flip-hint { color: var(--accent); }
/* Cards get narrow well before the timeline collapses, so the hint drops to
   its icon alone and the text column takes its gap back. */
@media (max-width: 1040px) {
  .tl-flip-hint__t { display: none; }
  .tl-front-text { padding-right: 26px; }
}
/* The detail side is tinted a touch brighter so the flip visibly lands on a
   different face rather than the same-looking card. */
.tl-face--back { flex-direction: column; justify-content: center; transform: rotateY(180deg); background: var(--glassHi); }
.tl-face--back .tl-eyebrow { margin-top: 0; }
.tl-back-head { margin-top: 6px; font-family: var(--fontDisplay); font-size: 16px; font-weight: 600; color: var(--text); letter-spacing: -.01em; }
.tl-back-text { margin-top: 8px; font-size: 14px; line-height: 1.6; color: var(--muted); }
.tl-back-text p { margin: 0; }
@media (prefers-reduced-motion: reduce) { .tl-inner { transition: none; } }
/* Collapse to a single left rail: one column, the thread on the left, the date
   stacked above its card. */
@media (max-width: 720px) {
  .timeline::before { left: 8px; }
  .tl-item { grid-template-columns: 1fr; padding-left: 34px; padding-bottom: 22px; }
  /* :nth-child(n) matches all rows at the same specificity as the desktop
     odd/even rules, so — coming later in source — it wins and forces every card
     and date back into the single column (otherwise grid-column:2 survives and
     spawns a phantom second column that overflows the phone). */
  .tl-item:nth-child(n) .tl-card,
  .tl-item:nth-child(n) .tl-dateside { grid-column: 1; justify-self: start; text-align: left; }
  .tl-dateside { padding-top: 0; margin-bottom: 8px; }
  .tl-dot { left: 8px; transform: translateX(-50%); }
}

/* ---------- projects ---------- */
.proj-grid { margin-top: 44px; display: grid; grid-template-columns: repeat(auto-fill, minmax(min(360px, 100%), 1fr)); gap: 20px; }
.proj-card { padding: 0; display: flex; flex-direction: column; overflow: hidden; }
/* The all-projects card uses its own media class, so it needs its own sizing.
   Without this an image keeps its natural height and spills over the card body
   below it, since the box only ever clipped when it held a placeholder. */
.proj-card .proj-card__media { height: 210px; border-radius: 0; flex-shrink: 0; overflow: hidden; }
.proj-card .proj-card__media img,
.proj-card .proj-card__media video,
.proj-card .proj-card__media iframe { width: 100%; height: 100%; object-fit: cover; border: 0; display: block; }
.proj-card__body { padding: 22px 24px 24px; display: flex; flex-direction: column; flex: 1; }
.proj-card__title { margin-top: 12px; font-size: 23px; font-weight: 600; letter-spacing: -.02em; line-height: 1.15; }
.proj-card__text { margin-top: 10px; font-size: 14.5px; line-height: 1.6; color: var(--muted); flex: 1; }
.proj-card__text p { margin: 0; }
.proj-card .proj__stats { gap: 20px; margin-top: 16px; }
.proj-card .proj__stats .stat__num { font-size: 18px; }
.proj-card .tags { margin-top: 14px; }
.proj-card .proj__links { margin-top: 16px; }

/* ---------- all-projects subpage (projects.html) ---------- */
.subhead { padding-top: clamp(48px, 8vw, 96px); }
.subpage__main { padding-bottom: 90px; }
.backlink {
  display: inline-flex; align-items: center; gap: 8px; margin-bottom: 26px;
  font-family: 'Geist Mono', monospace; font-size: 13px; color: var(--muted);
  transition: color .2s ease;
}
.backlink:hover { color: var(--text); }
.subpage #proj-all.proj-grid { margin-top: 34px; }

.proj-list { margin-top: 56px; }
.proj { display: flex; gap: 48px; align-items: center; flex-wrap: wrap; padding: 40px 0; border-top: 1px solid var(--border); }
.proj--flip { flex-wrap: wrap-reverse; }
.proj-list .proj:last-child { border-bottom: 1px solid var(--border); }
.proj__info { flex: 1 1 440px; min-width: min(440px, 90vw); }
.proj__kicker { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.proj__num { font-family: 'Doto', monospace; font-weight: 700; font-size: 12px; letter-spacing: .26em; color: var(--faint); }
.proj__title { margin-top: 14px; font-size: clamp(28px, 3.4vw, 42px); font-weight: 600; letter-spacing: -.025em; line-height: 1.05; }
.proj__text { margin: 14px 0 0; font-size: 16px; line-height: 1.6; color: var(--muted); }
.proj__stats { display: flex; gap: 28px; margin-top: 22px; flex-wrap: wrap; }
.proj__stats .stat__num { font-size: 22px; }
.proj__stats .stat__cap { font-size: 12px; }
.proj__links { display: flex; gap: 10px; margin-top: 24px; flex-wrap: wrap; align-items: center; }
.proj__media { flex: 1 1 480px; min-width: min(480px, 90vw); }
.media-panel { width: 100%; height: 340px; border-radius: 20px; overflow: hidden; }
.media-panel img, .media-panel video, .media-panel iframe { width: 100%; height: 100%; object-fit: cover; border: 0; display: block; }

/* ---------- projects: index + preview ----------
   A scannable title list on the left; only the selected project expands on the
   right, so the section never forces six full cards on a visitor. Hover (on a
   fine pointer) or click swaps the single stage. */
.proj-split { margin-top: 44px; display: grid; grid-template-columns: minmax(240px, 330px) 1fr; gap: 30px; align-items: start; }
.proj-index { display: flex; flex-direction: column; gap: 4px; position: sticky; top: 96px; }
.proj-index__item {
  display: flex; align-items: center; gap: 14px; width: 100%; text-align: left;
  padding: 13px 15px; border-radius: 14px; border: 1px solid transparent; background: none;
  color: var(--muted); cursor: pointer;
  /* It is a <button>, which otherwise renders in the UA font (Arial). Inherit
     the site face so the index reads in the same type as everything else. */
  font-family: inherit;
  transition: background .2s ease, border-color .2s ease, color .2s ease;
}
.proj-index__item:hover { background: var(--chip); color: var(--text); }
.proj-index__item--on { background: var(--glass); border-color: var(--borderHi); color: var(--text); box-shadow: var(--inset); }
.proj-index__num { font-family: 'Doto', monospace; font-weight: 700; font-size: 12px; letter-spacing: .1em; color: var(--faint); }
.proj-index__item--on .proj-index__num { color: var(--accent); }
.proj-index__main { flex: 1; min-width: 0; }
.proj-index__title { display: block; font-size: 15.5px; font-weight: 600; line-height: 1.25; }
.proj-index__doms { display: block; font-size: 11.5px; color: var(--faint); margin-top: 2px; }
.proj-index__arrow { display: flex; color: var(--accent); opacity: 0; transform: translateX(-4px); transition: opacity .2s ease, transform .2s ease; }
.proj-index__item--on .proj-index__arrow, .proj-index__item:hover .proj-index__arrow { opacity: 1; transform: none; }
.proj-more { display: flex; align-items: center; gap: 8px; margin-top: 10px; padding: 13px 15px; font-size: 13.5px; color: var(--muted); text-decoration: none; border-top: 1px solid var(--border); transition: color .2s ease; }
.proj-more:hover { color: var(--text); }

.proj-stage { position: relative; min-width: 0; }
.proj-preview[hidden] { display: none; }
/* Framed rather than a bare screenshot floating on the page: glass panel,
   hairline border, and 10px of padding so the image sits inside the frame. */
.proj-preview__media {
  height: clamp(240px, 34vw, 380px); border-radius: 20px; overflow: hidden;
  box-sizing: border-box; padding: 10px;
  background: var(--glass); border: 1px solid var(--border); box-shadow: var(--inset);
}
.proj-preview__media img, .proj-preview__media video, .proj-preview__media iframe { border-radius: 12px; }
.proj-preview__body { margin-top: 22px; }
.proj-preview__title { margin-top: 12px; font-size: clamp(24px, 3vw, 34px); font-weight: 600; letter-spacing: -.02em; line-height: 1.1; }
.proj-preview__text { margin-top: 12px; font-size: 15.5px; line-height: 1.65; color: var(--muted); max-width: 640px; }
.proj-preview__text p { margin: 0; }
@media (prefers-reduced-motion: no-preference) {
  .proj-preview--on { animation: projFade .35s ease; }
  @keyframes projFade { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
}
@media (max-width: 860px) {
  .proj-split { grid-template-columns: 1fr; gap: 20px; }
  .proj-index { position: static; }
}
.media-panel.ph { overflow: visible; }
.thumb-strip { display: flex; gap: 10px; margin-top: 12px; }
.thumb {
  flex: 1; height: 72px; border-radius: 12px; border: 1px solid var(--border); background: var(--stripe);
  display: flex; align-items: center; justify-content: center; overflow: hidden;
  font-family: 'Geist Mono', monospace; font-size: 10px; color: var(--faint); text-align: center;
}
.thumb img { width: 100%; height: 100%; object-fit: cover; }

/* ---------- skills ---------- */
.skill-nodes { display: flex; flex-wrap: wrap; gap: 14px; justify-content: center; margin-top: 40px; }
.skill-node {
  display: flex; align-items: center; gap: 12px; padding: 16px 22px; border-radius: 100px;
  background: var(--glass); border: 1px solid var(--border); backdrop-filter: blur(9px);
  box-shadow: var(--inset); transition: all .3s cubic-bezier(.2,1.2,.4,1);
}
.skill-node:hover, .skill-node--active { background: var(--glassHi); border-color: var(--borderHi); }
.skill-node img { width: 20px; height: 20px; flex: none; }
.skill-node__label { display: block; font-size: 16px; font-weight: 600; color: var(--text); }
.skill-node__tech { display: block; font-family: 'Geist Mono', monospace; font-size: 11px; color: var(--faint); margin-top: 3px; }
.skill-evidence { margin-top: 28px; min-height: 54px; display: flex; align-items: center; justify-content: center; }
.skill-evidence div { font-family: 'Geist Mono', monospace; font-size: 13px; color: var(--accent); text-align: center; line-height: 1.7; transition: opacity .3s; }
.chips { margin-top: 24px; display: flex; flex-wrap: wrap; gap: 9px; justify-content: center; }
.chip {
  display: inline-flex; align-items: center; gap: 8px; padding: 8px 14px; border-radius: 100px;
  background: var(--chip); border: 1px solid var(--border);
  font-family: 'Geist Mono', monospace; font-size: 12px; color: var(--muted);
}
.chip img { width: 14px; height: 14px; }
/* CDN icon failed to load and was removed — close the gap it left behind. */
.chip.has-broken-icon, .skill-node.has-broken-icon { gap: 0; }
.xp-logo.has-broken-icon::after { content: 'logo'; font-family: 'Geist Mono', monospace; font-size: 10px; color: var(--faint); }
img[data-failed] { display: none; }

/* ---------- filter chips (experience) ---------- */
.filter-bar { display: flex; flex-wrap: wrap; gap: 9px; margin-top: 30px; }
.chip--filter { user-select: none; transition: color .25s, border-color .25s, background .25s; }
.chip--filter:hover { color: var(--text); border-color: var(--accentBorder); }
.chip--active, .chip--active:hover { color: var(--accent); border-color: var(--accentBorder); }
.xp-badges { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }

/* ---------- research ---------- */
.pub-scholar {
  display: inline-flex; align-items: center; gap: 10px; margin-top: 26px;
  padding: 11px 18px; border-radius: 999px; text-decoration: none;
  border: 1px solid var(--line); color: var(--dim);
  font-family: 'Geist Mono', monospace; font-size: 12px; letter-spacing: .04em;
  transition: color .18s ease, border-color .18s ease;
}
.pub-scholar:hover { color: var(--text); border-color: var(--lineStrong, rgba(255,255,255,.28)); }
.pub-scholar img { display: block; opacity: .85; }
.pub-scholar em { font-style: normal; opacity: .7; }
/* Mark, then text, then the paper button pushed to the far edge. The button is
   what claims the right side, not space-between: with no live link the row
   would otherwise open a gap between the mark and the title it belongs to. */
.pub-card { margin-top: 32px; padding: 28px 32px; display: flex; gap: 24px; flex-wrap: wrap; align-items: center; }
.pub-card .pill-link { margin-left: auto; }
.pub-card__title { font-size: 20px; font-weight: 600; line-height: 1.3; }
.pub-card__meta { font-family: 'Geist Mono', monospace; font-size: 12.5px; color: var(--faint); margin-top: 8px; }
.pub-card__text { font-size: 14.5px; color: var(--muted); margin-top: 10px; line-height: 1.6; }
.pub-card__body { max-width: 680px; flex: 0 1 auto; }
.pub-card__doi {
  font-family: 'Geist Mono', monospace; font-size: 12px; color: var(--muted);
  margin-top: 12px; word-break: break-all;
}
.pub-card__doi span {
  color: var(--faint); letter-spacing: .12em; font-size: 10.5px;
  border: 1px solid var(--border); border-radius: 5px; padding: 2px 6px; margin-right: 8px;
}
/* Publisher disc, the same circle the About board uses, so a publisher looks
   identical in both places. The fill is the disc's own, which is why the marks
   are stored as transparent PNGs: an image carrying its own white card would
   show up as a square sitting inside the circle. */
.pub-card__logo {
  flex: 0 0 auto; width: 84px; height: 84px; border-radius: 50%;
  background: #fff; display: flex; align-items: center; justify-content: center;
}
/* Capped against the inscribed square, not the diameter: a wide wordmark set to
   the full width would cross the curve at top and bottom. Flex, not grid, keeps
   the percentage heights definite. */
.pub-card__logo img { max-width: 70%; max-height: 70%; width: auto; height: auto; display: block; object-fit: contain; }
@media (max-width: 720px) {
  .pub-card { padding: 22px 20px; gap: 16px; }
  .pub-card__logo { width: 64px; height: 64px; }
}
.pill-link {
  white-space: nowrap; font-size: 14px; color: var(--text); padding: 12px 20px; border-radius: 100px;
  border: 1px solid var(--borderHi); background: var(--chip); transition: background .24s cubic-bezier(.4,0,.2,1), border-color .24s cubic-bezier(.4,0,.2,1), color .24s;
  display: inline-flex; align-items: center; gap: 8px;
}
/* The drawn arrows: sized to the text they sit beside, and dimmer, so they read
   as a mark on the link rather than a second word. */
.ico-arrow, .ico-ext { width: 15px; height: 15px; flex: 0 0 auto; display: block; }
.pill-link .ico-ext, .contact-card__val .ico-ext { opacity: .55; }
.pill-link:hover .ico-ext { opacity: 1; }
.pill-link:hover { border-color: var(--accentBorder); background: var(--btnHover); color: var(--text); }
.pill-link:active { filter: brightness(.97); transition-duration: .07s; }
.pill-link--dim { color: var(--muted); border-color: var(--border); background: none; padding: 12px 18px; }
.pill-link--dim:hover { color: var(--text); background: var(--btnHover); border-color: var(--accentBorder); }
.pill-link svg { width: 15px; height: 15px; }

/* ---------- certifications grid ---------- */
.cert-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 14px; margin-top: 32px; }
.cert-card { border-radius: 18px; padding: 22px 24px; backdrop-filter: blur(9px); }
.cert-card__org { display: block; font-family: 'Geist Mono', monospace; font-size: 11px; color: var(--faint); letter-spacing: .1em; }
.cert-card__title { display: block; font-size: 16.5px; font-weight: 600; margin-top: 8px; line-height: 1.35; }
.cert-card__link { font-family: 'Geist Mono', monospace; font-size: 12px; color: var(--accent); margin-top: 12px; display: inline-block; }

/* A certificate card that has a scan opens in place. `details` carries the open
   state itself, so there is no JavaScript here and none needed: the card works
   with scripting off, answers the keyboard, and in-page search finds the text
   inside it. The grid keeps auto-rows, so an opened card grows its own row
   without dragging its neighbours taller. */
.cert-card--open { padding: 0; align-self: start; }
.cert-card--open > summary {
  padding: 22px 24px; cursor: pointer; list-style: none; display: block;
  border-radius: 18px; transition: background .24s cubic-bezier(.4,0,.2,1);
}
.cert-card--open > summary::-webkit-details-marker { display: none; }
.cert-card--open > summary:hover { background: var(--btnHover); }
.cert-card--open > summary:focus-visible { outline: 2px solid var(--accentBorder); outline-offset: 2px; }
.cert-card__cue {
  display: inline-flex; align-items: center; gap: 6px; margin-top: 14px;
  font-family: 'Geist Mono', monospace; font-size: 11px; letter-spacing: .1em;
  color: var(--accent);
}
.cert-card__cue svg { transition: transform .24s cubic-bezier(.4,0,.2,1); }
.cert-card--open[open] > summary .cert-card__cue svg { transform: rotate(180deg); }
.cert-card__scan { padding: 0 24px 24px; }
/* The scans are white documents landing on a dark card, so they get their own
   light frame rather than floating with a hard cut edge. */
.cert-card__scan img {
  display: block; width: 100%; height: auto; border-radius: 10px;
  background: #fff; border: 1px solid var(--border);
}
.cert-card__scan .pill-link { margin-top: 14px; font-size: 13px; padding: 10px 16px; }

/* ---------- duo (achievements + beyond) ---------- */
.duo { display: flex; gap: 48px; flex-wrap: wrap; }
.duo__col { flex: 1 1 460px; min-width: min(460px, 90vw); }
.duo__list { display: flex; flex-direction: column; gap: 12px; margin-top: 24px; }
.li-card { border-radius: 16px; padding: 18px 22px; backdrop-filter: blur(10px); display: flex; gap: 14px; align-items: baseline; }
.li-num { font-family: 'Doto', monospace; font-weight: 900; font-size: 16px; color: var(--text); }
.li-title { font-size: 16px; font-weight: 600; }
.li-text { font-size: 13.5px; color: var(--muted); margin-top: 3px; }

/* ---------- banner ---------- */
.banner {
  border-radius: 24px; padding: clamp(28px, 4vw, 48px); background: var(--glassHi); border: 1px solid var(--borderHi);
  backdrop-filter: blur(12px); box-shadow: 0 30px 70px rgba(0,0,0,.4), var(--inset);
  display: flex; justify-content: space-between; gap: 28px; flex-wrap: wrap; align-items: center;
}
.banner__body { max-width: 620px; }
.banner__title { margin-top: 14px; font-size: clamp(28px, 3.4vw, 40px); font-weight: 600; letter-spacing: -.025em; line-height: 1.1; }
.banner__text { margin: 12px 0 0; font-size: 16px; line-height: 1.6; color: var(--muted); }
.banner__status { font-family: 'Geist Mono', monospace; font-size: 13px; color: var(--accent); display: flex; align-items: center; gap: 10px; }
.banner__status i { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); animation: pulseDot 2s ease-in-out infinite; }
.banner__status .blink { animation: blink 1.1s step-end infinite; }

/* A banner carrying a drawing gives it the room the status line would take. */
.banner--art { align-items: center; }
.banner__art { flex: none; width: clamp(150px, 20vw, 216px); color: var(--text); line-height: 0; }
.banner__art svg { width: 100%; height: auto; display: block; }
/* The seed that fell out of the slice. Quiet enough to be a second read. */
.art-melon__stray { opacity: .38; }
@media (max-width: 720px) {
  .banner__art { width: 140px; margin-top: 4px; }
}

/* ---------- contact ---------- */
.contact__title { margin: 16px 0 8px; font-size: clamp(40px, 5.5vw, 68px); font-weight: 600; letter-spacing: -.03em; line-height: 1.02; }
.contact__lead { margin-top: 16px; }
.contact__grid { display: flex; gap: 40px; flex-wrap: wrap; margin-top: 40px; }
.contact__cards { flex: 1 1 420px; min-width: min(420px, 90vw); display: grid; grid-template-columns: 1fr 1fr; gap: 12px; align-content: start; }
/* Hover lights the card rather than lifting it. Four cards in a tight grid
   each jumping 3px under the pointer made the block twitch on the way past,
   and a lift says "this moves" when what it does is open a link. So: the
   glass brightens, an accent rule wipes down the leading edge, and the label
   takes the accent colour, which is the same language the links use. */
.contact-card {
  position: relative; overflow: hidden;
  padding: 18px 20px; border-radius: 16px; background: var(--glass);
  border: 1px solid var(--border); backdrop-filter: blur(9px); color: var(--text);
  transition: background .3s, border-color .3s, box-shadow .3s;
}
.contact-card::before {
  content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 2px;
  background: var(--accent); transform: scaleY(0); transform-origin: top;
  transition: transform .32s cubic-bezier(.22,1,.36,1);
}
.contact-card:hover {
  background: var(--glassHi); border-color: var(--accentBorder); color: var(--text);
  box-shadow: 0 0 0 1px var(--accentBorder);
}
.contact-card:hover::before { transform: scaleY(1); }
.contact-card:hover .contact-card__kind,
.contact-card:hover .contact-card__kind .in-box { color: var(--accent); border-color: var(--accent); }
@media (prefers-reduced-motion: reduce) { .contact-card::before { transition: none; } }
.contact-card__kind { display: flex; align-items: center; gap: 8px; font-family: 'Geist Mono', monospace; font-size: 11px; color: var(--faint); }
.contact-card__kind svg { width: 15px; height: 15px; }
.contact-card__kind .in-box { width: 15px; height: 15px; border-radius: 3.5px; font-size: 8.5px; color: var(--faint); border-color: var(--faint); }
.contact-card__val { display: flex; align-items: center; gap: 7px; font-size: 15px; font-weight: 500; margin-top: 6px; word-break: break-word; }
/* With no card column beside it the message box stops stretching to fill the
   row and sits at a readable width instead. */
.contact__grid--solo { max-width: 720px; }
.form-card { flex: 1 1 460px; min-width: min(460px, 90vw); padding: 26px; }
.form-card__head {
  font-family: 'Geist Mono', monospace; font-size: 11px; color: var(--faint); letter-spacing: .14em;
  margin-bottom: 16px; display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap;
}
.form-card__status { display: inline-flex; align-items: center; gap: 7px; color: var(--accent); letter-spacing: .06em; }
.form-card__status i { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); animation: pulseDot 2s ease-in-out infinite; }
.form { display: flex; flex-direction: column; gap: 12px; }
.form input, .form textarea {
  font-family: 'Instrument Sans', sans-serif; font-size: 15px; color: var(--text);
  padding: 14px 16px; border-radius: 12px; background: var(--chip); border: 1px solid var(--border); outline: none;
  resize: vertical;
}
.form input:focus, .form textarea:focus { border-color: rgba(120,150,255,.55); box-shadow: 0 0 0 3px rgba(110,140,255,.15); }
.form button {
  font-family: 'Instrument Sans', sans-serif; font-size: 15px; font-weight: 600; color: var(--btnText);
  padding: 14px; border-radius: 12px; border: 0; background: var(--btnFill);
  transition: background .22s ease;
}
.form button:hover { background: var(--btnFillHover); }
.form button:active { background: var(--btnFillActive); }
.form-card__note { font-family: 'Geist Mono', monospace; font-size: 11px; color: var(--faint); margin-top: 12px; }
/* The honeypot must be reachable by a bot parsing the DOM and invisible to a
   person, so it is moved off-screen rather than display:none. */
.form__trap { position: absolute; left: -9999px; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
.form button[disabled] { opacity: .6; cursor: progress; }
.form__status { font-family: 'Geist Mono', monospace; font-size: 12px; line-height: 1.5; margin: 12px 0 0; min-height: 0; color: var(--dim); }
.form__status:empty { display: none; }
.form__status[data-state='ok'] { color: #6ee7a8; }
.form__status[data-state='err'] { color: #ff9a8a; }

/* ---------- footer ---------- */
footer { padding: 40px 0 90px; border-top: 1px solid var(--border); position: relative; z-index: 2; }
.footer__inner { display: flex; justify-content: space-between; gap: 20px; flex-wrap: wrap; align-items: center; }
.footer__brand { display: flex; align-items: center; gap: 14px; }
.footer__logo { font-family: 'Doto', monospace; font-weight: 900; font-size: 20px; }
.footer__note { font-size: 13.5px; color: var(--faint); }
.footer__keys { font-family: 'Geist Mono', monospace; font-size: 11.5px; color: var(--dim); }
.footer__keys b { color: var(--muted); font-weight: 400; }

/* ---------- scroll-to-next ----------
   Fixed in the corner rather than centred under the hero, so it stays useful
   the whole way down instead of being a one-time hint. Hidden once there is
   nothing left to scroll to. */
.scroll-next {
  position: fixed; right: 26px; bottom: 26px; z-index: 850;
  width: 46px; height: 46px; border-radius: 50%;
  display: inline-flex; align-items: center; justify-content: center;
  color: var(--text); background: var(--glass);
  border: 1px solid var(--borderHi); backdrop-filter: blur(10px);
  transition: background .22s ease, border-color .22s ease, color .22s ease,
              opacity .35s ease, visibility .35s ease;
}
.scroll-next svg { width: 19px; height: 19px; }
.scroll-next:hover { background: var(--chip); border-color: var(--accentBorder); color: var(--accent); }
.scroll-next--done { opacity: 0; visibility: hidden; }
@media (max-width: 720px) { .scroll-next { right: 16px; bottom: 16px; width: 42px; height: 42px; } }

/* ---------- terminal ---------- */
.term {
  position: fixed; left: 50%; bottom: 26px; transform: translateX(-50%) translateY(140%);
  width: min(640px, 92vw); z-index: 1500; border-radius: 16px;
  background: var(--termBg); border: 1px solid var(--borderHi); backdrop-filter: blur(14px);
  box-shadow: 0 30px 80px rgba(0,0,0,.6); padding: 16px 18px;
  transition: transform .45s cubic-bezier(.3,1.2,.4,1);
}
.term--open { transform: translateX(-50%); }
.term__head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; font-family: 'Geist Mono', monospace; font-size: 11px; }
.term__title { color: var(--accent); }
.term__close { color: var(--faint); }
.term__out { font-family: 'Geist Mono', monospace; font-size: 12.5px; color: var(--muted); line-height: 1.7; max-height: 200px; overflow: auto; white-space: pre-wrap; }
.term__row { display: flex; gap: 8px; align-items: center; margin-top: 10px; border-top: 1px solid var(--border); padding-top: 10px; }
.term__prompt { font-family: 'Geist Mono', monospace; font-size: 12.5px; color: var(--accent); }
.term__in { flex: 1; font-family: 'Geist Mono', monospace; font-size: 12.5px; color: var(--text); background: transparent; border: none; outline: none; padding: 2px; }

/* ---------- fatal error notice ---------- */
.fatal {
  position: fixed; inset: 0; z-index: 3000; background: var(--bg);
  display: flex; align-items: center; justify-content: center; padding: 24px;
}
.fatal__card { max-width: 560px; padding: 32px 36px; border-radius: 20px; background: var(--glass); border: 1px solid var(--borderHi); }
.fatal__card h1 { font-size: 22px; margin: 0 0 12px; }
.fatal__card p { color: var(--muted); line-height: 1.65; font-size: 15px; }
.fatal__card code {
  font-family: 'Geist Mono', monospace; font-size: 13px; color: var(--accent);
  background: var(--chip); padding: 2px 8px; border-radius: 6px;
}

/* inline code in markdown bodies */
.md-code { font-family: 'Geist Mono', monospace; font-size: .9em; background: var(--chip); border: 1px solid var(--border); padding: 1px 6px; border-radius: 6px; }

/* ---------- no-JS fallback ----------
   Generated by tools/prerender.mjs into the <noscript> in index.html, so
   crawlers and JS-less browsers get the real content instead of a blank page.
   These rules never apply when scripting is on — the markup isn't parsed. */
.noscript-fallback { width: min(760px, 90vw); margin: 0 auto; padding: 40px 0 80px; }
.noscript-fallback header { padding-bottom: 28px; border-bottom: 1px solid var(--border); margin-bottom: 12px; }
.noscript-fallback h1 { font-size: clamp(38px, 7vw, 64px); font-weight: 600; letter-spacing: -.03em; line-height: 1.05; margin: 0 0 10px; }
.noscript-fallback h2 { font-size: clamp(24px, 3.2vw, 32px); font-weight: 600; letter-spacing: -.02em; margin: 44px 0 4px; }
.noscript-fallback h3 { font-size: 17px; font-weight: 600; margin: 22px 0 2px; }
.noscript-fallback p, .noscript-fallback li { font-size: 15.5px; line-height: 1.65; color: var(--muted); }
.noscript-fallback header p:first-of-type { font-size: 20px; color: var(--text); opacity: .85; }
.noscript-fallback a { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; }
.noscript-fallback article { padding-left: 16px; border-left: 1px solid var(--border); margin-top: 20px; }
.noscript-fallback em { font-family: 'Geist Mono', monospace; font-size: 12.5px; font-style: normal; color: var(--faint); }
