/* ==========================================================================
   Matthew Diep — Portfolio
   --------------------------------------------------------------------------
   TUNABLES — every knob you'll realistically want to turn lives in :root
   below (plus the CONFIG object at the top of js/effects.js):

     --accent             the one accent color (links, tags)
     --grain-opacity      film-grain strength (0 turns it off)
     --intro-name-ms      how long the intro's 0-100 count takes
                          (matched by INTRO.nameMs in js/intro.js)
     --curtain-ms         page-transition curtain speed
     magnetic pull / tilt amounts → CONFIG in js/effects.js
   ========================================================================== */

/* ── Satoshi (self-hosted) ─────────────────────────────────────────────────
   Two faces for the whole site: Regular for body text, Medium for headings.
   Self-hosted from Fontshare (free for commercial use) — no font CDN.

   Each face claims a weight RANGE rather than a single value, so the ~24
   font-weight declarations scattered through this file resolve onto the two
   files we ship without needing to be rewritten:
       100–450 → Regular      451–900 → Medium
   In practice that means 300 and 400 render Regular, 500 and 600 render
   Medium. Headings are set to 500 explicitly further down.

   (This replaced PP Mori. fonts/PPMori-*.woff2 and the "PP Mori/" folder of
   .otf originals are now unreferenced and safe to delete.) */

@font-face {
  font-family: 'Satoshi';
  src: url('../fonts/Satoshi-Regular.woff2') format('woff2');
  font-weight: 100 450;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: 'Satoshi';
  src: url('../fonts/Satoshi-Medium.woff2') format('woff2');
  font-weight: 451 900;
  font-style: normal;
  font-display: swap;
}

:root {
  /* palette — full light theme */
  --bg:        #f6f6f5;
  --surface:   #ffffff;
  --ink:       #131313;
  --muted:     #6d6d6d;
  --line:      #e4e4df;
  --accent:    #2b4cff;
  --accent-soft: rgba(43, 76, 255, 0.08);

  /* dark bands: the hero, the role statement and the closing card */
  --ink-deep:  #000000;
  --paper:     #f4f4f2;

  /* Wind River's brand teal, for the company name in the role statement.
     This is their brighter accent cut; their primary is the deeper #00ada4.
     DARK BACKGROUNDS ONLY — 13.13:1 on --ink-deep (AAA), but 1.48:1 on --bg,
     which fails badly. Don't reuse it on the light panel. */
  --wr-teal:   #30e4d0;

  /* effect intensity */
  --grain-opacity: 0.05;

  /* timing */
  --intro-name-ms: 1500ms;
  --curtain-ms: 420ms;
  --ease-out: cubic-bezier(0.16, 1, 0.3, 1);
  --ease-io:  cubic-bezier(0.65, 0, 0.35, 1);

  --font: 'Satoshi', system-ui, -apple-system, sans-serif;
  --font-display: 'Satoshi', system-ui, sans-serif; /* titles: tight tracking */
}

/* ── Reset / base ──────────────────────────────────────────────────────── */

* { box-sizing: border-box; margin: 0; padding: 0; }

/* auto, not smooth: ScrollSmoother owns scrolling and native smooth behaviour
   fights it. js/smooth.js routes anchor links through smoother.scrollTo. */
html { scroll-behavior: auto; }

body {
  font-family: var(--font);
  background: var(--bg);
  color: var(--ink);
  -webkit-font-smoothing: antialiased;
}

/* no overflow-x:hidden on body — it forces overflow-y:auto, which makes body
   a scroll container and conflicts with ScrollSmoother's wrapper. The wrapper
   is overflow:hidden already, so horizontal overflow is still clipped.
   #smooth-content carries it for the no-JS / no-GSAP case. */
#smooth-content { overflow-x: hidden; }

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

a { color: inherit; }

/* visible keyboard focus everywhere */
:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
  border-radius: 2px;
}

/* ── Display typography ────────────────────────────────────────────────── */

.display {
  font-family: var(--font-display);
  font-size: clamp(2.6rem, 7.08vw, 5.43rem);
  font-weight: 500; /* Satoshi Medium — section headings */
  letter-spacing: -0.06em;
  line-height: 1.02;
  color: var(--ink);
}

.section {
  max-width: 1160px;
  margin: 0 auto;
  padding: clamp(4.5rem, 12vh, 8rem) clamp(1.4rem, 6vw, 4rem);
  scroll-margin-top: 60px;
}

.section-head { margin-bottom: clamp(2rem, 6vh, 3.5rem); }

.section-sub {
  margin-top: 1rem;
  font-size: 1.12rem;
  font-weight: 300;
  color: var(--muted);
  letter-spacing: 0.02em;
}

/* ── Pill nav ──────────────────────────────────────────────────────────────
   No bubble and no panel — collapsed it is just three bare lines, and it
   widens into a bare row of links. js/pill-nav.js animates ONLY the width
   (and the items/bars inside); the rest is static here.

   `difference` computes |backdrop - source|, so WHITE marks render white over
   the dark hero and closing card, and flip to black over the light middle
   panel. That's what makes it readable everywhere with no background, no
   shadow and no scroll logic — everything below is therefore white.

   overflow:hidden is load-bearing: it's what clips the links away while the
   element is collapsed, so no separate hide/show is needed. */

