/* ==========================================================================
   Sahil Navani — Portfolio
   Rebuilt from the design handoff (README.md + "Sahil Navani Portfolio.dc.html").
   Token values below are final per the handoff — treat them as spec.
   ========================================================================== */

/* ---------- Fonts ---------- */
@font-face {
  font-family: "PP Object Sans";
  src: url("fonts/PPObjectSans-Regular.ttf") format("truetype");
  font-weight: 400; font-style: normal; font-display: swap;
}
@font-face {
  font-family: "PP Object Sans";
  src: url("fonts/PPObjectSans-Medium.ttf") format("truetype");
  font-weight: 500; font-style: normal; font-display: swap;
}
@font-face {
  font-family: "PP Object Sans";
  src: url("fonts/PPObjectSans-Semibold.ttf") format("truetype");
  font-weight: 600; font-style: normal; font-display: swap;
}

/* ---------- Tokens ---------- */
:root {
  /* palette pair — drives every gradient on the page */
  --p-a1: #8B5CF6;
  --p-a2: #EC5FA8;

  /* slate ramp (from the source design system) */
  --slate-900: #0F172A;
  --slate-800: #1E293B;
  --slate-700: #334155;
  --slate-600: #475569;
  --slate-500: #64748B;
  --slate-400: #94A3B8;
  --slate-300: #CBD5E1;
  --slate-150: #E9EEF4;
  --slate-100: #F0F4F8;

  --font-sans: "PP Object Sans", "Object Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;

  /* 24 so the card's corner speaks the section's own module: 24px body
     padding, 24px grid gap, and a concentric 12px (24 − 12 mat) for anything
     inset inside it — the HIG concentricity rule the imageslot follows. */
  --radius-card: 24px;
  --radius-pill: 1000px;

  --shadow-card: 0px 1px 2px rgba(2,6,23,.03), 0px 6px 16px rgba(2,6,23,.04);
  --shadow-card-hover: 0px 12px 24px rgba(139,92,246,0.14), 0px 24px 48px rgba(2,6,23,.08);
  --shadow-white-pill: inset 0 0 0 1px var(--slate-150), 0px 1px 2px rgba(2,6,23,.04), 0px 4px 8px rgba(2,6,23,.04);
  --shadow-white-pill-hover: inset 0 0 0 1px var(--slate-150), 0px 2px 4px rgba(2,6,23,.06), 0px 8px 16px rgba(2,6,23,.08);
  --shadow-gradient-pill: 0px 4px 14px rgba(139,92,246,0.35), 0px 1px 3px rgba(76,29,149,0.3), inset 0px 1px 4px rgba(255,255,255,0.25);
  --shadow-gradient-pill-hover: 0px 8px 22px rgba(139,92,246,0.45), 0px 2px 5px rgba(76,29,149,0.35), inset 0px 1px 4px rgba(255,255,255,0.25);

  --ease-card: cubic-bezier(0.2,0.7,0.2,1);

  /* Motion. ease-out for anything entering the screen (the fast start reads as
     responsive), ease-in-out for things already on screen that move, plain
     `ease` for hover and colour shifts, linear only for constant motion. */
  --ease-out-quart: cubic-bezier(0.165, 0.84, 0.44, 1);
  --ease-out-quint: cubic-bezier(0.23, 1, 0.32, 1);
  --ease-in-out-quad: cubic-bezier(0.455, 0.03, 0.515, 0.955);

  --max-w: 1040px;
}

/* ---------- Base ---------- */
* { box-sizing: border-box; }

html { scroll-behavior: smooth; }
/* No root background override: the canvas takes the body's light gradient,
   and with viewport-fit=cover the dark hero itself paints under the status
   bar — Safari's chrome then samples the page instead of showing a bar. */
html, body { margin: 0; padding: 0; overflow-x: clip; }