#pill-nav {
  position: fixed;
  top: 1.4rem;
  right: 1.4rem;
  z-index: 1001;
  height: 64px;
  width: 64px;            /* collapsed; JS animates this */
  display: flex;
  align-items: center;
  justify-content: flex-end;
  overflow: hidden;
  mix-blend-mode: difference;
}

/* while pill-nav.js reads the natural expanded width */
#pill-nav.measuring { visibility: hidden; }

#pill-items {
  display: flex;
  align-items: center;
  gap: 1.9rem;
  list-style: none;
  margin: 0;
  padding: 0 1.6rem 0 1.9rem;
  white-space: nowrap;
}

#pill-items a {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  text-decoration: none;
  color: #fff; /* inverted by the blend mode above — not var(--ink) */
  font-family: var(--font); /* Satoshi, matching the rest of the site */
  font-weight: 400;         /* Regular */
  font-size: 13px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  transition: opacity 0.2s var(--ease-out);
}

#pill-items a:hover { opacity: 0.55; }

/* the toggle never moves; the row of links grows out to its left */
#pill-toggle {
  flex: none;
  width: 64px;
  height: 64px;
  border: none;
  background: none;
  padding: 0;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
}

.pill-bars {
  position: relative;
  width: 20px;
  height: 14px;
  display: block;
}

.pill-bars i {
  position: absolute;
  left: 0;
  width: 20px;
  height: 2px;
  border-radius: 2px;
  background: #fff; /* inverted by the blend mode — see #pill-nav */
}

.pill-bars i:nth-child(1) { top: 0; }
.pill-bars i:nth-child(2) { top: 6px; }
.pill-bars i:nth-child(3) { top: 12px; }

/* no JS: it can't expand, so show the links inline rather than trapping them
   inside a 64px box */
html.no-js #pill-nav { width: auto; }
html.no-js #pill-toggle { display: none; }

/* ── Intro preloader ───────────────────────────────────────────────────────
   Markup is display:none by default; only html.intro-pending (set by the
   tiny inline script in <head> when JS is on, no reduced-motion, and the
   intro hasn't played this session) shows it. Fully invisible to no-JS. */

/* dark to match the hero it wipes away to reveal — on --bg it flashed light */
#intro {
  display: none;
  position: fixed;
  inset: 0;
  z-index: 10000;
  background: var(--ink-deep);
  color: var(--paper);
  align-items: center;
  justify-content: center;
  flex-direction: column;
  gap: 1.1rem;
  clip-path: inset(0 0 0 0);
}

html.intro-pending #intro { display: flex; }

.intro-counter {
  font-variant-numeric: tabular-nums;
  font-size: 0.94rem;
  font-weight: 400;
  letter-spacing: 0.22em;
  color: var(--muted);
}

/* curtain wipe out */
#intro.intro-leave {
  transition: clip-path 0.7s var(--ease-io);
  clip-path: inset(0 0 100% 0);
  pointer-events: none;
}

/* keep hero hidden underneath until the intro lifts */
html.intro-pending:not(.intro-done) .hero-content { opacity: 0; }

/* ── Hero ──────────────────────────────────────────────────────────────────
   At exactly 100vh .hero-sticky has no sticky travel, so nothing actually
   pins — the hero fills the first screen, then scrolls off naturally and the
   statement below follows with no blank gap. It has no scroll animation at
   all; only the load-time riseIn entrance below. */
/* The hero and the role statement form one unbroken dark band. The background
   lives on these two sections rather than on body, because projects.html shares
   this stylesheet and has neither of them — a dark body would break that page.
   .now-pin's -45vh pull-up overlaps the hero, and both being the same black
   makes that seam invisible. */
.hero-pin {
  height: 100vh;
  background: var(--ink-deep);
}

.hero-sticky {
  position: sticky;
  top: 0;
  height: 100vh;
  height: 100svh;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 2rem;
}

.hero-content {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 1.6rem;
  text-align: center;
  will-change: transform, opacity;
}

/* entrance — runs immediately on normal loads, or when the intro finishes
   (html.intro-done). Static (visible) when JS is off. .hero-content holds a
   single <h1> now, so there's no stagger. */
html.js:not(.intro-pending) .hero-content > *,
html.intro-done .hero-content > * {
  animation: riseIn 1s var(--ease-out) both;
}

/* hero entrance on page LOAD (not scroll). Rise + fade, no scale. */
@keyframes riseIn {
  from { opacity: 0; transform: translateY(26px); }
  to   { opacity: 1; transform: none; }
}

.hero-name {
  font-family: var(--font-display);
  /* Stays Regular (400) while section headings are Medium (500) — this and the
     role statement read as display statements, not headings. The divisor below
     is measured from Satoshi Regular, so weight and divisor must change
     together. */
  font-weight: 400;
  /* The clamp is the size we WANT. The calc is a hard ceiling that keeps the
     name inside the viewport: "Matthew Diep" measures 5.377em wide in Satoshi
     (summed advance widths minus 0.06em tracking x 12 chars), PLUS 0.360em of
     .letter padding (12 letters x 2 sides x 0.015em) = 5.737em. .hero-sticky
     pads 2rem a side, so any font-size above (100vw - padding) / 5.78 would
     overflow. It can't wrap (a non-wrapping flex row, see below) and
     #smooth-content is overflow-x:hidden, so overflow would CLIP the name.
     NB: 5.78 is tied to this string, this font, the tracking AND the .letter
     padding — re-measure if any of them change. It was 6.15 under PP Mori,
     which is 5.6% wider for this string. */
  font-size: min(clamp(4.25rem, 11.5vw, 8.85rem), calc((100vw - 4.5rem) / 5.78));
  letter-spacing: -0.06em;
  line-height: 1;
  color: var(--paper); /* on the dark band */
  will-change: transform;
}

/* ── Hero letter photos ────────────────────────────────────────────────────
   js/hero-letters.js splits the name into one .letter per character. Three of
   them carry a hidden .thumb; hovering pops the photo in over the glyph while
   the neighbouring letters slide aside to make room.

   Every letter is plain --ink at rest — the photos only ever appear on hover.
   The flex row is what lets neighbours be shifted independently, and is only
   applied once the split has actually happened (html.hero-split), so without
   JS this stays an ordinary centred heading. */

html.hero-split .hero-name {
  display: flex;
  justify-content: center;
  user-select: none;
}

.hero-name .letter {
  position: relative;
  display: inline-block;
  /* Guarantees a gap that the settle overshoot can never close — see the
     settleEase note in js/hero-letters.js. Kept small so the wordmark stays
     tight: 0.015em a side against -0.06em tracking nets -0.03em between
     letters, i.e. still tighter than neutral. Changing this value means
     recomputing the 6.15 divisor in .hero-name's font-size above, and checking
     it still exceeds the settle overshoot. */
  padding: 0 0.015em;
  will-change: transform;
}

.hero-name .letter.has-media { cursor: pointer; }

.hero-name .char {
  display: inline-block;
  position: relative;
  z-index: 1;
}

/* centring and the rest state (scale 0.3, opacity 0) are set by GSAP via
   xPercent/yPercent so it owns the whole transform — no CSS transform here
   for it to clobber */
.hero-name .thumb {
  position: absolute;
  top: 50%;
  left: 50%;
  width: 1.05em;
  height: 1.05em;
  border-radius: 12px;
  overflow: hidden;
  opacity: 0;
  pointer-events: none;
  z-index: 2;
}

.hero-name .thumb img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

/* ── Wind River statement (full-viewport moment) + project strip ───────── */

/* No fixed height: js/now-line.js pins .now-sticky and ScrollTrigger's pin
   spacer supplies the runway (TUNE.pinVh viewport heights).

   The negative margin pulls this section up over the hero so the statement
   starts while "Matthew Diep" is only just leaving the top of the screen.
   now-line.js pins at `top top` — when this section's top reaches the viewport
   top — which at margin-top:0 would be scrollY 100vh, a full screen after the
   name has gone. The name is centred at ~50vh with a ~7vh half-height, so it
   clears the top around 57vh; -45vh brings the pin forward to ~55vh, catching
   it right as it exits.

   pointer-events:none because the pinned .now-sticky is a full-viewport box
   with no background, and during the overlap it sits ON TOP of the hero —
   without this it would swallow hover on the photo letters. The statement has
   no interactive elements, so nothing is lost. */
.now-pin {
  height: auto;
  margin-top: -45vh;
  pointer-events: none;
  background: var(--ink-deep); /* continues the hero's dark band */
}

/* Default state is the FALLBACK — a plain wrapped statement that reads fine
   with no JS, a blocked GSAP CDN, or reduced motion. js/now-line.js adds
   html.now-anim once it's actually running, which switches on the animated
   layout below. */
.now-sticky {
  min-height: 60vh;
  display: flex;
  align-items: center;
  padding: 2rem clamp(1.4rem, 6vw, 4rem);
}

/* animated mode: full-viewport pinned frame, statement centred in it */
html.now-anim .now-sticky {
  height: 100vh;
  min-height: 0;
  padding: 0;
  align-items: center;
  justify-content: center;
  overflow: hidden; /* clips each line while it's off-screen right, then left */
  position: relative; /* anchors the absolutely-placed .now-welcome below */
}

/* ── the "Welcome to my Website!" greeting ──
   Plays first, in the same pinned frame, then clears out before the statement
   arrives. Hidden by default and switched on only by html.welcome-anim, which
   js/now-line.js adds when it has actually tweened it — so no JS, a blocked
   CDN or reduced motion all leave the statement's own layout untouched rather
   than showing a stray line.

   Taken out of flow (the statement is the only thing in the flex box) and
   centred by its own full-size flex box, so nothing writes a base transform
   that GSAP would then have to fight over. */
.now-welcome { display: none; }

html.welcome-anim .now-welcome {
  display: flex;
  position: absolute;
  inset: 0;
  margin: 0;
  align-items: center;
  justify-content: center;
}

/* the tween drives this inner span, not the flex box itself — the box is what
   does the centring, so writing a transform onto it would fight the layout */
html.welcome-anim .now-w {
  white-space: nowrap;
  will-change: transform, opacity;
}