body {
  background: linear-gradient(180deg, #FCFDFE 0%, #F8FAFC 100%);
  font-family: var(--font-sans);
  -webkit-font-smoothing: antialiased;
  color: var(--slate-900);
}

a { color: var(--p-a1); text-decoration: none; }
a:hover { color: var(--p-a2); }

img { max-width: 100%; }

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

.wrap { width: 100%; max-width: var(--max-w); }

.section {
  position: relative; z-index: 1;
  padding: 90px 32px;
  display: flex; justify-content: center;
}
.section--work { padding: 90px 32px 40px; }
/* The close gets a full screen to itself — the last beat shouldn't feel like a
   footer's neighbour. Dot grid echoes the hero's canvas, bookending the page. */
/* symmetric padding, or the card centres against an uneven content box and
   sits ~30px high on the screen. .closing owns the viewport height now. */
.section--contact {
  padding: 60px 32px;
  align-items: center;
}
.section--contact::before {
  content: '';
  position: absolute; inset: 0;
  z-index: -1;
  pointer-events: none;
  background-image: radial-gradient(circle, rgba(100,116,139,0.28) 1.2px, transparent 1.2px);
  background-size: 28px 28px;
  -webkit-mask-image: radial-gradient(ellipse 80% 70% at 50% 50%, #000 45%, transparent 100%);
          mask-image: radial-gradient(ellipse 80% 70% at 50% 50%, #000 45%, transparent 100%);
}

.eyebrow {
  font-size: 13px; font-weight: 500; letter-spacing: -0.1px;
  color: var(--p-a1);
}

/* ---------- Ambient cursor glow ---------- */
#ambient {
  /* Above the sheet so the pointer glow survives on the light sections; the
     tints are 4–12% through a 60px blur, so content stays crisp beneath it. */
  position: fixed; inset: 0; z-index: 3;
  pointer-events: none;
  filter: blur(60px);
  background: radial-gradient(700px circle at 30% 20%,
    rgba(139,92,246,0.12) 0%, rgba(236,95,168,0.08) 35%,
    rgba(0,122,255,0.04) 55%, transparent 72%);
}

/* ---------- Fixed nav ----------
   One floating glass capsule, centred — the SN mark, the links, and Resume
   live together instead of two loose clusters pinned to opposite corners.
   Glass + pill radius is the site's own language (hero buttons, chips), so
   the bar reads as part of the page rather than furniture on top of it.
   On the home page `is-hero` keeps it hidden until the hero has scrolled
   past (main.js adds `is-visible`); `visibility` joins the transition so
   hidden links leave the tab order. */
header {
  position: fixed; top: max(16px, calc(env(safe-area-inset-top, 0px) + 8px)); left: 50%;
  translate: -50% 0;
  z-index: 100;
  display: flex; align-items: center; gap: 12px;
  padding: 7px 8px;
  border-radius: var(--radius-pill);
  background: rgba(252, 253, 254, 0.76);
  backdrop-filter: blur(20px) saturate(1.5);
  -webkit-backdrop-filter: blur(20px) saturate(1.5);
  box-shadow: 0 1px 0 rgba(255,255,255,0.7) inset,
              0 0 0 1px rgba(2,6,23,0.06),
              0 12px 32px rgba(2,6,23,0.14);
}
header.is-hero {
  opacity: 0;
  visibility: hidden;
  translate: -50% -18px;
  transition: opacity 260ms var(--ease-out-quart),
              translate 260ms var(--ease-out-quart),
              visibility 0s linear 260ms;
}
header.is-hero.is-visible {
  opacity: 1;
  visibility: visible;
  translate: -50% 0;
  transition: opacity 380ms var(--ease-out-quint),
              translate 380ms var(--ease-out-quint),
              visibility 0s linear 0s;
}

/* the mark: Sahil's star, on the brand ramp with a sheen for depth. Hover
   spins it a FULL turn with a little overshoot — the previous mark was
   180°-symmetric so a half turn landed identical, but this star is
   irregular, and a half turn would leave it resting the wrong way up. */
header .logo {
  display: flex; align-items: center; justify-content: center;
  width: 36px; height: 36px;
  border-radius: var(--radius-pill);
  transition: transform 550ms cubic-bezier(0.34, 1.4, 0.64, 1);
}
header .logo svg {
  display: block;
  overflow: visible;
  filter: drop-shadow(0 2px 6px rgba(139,92,246,0.4));
}
header .logo:hover { transform: rotate(360deg) scale(1.1); }
header .logo:active { transform: rotate(360deg) scale(0.95); }

.navpills {
  display: flex; align-items: center; gap: 2px;
}
/* quiet links inside the capsule — the capsule carries the surface, so the
   links only need a soft tint to answer the cursor */
.pill {
  display: flex; align-items: center;
  border-radius: var(--radius-pill);
  padding: 10px 16px;
  font-size: 13px; font-weight: 500; letter-spacing: -0.1px;
  color: var(--slate-700);
  transition: background 140ms ease, color 140ms ease, transform 120ms ease;
  white-space: nowrap;
}
/* Hover: a solid white pill POPS in from the centre with a springy
   overshoot (translucent tints vanished over busy content through the
   glass, and a flat fill was lifeless). Scoped :not(.pill--dark) — an
   unscoped .pill:hover outranks .pill--dark's gradient and turned Resume
   white-on-white. */
.pill { position: relative; }
.pill:not(.pill--dark)::before {
  content: '';
  position: absolute; inset: 0;
  z-index: -1;
  border-radius: var(--radius-pill);
  background: #FFFFFF;
  box-shadow: 0 2px 10px rgba(2,6,23,0.12);
  opacity: 0;
  scale: 0.7;
  transition: scale 300ms cubic-bezier(0.34, 1.56, 0.64, 1), opacity 140ms ease;
}
.pill:not(.pill--dark):hover { color: var(--slate-900); }
.pill:not(.pill--dark):hover::before { opacity: 1; scale: 1; }
.pill:active { transform: scale(0.97); }

/* Scroll-spy: the section under the viewport lights its link — a gradient
   keel under the label, deliberately different from the hover pill so
   "where I am" and "where I'm pointing" never look alike. main.js toggles
   .pill--active. */
.pill[href^="#"]::after {
  content: '';
  position: absolute; left: 50%; bottom: 4px;
  width: 16px; height: 3px;
  border-radius: 2px;
  translate: -50% 0;
  background: linear-gradient(90deg, var(--p-a1), var(--p-a2));
  scale: 0 1;
  transition: scale 300ms var(--ease-out-quart);
}
.pill--active { color: #6D28D9; }
.pill--active::after { scale: 1 1; }

/* Resume: the capsule's one loud element — brand gradient instead of a
   black hole, and the gradient slides through on hover (same move as the
   hero's Work button). Extra width in the background is what the slide
   travels through. */
.pill--dark {
  /* breathing room before the loud pill */
  margin-left: 10px;
  /* violet↔pink only — the cyan stop read off-palette here */
  background: linear-gradient(120deg, var(--p-a1) 0%, var(--p-a2) 55%, var(--p-a1) 115%);
  background-size: 230% 100%;
  background-position: 0% 0;
  color: #FFFFFF;
  box-shadow: 0px 4px 14px rgba(139,92,246,0.38);
  transition: background-position 450ms var(--ease-out-quart),
              box-shadow 200ms ease, transform 120ms ease;
}
.pill--dark:hover {
  color: #FFFFFF;
  background-position: 88% 0;
  box-shadow: 0px 6px 20px rgba(236,95,168,0.45);
}

/* ---------- Hero ---------- */
/* The hero is always exactly one screen tall, at every viewport size.
   Everything inside it is therefore sized fluidly (clamped against vh) rather
   than at fixed px, so it compresses instead of pushing past the fold.
   `svh` = the small viewport height, i.e. with mobile browser chrome showing —
   the only unit that guarantees "one screen" without the content jumping as
   toolbars hide and reappear. */
.hero {
  --headshot: 300px;
  /* Full-bleed night sky, pinned under the page: the hero stays put while the
     light .sheet scrolls over it. Sticky (not fixed) keeps its scroll space. */
  position: sticky; top: 0;
  z-index: 1;
  overflow: clip;
  min-height: 100vh;
  min-height: 100svh;
  display: flex; flex-direction: column; justify-content: center; align-items: center;
  padding: 120px 32px 40px;
  --px: 0; --py: 0;   /* pointer parallax, -1..1, set by main.js */
  /* smooth the pointer-light swing on the planet shades */
  transition: --lx 500ms var(--ease-out-quint), --ly 500ms var(--ease-out-quint);
  background:
    radial-gradient(1100px 620px at 16% 26%, rgba(109,40,217,0.40), transparent 62%),
    radial-gradient(860px 540px at 80% 16%, rgba(14,165,233,0.14), transparent 60%),
    radial-gradient(940px 580px at 52% 98%, rgba(236,95,168,0.20), transparent 62%),
    #0A0918;
}
/* A faint dot grid — the canvas the whole thing is "designed on". Masked to an
   ellipse so it dissolves before the edges instead of stopping at a hard line. */
.hero::before {
  content: '';
  position: absolute; inset: -40px;
  z-index: -1;
  pointer-events: none;
  background-image: radial-gradient(circle, rgba(255,255,255,0.09) 1.1px, transparent 1.1px);
  background-size: 28px 28px;
  /* Fades only near the edges. The first pass masked it the other way round —
     densest in the middle, where the photo and copy sit on top of it. */
  -webkit-mask-image: radial-gradient(ellipse 88% 78% at 50% 45%, #000 58%, transparent 100%);
          mask-image: radial-gradient(ellipse 88% 78% at 50% 45%, #000 58%, transparent 100%);
  translate: var(--gx, 0px) var(--gy, 0px);
  transition: translate 500ms var(--ease-out-quint);
  /* Slow fly-through: the layer scales up from centre and hands off to its
     sibling, phase-shifted half a cycle — dots keep approaching like a
     starfield at low warp. The cycle starts at opacity 0, which doubles as
     the load fade. Under reduced motion the base opacity shows them static. */
  /* gridIn rides `filter` — dotFly owns opacity, and the near layer's -13s
     delay means it would otherwise be mid-cycle and fully visible at load */
  animation: dotFly 26s linear infinite,
             gridIn 1100ms 350ms linear backwards;
}
@keyframes gridIn { from { filter: opacity(0); } to { filter: opacity(1); } }
/* Near dot layer: sparser, larger, brighter, travelling further with the
   pointer — two layers at different rates read as depth without any real 3D
   (which the masks inside .system would flatten anyway). */
.hero::after {
  content: '';
  position: absolute; inset: -40px;
  z-index: -1;
  pointer-events: none;
  background-image: radial-gradient(circle, rgba(255,255,255,0.12) 1.3px, transparent 1.3px);
  background-size: 96px 96px;
  background-position: 19px 31px;
  -webkit-mask-image: radial-gradient(ellipse 84% 74% at 50% 45%, #000 52%, transparent 100%);
          mask-image: radial-gradient(ellipse 84% 74% at 50% 45%, #000 52%, transparent 100%);
  translate: var(--gx2, 0px) var(--gy2, 0px);
  transition: translate 380ms var(--ease-out-quint);
  animation: dotFly 26s -13s linear infinite,
             gridIn 1100ms 350ms linear backwards;
}
.hero__grid {
  width: 100%; max-width: var(--max-w);
  display: grid; grid-template-columns: 1fr 340px; gap: 60px;
  align-items: center;
}
/* On roomy desktops the optical centre sits higher than the geometric one —
   the ticker and scroll cue already occupy the floor, so a dead-centred grid
   reads as sunk with a slab of empty sky on top. Margin (not translate: the
   scroll-driven heroRecede owns this element's translate) biases it up. */
@media (min-width: 901px) and (min-height: 740px) {
  .hero__grid { margin-bottom: clamp(28px, 8vh, 96px); }
}
/* Hero entrance. The block used to fade in as one slab; staggering the children
   ~70ms apart lets the eye read them in the order they matter. Entering the
   screen, so ease-out. */
/* Best-in-class heroes group their content rather than spacing it evenly:
   kicker+name+role read as one unit, the bio sits just off it, and the CTAs
   and socials are separated as their own group. A uniform gap makes all five
   feel like an undifferentiated list. */
.hero__copy { display: flex; flex-direction: column; --copy-gap: 15px; gap: var(--copy-gap); }
.hero__bio   { margin-top: calc(var(--copy-gap) * 0.55); }
.hero__ctas  { margin-top: calc(var(--copy-gap) * 0.95); }
.socials     { margin-top: calc(var(--copy-gap) * 0.35); }
/* fill BACKWARDS, not both: a finished fill-both transform animation holds an
   identity MATRIX (interpolation never returns the `none` keyword), and that
   matrix makes each row a backdrop root — killing the glass buttons' blur.
   Backwards still hides children through their stagger delay, then releases
   the property entirely. */
.hero__copy > * { animation: heroIn 700ms var(--ease-out-quint) backwards; }
.hero__copy > *:nth-child(1) { animation-delay:  60ms; }
.hero__copy > *:nth-child(2) { animation-delay: 130ms; }
.hero__copy > *:nth-child(3) { animation-delay: 200ms; }
.hero__copy > *:nth-child(4) { animation-delay: 270ms; }
.hero__copy > *:nth-child(5) { animation-delay: 340ms; }
.hero__copy > *:nth-child(6) { animation-delay: 410ms; }

.badge-row { display: flex; }
.badge {
  display: inline-flex; align-items: center; gap: 9px;
  padding: 0;
  background: none;
  box-shadow: none;
  font-size: 12px; font-weight: 600;
  letter-spacing: 0.09em; text-transform: uppercase;
  color: #A6B0C3;
  white-space: nowrap;
}
.badge__dot {
  width: 7px; height: 7px; border-radius: var(--radius-pill);
  background: #14B8A6;
  box-shadow: 0 0 0 3px rgba(20,184,166,0.18);
  animation: badgePulse 2.8s var(--ease-in-out-quad) infinite;
}
@keyframes badgePulse {
  0%, 100% { box-shadow: 0 0 0 3px rgba(20,184,166,0.18); }
  50%      { box-shadow: 0 0 0 5px rgba(20,184,166,0.07); }
}
/* the kicker needs air under it, not a chip around it */
.badge-row { margin-bottom: calc(var(--copy-gap) * 0.25); }

/* The name is live text again — four render passes proved that at heading
   size, pixel-crisp beats physically-correct. The glass reads through layers:
   a vertical light-to-dark sheen over the hue ramp (both clipped to the
   glyphs), a tight dark under-edge and a soft violet spill — drop-shadow
   filters, NEVER text-shadow, which shows through transparent fills. */
.name3d {
  margin: 0;
  /* the entrance animation's fill would otherwise pin letter-spacing across
     breakpoints — the tiers override the variable, not the property */
  --name-ls: -1.4px;
  font-size: 74px; line-height: 78px; font-weight: 600; letter-spacing: var(--name-ls);
  background-image:
    linear-gradient(180deg, rgba(255,255,255,0.34) 0%, rgba(255,255,255,0.10) 33%,
      rgba(255,255,255,0) 47%, rgba(8,6,24,0.20) 90%),
    linear-gradient(105deg, transparent 38%, rgba(255,255,255,0.55) 50%, transparent 62%),
    linear-gradient(96deg, #C4B5FD 0%, #A78BFA 22%, #F0ABFC 42%,
      #F472B6 58%, #C084FC 76%, #7DD3FC 100%);
  background-size: 100% 100%, 46% 100%, 100% 100%;
  background-repeat: no-repeat;
  background-position: 0 0, 170% 0, 0 0;
  -webkit-background-clip: text;
          background-clip: text;
  -webkit-text-fill-color: transparent;
  filter: drop-shadow(0 3px 2px rgba(8,6,24,0.5))
          drop-shadow(0 12px 22px rgba(139,92,246,0.30));
  animation: nameIn 1250ms 120ms var(--ease-out-quint) both;
}
/* Letters condense into place while a light streak sweeps through the
   glyphs. One element, three clipped background layers — per-character
   splits stay banned (see the desync postmortem). */
@keyframes nameIn {
  0%   { opacity: 0; transform: translateY(16px); letter-spacing: 0.05em;
         background-position: 0 0, -70% 0, 0 0; }
  35%  { opacity: 1; }
  60%  { background-position: 0 0, -70% 0, 0 0; }
  100% { opacity: 1; transform: none; letter-spacing: var(--name-ls, -1.4px);
         background-position: 0 0, 170% 0, 0 0; }
}

/* Cast shadow — seats the slab on the page and gives it thickness. */

.hero__role {
  /* Pulled up against the name — it reads as that line's subtitle, not as
     another item in the stack. Proportional to the gap so it holds at every
     breakpoint. */
  /* the render carries ~6px of baked padding + glow, so the pull-up is
     gentler than the old text version needed */
  margin-top: calc(var(--copy-gap) * -0.30);
  font-size: 24px; line-height: 30px; font-weight: 500; letter-spacing: -0.4px;
  color: #E7ECF4;
}
/* The company sits inline in the role line: "Product designer at [FanPad]".
   The logo is optically sized against the cap height, not the font size. */
.hero__at {
  position: relative;
  display: inline-block;
  vertical-align: -0.30em;
  margin-left: 2px;
  transition: transform 180ms var(--ease-out-quart);
}
.hero__at:hover { transform: translateY(-2px); }
/* FanPad makes music products — on hover, notes drift off the wordmark */
.hero__at::before,
.hero__at::after {
  position: absolute;
  top: -2px;
  opacity: 0;
  font-size: 12px;
  pointer-events: none;
}
.hero__at::before { content: '\266A'; left: 30%; color: #7DB8FF; }
.hero__at::after  { content: '\266B'; left: 62%; color: #F9A8D4; font-size: 10px; }
.hero__at:hover::before { animation: noteFloat 950ms var(--ease-out-quad) infinite; }
.hero__at:hover::after  { animation: noteFloat 950ms 320ms var(--ease-out-quad) infinite; }
@keyframes noteFloat {
  0%   { opacity: 0; transform: translateY(2px) rotate(0deg); }
  25%  { opacity: 0.9; }
  100% { opacity: 0; transform: translateY(-16px) rotate(14deg); }
}
.hero__at img { display: block; height: 1.3em; width: auto; }

.hero__bio {
  margin: 0;
  font-size: 17px; line-height: 25px;
  color: #9AA7BD;
  max-width: 480px;
}

.hero__ctas { display: flex; gap: 12px; margin-top: 8px; flex-wrap: wrap; }

/* ---------- Buttons ---------- */
.btn {
  display: inline-flex; align-items: center; gap: 8px;
  border-radius: var(--radius-pill);
  padding: 13px 24px;
  font-size: 15px; font-weight: 500; letter-spacing: -0.2px;
  transition: box-shadow 120ms ease, transform 120ms ease;
}
.btn--lg { padding: 14px 26px; }
.btn:active { transform: scale(0.97); }

.btn--gradient {
  color: #FFFFFF;
  /* wider ramp than the box so hover can travel through it */
  background: linear-gradient(115deg, var(--p-a1) 0%, var(--p-a2) 48%, #7C3AED 88%, #6D28D9 120%);
  background-size: 220% 100%;
  background-position: 0% 50%;
  box-shadow: var(--shadow-gradient-pill);
  transition: box-shadow 120ms ease, transform 160ms var(--ease-out-quart),
              background-position 480ms var(--ease-out-quart);
}
.btn--gradient:hover {
  color: #FFFFFF;
  transform: translateY(-2px);
  background-position: 88% 50%;
  box-shadow: var(--shadow-gradient-pill-hover);
}

.btn--white {
  color: var(--slate-700);
  background: #FFFFFF;
  box-shadow: inset 0 0 0 1px var(--slate-150), 0px 1px 2px rgba(2,6,23,.05);
}
.btn--white:hover {
  color: var(--slate-900);
  transform: translateY(-2px);
  box-shadow: inset 0 0 0 1px var(--slate-150), 0px 8px 18px rgba(2,6,23,.10);
}
.btn--white { position: relative; }
/* The spectrum ring is the site's hover language — hero buttons, project
   cards and the headshot halo all speak it. White pills on the LIGHT
   surfaces (contact, about) were the only interactive things opting out,
   which is what made their hover read as flat next to the hero's. Same
   sweep, dialled back: on white, full-saturation stops shout. */
.btn--white::before {
  content: '';
  position: absolute; inset: -1.5px;
  border-radius: var(--radius-pill);
  padding: 1.5px;
  background: conic-gradient(from var(--btnAngle),
    transparent 0turn, transparent 0.55turn,
    var(--p-a1) 0.72turn, var(--p-a2) 0.84turn, #7DD3FC 0.94turn, transparent 1turn);
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
          mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
          mask-composite: exclude;
  opacity: 0;
  transition: opacity 200ms ease;
  pointer-events: none;
  animation: btnRing 1.4s linear infinite paused;
}
.btn--white:hover::before { opacity: 0.75; animation-play-state: running; }
.btn--white:active { transform: translateY(-1px) scale(0.98); }

/* On the dark hero the white pill becomes true glass: the aurora blurs
   through it. Hover draws a spectrum ring around the pill — the same ring
   language as the photo's conic halo — and lifts it a touch. */
/* pointer-light direction for the planet shades — registered so they can
   transition (unregistered custom properties snap) */
@property --lx { syntax: '<percentage>'; inherits: true; initial-value: 0%; }
@property --ly { syntax: '<percentage>'; inherits: true; initial-value: 0%; }

@property --btnAngle {
  syntax: '<angle>';
  initial-value: 0deg;
  inherits: false;
}
.hero .btn--white {
  position: relative;
  color: #E7ECF4;
  background: rgba(255,255,255,0.055);
  backdrop-filter: blur(14px) saturate(1.35);
  -webkit-backdrop-filter: blur(14px) saturate(1.35);
  box-shadow: inset 0 0 0 1px rgba(255,255,255,0.13),
              inset 0 1px 0 rgba(255,255,255,0.10),
              0px 1px 2px rgba(2,6,23,0.4);
  transition: box-shadow 160ms ease, transform 160ms var(--ease-out-quart), color 160ms ease;
}
/* the ring itself now comes from .btn--white above; on the dark sky it runs
   at full strength. It stays paused at rest so un-hovering freezes the sweep
   mid-turn while it fades — restarting on every hover snapped the angle to 0. */
.hero .btn--white:hover::before { opacity: 1; }
.hero .btn--white:hover {
  color: #FFFFFF;
  transform: translateY(-2px);
  background: rgba(255,255,255,0.085);
  box-shadow: inset 0 0 0 1px rgba(255,255,255,0.10),
              inset 0 1px 0 rgba(255,255,255,0.10),
              0px 6px 18px rgba(139,92,246,0.25);
}
@keyframes btnRing { to { --btnAngle: 360deg; } }
/* browsers without @property still show the ring, just without the sweep */

/* Tertiary. Four equal-weight pills in one row would flatten the hierarchy, so
   About and Resume sit quieter than "See the work" and "Say hi". */

/* ---------- Social icons ---------- */
.socials { display: flex; gap: 14px; margin-top: 4px; }
.social {
  display: flex; align-items: center; justify-content: center;
  width: 44px; height: 44px;
  border-radius: var(--radius-pill);
  color: #93A0B8;
  transition: color 140ms ease, background 140ms ease,
              transform 180ms var(--ease-out-quart);
}
.social:hover { transform: translateY(-3px); }
.social:active { transform: translateY(-1px) scale(0.94); transition-duration: 90ms; }
.social--li:hover { color: #A78BFA; background: rgba(139,92,246,0.16); }
.social--ig:hover { color: #F9A8D4; background: rgba(236,95,168,0.16); }
.social--sp:hover { color: #1ED760; background: rgba(29,185,84,0.14); }
.social--am:hover { color: #FF5468; background: rgba(250,36,60,0.14); }
.social--yt:hover { color: #FF4E45; background: rgba(255,0,0,0.12); }

/* ---------- Hero art: space motif ---------- */
.hero__art {
  position: relative;
  display: flex; justify-content: center;
  /* Scales up from 0.94, not from 0 — starting at zero reads as a pop, not an
     arrival. Leads the copy stagger slightly so the photo anchors the frame. */
  animation: artIn 900ms 40ms var(--ease-out-quint) both;
}

/* Ambient light, well behind everything — big and soft enough to read as glow
   rather than as two purple clouds. */
.blob { position: absolute; border-radius: var(--radius-pill); filter: blur(46px); }
.blob--violet {
  top: -2%; left: -12%; width: 300px; height: 300px;
  transition: translate 700ms var(--ease-out-quint);
  background: radial-gradient(circle, rgba(139,92,246,0.5) 0%, transparent 68%);
  animation: blobPulse 9s var(--ease-in-out-quad) infinite;
}
.blob--pink {
  bottom: -6%; right: -10%; width: 280px; height: 280px;
  transition: translate 700ms var(--ease-out-quint);
  background: radial-gradient(circle, rgba(236,95,168,0.4) 0%, transparent 68%);
  animation: blobPulse 11s 1.5s var(--ease-in-out-quad) infinite;
}

/* Parallax wrapper. Deliberately carries no animation of its own — the layers
   underneath already animate `transform`, and giving the pointer offset its own
   uncontested element is more robust than sharing a property with a keyframe. */
.parallax {
  position: relative;
  transition: translate 450ms var(--ease-out-quint);
  will-change: translate;
}

/* The system box is exactly the photo's size; everything else is placed as a
   percentage of it, so the whole composition scales as one. */
.system {
  position: relative;
  width: var(--headshot); height: var(--headshot);
  animation: floaty 9s var(--ease-in-out-quad) infinite;
  /* Nearest layer, so it travels furthest with the pointer (set by main.js). */
  transition: translate 450ms var(--ease-out-quint);
}

/* ---- outer tilted orbit ---- */
/* The orbit is two full ellipses stacked, not two clipped halves.
   The near one is thicker and brighter (it's closer to you); the far one is
   thinner and fainter. Each is masked with a soft vertical gradient so they
   cross-fade through the middle band instead of meeting at a hard seam — that
   seam was the abrupt thin→thick jump at the left and right extremes. */
.ellipse {
  position: absolute; top: 50%; left: 50%;
  width: 172%; height: 66%;
  border-radius: 50%;
  transform: translate(-50%, -50%) rotate(-16deg);
  animation: ringDraw 800ms 260ms var(--ease-out-quint) both;
}

/* far side — behind the photo, thinner and dimmer */
.ellipse--back {
  z-index: 0;
  border: 1.75px solid rgba(167,139,250,0.42);
  -webkit-mask-image: linear-gradient(to bottom, #000 0%, #000 26%, transparent 66%);
          mask-image: linear-gradient(to bottom, #000 0%, #000 26%, transparent 66%);
}

/* near side — drawn over the photo, thicker and brighter */
.ellipse--front {
  z-index: 5;
  border: 3px solid rgba(196,181,253,0.7);
  -webkit-mask-image: linear-gradient(to bottom, transparent 34%, #000 74%, #000 100%);
          mask-image: linear-gradient(to bottom, transparent 34%, #000 74%, #000 100%);
}

/* ---- the photo ---- */
.headshot {
  position: absolute; inset: 0;
  z-index: 2;
}
/* Everything below is a fraction of --headshot rather than a fixed px size, so
   the whole system scales as one unit — a 62px planet next to a 300px photo
   reads very differently to a 62px planet next to a 140px one. */

/* The ring's own ::before now supplies this glow — see .headshot__ring below —
   so this element is kept only as an empty hook (still referenced by index.html)
   rather than editing markup for a pure styling change. */
.headshot__bloom { display: none; }
/* Animated ring: a rotating conic gradient. Two layers stacked — an opaque
   base ring (blurred just enough to feel soft without losing its edge) plus a
   wider, heavier blur behind it that acts as backing, so no background shows
   through the antialiased gap where the gradient meets the photo. Both rotate
   together as one unit rather than as separate loops, so the seam never drifts
   apart from its own backing. */
.headshot__ring {
  position: absolute; inset: calc(var(--headshot) * -0.037);
  border-radius: var(--radius-pill);
  animation: ringSpin 10s linear infinite;
}
.headshot__ring::before {
  content: '';
  position: absolute; inset: calc(var(--headshot) * -0.006);
  border-radius: var(--radius-pill);
  background: conic-gradient(from 120deg, var(--p-a1) 0%, var(--p-a2) 32%, #7DD3FC 62%, var(--p-a1) 100%);
  filter: blur(calc(var(--headshot) * 0.01));
  opacity: 0.7;
}
.headshot__ring::after {
  content: '';
  position: absolute; inset: 0;
  border-radius: var(--radius-pill);
  background: conic-gradient(from 120deg, var(--p-a1) 0%, var(--p-a2) 32%, #7DD3FC 62%, var(--p-a1) 100%);
  filter: blur(calc(var(--headshot) * 0.004));
}
.headshot img {
  position: relative; display: block;
  width: 100%; height: 100%;
  border-radius: var(--radius-pill);
  object-fit: cover;
  box-shadow:
    0 0 0 calc(var(--headshot) * 0.02) #0A0918,
    0px 22px 44px rgba(2,6,23,0.6),
    inset 0 0 0 1px rgba(255,255,255,0.35);
}

/* ---- music: circular spectrum ----
   48 bars on a shared keyframe, each held a little behind its neighbour by a
   negative animation-delay, which makes the level run around the ring as a
   travelling wave. `scale` is animated rather than `height` so nothing hits
   layout, and it's a separate property from `rotate`, so the bar's angle and
   its level never fight over `transform`. */
.eq { position: absolute; inset: 0; z-index: 1; }
.bar {
  position: absolute; left: 50%; top: 50%;
  width: 0; height: 0;
  rotate: calc(var(--i) * 7.5deg);
}
.bar i {
  position: absolute;
  left: calc(var(--headshot) * -0.0065);
  bottom: calc(var(--headshot) * 0.575);
  width: calc(var(--headshot) * 0.013);
  height: calc(var(--headshot) * 0.10);
  border-radius: calc(var(--headshot) * 0.007);
  transform-origin: bottom center;
  background: var(--p-a1);
  /* The colour travels around the ring too — same trick as the level: one
     hue-cycle keyframe, each bar held back by its position, so a band of
     pink/sky light orbits the photo. Slow on purpose: at 1-2s it reads as
     RGB gamer lights. eq owns scale, barIn owns opacity, this owns
     background — three animations, no shared property. */
  animation:
    eq 1.7s var(--ease-in-out-quad) infinite alternate,
    barIn 380ms var(--ease-out-quint) both,
    barHue 9s linear infinite;
  animation-delay: calc(var(--i) * -0.052s), calc(420ms + var(--i) * 13ms), calc(var(--i) * -0.1875s);
}
@keyframes barHue {
  0%, 100% { background: var(--p-a1); }
  36%      { background: var(--p-a2); }
  68%      { background: #7DD3FC; }
}
/* static thirds — the resting palette under prefers-reduced-motion, where
   the hue cycle (like every animation) is switched off */
.bar:nth-child(n+17) i { background: var(--p-a2); }
.bar:nth-child(n+33) i { background: #7DD3FC; }

/* ---- UI: design-tool cursor drawing selection marquees ----
   The cursor travels to a corner, drags a stroked box out to the opposite
   corner, holds the selection, then it fades and the cursor moves on. All
   offsets are percentages of `.system`, so it tracks the photo at any size.
   One 16s loop drives the cursor and both boxes off the same clock. */
.selbox {
  position: absolute;
  z-index: 8;
  border: 1.5px solid #A78BFA;
  border-radius: 2px;
  /* Marquee fill plus a faint halo — a bare violet stroke disappears where the
     box crosses the photo, which is violet too. */
  background: rgba(167,139,250,0.13);
  box-shadow: 0 0 0 1px rgba(10,9,24,0.45);
  width: 0; height: 0;
  opacity: 0;
}
/* corner handles — the detail that makes it read as a design tool */
.selbox i {
  position: absolute;
  width: 6px; height: 6px;
  background: #FFFFFF;
  border: 1.5px solid #A78BFA;
  border-radius: 1px;
}
.selbox i:nth-child(1) { left: -4px; top: -4px; }
.selbox i:nth-child(2) { right: -4px; top: -4px; }
.selbox i:nth-child(3) { left: -4px; bottom: -4px; }
.selbox i:nth-child(4) { right: -4px; bottom: -4px; }

/* Drawn BEHIND the photo (below .headshot's z-index 2), so the head occludes
   its right-hand side — the clearest depth cue in the whole composition. Its
   drag corner stays clear of the circle so the cursor never sits on a corner
   you can't see. */
.selbox--behind {
  left: -42%; top: 34%;
  z-index: 1;
  animation: selBehind 20s 1.6s linear infinite;
}
.selbox--a {
  left: 8%; top: 14%;
  animation: selA 20s 1.6s linear infinite;
}
.selbox--b {
  left: -7%; top: -7%;
  animation: selB 20s 1.6s linear infinite;
}

.cursor {
  position: absolute; left: 0; top: 0;
  will-change: translate;
  /* offset so the arrow's TIP lands on the corner, not the svg's box */
  margin-left: -2.75px; margin-top: -2px;
  z-index: 9;
  display: flex; align-items: flex-start; gap: 3px;
  animation:
    cursorPath 20s 1.6s var(--ease-in-out-quad) infinite,
    cursorVis 20s 1.6s linear both infinite,
    cursorPress 20s 1.6s linear infinite;
}
/* The scene's own ambient glow: a faint pool of light riding the fake cursor,
   echoing the real pointer's #ambient effect at lower intensity. */
.cursor::before {
  content: '';
  position: absolute; left: -60px; top: -60px;
  width: 132px; height: 132px;
  border-radius: var(--radius-pill);
  background: radial-gradient(circle,
    rgba(139,92,246,0.06) 0%, rgba(236,95,168,0.035) 42%, transparent 70%);
  pointer-events: none;
}
.cursor svg { display: block; filter: drop-shadow(0 2px 5px rgba(76,29,149,0.3)); }
.cursor span {
  margin-top: 13px;
  transform-origin: 0 50%;
  animation: chipSettle 20s 1.6s var(--ease-out-quart) infinite;
  padding: 4px 11px;
  border-radius: var(--radius-pill);
  background: linear-gradient(135deg, var(--p-a1), var(--p-a2));
  color: #FFFFFF;
  font-size: 12px; font-weight: 600; letter-spacing: -0.2px;
  white-space: nowrap;
  box-shadow: 0 4px 12px rgba(139,92,246,0.35);
}

/* ---- space: a rendered ringed planet, deep in the background ----
   The tilted ring rhymes with the orbit ellipses around the photo, and its
   lit limb faces the headshot's glowing ring — one light source for the whole
   composition. The image is mostly ring: the ball is ~42% of its width. */
.planet {
  position: absolute;
  left: -54%; top: -40%;
  width: 64%; height: auto;
  z-index: 0;
  opacity: 0.78;
  pointer-events: none;
  /* fadeToBase has no `to` keyframe, so it lands on each element's own base
     opacity — one entrance serves every faintness level, and backwards fill
     releases the property instead of pinning 1 (the stars bug). */
  animation: planetDrift 16s var(--ease-in-out-quad) infinite,
             fadeToBase 700ms 1300ms linear backwards;
}
@keyframes fadeToBase { from { opacity: 0; } }
@keyframes planetDrift {
  0%, 100% { translate: 0 0; rotate: 0deg; }
  50%      { translate: 6px -8px; rotate: 2.2deg; }
}
/* The distant sibling: mostly night-side, a luminous crescent along the
   upper-right limb — the part that actually shows from behind the photo. A
   dark body needs far less opacity-fading to sit back, which is what kills
   the old muddy-smudge read. */
.planet--big {
  left: 44%; top: -42%;
  width: 100%;
  opacity: 0.42;
  filter: blur(0.4px) saturate(0.95);
  animation: planetDriftBig 22s var(--ease-in-out-quad) infinite,
             fadeToBase 900ms 1500ms linear backwards;
}

/* ---- pointer-lit planet shading ----
   The cursor is the scene's light source (the #ambient glow already follows
   it), so each ball carries a shade whose dark side sits opposite the
   pointer. --lx/--ly are registered so they transition — the terminator
   swings smoothly instead of ticking per mousemove event. */
.planetshade {
  position: absolute;
  z-index: 0;
  border-radius: var(--radius-pill);
  pointer-events: none;
  /* The renders are flat-lit on purpose — this overlay is the ONLY
     directional light, so it carries both the highlight toward the pointer
     and the terminator opposite it (baked + overlay shadows fought). */
  background:
    radial-gradient(circle at calc(50% + var(--lx, 0%)) calc(50% + var(--ly, 0%)),
      rgba(237, 230, 255, 0.20) 0%, transparent 40%),
    radial-gradient(circle at calc(50% + var(--lx, 0%)) calc(50% + var(--ly, 0%)),
      transparent 26%, rgba(6, 5, 22, 0.68) 78%);
}
/* geometry: the balls inside the square renders — small ball is 41.7% of its
   image (the rest is ring), big ball 72.7% (ortho margins) */
.planetshade--small {
  left: -35.4%; top: -21.4%;
  width: 26.7%; height: 26.7%;
  opacity: 0.8;
  animation: planetDrift 16s var(--ease-in-out-quad) infinite,
             fadeToBase 700ms 1300ms linear backwards;
}
.planetshade--big {
  left: 57.65%; top: -28.35%;
  width: 72.7%; height: 72.7%;
  opacity: 0.5;
  animation: planetDriftBig 22s var(--ease-in-out-quad) infinite,
             fadeToBase 900ms 1500ms linear backwards;
}
@keyframes planetDriftBig {
  0%, 100% { translate: 0 0; }
  50%      { translate: -9px 6px; }
}

/* ---- space: stars, sparse and in the negative space ---- */
.star { position: absolute; z-index: 1; }
.star--1 {
  top: -4%; left: -42%; color: #B7A6FD;
  animation: twinkle 4.6s var(--ease-in-out-quad) infinite,
             starIn 400ms 1450ms linear both;
}
.star--2 {
  bottom: -18%; right: -6%; color: #F9A8D4;
  animation: twinkle 5.8s 1.4s var(--ease-in-out-quad) infinite,
             starIn 400ms 1560ms linear both;
}
.star--4 {
  top: 62%; right: -30%; color: #B7A6FD;
  animation: twinkle 6.4s 3.1s var(--ease-in-out-quad) infinite,
             starIn 400ms 1780ms linear both;
}
.star--5 {
  top: -22%; right: 18%; color: #7DD3FC;
  animation: twinkle 5.5s 3.8s var(--ease-in-out-quad) infinite,
             starIn 400ms 1890ms linear both;
}
.star--6 {
  bottom: 6%; left: -34%; color: #F9A8D4;
  animation: twinkle 7.1s 4.4s var(--ease-in-out-quad) infinite,
             starIn 400ms 2000ms linear both;
}
.star--3 {
  top: 30%; left: -38%; color: #7DD3FC;
  animation: twinkle 5.2s 2.6s var(--ease-in-out-quad) infinite,
             starIn 400ms 1670ms linear both;
}

/* ---- space: rocket on a 3D orbit ----
   Rides the same tilted ellipse as the visible ring. Position, depth-scale,
   tangent bank and stacking are all sampled from one ellipse every 10deg and
   baked into keyframes — z-index steps between 0 (far, behind the photo) and 6
   (near, in front), which is what makes it read as going around rather than
   sliding across. Generated to match motion/src/HeroArt.tsx. */
.rocket3d {
  position: absolute;
  /* The orbit lives entirely in translate/rotate/scale keyframes (own-box
     percentages), so every frame is compositor-only — no layout, no paint.
     left/top just park the element at the system's centre. */
  left: 50%; top: 50%;
  width: calc(var(--headshot) * 0.225);
  height: calc(var(--headshot) * 0.225);
  margin-left: calc(var(--headshot) * -0.1125);
  margin-top: calc(var(--headshot) * -0.1125);
  pointer-events: none;
  will-change: translate, rotate, scale;
  opacity: 1;
  animation: rocketOrbit3d 18s linear infinite;
}
.rocket3d svg { display: block; width: 100%; height: 100%;
  overflow: visible;  /* the long flame frame and its spark exit the viewBox */
  filter: drop-shadow(0px 3px 7px rgba(76,29,149,0.35));
  position: relative; z-index: 1;
  /* Launch: the child svg arrives along the ship's own +Y (its tail), which
     the parent's orbit rotation turns into "zooming in along the flight
     path". The orbit's translate/rotate/scale stay untouched. */
  animation: rocketLaunch 1100ms 200ms var(--ease-out-quint) both; }
@keyframes rocketLaunch {
  from { transform: translateY(130px) scale(0.25); opacity: 0; }
  30%  { opacity: 1; }
  to   { transform: none; opacity: 1; }
}
@keyframes trailIn { from { opacity: 0; } to { opacity: 1; } }

/* Nozzle flame: three cel frames on a 270ms loop — 2D FX animation "on
   threes". steps(1) holds each drawing; no tweening between frames. */
.rocket3d .fx { opacity: 0; }
.rocket3d .fx--1 { animation: fxSlot1 270ms steps(1) infinite; }
.rocket3d .fx--2 { animation: fxSlot2 270ms steps(1) infinite; }
.rocket3d .fx--3 { animation: fxSlot3 270ms steps(1) infinite; }
@keyframes fxSlot1 { 0%, 33.3% { opacity: 1; } 33.34%, 100% { opacity: 0; } }
@keyframes fxSlot2 { 0%, 33.3% { opacity: 0; } 33.34%, 66.6% { opacity: 1; } 66.67%, 100% { opacity: 0; } }
@keyframes fxSlot3 { 0%, 66.6% { opacity: 0; } 66.67%, 100% { opacity: 1; } }

/* Comet tail: echoes riding the EXACT same rocketOrbit3d keyframes, each a
   positive animation-delay behind, so the tail genuinely curves with the ring
   and crosses behind the photo where the rocket does.

   They are small round embers, not streaks. The previous version drew each
   echo as a blurred shape 165% of the rocket's height, centred on its point —
   eight of those overlapping near the nose merged into one blob that covered
   the rocket entirely at the horizon crossings. */
.comet {
  position: absolute;
  left: 50%; top: 50%;
  width: calc(var(--headshot) * 0.225);
  height: calc(var(--headshot) * 0.225);
  margin-left: calc(var(--headshot) * -0.1125);
  margin-top: calc(var(--headshot) * -0.1125);
  pointer-events: none;
  will-change: translate, scale;
  animation: cometOrbit3d 18s linear infinite, trailIn 700ms 900ms linear both;
  /* Negative delay = same trailing phase but running from the first frame.
     A positive delay left the echoes unpositioned for --lag seconds, which
     painted all six stacked at the system's corner as one orange flash. */
  animation-delay: calc(var(--lag) - 18s), 900ms;
}
/* Trail puffs: four hand-keyed cel smoke frames swapped at ~11fps (discrete
   background-image animation, no tween). Each echo starts at a different
   phase so the chain roils instead of strobing in unison. */
.comet i {
  position: absolute; left: 50%; top: 50%; display: block;
  width: calc(66% * var(--sc));
  height: calc(66% * var(--sc));
  margin-left: calc(-33% * var(--sc));
  margin-top: calc(-33% * var(--sc));
  background-repeat: no-repeat;
  background-size: 100% 100%;
  background-image: var(--puff1);
  opacity: var(--op);
  filter: drop-shadow(0 0 5px rgba(253,186,116,0.45));
  animation: puffFrames 420ms steps(1) infinite;
  animation-delay: calc(var(--lag) * -2);
}
.comet { --puff1: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cpath d='M16 4C19 3 23 4 25 7C28 9 29 12 28 15C30 18 28 22 25 24C23 27 19 29 16 28C12 29 8 27 7 24C4 22 3 18 5 15C4 12 5 8 8 6C10 4 13 4 16 4Z' fill='%23F9A8D4' fill-opacity='.8'/%3E%3Ccircle cx='14.5' cy='15' r='7.5' fill='%23FFD79A'/%3E%3Ccircle cx='13.5' cy='14' r='3.6' fill='%23FFF7D6'/%3E%3C/svg%3E");
  --puff2: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cpath d='M16 5C20 4 24 6 26 9C28 11 27 14 28 16C27 20 25 22 22 23L23 20L20 22C18 26 12 28 9 25C6 23 4 19 6 16C5 13 6 9 9 7C11 5 13 5 16 5Z' fill='%23F9A8D4' fill-opacity='.8'/%3E%3Ccircle cx='15' cy='15.5' r='7' fill='%23FFD79A'/%3E%3Ccircle cx='14' cy='14.5' r='3.2' fill='%23FFF7D6'/%3E%3C/svg%3E");
  --puff3: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cpath d='M15 3C18 3 21 5 23 7C26 8 28 12 27 15C29 19 26 23 23 25C21 28 17 30 14 28C10 29 7 26 6 22C4 20 4 16 6 14C5 10 8 6 11 5C12 4 13 3 15 3Z' fill='%23F9A8D4' fill-opacity='.8'/%3E%3Ccircle cx='15.5' cy='14.5' r='7.8' fill='%23FFD79A'/%3E%3Ccircle cx='15' cy='13.5' r='3.8' fill='%23FFF7D6'/%3E%3C/svg%3E");
  --puff4: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cpath d='M16 4C19 4 22 5 24 8L22 9L25 11C27 13 28 16 27 18C28 21 25 24 22 25L21 23L20 26C17 29 13 28 10 26C7 24 5 21 6 18C4 15 5 11 8 8C10 6 13 4 16 4Z' fill='%23F9A8D4' fill-opacity='.8'/%3E%3Ccircle cx='14.5' cy='15.5' r='7' fill='%23FFD79A'/%3E%3Ccircle cx='14' cy='15' r='3.4' fill='%23FFF7D6'/%3E%3C/svg%3E");
}
@keyframes puffFrames {
  0%, 24.99%  { background-image: var(--puff1); }
  25%, 49.99% { background-image: var(--puff2); }
  50%, 74.99% { background-image: var(--puff3); }
  75%, 100%   { background-image: var(--puff4); }
}


.scrollcue {
  position: absolute; bottom: 28px; left: 50%;
  transform: translateX(-50%);
  display: flex; align-items: center; justify-content: center;
  width: 40px; height: 40px;
  border-radius: var(--radius-pill);
  color: rgba(231,236,244,0.55);
  animation: bounceDown 1.8s ease-in-out infinite;
}
.scrollcue:hover { color: #C4B5FD; }
/* entrance on the svg child: the <a>'s own opacity belongs to bounceDown and
   its filter belongs to the scroll-driven cueFade */
.scrollcue svg {
  animation: cueIn 600ms 2000ms var(--ease-out-quad) backwards;
}
@keyframes cueIn {
  from { opacity: 0; transform: translateY(-10px); }
  to   { opacity: 1; transform: none; }
}

/* ---------- Skills ticker — a quiet marquee along the hero's floor ----------
   Desktop-and-tall-screens only: on phones the stacked hero needs the room. */
.ticker {
  position: absolute; left: 0; right: 0; bottom: 78px;
  overflow: hidden;
  -webkit-mask-image: linear-gradient(to right, transparent, #000 18%, #000 82%, transparent);
          mask-image: linear-gradient(to right, transparent, #000 18%, #000 82%, transparent);
}
.ticker__track {
  display: flex; width: max-content; gap: 44px;
  animation: marquee 26s linear infinite;
  white-space: nowrap;
}
.ticker__group {
  display: flex; gap: 44px;
  font-size: 13px; font-weight: 500; letter-spacing: 0.02em;
  color: rgba(231,236,244,0.38);
  /* Fades in last, after the hero copy has finished staggering — otherwise
     the marquee is simply present at frame one while everything above it
     arrives, which reads as a missing step.
     Rides `filter`, not `opacity`: .ticker's opacity belongs to the
     scroll-driven heroRecede, and the group is the nearest element with no
     animation of its own (the track owns `marquee`). */
  animation: tickerIn 800ms 900ms linear backwards;
}
@keyframes tickerIn { from { filter: opacity(0); } }
.sep--a { color: rgba(167,139,250,0.45); }
.sep--b { color: rgba(249,168,212,0.40); }
@media (max-width: 900px), (max-height: 760px) {
  .ticker { display: none; }
}

/* ---------- The sheet: the light page sliding over the pinned hero ----------
   The container is transparent; the white surface lives on .sheet__bg, a
   sticky viewport-sized panel. While the sheet travels up the screen the
   panel rides its top edge (looks identical to a background on the sheet);
   when that edge reaches the top of the viewport the panel DOCKS and stays
   pinned, and the content — normal document flow, painted above it — keeps
   scrolling past. Corners hold their full radius until the dock, then
   flatten to fill the screen edges (scroll-driven, below): the Apple sheet
   move. */
.sheet {
  position: relative; z-index: 2;
  --sheet-r: clamp(64px, 11vw, 160px);
}
.sheet__bg {
  position: sticky; top: 0;
  height: 100vh;
  height: 100svh;
  /* pull the content up over the panel — it's a background, not a block */
  margin-bottom: -100vh;
  margin-bottom: -100svh;
  /* behind the sheet's content, but still above the hero: the sheet's own
     stacking context (z-index 2) contains this, so the negative value only
     drops it below its siblings */
  z-index: -1;
  background: linear-gradient(180deg, #FCFDFE 0%, #F8FAFC 100%);
  border-radius: var(--sheet-r) var(--sheet-r) 0 0;
  /* Plain round corners, deliberately: this radius ANIMATES (sheetDock), and
     interpolating it through corner-shape: superellipse is a brand-new paint
     path that renders with visible artifacts mid-animation. The superellipse
     smoothing stays on static corners elsewhere (.about__photo). */
  corner-shape: round;
  box-shadow: 0 -20px 48px rgba(2, 6, 23, 0.45);
}

/* As the sheet arrives, the hero's content recedes into the sky — scroll-driven,
   so it tracks the thumb exactly. Progressive enhancement; browsers without
   scroll timelines just get the sheet sliding over a static hero. */
@supports (animation-timeline: scroll()) {
  /* Apple-style sheet dock. The sheet's top reaches the top of the screen
     at exactly one hero-height (100svh) of scroll — the corners hold their
     FULL radius all the way there, and only once the panel has docked do
     they flatten out to fill the screen edges, over the next ~160px while
     the content continues scrolling. Scroll-driven, so it tracks the thumb
     and plays in reverse as the sheet un-docks on the way back up. */
  .sheet__bg {
    animation: sheetDock linear both;
    animation-timeline: scroll(root);
    animation-range: 100svh calc(100svh + 160px);
  }

  .hero__grid, .hero .ticker {
    /* no fill, and the range starts past 0: at rest the grid must be
       genuinely transform-free or it becomes a backdrop root and the glass
       buttons can't blur the page behind them */
    animation: heroRecede linear;
    animation-timeline: scroll(root);
    animation-range: 2px 100svh;
  }
  .hero .scrollcue {
    animation: bounceDown 1.8s ease-in-out infinite, cueFade linear both;
    animation-timeline: auto, scroll(root);
    animation-range: normal, 0 40svh;
  }
}
@keyframes sheetDock {
  to { border-radius: 0; }
}
@keyframes heroRecede {
  from { translate: none; scale: none; }
  to   { translate: 0 -7svh; scale: 0.955; opacity: 0.42; }
}
/* bounceDown already owns opacity, so the scroll fade rides on filter */
@keyframes cueFade { to { filter: opacity(0); } }

/* ---------- Selected work ---------- */
.section--work .wrap { display: flex; flex-direction: column; gap: 36px; }

.sectionhead {
  display: flex; flex-direction: column; gap: 12px;
  animation: fadeUp linear both;
  animation-timeline: view();
  animation-range: entry 0% entry 50%;
}
.sectionhead h2,
.about__intro h2 {
  margin: 0;
  font-size: 44px; line-height: 50px; font-weight: 600; letter-spacing: -1.2px;
  color: var(--slate-900);
}

/* 2-up today; adding a 3rd/4th card fills a second row with no CSS change */
.cards {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 24px;
}

.card {
  display: flex; flex-direction: column;
  border-radius: var(--radius-card);
  /* same continuous curve as .about__photo — the two framed surfaces on the
     sheet should bend identically (falls back to round where unsupported) */
  corner-shape: superellipse(2.6);
  overflow: hidden;
  background: rgba(255,255,255,0.75);
  backdrop-filter: blur(12px);
  -webkit-backdrop-filter: blur(12px);
  border: 1px solid rgba(233,238,244,0.9);
  box-shadow: var(--shadow-card);
  /* ease-out-quart on the lift so it leaves under the cursor immediately;
     colour/shadow ride plain `ease` per the hover convention. */
  transition: transform 300ms var(--ease-out-quart),
              box-shadow 300ms ease, border-color 300ms ease;
  color: inherit;
  /* opacity-only scroll-in so the hover lift isn't blocked */
  animation: cardIn linear both;
  animation-timeline: view();
  animation-range: entry 0% entry 45%;
}
/* Second card trails the first by a few percent of scroll — a stagger you feel
   rather than notice. */
.cards .card:nth-child(2) { animation-range: entry 5% entry 50%; }

a.card:hover {
  color: inherit;
  transform: translateY(-8px);
  box-shadow: var(--shadow-card-hover);
}
a.card:active { transform: translateY(-4px) scale(0.995); transition-duration: 120ms; }

/* Hover stroke: instead of a static violet border, a spectrum comet chases
   around the card's edge — the same ring language as the photo's conic halo
   and the hero buttons' sweep, at card scale. Runs paused at rest so
   un-hovering freezes it mid-lap while it fades (restarting snaps to 0). */
@property --cardAngle {
  syntax: '<angle>';
  initial-value: 0deg;
  inherits: false;
}
a.card { position: relative; }
a.card::after {
  content: '';
  position: absolute; inset: 0;
  z-index: 6;
  border-radius: var(--radius-card);
  /* the ring must bend exactly like the card it traces */
  corner-shape: superellipse(2.6);
  padding: 1.5px;
  background: conic-gradient(from var(--cardAngle),
    transparent 0turn, transparent 0.58turn,
    var(--p-a1) 0.74turn, var(--p-a2) 0.85turn, #7DD3FC 0.95turn, transparent 1turn);
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
          mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
          mask-composite: exclude;
  opacity: 0;
  transition: opacity 260ms ease;
  pointer-events: none;
  animation: cardRing 2.8s linear infinite paused;
}
a.card:hover::after {
  opacity: 1;
  animation-play-state: running;
}
@keyframes cardRing { to { --cardAngle: 360deg; } }

/* Every cover sizes by ratio, not by a fixed height. This lives on the BASE
   class so sibling cards can't disagree: when the placeholder held a fixed
   280px and Vida tracked a ratio (~211px at the 2-up width), the taller one
   set the grid row and the shorter card was left with ~70px of dead space
   under its body. The clamps keep phones from going squat and wide cards
   from going tall. */
.card__media {
  position: relative;
  aspect-ratio: 2.05 / 1;
  min-height: 185px;
  max-height: 320px;
  background: linear-gradient(135deg, #EEF2FF 0%, #FDF2F8 100%);
}
.card__img {
  display: block; width: 100%; height: 100%;
  object-fit: cover; object-position: top left;
}
/* placeholder cover is drawn to fit, not cropped */
.card__img--placeholder { object-fit: cover; object-position: center; }

/* Shared-element hook for the future case-study page. Inert while this is the
   only page — it only does anything once a cross-document transition runs. */
.card__img--hero { view-transition-name: project-hero; }

/* ---- Vida card: three screens fanned with depth ----
   One screen says "a website"; a fan of three says "a body of decisions".
   The ground is a slice of the hero's night sky, so the card reads as part
   of the same world. Hover spreads the fan slightly. */
.card__media--vida {
  /* the perspective camera for the fan — children of .vidastack get depth */
  perspective: 900px;
  perspective-origin: 50% 38%;
  /* sizing comes from .card__media — deliberately not repeated here, so the
     placeholder and this card always agree */
  /* The sky has weather now: a magenta horizon the fan stands in (light from
     below, like a stage), violet depth up top, a whisper of cyan. */
  background:
    radial-gradient(120% 95% at 50% 112%, rgba(236,95,168,0.5), rgba(139,92,246,0.22) 45%, transparent 70%),
    radial-gradient(130% 100% at 12% -12%, rgba(109,40,217,0.55), transparent 60%),
    radial-gradient(100% 80% at 92% 6%, rgba(56,189,248,0.16), transparent 55%),
    linear-gradient(180deg, #0B0A1F 0%, #171036 55%, #251048 100%);
  display: flex;
  align-items: flex-end;
  justify-content: center;
  overflow: hidden;
}
/* Nintendo-style living sky: a tiled field of coloured sparkles drifting
   diagonally forever — the game-select-screen trick where the background is
   quietly alive. The element is oversized and translates exactly one tile
   per loop, so the seam never shows; compositor-only. */
.vidasky {
  position: absolute;
  left: -300px; top: -300px;
  width: calc(100% + 600px); height: calc(100% + 600px);
  pointer-events: none;
  background-image:
    radial-gradient(circle 1.7px at 22px 34px,   rgba(196,181,253,0.85) 99%, transparent),
    radial-gradient(circle 1px   at 96px 128px,  rgba(255,255,255,0.6)  99%, transparent),
    radial-gradient(circle 2.4px at 168px 62px,  rgba(249,168,212,0.75) 99%, transparent),
    radial-gradient(circle 1.2px at 226px 186px, rgba(125,211,252,0.7)  99%, transparent),
    radial-gradient(circle 1.5px at 64px 224px,  rgba(255,255,255,0.45) 99%, transparent),
    radial-gradient(circle 1px   at 190px 246px, rgba(196,181,253,0.55) 99%, transparent),
    radial-gradient(circle 2px   at 134px 176px, rgba(255,255,255,0.3)  99%, transparent);
  background-size: 260px 260px;
  animation: skyDrift 42s linear infinite;
}
@keyframes skyDrift {
  from { translate: 0 0; }
  to   { translate: -260px -260px; }
}

/* Two aurora orbs roaming the sky — the big soft colour movement that makes
   game menus feel alive. Slow, blurred, opposite phases. */
.vidaglow { position: absolute; inset: 0; pointer-events: none; }
.vidaglow::before, .vidaglow::after {
  content: '';
  position: absolute;
  width: 58%; aspect-ratio: 1;
  border-radius: var(--radius-pill);
  filter: blur(28px);
}
.vidaglow::before {
  left: -14%; top: -30%;
  background: radial-gradient(circle, rgba(139,92,246,0.5) 0%, transparent 66%);
  animation: glowRoam 13s var(--ease-in-out-quad) infinite alternate;
}
.vidaglow::after {
  right: -16%; top: -12%;
  background: radial-gradient(circle, rgba(56,189,248,0.34) 0%, transparent 66%);
  animation: glowRoam 17s -6s var(--ease-in-out-quad) infinite alternate-reverse;
}
@keyframes glowRoam {
  from { translate: 0 0;       scale: 1; }
  to   { translate: 46px 34px; scale: 1.28; }
}

/* A few hand-placed stars in the sky above the fan — the same night the hero
   lives in, so the card reads as a window into that world, not a gradient.
   They sit still while the sparkle field drifts past = free parallax; the
   slow breathe cycles their colour like a sky with weather. */
.card__media--vida::before {
  content: '';
  position: absolute; inset: 0;
  pointer-events: none;
  background-image:
    radial-gradient(circle 1.1px at 12% 22%, rgba(255,255,255,0.75) 99%, transparent),
    radial-gradient(circle 0.8px at 28% 11%, rgba(255,255,255,0.5) 99%, transparent),
    radial-gradient(circle 1.4px at 44% 26%, rgba(196,181,253,0.8) 99%, transparent),
    radial-gradient(circle 0.9px at 58% 9%,  rgba(255,255,255,0.55) 99%, transparent),
    radial-gradient(circle 1.2px at 73% 19%, rgba(249,168,212,0.7) 99%, transparent),
    radial-gradient(circle 0.8px at 86% 30%, rgba(255,255,255,0.5) 99%, transparent),
    radial-gradient(circle 1px   at 93% 12%, rgba(196,181,253,0.6) 99%, transparent);
  opacity: 0.8;
  transition: opacity 400ms ease;
  animation: skyBreathe 11s var(--ease-in-out-quad) infinite;
}
@keyframes skyBreathe {
  0%, 100% { filter: none; }
  50%      { filter: hue-rotate(28deg) brightness(1.18); }
}
a.card:hover .card__media--vida::before { opacity: 1; }
/* Light pooling under the cluster — brightens as the fan lifts, which is what
   sells the screens as objects above a ground rather than stickers. */
.card__media--vida::after {
  content: '';
  position: absolute; left: 50%; bottom: -32%;
  width: 96%; height: 62%;
  translate: -50% 0;
  pointer-events: none;
  background: radial-gradient(ellipse 50% 50% at 50% 50%, rgba(139,92,246,0.42) 0%, rgba(236,95,168,0.16) 48%, transparent 72%);
  opacity: 0.55;
  transition: opacity 420ms ease, scale 420ms var(--ease-out-quart);
}
a.card:hover .card__media--vida::after { opacity: 0.95; scale: 1.12 1; }
.vidastack {
  position: relative;
  width: 100%; height: 100%;
}
/* ---- the fan in real 3D ----
   .vidastack is the stage: preserve-3d, tilted toward the pointer via
   --fanx/--fany (set per-card in main.js, transitioned so it glides).
   Each .fancard wrapper holds a static 3D pose on `transform` (hover
   re-poses it through a transition); its img floats on `translate`/`rotate`
   keyframes — separate properties, so pose and float never fight. */
.vidastack {
  position: relative;
  width: 100%; height: 100%;
  transform-style: preserve-3d;
  transform: rotateX(var(--fany, 0deg)) rotateY(var(--fanx, 0deg));
  transition: transform 350ms var(--ease-out-quint);
}
.fancard {
  position: absolute;
  transform-style: preserve-3d;
  transition: transform 480ms var(--ease-out-quart);
}
.fancard img {
  display: block;
  width: 100%; height: auto;
  border-radius: 10px;
  animation: fanFloat 6.5s var(--ease-in-out-quad) infinite;
}
/* cover: nearly frontal, floated closest to the camera. Sized so the
   cluster FILLS the frame — the empty band of sky above the fan read as
   wasted space at every width. */
.fancard--hero {
  left: 50%; bottom: 16px;
  /* Sized with PROJECTION headroom, not flat geometry: translateZ(56px) at
     900px perspective magnifies the cover ~1.07x at rest and ~1.11x on
     hover (translateZ 88px). 70%/480px keeps the projected cover inside
     the box at every width including mid-hover — 76%/520 clipped its top. */
  width: min(70%, 480px);
  margin-left: max(-35%, -240px);
  transform: translateZ(56px) rotateX(5deg);
}
.fancard--hero img {
  box-shadow: 0 18px 40px rgba(2,6,23,0.6), 0 0 0 1px rgba(255,255,255,0.14);
  transition: box-shadow 480ms ease;
}
/* sides: angled back like the wings of a folding screen — outer edges
   recede, inner edges reach toward the cover's light */
.fancard--events, .fancard--sponsors { width: min(50%, 350px); bottom: 42px; }
.fancard--events   { left: -10%;  transform: translateZ(-64px) rotateY(30deg)  rotateZ(-5.5deg); }
.fancard--sponsors { right: -10%; transform: translateZ(-64px) rotateY(-30deg) rotateZ(5.5deg); }
.fancard--events img, .fancard--sponsors img {
  box-shadow: 0 12px 28px rgba(2,6,23,0.5), 0 0 0 1px rgba(255,255,255,0.10);
  filter: brightness(0.82) saturate(0.92);
  transition: filter 480ms ease;
}
/* each screen bobs on its own phase — the cluster breathes instead of
   levitating as one rigid slab */
.fancard--events img   { animation-duration: 7.4s; animation-delay: -2.4s; }
.fancard--sponsors img { animation-duration: 8.2s; animation-delay: -5.1s; }
@keyframes fanFloat {
  0%, 100% { translate: 0 0;    rotate: 0deg; }
  50%      { translate: 0 -9px; rotate: 0.5deg; }
}

/* Hover: the camera pushes in — cover rises toward you, wings swing wider
   and brighten, the sheen sweeps last. */
a.card:hover .fancard--hero { transform: translateZ(88px) rotateX(3deg); }
a.card:hover .fancard--hero img {
  box-shadow: 0 26px 52px rgba(2,6,23,0.68), 0 0 0 1px rgba(255,255,255,0.2);
}
a.card:hover .fancard--events   { transform: translateZ(-42px) rotateY(38deg)  rotateZ(-7.5deg) translateX(-10px); }
a.card:hover .fancard--sponsors { transform: translateZ(-42px) rotateY(-38deg) rotateZ(7.5deg) translateX(10px); }
a.card:hover .fancard--events img, a.card:hover .fancard--sponsors img {
  filter: brightness(0.95) saturate(1);
}
.vidastack::after {
  content: '';
  position: absolute; inset: 0;
  z-index: 3;
  pointer-events: none;
  background: linear-gradient(115deg, transparent 32%, rgba(255,255,255,0.13) 46%, rgba(255,255,255,0.02) 54%, transparent 68%);
  translate: -120% 0;
  transition: translate 700ms 90ms var(--ease-out-quart);
}
a.card:hover .vidastack::after { translate: 120% 0; }
@media (hover: none) {
  /* stuck-tap hover must not re-pose the fan */
  a.card:hover .fancard--hero { transform: translateZ(56px) rotateX(5deg); }
  a.card:hover .fancard--events   { transform: translateZ(-64px) rotateY(30deg)  rotateZ(-5.5deg); }
  a.card:hover .fancard--sponsors { transform: translateZ(-64px) rotateY(-30deg) rotateZ(5.5deg); }
  a.card:hover .fancard--events img, a.card:hover .fancard--sponsors img { filter: brightness(0.82) saturate(0.92); }
  a.card:hover .vidastack::after { translate: -120% 0; }
  a.card:hover .card__media--vida::before { opacity: 0.8; }
  a.card:hover .card__media--vida::after  { opacity: 0.55; scale: none; }
}

.card__body {
  display: flex; flex-direction: column; gap: 8px;
  padding: 24px;
}
.card__row {
  display: flex; align-items: baseline; justify-content: space-between; gap: 12px;
}
.card__body h3 {
  margin: 0;
  font-size: 21px; line-height: 26px; font-weight: 600; letter-spacing: -0.4px;
  color: var(--slate-900);
}
.card__year { font-size: 13px; color: var(--slate-400); white-space: nowrap; }
.card__body p {
  margin: 0;
  font-size: 15px; line-height: 22px;
  color: var(--slate-600);
}

.tags { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 6px; }
.tag {
  border-radius: var(--radius-pill);
  padding: 6px 14px;
  font-size: 13px; font-weight: 500;
  white-space: nowrap;
}
.tag--violet { color: #6D28D9; background: rgba(139,92,246,0.1); }
.tag--pink   { color: #BE185D; background: rgba(236,95,168,0.12); }
.tag--muted  { color: var(--slate-500); background: rgba(100,116,139,0.1); }

/* empty slot — explicitly not a link, so it doesn't invite a click */
.card--empty { cursor: default; }
.card--empty .card__media { background: rgba(255,255,255,0.4); }
.card--empty .card__body h3,
.card--empty .card__body p { color: var(--slate-400); }

.imageslot {
  position: absolute; inset: 12px;
  display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px;
  border: 1px dashed var(--slate-300);
  /* concentric with the card: 24px corner − 12px mat. The old 16px shared no
     centre with the outer corner, which is what made the corners look doubled
     — same rule as the About photo's frame (30 − 14 ≈ its img radius). */
  border-radius: calc(var(--radius-card) - 12px);
  color: var(--slate-400);
  font-size: 13px; font-weight: 500; letter-spacing: -0.1px;
  background: linear-gradient(135deg, rgba(139,92,246,0.03) 0%, rgba(236,95,168,0.03) 100%);
}

/* ---------- About ----------
   Was: a very tall photo with the text vertically centred beside it, which
   left a dead zone under the copy and no shared language with the hero. Now
   the columns align at the top, the photo sits in a glass frame that echoes
   the headshot's treatment, and the copy carries a fact list so the section
   says something instead of just talking. */
/* Intro spans both columns, so the headline and lead get the full measure
   instead of being squeezed into a half-width column (which is what forced
   the old fact cards to ~165px each and wrapped their titles). Below it the
   photo anchors the left and the record runs down the right. */
.about {
  display: grid;
  grid-template-columns: 340px 1fr;
  grid-template-areas:
    "intro  intro"
    "photo  detail";
  column-gap: 64px;
  row-gap: 52px;
  align-items: start;
  animation: fadeUp linear both;
  animation-timeline: view();
  animation-range: entry 0% entry 45%;
}
.about__intro { grid-area: intro; display: flex; flex-direction: column; gap: 18px; }
.about__photo { grid-area: photo; }
.about__detail { grid-area: detail; }

/* The one piece of prose left. Larger than body copy because it's carrying
   the whole point of view — everything after it is evidence. */
.about__lead {
  margin: 0;
  font-size: 20px; line-height: 31px;
  color: var(--slate-600);
  max-width: 660px;
}
.about__lead strong { font-weight: 600; color: var(--slate-900); }

/* The photo sits on a night-stage panel — a slice of the same sky the hero
   and the Vida card live in, with a magenta stage glow rising from the
   floor. The red concert photo clashed with a bare white ground and a thin
   hairline; on the dark panel, with the glow bleeding onto its lower edge,
   it reads as one lit scene. */
.about__photo {
  position: relative;
  padding: 14px;
  border-radius: 30px;
  corner-shape: superellipse(2.6);
  background:
    radial-gradient(130% 75% at 50% 114%, rgba(236,95,168,0.5), rgba(139,92,246,0.18) 48%, transparent 74%),
    radial-gradient(130% 90% at 12% -10%, rgba(109,40,217,0.5), transparent 60%),
    #12102B;
  box-shadow: 0px 28px 64px rgba(20,8,55,0.35);
  overflow: hidden;
}
/* a few quiet sparkles in the panel margins */
.about__photo::before {
  content: '';
  position: absolute; inset: 0;
  pointer-events: none;
  background-image:
    radial-gradient(circle 1.3px at 16% 3%,  rgba(255,255,255,0.6)  99%, transparent),
    radial-gradient(circle 1px   at 55% 1.6%, rgba(196,181,253,0.6) 99%, transparent),
    radial-gradient(circle 1.4px at 88% 2.4%, rgba(249,168,212,0.55) 99%, transparent),
    radial-gradient(circle 1px   at 97.5% 40%, rgba(255,255,255,0.4) 99%, transparent),
    radial-gradient(circle 1.2px at 2% 68%,  rgba(196,181,253,0.45) 99%, transparent);
}
.about__photo img {
  display: block; width: 100%;
  /* height:auto beats the height="1066" attribute — without it the
     presentational hint wins and aspect-ratio never engages (the giant
     uncropped photo bug) */
  height: auto;
  border-radius: 18px;
  aspect-ratio: 3 / 4;
  object-fit: cover;
  box-shadow: 0 10px 30px rgba(2,6,23,0.45), 0 0 0 1px rgba(255,255,255,0.10);
}
/* the stage light bleeds onto the photo's lower edge — what fuses the red
   photo and the violet world into one scene */
.about__photo::after {
  content: '';
  position: absolute; inset: 14px;
  border-radius: 18px;
  pointer-events: none;
  background: linear-gradient(180deg, transparent 58%, rgba(236,95,168,0.22) 86%, rgba(139,92,246,0.3) 100%);
}

/* ---- the record ----
   Replaces three pastel chips whose copy ("End-to-end, research to ship")
   said nothing a reader couldn't have guessed. Rows of real work with dates
   are what an About section is actually for; hairlines carry them, so the
   evidence is the only thing on screen. */
.about__roles {
  list-style: none; margin: 0; padding: 0;
  display: flex; flex-direction: column;
  counter-reset: role;
}
/* Child combinator throughout: these are ROW styles. A bare `.about__roles li`
   also matches the nested .role__bullets items and hands every bullet a
   hairline, row padding, and the hover accent — which is exactly the broken
   render that shipped once. */
.about__roles > li {
  position: relative;
  display: flex; flex-direction: column; gap: 7px;
  padding: 22px 0 24px;
  border-top: 1px solid var(--slate-150);
}
.about__roles > li:first-child { border-top: 0; padding-top: 0; }
/* the accent creeps in from the rule on hover — a quiet "this row is a thing
   that happened", not a card lifting off the page */
.about__roles > li::before {
  content: '';
  position: absolute; left: -18px; top: 24px;
  width: 2px; height: 0;
  border-radius: 2px;
  background: linear-gradient(180deg, var(--p-a1), var(--p-a2));
  transition: height 320ms var(--ease-out-quart);
}
.about__roles > li:hover::before { height: calc(100% - 44px); }
.about__roles > li:first-child::before { top: 2px; }

.role__head { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; }
.role__org {
  font-size: 18px; font-weight: 600; letter-spacing: -0.35px;
  color: var(--slate-900);
}
.role__year {
  font-size: 13px; color: var(--slate-400);
  white-space: nowrap; font-variant-numeric: tabular-nums;
}
/* same uppercase kicker as the hero's "OPEN TO WORK" — the two ends of the
   page speak the same language */
.role__title {
  font-size: 11.5px; font-weight: 600;
  letter-spacing: 0.09em; text-transform: uppercase;
  color: var(--p-a1);
}
.about__roles p {
  margin: 0;
  font-size: 15px; line-height: 23.5px;
  color: var(--slate-600);
  max-width: 560px;
}
/* Same type ramp as the prose .p above, so a role reads consistently. A small
   violet dot rather than a default disc, echoing the accent already used for
   .role__title's kicker just above each list. */
.role__bullets {
  list-style: none;
  margin: 4px 0 0;
  padding: 0;
  max-width: 560px;
  display: flex; flex-direction: column; gap: 7px;
}
.role__bullets li {
  position: relative;
  padding-left: 15px;
  font-size: 15px; line-height: 22.5px;
  color: var(--slate-600);
}
.role__bullets li::before {
  content: '';
  position: absolute; left: 0; top: 9px;
  width: 4px; height: 4px;
  border-radius: var(--radius-pill);
  background: var(--p-a1);
  opacity: 0.55;
}

/* Long lists clamp; main.js owns the count and tags the overflow bullets, so
   the cutoff lives in exactly one place. It also appends the toggle only when
   a list actually overflows — short roles never grow a control, and without
   JS nothing is tagged and everything simply shows. */
.role__bullets.is-clamped .is-overflow { display: none; }
/* is-expanded is only ever set by a click, so the entrance never plays on
   page load */
.role__bullets.is-expanded .is-overflow {
  animation: bullet-in 260ms var(--ease-out-quart) backwards;
}
@keyframes bullet-in {
  from { opacity: 0; translate: 0 -4px; }
}
.role__more {
  align-self: flex-start;
  margin: -1px 0 0; padding: 0; border: 0; background: none;
  font: inherit; font-size: 13.5px; font-weight: 500; letter-spacing: -0.1px;
  color: var(--slate-400);
  cursor: pointer;
  transition: color 140ms ease;
}
.role__more:hover { color: var(--p-a1); }

/* Lives inside the Navani Music row now. Deliberately a text link, not the
   white pill it used to be: a pill inside a list row reads as a second-level
   CTA competing with the section, where an inline link just continues the
   sentence the row is already making. Spotify green on hover, since that's
   where it goes. */
.role__link {
  align-self: flex-start;
  display: inline-flex; align-items: center; gap: 6px;
  margin-top: 3px;
  font-size: 14px; font-weight: 500; letter-spacing: -0.15px;
  color: var(--p-a1);
  transition: color 140ms ease, gap 180ms var(--ease-out-quart);
}
.role__link svg { transition: transform 180ms var(--ease-out-quart); }
.role__link:hover { color: #1DB954; gap: 9px; }
.role__link:hover svg { transform: translate(1px, -1px); }

/* ---------- Contact ---------- */
.contact {
  position: relative; width: 100%; max-width: var(--max-w);
  border-radius: var(--radius-card);
  padding: 80px 40px;
  display: flex; flex-direction: column; align-items: center; gap: 20px;
  text-align: center;
  overflow: hidden;
  background: linear-gradient(135deg, rgba(139,92,246,0.08) 0%, rgba(236,95,168,0.08) 100%);
  box-shadow: inset 0 0 0 1px var(--slate-100);
  animation: fadeUp linear both;
  animation-timeline: view();
  animation-range: entry 0% entry 45%;
}
.contact h2 {
  margin: 0;
  font-size: 52px; line-height: 58px; font-weight: 600; letter-spacing: -1.4px;
  color: var(--slate-900);
  max-width: 620px;
}
.contact p {
  margin: 0;
  font-size: 17px; line-height: 27px;
  color: var(--slate-600);
  max-width: 480px;
}
/* ---- copyable address ----
   Sized and weighted like a heading, because it IS the point of the section.
   Reads as a control (surface + border + a Copy affordance) so it's obvious
   it does something, rather than looking like selectable text. */
.mailcopy {
  display: inline-flex; align-items: center; gap: 11px;
  margin: 10px 0 2px;
  padding: 13px 16px 13px 19px;
  border: 0;
  border-radius: var(--radius-pill);
  background: #FFFFFF;
  box-shadow: inset 0 0 0 1px var(--slate-150), 0px 1px 2px rgba(2,6,23,.05);
  font-family: inherit;
  font-size: 19px; font-weight: 500; letter-spacing: -0.35px;
  color: var(--slate-900);
  cursor: pointer;
  max-width: 100%;
  /* it's a control, not a text field — a tap that highlights half the address
     looks like a bug, and on iOS a long press would offer "Copy" twice over */
  -webkit-user-select: none; user-select: none;
  -webkit-tap-highlight-color: transparent;
  transition: box-shadow 180ms ease, transform 160ms var(--ease-out-quart);
}
.mailcopy__ico { color: var(--slate-400); flex: none; transition: color 180ms ease; }
.mailcopy__addr { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* icon affordance. Both glyphs are stacked in the same box so the row's
   width never changes when it flips to the check — a reflow at the moment
   of confirmation reads as a glitch. */
.mailcopy__hint {
  position: relative;
  flex: none;
  display: grid; place-items: center;
  width: 34px; height: 34px;
  border-radius: var(--radius-pill);
  background: rgba(139,92,246,0.10);
  color: var(--p-a1);
  transition: background 180ms ease, color 180ms ease;
}
.mailcopy__i {
  grid-area: 1 / 1;
  transition: opacity 160ms ease, scale 220ms var(--ease-out-quart);
}
.mailcopy__i--check { opacity: 0; scale: 0.6; }
.mailcopy.is-copied .mailcopy__i--copy { opacity: 0; scale: 0.6; }
.mailcopy.is-copied .mailcopy__i--check { opacity: 1; scale: 1; }
.mailcopy:hover {
  box-shadow: inset 0 0 0 1px rgba(139,92,246,0.45), 0px 6px 18px rgba(139,92,246,0.16);
}
.mailcopy:hover .mailcopy__ico { color: var(--p-a1); }
.mailcopy:active { transform: scale(0.985); }
/* confirmation lands on the hint chip — teal, matching the hero's live dot,
   so "done" reads the same everywhere on the site */
.mailcopy.is-copied .mailcopy__hint { background: rgba(20,184,166,0.14); color: #0F766E; }
.mailcopy.is-copied { box-shadow: inset 0 0 0 1px rgba(20,184,166,0.5), 0px 6px 18px rgba(20,184,166,0.14); }
.mailcopy.is-copied .mailcopy__ico { color: #14B8A6; }
/* visually-hidden announcement for screen readers */
.mailcopy__live {
  position: absolute; width: 1px; height: 1px;
  overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%);
  white-space: nowrap;
}

.contact__ctas {
  display: flex; gap: 12px; margin-top: 14px;
  flex-wrap: wrap; justify-content: center;
}

/* availability line — the last thing a recruiter needs and the smallest thing
   on screen, which is the right order */
/* ---- closing screen ----
   Contact + footer occupy exactly one viewport between them. The content row
   grows to fill whatever the footer doesn't use and centres its card there,
   so the end of the page is a composed screen rather than a card sitting
   above a gap with the footer stranded underneath.
   Grid, not flex: the previous version used flex:1 1 auto + min-height:0 on
   the middle row, which asks the browser to size a flex item AGAINST an
   ancestor's min-height rather than an explicit height — exactly the
   combination that has known cross-browser inconsistencies (Safari
   especially). A `1fr` grid row has no such escape hatch to get wrong; it
   unambiguously claims "the space everything else doesn't". */
.closing {
  min-height: 100svh;
  display: grid;
  grid-template-rows: 1fr auto;
}
.closing .section--contact { min-height: 0; }
.closing .footer { min-height: 0; }

/* ---------- Footer ---------- */
.footer {
  position: relative; z-index: 1;
  padding: 30px 32px calc(40px + env(safe-area-inset-bottom, 0px));
  display: flex; justify-content: center;
}
.footer__inner {
  display: flex; align-items: center; justify-content: space-between;
  gap: 20px; flex-wrap: wrap;
}
.footer__copy { font-size: 13px; color: var(--slate-500); }
.footer__socials { display: flex; gap: 2px; }
/* 40px here, 46px on touch below — a 32px target with a 15px glyph was under
   every platform's minimum (44pt iOS / 48dp Android) and genuinely hard to
   hit with a thumb */
.fsocial {
  display: flex; align-items: center; justify-content: center;
  width: 40px; height: 40px;
  border-radius: var(--radius-pill);
  color: var(--slate-400);
  transition: color 120ms ease, background 120ms ease, transform 120ms ease;
}
.fsocial:hover { transform: translateY(-2px); }
.fsocial--li:hover { color: var(--p-a1); }
.fsocial--ig:hover { color: var(--p-a2); }
.fsocial--sp:hover { color: #1DB954; }
.fsocial--am:hover { color: #FA243C; }
.fsocial--yt:hover { color: #FF0000; }

/* ---------- Keyframes ---------- */
@keyframes floaty { 0%, 100% { transform: translateY(0px); } 50% { transform: translateY(-14px); } }
@keyframes blobPulse { 0%, 100% { transform: scale(1); opacity: 0.55; } 50% { transform: scale(1.18); opacity: 0.8; } }
@keyframes bounceDown { 0%, 100% { transform: translateX(-50%) translateY(0); opacity: 0.9; } 50% { transform: translateX(-50%) translateY(8px); opacity: 0.4; } }
/* A seamless loop must travel exactly one group PLUS the gap between groups.
   The track is (group + gap + group), so -50% of it is only (group + gap/2) —
   22px short every cycle, which is the visible stutter. */
@keyframes marquee {
  0%   { transform: translateX(0); }
  100% { transform: translateX(calc(-50% - 22px)); }
}
@keyframes cardIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes fadeUp { from { opacity: 0; transform: translateY(36px); } to { opacity: 1; transform: translateY(0); } }
/* ends at `none`, not translateY(0): a held transform makes the element a
   backdrop root and the glass buttons stop seeing the page behind them */
@keyframes heroIn { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: none; } }

@keyframes ringSpin { from { rotate: 0deg; } to { rotate: 360deg; } }
@keyframes artIn { from { opacity: 0; transform: scale(0.94); } to { opacity: 1; transform: none; } }

/* Hero-art assembly. The satellites only animate opacity here — their ambient
   drift owns `transform`, and two CSS animations can't share a property. */
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
/* forward motion through the dot field: grow from just-behind to just-past */
@keyframes dotFly {
  0%   { scale: 0.86; opacity: 0; }
  20%  { opacity: 1; }
  78%  { opacity: 0.85; }
  100% { scale: 1.42; opacity: 0; }
}
/* star entrance rides `filter` — twinkle already owns `opacity`, and a filled
   fadeIn was pinning it at 1 forever, silently killing the brightness pulse */
@keyframes starIn { from { filter: opacity(0); } to { filter: opacity(1); } }
@keyframes ringDraw {
  from { opacity: 0; transform: translate(-50%, -50%) rotate(-16deg) scale(0.82); }
  to   { opacity: 1; transform: translate(-50%, -50%) rotate(-16deg) scale(1); }
}
@keyframes eq { from { scale: 1 0.22; } to { scale: 1 1; } }
@keyframes barIn { from { opacity: 0; } to { opacity: 1; } }
/* Cursor path. Percentages are of `.system`, so the tip lands on the box
   corners at any photo size. Idle → corner A → drag → hold → corner B → drag
   → hold → back to idle. */
/* Per-keyframe timing: each declaration governs the segment that STARTS there.
   Every drag segment runs linear so the tip tracks its marquee corner exactly —
   the boxes are linear, and any other curve drifts mid-drag. */
@keyframes cursorPath {
  /* Per-keyframe timing governs the segment that STARTS there. Drag segments
     run linear so the tip tracks its marquee corner exactly.

     Human hands don't travel in straight lines at constant speed: there are
     overshoots, small corrections, pauses before acting, and idle drift. Each
     of those is a keyframe pair here rather than one clean move. */
  0%    { translate: calc(var(--headshot) * 1.08) calc(var(--headshot) * 0.76);  animation-timing-function: var(--ease-in-out-quad); }
  3%    { translate: calc(var(--headshot) * 1.06) calc(var(--headshot) * 0.8);  animation-timing-function: var(--ease-in-out-quad); }  /* idle drift */
  6%    { translate: calc(var(--headshot) * -0.48) calc(var(--headshot) * 0.29);  animation-timing-function: var(--ease-out-quart); }    /* overshoots... */
  8%    { translate: calc(var(--headshot) * -0.42) calc(var(--headshot) * 0.34);  animation-timing-function: linear; }                   /* ...corrects back */
  14%   { translate: calc(var(--headshot) * 0.14) calc(var(--headshot) * 0.78); animation-timing-function: var(--ease-in-out-quad); }
  17%   { translate: calc(var(--headshot) * 0.12) calc(var(--headshot) * 0.76); animation-timing-function: var(--ease-in-out-quad); }  /* settle after drag */
  26%   { translate: calc(var(--headshot) * 0.12) calc(var(--headshot) * 0.76); animation-timing-function: steps(1, end); }
  29%   { translate: calc(var(--headshot) * 0.08) calc(var(--headshot) * 0.14);  animation-timing-function: var(--ease-in-out-quad); }  /* teleport 1 */
  31%   { translate: calc(var(--headshot) * 0.1) calc(var(--headshot) * 0.16);  animation-timing-function: linear; }                   /* pause before acting */
  32%   { translate: calc(var(--headshot) * 0.08) calc(var(--headshot) * 0.14);  animation-timing-function: linear; }  /* on the corner as the box starts */
  38%   { translate: calc(var(--headshot) * 0.54) calc(var(--headshot) * 0.6);  animation-timing-function: var(--ease-in-out-quad); }
  41%   { translate: calc(var(--headshot) * 0.56) calc(var(--headshot) * 0.62); animation-timing-function: var(--ease-in-out-quad); }  /* pauses, then goes drawing */
  46%   { translate: calc(var(--headshot) * -0.06) calc(var(--headshot) * 0.60); animation-timing-function: var(--ease-in-out-quad); }  /* wanders left for a look */
  48%   { translate: calc(var(--headshot) * -0.09) calc(var(--headshot) * 0.63); animation-timing-function: linear; }                   /* settles */
  53%   { translate: calc(var(--headshot) * -0.09) calc(var(--headshot) * 0.63); animation-timing-function: var(--ease-in-out-quad); }  /* rests, then moves on */
  55.5% { translate: calc(var(--headshot) * -0.16) calc(var(--headshot) * 0.2); animation-timing-function: var(--ease-in-out-quad); }  /* exits upward */
  56.3% { translate: calc(var(--headshot) * -0.11) calc(var(--headshot) * -0.1); animation-timing-function: linear; }
  57%   { translate: calc(var(--headshot) * -0.07) calc(var(--headshot) * -0.07); animation-timing-function: linear; }
  64%   { translate: calc(var(--headshot) * 1.05) calc(var(--headshot) * 1.05); animation-timing-function: var(--ease-in-out-quad); }
  70%   { translate: calc(var(--headshot) * 1.02) calc(var(--headshot) * 1.01); animation-timing-function: var(--ease-in-out-quad); }
  78%   { translate: calc(var(--headshot) * 1.02) calc(var(--headshot) * 1.01); animation-timing-function: steps(1, end); }
  81%   { translate: calc(var(--headshot) * 1.08) calc(var(--headshot) * 0.76);  animation-timing-function: var(--ease-in-out-quad); }  /* teleport 2 */
  90%   { translate: calc(var(--headshot) * 1.11) calc(var(--headshot) * 0.72);  animation-timing-function: var(--ease-in-out-quad); }
  100%  { translate: calc(var(--headshot) * 1.08) calc(var(--headshot) * 0.76); }
}
/* Opacity track. Also covers the entrance, because two animations on the same
   property means only the last one applies. */
@keyframes cursorVis {
  0%     { opacity: 0; }
  2%     { opacity: 1; }
  25.5%  { opacity: 1; }
  27%    { opacity: 0; }   /* teleport 1 out */
  30.5%  { opacity: 0; }
  32%    { opacity: 1; }   /* teleport 1 in  */
  77.5%  { opacity: 1; }
  79%    { opacity: 0; }   /* teleport 2 out */
  82%    { opacity: 0; }
  83.5%  { opacity: 1; }   /* teleport 2 in  */
  100%   { opacity: 1; }
}
/* A press at the moment it clicks, before heading back to idle. */
/* The name chip lags a beat behind the arrow on the big moves, the way a
   trailing label does, then settles. */
@keyframes chipSettle {
  0%, 7.6%     { transform: translateX(0) scale(1); }
  9%           { transform: translateX(-7px) scale(0.96); }
  12%          { transform: translateX(0) scale(1); }
  31.6%        { transform: translateX(0) scale(1); }
  33.5%        { transform: translateX(-5px) scale(0.97); }
  37%          { transform: translateX(0) scale(1); }
  56.6%        { transform: translateX(0) scale(1); }
  58.5%        { transform: translateX(-6px) scale(0.96); }
  62%          { transform: translateX(0) scale(1); }
  83.2%        { transform: translateX(0) scale(1); }
  85%          { transform: translateX(-5px) scale(0.97); }
  88%, 100%    { transform: translateX(0) scale(1); }
}
@keyframes cursorPress {
  0%, 7.6%     { scale: 1; }
  8.2%         { scale: 0.86; }
  9%           { scale: 1; }
  31.6%        { scale: 1; }
  32.2%        { scale: 0.86; }
  33%          { scale: 1; }
  86.9%        { scale: 1; }
  87.6%        { scale: 0.82; }
  88.6%, 100%  { scale: 1; }
}
/* The click itself. */
.clickring {
  position: absolute;
  /* the cursor's position at the 87.6% tap moment — keep in lockstep with
     cursorPath's 81->90 drift if that segment ever moves */
  left: 110.3%; top: 73%;
  width: 12px; height: 12px;
  margin: -6px 0 0 -6px;
  border-radius: var(--radius-pill);
  border: 2px solid var(--p-a1);
  opacity: 0;
  z-index: 8;
  pointer-events: none;
  animation: clickPulse 20s 1.6s var(--ease-out-quart) infinite;
}
@keyframes clickPulse {
  0%, 87.3%  { scale: 0.35; opacity: 0; }
  87.6%      { scale: 0.35; opacity: 0.85; }
  92%        { scale: 3.4;  opacity: 0; }
  100%       { scale: 3.4;  opacity: 0; }
}

@keyframes selBehind {
  0%, 7.9%   { width: 0;   height: 0;   opacity: 0; }
  8%         { width: 0;   height: 0;   opacity: 1; }
  14%        { width: 56%; height: 44%; opacity: 1; }
  26%        { width: 56%; height: 44%; opacity: 1; }
  29%, 100%  { width: 56%; height: 44%; opacity: 0; }
}
/* Box A: drawn between 11% and 17% of the loop, held, then released. */
@keyframes selA {
  0%, 31.9%    { width: 0;   height: 0;   opacity: 0; }
  32%          { width: 0;   height: 0;   opacity: 1; }
  38%          { width: 46%; height: 46%; opacity: 1; }
  48%          { width: 46%; height: 46%; opacity: 1; }
  52%, 100%    { width: 46%; height: 46%; opacity: 0; }
}
/* Box B: the whole avatar, drawn between 41% and 50%. */
@keyframes selB {
  0%, 56.9%    { width: 0;    height: 0;    opacity: 0; }
  57%          { width: 0;    height: 0;    opacity: 1; }
  64%          { width: 112%; height: 112%; opacity: 1; }
  78%          { width: 112%; height: 112%; opacity: 1; }
  81%, 100%    { width: 112%; height: 112%; opacity: 0; }
}
@keyframes rocketOrbit3d {
  0% { translate: 354.60% -102.60%; scale: 0.950; rotate: 165.3deg; z-index: 6; }
  1.042% { translate: 356.20% -94.28%; scale: 0.970; rotate: 173.2deg; z-index: 6; }
  2.083% { translate: 356.68% -85.18%; scale: 0.990; rotate: 181.1deg; z-index: 6; }
  3.125% { translate: 355.82% -75.10%; scale: 1.011; rotate: 188.9deg; z-index: 6; }
  4.167% { translate: 353.34% -63.94%; scale: 1.031; rotate: 196.3deg; z-index: 6; }
  5.208% { translate: 348.94% -51.69%; scale: 1.051; rotate: 203.2deg; z-index: 6; }
  6.25% { translate: 342.39% -38.42%; scale: 1.070; rotate: 209.4deg; z-index: 6; }
  7.292% { translate: 333.52% -24.26%; scale: 1.089; rotate: 214.8deg; z-index: 6; }
  8.333% { translate: 322.18% -9.34%; scale: 1.107; rotate: 219.7deg; z-index: 6; }
  9.375% { translate: 308.30% 6.17%; scale: 1.123; rotate: 224.0deg; z-index: 6; }
  10.417% { translate: 291.85% 22.09%; scale: 1.138; rotate: 227.9deg; z-index: 6; }
  11.458% { translate: 272.84% 38.24%; scale: 1.151; rotate: 231.5deg; z-index: 6; }
  12.5% { translate: 251.33% 54.41%; scale: 1.162; rotate: 234.8deg; z-index: 6; }
  13.542% { translate: 227.40% 70.39%; scale: 1.172; rotate: 237.8deg; z-index: 6; }
  14.583% { translate: 201.22% 85.96%; scale: 1.179; rotate: 240.8deg; z-index: 6; }
  15.625% { translate: 172.98% 100.90%; scale: 1.184; rotate: 243.6deg; z-index: 6; }
  16.667% { translate: 142.92% 115.00%; scale: 1.186; rotate: 246.3deg; z-index: 6; }
  17.708% { translate: 111.33% 128.06%; scale: 1.186; rotate: 248.9deg; z-index: 6; }
  18.75% { translate: 78.53% 139.90%; scale: 1.184; rotate: 251.5deg; z-index: 6; }
  19.792% { translate: 44.89% 150.37%; scale: 1.180; rotate: 254.1deg; z-index: 6; }
  20.833% { translate: 10.78% 159.34%; scale: 1.174; rotate: 256.6deg; z-index: 6; }
  21.875% { translate: -23.42% 166.72%; scale: 1.166; rotate: 259.2deg; z-index: 6; }
  22.917% { translate: -57.31% 172.46%; scale: 1.157; rotate: 261.8deg; z-index: 6; }
  23.958% { translate: -90.51% 176.54%; scale: 1.147; rotate: 264.4deg; z-index: 6; }
  25% { translate: -122.66% 178.97%; scale: 1.135; rotate: 267.1deg; z-index: 6; }
  26.042% { translate: -153.43% 179.79%; scale: 1.123; rotate: 270.7deg; z-index: 6; }
  27.083% { translate: -179.28% 175.92%; scale: 1.113; rotate: 289.6deg; z-index: 6; }
  28.125% { translate: -195.93% 165.38%; scale: 1.108; rotate: 319.5deg; z-index: 6; }
  29.167% { translate: -202.88% 150.05%; scale: 1.106; rotate: 352.4deg; z-index: 6; }
  30.208% { translate: -201.76% 132.50%; scale: 1.107; rotate: 373.5deg; z-index: 6; }
  31.25% { translate: -196.00% 115.41%; scale: 1.106; rotate: 382.5deg; z-index: 6; }
  32.292% { translate: -190.11% 101.10%; scale: 1.104; rotate: 379.4deg; z-index: 6; }
  33.333% { translate: -188.55% 91.09%; scale: 1.096; rotate: 344.5deg; z-index: 6; }
  34.375% { translate: -194.75% 85.86%; scale: 1.084; rotate: 284.4deg; z-index: 6; }
  35.417% { translate: -210.26% 84.92%; scale: 1.066; rotate: 267.5deg; z-index: 6; }
  36.458% { translate: -234.41% 87.01%; scale: 1.043; rotate: 263.7deg; z-index: 6; }
  37.5% { translate: -264.46% 90.43%; scale: 1.017; rotate: 263.7deg; z-index: 6; }
  38.542% { translate: -296.22% 93.41%; scale: 0.990; rotate: 266.0deg; z-index: 6; }
  39.583% { translate: -324.90% 94.37%; scale: 0.965; rotate: 271.3deg; z-index: 6; }
  40.625% { translate: -346.00% 92.15%; scale: 0.944; rotate: 284.7deg; z-index: 0; }
  41.667% { translate: -356.08% 86.24%; scale: 0.927; rotate: 335.8deg; z-index: 0; }
  42.708% { translate: -356.15% 77.48%; scale: 0.916; rotate: 367.1deg; z-index: 0; }
  43.75% { translate: -354.44% 68.12%; scale: 0.905; rotate: 373.7deg; z-index: 0; }
  44.792% { translate: -351.53% 58.34%; scale: 0.894; rotate: 379.6deg; z-index: 0; }
  45.833% { translate: -347.41% 48.23%; scale: 0.883; rotate: 384.9deg; z-index: 0; }
  46.875% { translate: -342.09% 37.89%; scale: 0.873; rotate: 389.6deg; z-index: 0; }
  47.917% { translate: -335.64% 27.39%; scale: 0.862; rotate: 393.7deg; z-index: 0; }
  48.958% { translate: -328.12% 16.83%; scale: 0.853; rotate: 397.3deg; z-index: 0; }
  50% { translate: -319.59% 6.27%; scale: 0.843; rotate: 400.6deg; z-index: 0; }
  51.042% { translate: -310.14% -4.24%; scale: 0.834; rotate: 403.5deg; z-index: 0; }
  52.083% { translate: -299.82% -14.65%; scale: 0.824; rotate: 406.1deg; z-index: 0; }
  53.125% { translate: -288.72% -24.90%; scale: 0.816; rotate: 408.5deg; z-index: 0; }
  54.167% { translate: -276.89% -34.97%; scale: 0.807; rotate: 410.7deg; z-index: 0; }
  55.208% { translate: -264.38% -44.83%; scale: 0.799; rotate: 412.8deg; z-index: 0; }
  56.25% { translate: -251.27% -54.45%; scale: 0.791; rotate: 414.7deg; z-index: 0; }
  57.292% { translate: -237.60% -63.81%; scale: 0.783; rotate: 416.6deg; z-index: 0; }
  58.333% { translate: -223.42% -72.88%; scale: 0.776; rotate: 418.3deg; z-index: 0; }
  59.375% { translate: -208.76% -81.65%; scale: 0.769; rotate: 419.9deg; z-index: 0; }
  60.417% { translate: -193.69% -90.11%; scale: 0.762; rotate: 421.5deg; z-index: 0; }
  61.458% { translate: -178.23% -98.25%; scale: 0.756; rotate: 423.0deg; z-index: 0; }
  62.5% { translate: -162.42% -106.05%; scale: 0.750; rotate: 424.5deg; z-index: 0; }
  63.542% { translate: -146.29% -113.50%; scale: 0.744; rotate: 425.9deg; z-index: 0; }
  64.583% { translate: -129.87% -120.60%; scale: 0.739; rotate: 427.4deg; z-index: 0; }
  65.625% { translate: -113.20% -127.33%; scale: 0.734; rotate: 428.7deg; z-index: 0; }
  66.667% { translate: -96.30% -133.69%; scale: 0.730; rotate: 430.1deg; z-index: 0; }
  67.708% { translate: -79.20% -139.68%; scale: 0.726; rotate: 431.4deg; z-index: 0; }
  68.75% { translate: -61.93% -145.28%; scale: 0.723; rotate: 432.7deg; z-index: 0; }
  69.792% { translate: -44.51% -150.48%; scale: 0.720; rotate: 434.1deg; z-index: 0; }
  70.833% { translate: -26.96% -155.29%; scale: 0.718; rotate: 435.4deg; z-index: 0; }
  71.875% { translate: -9.32% -159.69%; scale: 0.716; rotate: 436.7deg; z-index: 0; }
  72.917% { translate: 8.40% -163.68%; scale: 0.715; rotate: 438.0deg; z-index: 0; }
  73.958% { translate: 26.16% -167.25%; scale: 0.714; rotate: 439.3deg; z-index: 0; }
  75% { translate: 43.94% -170.39%; scale: 0.714; rotate: 440.7deg; z-index: 0; }
  76.042% { translate: 61.71% -173.09%; scale: 0.714; rotate: 442.1deg; z-index: 0; }
  77.083% { translate: 79.44% -175.35%; scale: 0.715; rotate: 443.5deg; z-index: 0; }
  78.125% { translate: 97.10% -177.16%; scale: 0.717; rotate: 444.9deg; z-index: 0; }
  79.167% { translate: 114.66% -178.50%; scale: 0.720; rotate: 446.4deg; z-index: 0; }
  80.208% { translate: 132.07% -179.38%; scale: 0.723; rotate: 447.9deg; z-index: 0; }
  81.25% { translate: 149.29% -179.77%; scale: 0.727; rotate: 449.5deg; z-index: 0; }
  82.292% { translate: 166.29% -179.68%; scale: 0.732; rotate: 451.2deg; z-index: 0; }
  83.333% { translate: 183.01% -179.09%; scale: 0.737; rotate: 453.0deg; z-index: 0; }
  84.375% { translate: 199.40% -177.99%; scale: 0.744; rotate: 454.8deg; z-index: 0; }
  85.417% { translate: 215.40% -176.37%; scale: 0.751; rotate: 456.8deg; z-index: 0; }
  86.458% { translate: 230.95% -174.23%; scale: 0.759; rotate: 459.0deg; z-index: 0; }
  87.5% { translate: 245.97% -171.56%; scale: 0.768; rotate: 461.3deg; z-index: 0; }
  88.542% { translate: 260.40% -168.36%; scale: 0.778; rotate: 463.9deg; z-index: 0; }
  89.583% { translate: 274.14% -164.63%; scale: 0.789; rotate: 466.7deg; z-index: 0; }
  90.625% { translate: 287.11% -160.37%; scale: 0.801; rotate: 469.9deg; z-index: 0; }
  91.667% { translate: 299.21% -155.59%; scale: 0.814; rotate: 473.5deg; z-index: 0; }
  92.708% { translate: 310.34% -150.32%; scale: 0.828; rotate: 477.6deg; z-index: 0; }
  93.75% { translate: 320.38% -144.56%; scale: 0.843; rotate: 482.4deg; z-index: 0; }
  94.792% { translate: 329.25% -138.38%; scale: 0.859; rotate: 487.9deg; z-index: 0; }
  95.833% { translate: 336.86% -131.82%; scale: 0.876; rotate: 494.2deg; z-index: 0; }
  96.875% { translate: 343.16% -124.94%; scale: 0.893; rotate: 501.4deg; z-index: 0; }
  97.917% { translate: 348.16% -117.79%; scale: 0.912; rotate: 509.2deg; z-index: 0; }
  98.958% { translate: 351.94% -110.37%; scale: 0.930; rotate: 517.3deg; z-index: 0; }
  100% { translate: 354.60% -102.60%; scale: 0.950; rotate: 525.3deg; z-index: 6; }
}
/* Same path for the smoke, WITHOUT rotate — puffs don't spin with the ship. */
@keyframes cometOrbit3d {
  0% { translate: 354.60% -102.60%; scale: 0.950; z-index: 6; }
  1.042% { translate: 356.20% -94.28%; scale: 0.970; z-index: 6; }
  2.083% { translate: 356.68% -85.18%; scale: 0.990; z-index: 6; }
  3.125% { translate: 355.82% -75.10%; scale: 1.011; z-index: 6; }
  4.167% { translate: 353.34% -63.94%; scale: 1.031; z-index: 6; }
  5.208% { translate: 348.94% -51.69%; scale: 1.051; z-index: 6; }
  6.25% { translate: 342.39% -38.42%; scale: 1.070; z-index: 6; }
  7.292% { translate: 333.52% -24.26%; scale: 1.089; z-index: 6; }
  8.333% { translate: 322.18% -9.34%; scale: 1.107; z-index: 6; }
  9.375% { translate: 308.30% 6.17%; scale: 1.123; z-index: 6; }
  10.417% { translate: 291.85% 22.09%; scale: 1.138; z-index: 6; }
  11.458% { translate: 272.84% 38.24%; scale: 1.151; z-index: 6; }
  12.5% { translate: 251.33% 54.41%; scale: 1.162; z-index: 6; }
  13.542% { translate: 227.40% 70.39%; scale: 1.172; z-index: 6; }
  14.583% { translate: 201.22% 85.96%; scale: 1.179; z-index: 6; }
  15.625% { translate: 172.98% 100.90%; scale: 1.184; z-index: 6; }
  16.667% { translate: 142.92% 115.00%; scale: 1.186; z-index: 6; }
  17.708% { translate: 111.33% 128.06%; scale: 1.186; z-index: 6; }
  18.75% { translate: 78.53% 139.90%; scale: 1.184; z-index: 6; }
  19.792% { translate: 44.89% 150.37%; scale: 1.180; z-index: 6; }
  20.833% { translate: 10.78% 159.34%; scale: 1.174; z-index: 6; }
  21.875% { translate: -23.42% 166.72%; scale: 1.166; z-index: 6; }
  22.917% { translate: -57.31% 172.46%; scale: 1.157; z-index: 6; }
  23.958% { translate: -90.51% 176.54%; scale: 1.147; z-index: 6; }
  25% { translate: -122.66% 178.97%; scale: 1.135; z-index: 6; }
  26.042% { translate: -153.43% 179.79%; scale: 1.123; z-index: 6; }
  27.083% { translate: -179.28% 175.92%; scale: 1.113; z-index: 6; }
  28.125% { translate: -195.93% 165.38%; scale: 1.108; z-index: 6; }
  29.167% { translate: -202.88% 150.05%; scale: 1.106; z-index: 6; }
  30.208% { translate: -201.76% 132.50%; scale: 1.107; z-index: 6; }
  31.25% { translate: -196.00% 115.41%; scale: 1.106; z-index: 6; }
  32.292% { translate: -190.11% 101.10%; scale: 1.104; z-index: 6; }
  33.333% { translate: -188.55% 91.09%; scale: 1.096; z-index: 6; }
  34.375% { translate: -194.75% 85.86%; scale: 1.084; z-index: 6; }
  35.417% { translate: -210.26% 84.92%; scale: 1.066; z-index: 6; }
  36.458% { translate: -234.41% 87.01%; scale: 1.043; z-index: 6; }
  37.5% { translate: -264.46% 90.43%; scale: 1.017; z-index: 6; }
  38.542% { translate: -296.22% 93.41%; scale: 0.990; z-index: 6; }
  39.583% { translate: -324.90% 94.37%; scale: 0.965; z-index: 6; }
  40.625% { translate: -346.00% 92.15%; scale: 0.944; z-index: 0; }
  41.667% { translate: -356.08% 86.24%; scale: 0.927; z-index: 0; }
  42.708% { translate: -356.15% 77.48%; scale: 0.916; z-index: 0; }
  43.75% { translate: -354.44% 68.12%; scale: 0.905; z-index: 0; }
  44.792% { translate: -351.53% 58.34%; scale: 0.894; z-index: 0; }
  45.833% { translate: -347.41% 48.23%; scale: 0.883; z-index: 0; }
  46.875% { translate: -342.09% 37.89%; scale: 0.873; z-index: 0; }
  47.917% { translate: -335.64% 27.39%; scale: 0.862; z-index: 0; }
  48.958% { translate: -328.12% 16.83%; scale: 0.853; z-index: 0; }
  50% { translate: -319.59% 6.27%; scale: 0.843; z-index: 0; }
  51.042% { translate: -310.14% -4.24%; scale: 0.834; z-index: 0; }
  52.083% { translate: -299.82% -14.65%; scale: 0.824; z-index: 0; }
  53.125% { translate: -288.72% -24.90%; scale: 0.816; z-index: 0; }
  54.167% { translate: -276.89% -34.97%; scale: 0.807; z-index: 0; }
  55.208% { translate: -264.38% -44.83%; scale: 0.799; z-index: 0; }
  56.25% { translate: -251.27% -54.45%; scale: 0.791; z-index: 0; }
  57.292% { translate: -237.60% -63.81%; scale: 0.783; z-index: 0; }
  58.333% { translate: -223.42% -72.88%; scale: 0.776; z-index: 0; }
  59.375% { translate: -208.76% -81.65%; scale: 0.769; z-index: 0; }
  60.417% { translate: -193.69% -90.11%; scale: 0.762; z-index: 0; }
  61.458% { translate: -178.23% -98.25%; scale: 0.756; z-index: 0; }
  62.5% { translate: -162.42% -106.05%; scale: 0.750; z-index: 0; }
  63.542% { translate: -146.29% -113.50%; scale: 0.744; z-index: 0; }
  64.583% { translate: -129.87% -120.60%; scale: 0.739; z-index: 0; }
  65.625% { translate: -113.20% -127.33%; scale: 0.734; z-index: 0; }
  66.667% { translate: -96.30% -133.69%; scale: 0.730; z-index: 0; }
  67.708% { translate: -79.20% -139.68%; scale: 0.726; z-index: 0; }
  68.75% { translate: -61.93% -145.28%; scale: 0.723; z-index: 0; }
  69.792% { translate: -44.51% -150.48%; scale: 0.720; z-index: 0; }
  70.833% { translate: -26.96% -155.29%; scale: 0.718; z-index: 0; }
  71.875% { translate: -9.32% -159.69%; scale: 0.716; z-index: 0; }
  72.917% { translate: 8.40% -163.68%; scale: 0.715; z-index: 0; }
  73.958% { translate: 26.16% -167.25%; scale: 0.714; z-index: 0; }
  75% { translate: 43.94% -170.39%; scale: 0.714; z-index: 0; }
  76.042% { translate: 61.71% -173.09%; scale: 0.714; z-index: 0; }
  77.083% { translate: 79.44% -175.35%; scale: 0.715; z-index: 0; }
  78.125% { translate: 97.10% -177.16%; scale: 0.717; z-index: 0; }
  79.167% { translate: 114.66% -178.50%; scale: 0.720; z-index: 0; }
  80.208% { translate: 132.07% -179.38%; scale: 0.723; z-index: 0; }
  81.25% { translate: 149.29% -179.77%; scale: 0.727; z-index: 0; }
  82.292% { translate: 166.29% -179.68%; scale: 0.732; z-index: 0; }
  83.333% { translate: 183.01% -179.09%; scale: 0.737; z-index: 0; }
  84.375% { translate: 199.40% -177.99%; scale: 0.744; z-index: 0; }
  85.417% { translate: 215.40% -176.37%; scale: 0.751; z-index: 0; }
  86.458% { translate: 230.95% -174.23%; scale: 0.759; z-index: 0; }
  87.5% { translate: 245.97% -171.56%; scale: 0.768; z-index: 0; }
  88.542% { translate: 260.40% -168.36%; scale: 0.778; z-index: 0; }
  89.583% { translate: 274.14% -164.63%; scale: 0.789; z-index: 0; }
  90.625% { translate: 287.11% -160.37%; scale: 0.801; z-index: 0; }
  91.667% { translate: 299.21% -155.59%; scale: 0.814; z-index: 0; }
  92.708% { translate: 310.34% -150.32%; scale: 0.828; z-index: 0; }
  93.75% { translate: 320.38% -144.56%; scale: 0.843; z-index: 0; }
  94.792% { translate: 329.25% -138.38%; scale: 0.859; z-index: 0; }
  95.833% { translate: 336.86% -131.82%; scale: 0.876; z-index: 0; }
  96.875% { translate: 343.16% -124.94%; scale: 0.893; z-index: 0; }
  97.917% { translate: 348.16% -117.79%; scale: 0.912; z-index: 0; }
  98.958% { translate: 351.94% -110.37%; scale: 0.930; z-index: 0; }
  100% { translate: 354.60% -102.60%; scale: 0.950; z-index: 6; }
}

@keyframes twinkle { 0%, 100% { opacity: 0.25; transform: scale(0.6) rotate(0deg); } 50% { opacity: 1; transform: scale(1.15) rotate(20deg); } }

/* Scroll-driven fade-ins need a fallback where `animation-timeline` is unsupported
   (Safari / Firefox today) — otherwise those sections sit at opacity 0 forever. */
@supports not (animation-timeline: view()) {
  .sectionhead, .card, .about, .contact { animation: none; opacity: 1; transform: none; }
}

/* ==========================================================================
   Cross-document view transitions. The Vida card's cover and the case study's
   hero image share `view-transition-name: project-hero`, so the browser morphs
   one into the other; everything else blurs out and slides up in.
   Chrome/Edge/Safari today; degrades to a plain navigation elsewhere.
   ========================================================================== */
@view-transition { navigation: auto; }

/* Frame-by-frame rules, learned the hard way:
   - The old root never fades below 1: the new root is stacked above it and
     fades up, so the default white backdrop can't flash between a light page
     and a dark one.
   - No blur filters. Full-viewport blurs on snapshot images drop frames, and
     a stuttering morph reads as broken.
   - Both ends of project-hero show the SAME artwork (the card cover is a
     derivative of the case hero frame), so the pair's default cross-fade is
     disabled — one object flies, nothing shimmers.
   - The arriving page suppresses its own hero entrance during the transition
     (html.vt-arrive, set by the pagereveal hook) — artIn's delayed opacity-0
     start was blanking the morph target mid-flight. */
::view-transition-old(root) { animation: 420ms cubic-bezier(0.4,0,0.2,1) both vt-out; }
::view-transition-new(root) { animation: 420ms cubic-bezier(0.22,1,0.36,1) both vt-in; }
@keyframes vt-out { to { transform: scale(0.985); } }
@keyframes vt-in { from { opacity: 0; transform: translateY(12px); } }

::view-transition-group(project-hero) {
  animation-duration: 560ms;
  animation-timing-function: cubic-bezier(0.22,1,0.36,1);
}
/* the pair carries the clip: a radius between the card's 10px and the case
   cover's 18px, constant through the flight so corners never pop square */
::view-transition-image-pair(project-hero) {
  border-radius: 14px;
  overflow: hidden;
}
::view-transition-old(project-hero),
::view-transition-new(project-hero) {
  width: 100%; height: 100%;
  object-fit: cover;
  animation: none;
  opacity: 1;
}

/* ==========================================================================
   Responsive — not in the handoff (desktop-only spec); added for publishing.
   ========================================================================== */
/* ---- Mid-desktop (roughly 900-1150px): two columns, but not much room ----
   The art composition is deliberately wider than its column — the big planet
   sits at 144% of the headshot from the system's left edge, so it bleeds into
   the page margin. Above ~1150px there's margin to bleed INTO; here there
   isn't, so it hit the viewport edge and got sheared off while the ring
   crowded the right side. Shrinking the system and widening the gutters buys
   back the margin without restaging the composition. */
@media (min-width: 901px) and (max-width: 1150px) {
  .hero {
    --headshot: clamp(210px, 25vw, 262px);
    /* Bottom padding reserves the ticker + scroll-cue zone, so the block
       centres against the ticker as its floor rather than against the raw
       viewport — that stranded band of sky under the socials was the block
       centring below a floor it couldn't see. */
    padding: 96px 40px clamp(110px, 20vh, 190px);
  }
  .hero__grid {
    grid-template-columns: 1fr minmax(255px, 320px);
    gap: clamp(36px, 5vw, 52px);
    /* the padding above does this job now; the desktop tier's 8vh bias would
       double-count it and lift the block off centre again */
    margin-bottom: 0;
  }
  .name3d { font-size: clamp(50px, 6.8vw, 68px); line-height: 1.06; }
  .hero__role { font-size: clamp(19px, 2.3vw, 22px); line-height: 1.3; }
  .hero__bio { font-size: 16px; line-height: 24px; max-width: 430px; }
}

@media (max-width: 900px) {
  /* Stacked hero. Every size below is clamped against vh so the whole thing
     still lands inside one screen on a short phone. */
  .hero {
    --headshot: clamp(150px, 28vh, 260px);
    padding: clamp(60px, 11vh, 108px) 24px clamp(28px, 7vh, 64px);
  }
  /* the sheet's superellipse corners at 64px+ read cartoonish at phone width */
  .sheet { --sheet-r: clamp(28px, 9vw, 40px); }
  .hero__grid {
    grid-template-columns: 1fr;
    /* the cursor and its chip roam past the photo's lower edge, so the copy
       needs clearance or they land on the kicker */
    gap: clamp(30px, 5.4vh, 62px);
    justify-items: center;
    text-align: center;
  }
  /* Grouped rhythm, not even spacing. On tall phones the old 2.2vh base gap
     hit ~24px and the CTA/social overrides landed within a few px of it, so
     every gap read the same and the hierarchy dissolved. Three proximity
     groups instead: identity (badge + name + role) sits tight, the bio hangs
     just off it, and the actions (CTAs with socials tucked under) sit past
     the page's largest internal break. Roughly 9 / 10 / 22 / 40 / 12 px on a
     tall phone — each boundary visibly different from its neighbours. */
  .hero__copy { align-items: center; order: 2; --copy-gap: clamp(8px, 1.6vh, 14px); }
  .hero__art { order: 1; }
  .hero__bio { text-align: center; }
  .hero__ctas, .socials { justify-content: center; }

  .name3d { font-size: clamp(40px, 10.5vw, 60px); line-height: 1.07; --name-ls: -1.1px; }
  .hero__role { font-size: clamp(17px, 4.4vw, 21px); line-height: 1.28; }

  /* identity group: kicker hugs the name */
  .badge-row { margin-bottom: calc(var(--copy-gap) * -0.35); }

  .hero__bio { font-size: clamp(15.5px, 4.1vw, 17.5px); line-height: 1.56; margin-top: calc(var(--copy-gap) * 0.7); }
  /* the one big break: reading ends, acting begins */
  .hero__ctas { margin-top: clamp(16px, 3vh, 26px); }
  /* socials belong to the CTAs, so they tuck in tighter than the base gap */
  .socials { margin-top: calc(var(--copy-gap) * -0.15); gap: 10px; }
  .social { width: 40px; height: 40px; }
  .scrollcue { bottom: max(clamp(8px, 1.6vh, 28px), calc(env(safe-area-inset-bottom, 0px) + 8px)); }

  .section, .section--work, .section--contact { padding: 64px 24px; }
  .sectionhead h2, .about__intro h2 { font-size: 34px; line-height: 40px; letter-spacing: -0.9px; }

  .cards { grid-template-columns: 1fr; }
  /* One column, and everything stays LEFT-aligned. Centred paragraphs were
     half the reason this section read as a wall on a phone: ragged on both
     edges, so there's no margin for the eye to return to. */
  .about {
    grid-template-columns: 1fr;
    grid-template-areas: "intro" "photo" "detail";
    row-gap: 30px;
  }
  .about__lead { font-size: 17px; line-height: 27px; }
  /* a 3:4 portrait at full phone width swallows the viewport — a square crop
     biased to his head and the bass reads as a deliberate photo, not a poster */
  .about__photo { padding: 10px; }
  .about__photo::after { inset: 10px; }
  .about__photo img { aspect-ratio: 1 / 1; object-position: 50% 22%; }
  /* the hover accent has nowhere to live once the copy runs edge to edge */
  .about__roles > li::before { display: none; }
  .about__roles > li { padding: 20px 0 21px; }
  .role__org { font-size: 17px; }
  .about__roles p { font-size: 14.5px; line-height: 22.5px; }
  .role__bullets li { font-size: 14.5px; line-height: 22px; }

  /* Contact + footer share one phone screen (.closing owns the height now),
     so the padding here only has to keep the card off the edges */
  .section--contact { min-height: auto; padding: 32px 24px 12px; }
  .contact { padding: 40px 22px; gap: 16px; }
  .footer { padding: 18px 24px calc(24px + env(safe-area-inset-bottom, 0px)); }
  .contact h2 { font-size: 34px; line-height: 40px; letter-spacing: -1px; }
  .contact p { font-size: 16px; line-height: 24px; }

  .navpills { gap: 6px; }
  .pill { padding: 10px 14px; }
}

/* Three CTAs on one row — wrapping to two rows costs ~55px, which is the
   difference between the hero fitting one screen and not. */
@media (max-width: 620px) {
  .hero__ctas { gap: 10px; }
  .hero__ctas .btn { padding: 14px 22px; font-size: 15px; }
}

@media (max-width: 480px) {
}

@media (max-width: 560px) {
  /* Phone-size fan: a squat 2.05:1 box crops the cover's top at this width —
     open the frame up so the composition gets its sky back, centre the cover,
     and tuck the wings in behind it instead of off the card's edges. Applied
     to every cover, not just Vida's, so stacked cards keep one rhythm. */
  .card__media { aspect-ratio: 1.55 / 1; max-height: 260px; }
  .fancard--hero { width: 80%; margin-left: -40%; bottom: 16px; }
  .fancard--events, .fancard--sponsors { width: 46%; bottom: 52px; }
  .fancard--events   { left: -4%; }
  .fancard--sponsors { right: -4%; }

  /* the capsule compresses to fit the narrowest phones — but stays tappable */
  header { top: max(10px, calc(env(safe-area-inset-top, 0px) + 6px)); gap: 4px; padding: 6px 7px; }
  header .logo { width: 34px; height: 34px; }
  .pill { padding: 11px 13px; font-size: 13px; }
  .pill--dark { margin-left: 4px; }

  .contact__ctas .btn { font-size: 14px; padding: 13px 20px; }
  .footer__inner { justify-content: center; text-align: center; }

  /* The address is the whole point of the control, so it must never
     ellipsis. The row goes full-width, the mail icon (decorative) drops,
     and the type scales with the viewport so all 26 characters fit. */
  .mailcopy {
    display: flex; width: 100%;
    justify-content: space-between;
    gap: 8px;
    padding: 12px 12px 12px 16px;
    font-size: clamp(13px, 3.7vw, 16px);
  }
  .mailcopy__ico { display: none; }
  .mailcopy__addr { flex: 1 1 auto; min-width: 0; text-align: left; }
  .mailcopy__hint { width: 32px; height: 32px; }
}

/* Stacked layout on a shortish screen (roughly 650-780px tall — small laptops
   and landscape tablets). Restoring the "Open to work" badge cost ~30px, which
   pushed these past one screen; this band compresses to win it back. Sits
   before the very-short portrait tier so that one still wins below 660px. */
@media (max-width: 900px) and (max-height: 780px) {
  .hero {
    --headshot: clamp(132px, 25vh, 210px);
    padding: clamp(52px, 8.5vh, 84px) 24px clamp(20px, 4.5vh, 44px);
  }
  .hero__grid { gap: clamp(26px, 4.6vh, 46px); }
  .hero__copy { --copy-gap: clamp(8px, 1.8vh, 18px); }
  .hero__bio { font-size: clamp(15px, 3.9vw, 16.5px); line-height: 1.5; }
}

/* Short phones in portrait (SE-class, and anything with a lot of browser
   chrome). The clamps alone bottom out around 600px of height, so this tier
   takes another pass at everything to keep the hero inside one screen. */
@media (max-width: 900px) and (max-height: 660px) and (orientation: portrait) {
  .hero { --headshot: clamp(96px, 20vh, 150px); padding: 56px 24px 24px; }
  .hero__grid { gap: 12px; }
  .hero__copy { --copy-gap: 7px; }
  .name3d { font-size: clamp(32px, 8.5vw, 44px); line-height: 1.1; }
  .hero__role { font-size: 16px; line-height: 21px; }

.hero__bio { font-size: 14.5px; line-height: 21px; }
  .hero__ctas .btn { padding: 12px 19px; font-size: 14px; }
  .social { width: 34px; height: 34px; }
  .social svg { width: 18px; height: 18px; }
}

/* Short viewports — landscape phones and small laptops. Keeping the hero to a
   single screen matters more here than keeping the stacked layout, so go back
   to two columns and let the art shrink. */
@media (max-width: 900px) and (max-height: 620px) and (orientation: landscape) {
  /* The panel's 2x10px inset comes out of this tier's padding budget — it was
     tuned to land exactly on one screen when the hero was full-bleed. */
  .hero { --headshot: clamp(120px, 34vh, 190px); padding: 44px 24px 26px; }
  .hero__grid {
    grid-template-columns: 1fr minmax(140px, 34%);
    gap: clamp(16px, 4vw, 40px);
    text-align: left;
    align-items: center;
  }
  .hero__copy { align-items: flex-start; order: 1; }
  .hero__art { order: 2; }

.hero__bio { text-align: left; }
  .hero__ctas, .socials { justify-content: flex-start; }
  .name3d { font-size: clamp(30px, 5.2vw, 44px); line-height: 1.1; }
}

/* Very short screens: the bio is the first thing to go — the name, role, CTAs
   and social links all carry more weight, and the About section repeats it. */
@media (max-height: 520px) {

.hero__bio { display: none; }
  .scrollcue { display: none; }
}

/* Touch devices fire :hover on tap and then keep it stuck, so the lift reads as
   a stuck state rather than feedback. Presses still respond via :active. */
@media (hover: none) {
  a.card:hover { transform: none; box-shadow: var(--shadow-card); border-color: rgba(233,238,244,0.9); }
  a.card:hover::after { opacity: 0; animation-play-state: paused; }
  .social:hover, .fsocial:hover { transform: none; }

  /* thumbs, not cursors: full 46px targets with a resting tint so each icon
     reads as its own hit area rather than a loose glyph */
  .fsocial { width: 46px; height: 46px; background: rgba(100,116,139,0.06); }
  .footer__socials { gap: 6px; }

  /* No hover to reveal the tints, so the icons rest in their coloured state */
  .social--li { color: #A78BFA; background: rgba(139,92,246,0.16); }
  .social--ig { color: #F9A8D4; background: rgba(236,95,168,0.16); }
  .social--sp { color: #1ED760; background: rgba(29,185,84,0.14); }
  .social--am { color: #FF5468; background: rgba(250,36,60,0.14); }
  .social--yt { color: #FF4E45; background: rgba(255,0,0,0.12); }
}

/* ---------- Mobile / touch performance ----------
   The hero runs ~70 concurrent animations on desktop. Most are compositor-only
   and cheap, but a handful hit layout or paint every frame — fine on a laptop
   GPU, jank on a phone. This tier keeps the composition and drops exactly the
   expensive ones. */
@media (max-width: 900px), (hover: none) {
  /* full-screen 60px-blur layer above everything; only the mouse ever moved
     it, and there is no mouse here */
  #ambient { display: none; }

  /* dotFly rescales two viewport-sized masked layers forever — keep the dot
     field, park it (gridIn still fades it in on load) */
  .hero::before, .hero::after { animation: gridIn 1100ms 350ms linear backwards; }

  /* barHue repaints 48 elements' background continuously; the static thirds
     (declared at base) already colour the ring without it */
  .bar i {
    animation:
      eq 1.7s var(--ease-in-out-quad) infinite alternate,
      barIn 380ms var(--ease-out-quint) both;
    animation-delay: calc(var(--i) * -0.052s), calc(420ms + var(--i) * 13ms);
  }

  /* the marquee boxes animate width/height — layout every frame. The cursor
     itself (translate-only, compositor) stays; it reads as a multiplayer
     cursor drifting rather than one drawing selections. */
  .selbox, .clickring { display: none; }

  /* six echo layers swapping background-image at 11fps behind the rocket */
  .comet { display: none; }

  /* live blur behind semi-opaque cards re-filters on every scroll frame; the
     sheet behind them is near-white anyway */
  .card {
    background: #FFFFFF;
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
  }

  /* superellipse corners on a viewport-wide scrolling container are expensive
     to rasterize on iOS — plain round corners cost nothing */
  .about__photo, .card, a.card::after { corner-shape: round; }

  /* Safari's chrome is tinted flat #0A0918 while the hero shows (theme-color,
     main.js) — but the hero's own edges glow violet/pink, so the flat bar
     read as a bar. Fading the top and bottom edges into that exact colour
     makes the chrome a continuation of the sky instead of a strip on it. */
  .hero {
    background:
      linear-gradient(180deg, #0A0918 0%, rgba(10,9,24,0) clamp(70px, 13vh, 130px)),
      linear-gradient(0deg, #0A0918 0%, rgba(10,9,24,0) clamp(70px, 13vh, 130px)),
      radial-gradient(1100px 620px at 16% 26%, rgba(109,40,217,0.40), transparent 62%),
      radial-gradient(860px 540px at 80% 16%, rgba(14,165,233,0.14), transparent 60%),
      radial-gradient(940px 580px at 52% 98%, rgba(236,95,168,0.20), transparent 62%),
      #0A0918;
  }
}

/* Once the sheet fully covers the pinned hero (main.js toggles this), stop
   compositing the whole animated scene — planets, spectrum, rocket and all
   were otherwise burning GPU under every section of the page, which is what
   starved Safari of raster time and caused the white-tile flashes mid-scroll. */
.hero--asleep { visibility: hidden; }
.hero--asleep *,
.hero--asleep *::before,
.hero--asleep *::after { animation-play-state: paused !important; }

/* ---------- Reduced motion ---------- */
@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  *, *::before, *::after {
    animation: none !important;
    transition: none !important;
  }
  /* keep everything visible once its animation is off */
  .sectionhead, .card, .about, .contact,
  .hero__copy, .hero__copy > *, .hero__art, .ch,
  .ellipse, .star, .cursor, .rocket3d { opacity: 1; transform: none; }
  .clickring { opacity: 0; }
  .rocket3d { left: 50%; top: 92%; translate: none; scale: 1; rotate: 0deg; z-index: 6; }
  /* echoes have no resting position — without their keyframes they pile up
     at the system's corner. The trail is decoration; drop it. */
  .comet { display: none; }
  /* hold the first flame drawing so the parked rocket isn't engine-off */
  .rocket3d .fx--1 { opacity: 1; }
  /* the spectrum has no resting height without its keyframe */
  .bar i { scale: 1 0.6; opacity: 1; }
}