/* left-aligned so all three share one left edge — centring each line on its own
   width made the short first line sit noticeably inset from the other two.
   The block as a whole is still centred in the viewport (.now-sticky's
   justify-content), it's the lines inside it that align left. */
html.now-anim .now-line { text-align: left; }

html.now-anim .now-l {
  display: block;
  white-space: nowrap; /* a line must not re-wrap mid-glide */
  will-change: transform, opacity;
}

.now-line {
  font-family: var(--font-display);
  font-size: clamp(2.01rem, 5.31vw, 4.01rem);
  font-weight: 400;
  letter-spacing: -0.06em;
  line-height: 1.18;
  color: var(--paper); /* on the dark band */
}

/* ── Light panel ───────────────────────────────────────────────────────────
   Holds Projects, Experience and Education, riding up over the dark band with
   rounded top corners — the inverse of the .closing card at the page bottom.

   The negative margin is load-bearing: border-radius cuts the panel's own
   corners away, revealing whatever sits BEHIND it, which in normal flow is
   body (light). Pulling it 40px up into the dark section above means the
   corners reveal black instead, which is what makes it read as a panel sliding
   over rather than a shape with pale notches.

   --bg rather than pure white on purpose: .tl-card and .project-image-wrap are
   #ffffff, and on a pure-white panel they'd lose their fill contrast entirely.
   Against the black above, --bg reads as white anyway. */
.light-panel {
  position: relative;
  background: var(--bg);
  border-radius: 40px 40px 0 0;
  margin-top: -40px;
  /* breathing room below the rounded top edge, on top of the first section's
     own padding. The -40px pull-up means the top 40px sits over the dark band,
     so this is what actually separates the corner from the first heading. */
  padding-top: clamp(2rem, 5vh, 4rem);
}

.now-line em {
  font-style: normal;
  color: var(--wr-teal);
}

/* no overlap: the statement above now owns a real pinned runway, so Projects
   simply follows it. (Was -30vh, when the statement pinned with pinSpacing
   disabled and needed Projects pulled up over it.) */
#work { margin-top: 0; }

/* tighter gap under "Things I've built :)" — scoped to Projects so other
   section heads keep the larger default spacing */
#work .section-head { margin-bottom: clamp(0.3rem, 0.8vh, -5rem); }
/* push "Things I've built :)" further below the Projects heading */
#work .section-sub { margin-top: clamp(1.6rem, 4vh, 2rem); }

/* ── featured project rows (editorial: text left, image right) ── */

.feature-list { margin-top: clamp(0.2rem, 0.6vh, 0.5rem); }

.feature {
  display: grid;
  grid-template-columns: 1.1fr 1fr;
  gap: clamp(2rem, 5vw, 4rem);
  align-items: center;
  padding: clamp(2.2rem, 5vh, 3.5rem) 0;
  border-bottom: 1px solid var(--line);
  will-change: transform;
}

.feature:last-child { border-bottom: none; }

.feature-text h3 {
  font-size: clamp(1.53rem, 2.83vw, 2.01rem);
  font-weight: 500; /* Satoshi Medium */
  letter-spacing: -0.02em;
  margin-bottom: 0.7rem;
}

.feature-text > p {
  font-size: 1.12rem;
  font-weight: 300;
  line-height: 1.7;
  color: var(--muted);
  max-width: 44ch;
}

.feature-text .project-skills { margin-top: 1.1rem; }

.view-project {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  margin-top: 1.4rem;
  text-decoration: none;
  color: var(--accent);
  font-size: 1rem;
  font-weight: 500;
  letter-spacing: 0.04em;
  padding: 0.3rem 0.1rem;
  will-change: transform;
}

.view-project i { transition: transform 0.3s var(--ease-out); }
.view-project:hover i { transform: translateX(5px); }

/* inner wrapper carries the pointer tilt so it never fights the
   scroll-driven transform on the row/card itself */
.card-inner { will-change: transform; }

.feature-img {
  height: clamp(200px, 24vw, 310px);
  border-radius: 20px;
  overflow: hidden;
  background: #f0f0ee;
  will-change: transform;
}

.feature-img img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  filter: grayscale(10%);
  /* `scale`, not `transform: scale()` — ScrollSmoother owns `transform` on the
     [data-speed] images below, and an inline transform would override a CSS
     one. `scale` is an independent property, so the two compose instead. */
  transition: filter 0.4s var(--ease-out), scale 0.5s var(--ease-out);
}

.feature-img.contain img { object-fit: contain; padding: 20px; background: #fff; }

.feature:hover .feature-img img { filter: none; scale: 1.04; }

.view-all-wrap { text-align: center; margin-top: 2.2rem; }

.view-all {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  margin-top: 1.4rem;
  text-decoration: none;
  color: var(--ink);
  font-size: 1rem;
  font-weight: 500;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  padding: 0.4rem 0.2rem;
  will-change: transform;
}

.view-all i { transition: transform 0.3s var(--ease-out); color: var(--accent); }
.view-all:hover i { transform: translateX(5px); }

/* ── Projects page ─────────────────────────────────────────────────────── */

/* projects-page title: staggered entrance on load (not scroll-linked —
   it sits at the top of the viewport where a view-timeline would dim it) */
html.js .page-head > * { animation: riseIn 0.9s var(--ease-out) both; }
html.js .page-head > :nth-child(2) { animation-delay: 0.12s; }

/* extra scroll room below the list so the last card can fully resolve
   (scroll-linked entry needs it to reach the middle of the viewport) */
main.section { padding-bottom: 32vh; }

.back-link {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  text-decoration: none;
  color: var(--muted);
  font-size: 0.92rem;
  font-weight: 500;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  margin-bottom: 2.2rem;
  padding: 0.3rem 0.1rem;
  will-change: transform;
  transition: color 0.3s var(--ease-out);
}

.back-link:hover { color: var(--ink); }

.project-list { display: flex; flex-direction: column; gap: 1.2rem; margin-top: 1rem; }

.project-card {
  display: block;
  padding: 1.5rem;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 22px;
  text-decoration: none;
  color: var(--ink);
  scroll-margin-top: 100px;
  will-change: transform;
  transition: box-shadow 0.35s var(--ease-out), border-color 0.35s var(--ease-out);
}

.project-card:hover {
  box-shadow: 0 22px 54px rgba(0, 0, 0, 0.1);
  border-color: transparent;
}

.project-card .card-inner {
  display: flex;
  align-items: center;
  gap: 1.8rem;
}

.project-image-wrap {
  width: 190px;
  height: 130px;
  border-radius: 15px;
  overflow: hidden;
  flex-shrink: 0;
  background: #f0f0ee;
}

.project-image {
  width: 100%;
  height: 100%;
  object-fit: cover;
  filter: grayscale(12%);
  /* `scale` rather than `transform: scale()` — see .feature-img img above */
  transition: filter 0.4s var(--ease-out), scale 0.5s var(--ease-out);
}

.project-image-wrap.contain .project-image { object-fit: contain; padding: 12px; background: #fff; }

/* ── optical sizing for .contain logos ────────────────────────────────────
   object-fit: contain sizes the CANVAS, not the drawing inside it, so two
   logos that fill their canvases to different degrees render at visibly
   different sizes in an identical frame.

   feynman_icon.png (560×400) is cropped tight — its drawing covers ~89% of
   the canvas width and ~47% of the height. clean_visadino.png (2048×2048) is
   loose: ~66% × ~48% of a *square* canvas. Fitted to the same box height that
   works out to roughly 336×128 vs 178×131 — the same height, but barely half
   the width, which is what read as "the dino is smaller".

   1.35 = sqrt of the ratio between the two bounding-box areas, i.e. the linear
   scale that matches them by area. Matching by height instead would mean no
   change at all (they already agree); by width it would mean ~1.9, which
   overshoots badly. Area is the compromise the eye actually reads.

   Only empty canvas overflows the frame at this scale — the drawing itself
   still clears every edge with room to spare, at both the feature size and
   the smaller project-card one.

   `transform`, not `scale` — the hover lift on these images owns the `scale`
   property. The two are independent, so they multiply (1.35 × 1.04 on hover)
   instead of the hover clobbering the base size. */
.contain.art-small img { transform: scale(1.35); }

.project-card:hover .project-image { filter: none; scale: 1.06; }

/* ── ScrollSmoother parallax ──────────────────────────────────────────────
   data-speed="auto" derives its travel from the gap between the image and
   its clipping frame — an image that exactly fills its box computes to zero
   movement, and any fixed speed would expose a bare edge. So parallaxed
   images overfill by 28%, centred, giving "auto" room to work and
   guaranteeing the frame stays covered at both ends of the scroll.
   Only cover-fit photos are targeted; .contain logos sit on white padding
   and would look wrong drifting. */
.feature-img img[data-speed],
.project-image-wrap img[data-speed] {
  position: relative;
  height: 128%;
  top: -14%;
}

.project-card-body { flex: 1; min-width: 0; }

.project-card h3 {
  font-size: 1.36rem;
  font-weight: 500; /* Satoshi Medium */
  letter-spacing: -0.015em;
  margin-bottom: 0.4rem;
}

.project-card h3 i {
  font-size: 0.83rem;
  color: var(--muted);
  margin-left: 0.4rem;
  vertical-align: 2px;
}

.project-card p {
  font-size: 1.03rem;
  line-height: 1.65;
  color: var(--muted);
  font-weight: 300;
}

.project-skills {
  margin-top: 0.9rem;
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem;
}

.project-skills span {
  background: var(--bg);
  border: 1px solid var(--line);
  color: var(--muted);
  padding: 0.22rem 0.68rem;
  border-radius: 100vmax;
  font-size: 0.8rem;
  letter-spacing: 0.04em;
}

.project-skills .hackathon-tag {
  background: var(--accent-soft);
  border-color: transparent;
  color: var(--accent);
  font-weight: 500;
}

/* ── Experience timeline ───────────────────────────────────────────────── */

.timeline { position: relative; padding: 0.5rem 0; }

.timeline::before {
  content: '';
  position: absolute;
  left: 50%;
  top: 0;
  bottom: 0;
  width: 1.5px;
  background: var(--line);
  transform: translateX(-50%);
}

/* top-aligned (not center) so an opening card grows downward without
   re-centering the row — the logo and timeline dot stay put. Logos are the
   tallest cell (190px), so they already sat at the row top when the row was
   centered; the card and dot get fixed offsets below to recreate the
   centered rest look. */
.tl-entry {
  display: grid;
  grid-template-columns: 1fr 40px 1fr;
  align-items: start;
  margin-bottom: 2.6rem;
}

.tl-entry:last-child { margin-bottom: 0; }

.tl-mid { display: flex; justify-content: center; align-items: center; position: relative; z-index: 1; }

/* rest-state centering against the 190px logo column: the closed card is
   ~78px tall, so (190 − 78) / 2 ≈ 56px — tune the offset if card padding or
   type sizes change. The dot offset is exact: (190 − 12px dot) / 2. */
.tl-l:has(.tl-card),
.tl-r:has(.tl-card) { margin-top: 56px; }
.tl-mid { margin-top: calc((190px - 12px) / 2); }

.tl-dot {
  width: 12px;
  height: 12px;
  border-radius: 50%;
  background: var(--bg);
  border: 2px solid var(--ink);
  flex-shrink: 0;
}

.tl-l { display: flex; justify-content: flex-end; align-items: center; padding-right: 1.75rem; }
.tl-r { display: flex; justify-content: flex-start; align-items: center; padding-left: 1.75rem; }

.tl-logo {
  width: 190px;
  height: 190px;
  display: flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
}

/* auto width/height (not 100%) so the element hugs the drawn logo —
   otherwise border-radius would clip invisible letterbox corners */
.tl-logo img { max-width: 100%; max-height: 100%; width: auto; height: auto; object-fit: contain; }

.tl-logo img.rounded { border-radius: 12px; }

/* wide wordmark logos (Wind River, Exceptional STEAM) get a broader box so
   they render at a similar visual weight to the square logos */
.tl-logo.wide { width: 270px; }

.tl-card {
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 16px;
  padding: 1.15rem 1.4rem;
  width: 100%;
  outline-offset: 3px;
  user-select: none;
  will-change: transform;
  transition: box-shadow 0.3s var(--ease-out), border-color 0.3s var(--ease-out);
}

.tl-card:hover { box-shadow: 0 14px 36px rgba(0, 0, 0, 0.08); }

.tl-card-head {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  gap: 1rem;
}

.tl-info { flex: 1; min-width: 0; }
.tl-title { font-weight: 500; font-size: 1.14rem; letter-spacing: -0.01em; }
.tl-company { color: var(--muted); font-size: 1.01rem; margin-top: 2px; font-weight: 300; }

.tl-meta { display: flex; align-items: center; gap: 0.6rem; flex-shrink: 0; }
.tl-date { color: var(--muted); font-size: 0.98rem; white-space: nowrap; font-variant-numeric: tabular-nums; }

.tl-chevron {
  color: var(--muted);
  font-size: 0.89rem;
  transition: transform 0.25s var(--ease-out), color 0.25s var(--ease-out);
  display: inline-block;
}

.tl-details {
  max-height: 0;
  overflow: hidden;
  transition: max-height 0.4s var(--ease-out), padding-top 0.2s ease;
  padding-top: 0;
}

.tl-details ul {
  padding-left: 1.1rem;
  margin: 0;
  font-size: 1.01rem;
  line-height: 1.8;
  color: var(--muted);
  font-weight: 300;
}

.tl-card.open { border-color: #c9c9c2; }
.tl-card.open .tl-details { max-height: 420px; padding-top: 0.8rem; }
.tl-card.open .tl-chevron { transform: rotate(180deg); color: var(--accent); }

/* bullets slide + fade in from the right, one after another, when a card
   opens; closing has no delay so they clear out immediately */
.tl-details li,
.tl-details .coursework-column p {
  opacity: 0;
  transform: translateX(28px);
  transition: opacity 0.5s ease, transform 0.5s var(--ease-out);
}
.tl-card.open .tl-details li,
.tl-card.open .tl-details .coursework-column p {
  opacity: 1;
  transform: none;
}
.tl-card.open .tl-details .coursework-column p { transition-delay: 0.1s; }
.tl-card.open .tl-details li:nth-child(1) { transition-delay: 0.15s; }
.tl-card.open .tl-details li:nth-child(2) { transition-delay: 0.3s; }
.tl-card.open .tl-details li:nth-child(3) { transition-delay: 0.45s; }
.tl-card.open .tl-details li:nth-child(4) { transition-delay: 0.6s; }
.tl-card.open .tl-details li:nth-child(5) { transition-delay: 0.75s; }
.tl-card.open .tl-details li:nth-child(6) { transition-delay: 0.9s; }
.tl-card.open .tl-details li:nth-child(7) { transition-delay: 1.05s; }
.tl-card.open .tl-details li:nth-child(8) { transition-delay: 1.2s; }

/* no-JS: expander can't open, so show details statically */
html.no-js .tl-details { max-height: none; padding-top: 0.8rem; }
html.no-js .tl-details li,
html.no-js .tl-details .coursework-column p { opacity: 1; transform: none; }
html.no-js .tl-chevron { display: none; }

@media (max-width: 740px) {
  .timeline::before { display: none; }
  /* logo column must fit the widest logo (the wide wordmarks) so it never
     overflows onto the card — see .tl-logo.wide below */
  .tl-entry { grid-template-columns: 104px 1fr; gap: 1rem; margin-bottom: 1.5rem; align-items: start; }
  .tl-mid { display: none; }
  /* desktop rest-state centering offsets don't apply to the stacked layout */
  .tl-l:has(.tl-card), .tl-r:has(.tl-card) { margin-top: 0; }
  .tl-l { padding: 0; justify-content: flex-start; grid-row: 1; }
  .tl-r { padding: 0; grid-row: 1; grid-column: 2; }
  .tl-l:has(.tl-card) { grid-column: 2; }
  .tl-r:has(.tl-logo) { grid-column: 1; }
  /* cap both dimensions to the column so logos stay contained */
  .tl-logo { width: 80px !important; height: 80px !important; }
  .tl-logo.wide { width: 104px !important; }
}

/* ── Education ─────────────────────────────────────────────────────────── */

.school-icon { width: 44px; height: auto; border-radius: 4px; }

.coursework-column { flex: 1; min-width: 180px; }
.coursework-column p { font-size: 1.01rem; font-weight: 500; color: var(--muted); margin-bottom: 0.3rem; }
.coursework-column ul {
  padding-left: 1.1rem;
  margin: 0;
  font-size: 1.01rem;
  line-height: 1.8;
  color: var(--muted);
  font-weight: 300;
}

/* education card reuses .tl-card; give the taller coursework room */
#education .tl-card.open .tl-details { max-height: 900px; }

/* ── Resume ────────────────────────────────────────────────────────────── */

/* ── Closing card ──────────────────────────────────────────────────────────
   The page's final block: a near-black card with rounded top corners holding
   the contact heading, four pills (incl. the resume link) and the credit.
   It replaced the separate #resume section, #contact section and <footer>.

   Full-bleed on purpose — it does NOT use .section (which caps width at
   1160px); .closing-inner constrains the contents instead. */

.closing {
  background: var(--ink-deep);
  color: var(--paper);
  border-radius: 40px 40px 0 0;
  margin-top: clamp(3rem, 10vh, 6rem);
  padding: clamp(4rem, 12vh, 7rem) clamp(1.4rem, 6vw, 4rem) 2.2rem;
  text-align: center;
}

.closing-inner { max-width: 1160px; margin: 0 auto; }

/* .display hard-codes color: var(--ink) — inherit the card's light text */
.closing-title { color: inherit; }

.closing-credit {
  margin-top: clamp(3rem, 8vh, 5rem);
  font-size: 0.89rem;
  font-weight: 300;
  letter-spacing: 0.08em;
  color: rgba(244, 244, 242, 0.55);
}

.contact-links {
  display: flex;
  justify-content: center;
  gap: 0.9rem;
  margin-top: 2.4rem;
  flex-wrap: wrap;
}

/* light-on-dark: these only ever appear inside .closing */
.contact-pill {
  display: inline-flex;
  align-items: center;
  gap: 0.6rem;
  padding: 0.85rem 1.7rem;
  border: 1.5px solid rgba(244, 244, 242, 0.45);
  border-radius: 100vmax;
  text-decoration: none;
  color: var(--paper);
  font-size: 0.94rem;
  font-weight: 500;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  will-change: transform;
  transition: background 0.3s ease, color 0.3s ease,
              border-color 0.3s ease, box-shadow 0.3s var(--ease-out);
}

.contact-pill i { font-size: 1.18rem; }

.contact-pill:hover {
  background: var(--paper);
  border-color: var(--paper);
  color: var(--ink-deep);
  box-shadow: 0 12px 30px rgba(0, 0, 0, 0.35);
}

/* ── Plain footer ──────────────────────────────────────────────────────────
   projects.html still ends with a simple <footer>. :not(.closing) keeps this
   off the closing card, which is itself a <footer>. */

footer:not(.closing) {
  text-align: center;
  padding: 2.6rem 1rem;
  border-top: 1px solid var(--line);
  font-size: 0.89rem;
  font-weight: 300;
  letter-spacing: 0.08em;
  color: var(--muted);
}

/* ══ OVERLAY LAYERS ═════════════════════════════════════════════════════
   The cursor-following dot-grid spotlight that used to live here was removed;
   the page background is now plain --bg. Only the film grain remains.      */

/* film grain — animated SVG turbulence, oversized layer jittered by steps() */
#grain {
  position: fixed;
  inset: -100%;
  z-index: 9997;
  pointer-events: none;
  opacity: var(--grain-opacity);
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='240' height='240'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.72' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
  animation: grainShift 0.8s steps(9) infinite;
}

@keyframes grainShift {
  0%   { transform: translate(0, 0); }
  20%  { transform: translate(-3%, 2%); }
  40%  { transform: translate(2%, -3%); }
  60%  { transform: translate(-2%, -2%); }
  80%  { transform: translate(3%, 3%); }
  100% { transform: translate(0, 0); }
}

/* page-transition curtain */
/* The curtain carries a rounded LEADING edge as it travels, easing to square
   as it lands — so it reads as a panel sliding over the page rather than a
   plain wipe, matching .light-panel and .closing. The radius is animated
   alongside the transform, which is why it's square at full cover (no slivers
   of the old page showing through the corners). */
#curtain {
  position: fixed;
  inset: 0;
  z-index: 9998;
  background: var(--ink);
  transform: translateY(101%);
  border-radius: 40px 40px 0 0; /* leading top edge, rising from below */
  pointer-events: none;
}

#curtain.curtain-cover {
  transform: translateY(0);
  border-radius: 0;             /* square once it fully covers */
  transition: transform var(--curtain-ms) var(--ease-io),
              border-radius var(--curtain-ms) var(--ease-io);
}

/* arriving from another page: start covered (set in <head> to avoid flash),
   then main.js plays the lift. Here the BOTTOM edge is the trailing one, so
   the radius grows on the way out — the mirror of the cover. */
html.page-enter #curtain {
  transform: translateY(0);
  border-radius: 0;
}

html.page-enter #curtain.curtain-lift {
  transform: translateY(-101%);
  border-radius: 0 0 40px 40px;
  transition: transform calc(var(--curtain-ms) + 120ms) var(--ease-io),
              border-radius calc(var(--curtain-ms) + 120ms) var(--ease-io);
}

/* ══ LINE-SPLIT HEADINGS ════════════════════════════════════════════════
   A [data-split] heading is broken into per-line masks by js/split-lines.js
   (GSAP SplitText). SplitText builds each mask as a clone of the line and
   names it after the line's class + "-mask", so .sline → .sline-mask.

   Only "Let's Connect!" carries the attribute now, but this stays written for
   any heading: .display sets line-height: 1.02 while Satoshi's em box is
   1.25em tall (ascent 1.010 + descent 0.240), so glyphs overflow the line box
   by 0.115em top and bottom, and the mask's overflow:clip cuts descenders off
   — it took the tail off the "j" in Projects and the "p" in Experience back
   when those were split too. 0.14em of padding clears that 0.115em; the
   matching negative margin pulls it back out so line spacing and the heading's
   layout height are exactly what they were before the split. The current text
   happens to have no descenders, so this is doing nothing visible today —
   leave it in, or the first heading that grows one silently gets clipped.
   (js/split-lines.js starts lines at yPercent 120, not 100, so nothing peeks
   into this padding at the start of the reveal.)                          */

.sline-mask {
  padding-bottom: 0.14em;
  margin-bottom: -0.14em;
}

/* ══ SCROLL ENTRANCES — none ════════════════════════════════════════════
   Page content has NO scroll-driven entrance. Section headings, project rows,
   timeline entries and cards simply scroll into view with the page, at full
   opacity and their natural position — nothing rises, fades or scales.

   The former js/scroll.js engine (a data-scroll="card" rise+fade, with both a
   native animation-timeline path and a JS/IntersectionObserver fallback) was
   removed entirely for this reason.

   The two deliberate motion pieces that remain are separate systems:
     • the closing [data-split] heading — masked per-line reveal, one heading
       only, js/split-lines.js
     • the pinned .now-pin timeline — the greeting and the .now-l statement
       lines, js/now-line.js                                                */

/* ══ REDUCED MOTION ═════════════════════════════════════════════════════
   JS engines all bail out early; this kills the remaining CSS motion and
   keeps everything static and readable. */
@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }

  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    transition-delay: 0s !important; /* no staggered waits (e.g. card bullets) */
  }

  .hero-pin { height: auto; }
  .hero-sticky { position: static; height: auto; min-height: 100vh; }
  /* no pin here (now-line.js bails), so the base rule's -45vh pull-up would
     just collide the statement into the hero — neutralise it */
  .now-pin { height: auto; margin-top: 0; pointer-events: auto; }
  .now-sticky { height: auto; min-height: 40vh; }
  /* now-line.js bails before animating, so this is belt-and-braces: keep the
     statement wrapped and at rest even if it ever survives a pref change */
  .now-line { white-space: normal !important; }
  .now-l { transform: none !important; opacity: 1 !important; }
  /* the greeting is pure motion — there's nothing to show when it can't play */
  .now-welcome { display: none !important; }
  #grain { display: none !important; }
  /* split-lines.js bails before splitting, so the heading is still plain text;
     this is belt-and-braces in case a split ever survives a preference change */
  [data-split], .sline, .sline-mask { opacity: 1 !important; transform: none !important; }
  .sline-mask { overflow: visible !important; padding-bottom: 0; margin-bottom: 0; }
}

/* ══ RESPONSIVE ═════════════════════════════════════════════════════════ */

@media (max-width: 768px) {
  .hero-pin { height: 100vh; }

  .project-card .card-inner { flex-direction: column; align-items: stretch; gap: 1rem; }
  .project-image-wrap { width: 100%; height: 170px; }

  .tl-card-head { flex-wrap: wrap; gap: 0.5rem; }

  /* long bullets wrap a lot on narrow screens — give the expander room */
  .tl-card.open .tl-details { max-height: 900px; }
}

@media (max-width: 768px) {
  /* featured rows stack under ~tablet width (text above image) */
  .feature { grid-template-columns: 1fr; gap: 1.4rem; }
}

@media (max-width: 480px) {
  .contact-links { flex-direction: column; align-items: flex-start; }
}
