:root {
  --bg: #f8f8f8;
  --ink: #000000;
  --muted: #636363;
  --danger: #c0392b;
  --card-frame: #e2e2e2;
  --card-w: 122.583px;
  --card-h: 303.711px;
  --inner-w: 140.87px;
  --inner-h: 234.3px;
  --gap: 77px;
  --card-tilt-rest: perspective(1143px) rotateY(-50deg) skewY(20deg);
  --flip-ease: cubic-bezier(0.22, 1, 0.36, 1);
  --header-h: 78px;
  --header-pad-x: 30px;
}

/* Dark theme — same tokens, dark values. Toggled via the button next
   to the logo (see .theme-toggle / setupThemeToggle in script.js),
   which sets this attribute on <html>; an inline script in <head>
   applies the stored/system preference before first paint so there's
   no flash of the wrong theme. Photography (hero/work images) is left
   alone rather than filtered/inverted — only UI chrome repaints. */
html[data-theme="dark"] {
  --bg: #111113;
  --ink: #f2f2f2;
  --muted: #9a9a9a;
  --danger: #ff6b6b;
  --card-frame: #232326;
}

@keyframes fade-up {
  from {
    opacity: 0;
    transform: translateY(10px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

@keyframes fade-in {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }

  html, body {
    scroll-behavior: auto;
  }
}

/* Cross-document view transitions: a smooth crossfade between pages
   (e.g. index.html <-> about.html) instead of the browser's hard cut.
   Browsers without support (anything but Chromium/Safari 18+) just
   navigate normally — this is pure progressive enhancement. */
@view-transition {
  navigation: auto;
}

@media not (prefers-reduced-motion: reduce) {
  ::view-transition-old(root),
  ::view-transition-new(root) {
    animation-duration: 0.45s;
    animation-timing-function: var(--flip-ease);
  }
}

/* Font-cycling intro overlay (see setupIntro in script.js) — a
   one-time, per-tab-session animation that flips "Jibin Balakrishnan"
   through several system fonts before settling on the site's own
   Neue Montreal, covering the page on first load. html[data-intro="seen"]
   is set by the inline script in <head>, before first paint, whenever
   sessionStorage already has it or prefers-reduced-motion is set — so
   repeat page loads and reduced-motion visitors never see so much as
   a flash of it. */
.intro-overlay {
  position: fixed;
  inset: 0;
  z-index: 100;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--bg);
  transition: opacity 0.5s var(--flip-ease);
}

html[data-intro="seen"] .intro-overlay {
  display: none;
}

.intro-overlay.is-hiding {
  opacity: 0;
  pointer-events: none;
}

/* font-family is written per tick from JS — no font-family set here,
   so the no-JS/pre-JS fallback just inherits the page's own Neue
   Montreal instead of needing its own declared value. */
.intro-name {
  margin: 0;
  padding: 0 24px;
  font-size: clamp(20px, 4vw, 32px);
  font-weight: 500;
  color: var(--ink);
  text-align: center;
}

/* NOTE: these files are demo/trial-licensed (from befonts.com), not a
   purchased license from Pangram Pangram — swap for the real files from
   pangrampangram.com before this site is treated as production-ready. */
@font-face {
  font-family: "Neue Montreal";
  src: url("assets/fonts/neuemontreal-regular.otf") format("opentype");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: "Neue Montreal";
  src: url("assets/fonts/neuemontreal-medium.otf") format("opentype");
  font-weight: 500;
  font-style: normal;
  font-display: swap;
}

* {
  box-sizing: border-box;
}

/* overflow lives on body only, not html — html keeps its initial
   "visible" overflow so the browser propagates body's overflow to the
   actual viewport scrolling behavior (standard CSS propagation from
   the root's child; see the spec's "Overflow Propagation" section).
   Real user input (wheel/touch) respects that propagated value; it
   does NOT respect body's own overflow if html independently also
   says hidden, even though a *programmatic* el.scrollTop assignment
   would still silently succeed either way — that gap is exactly what
   let a real "can't scroll on mobile" bug pass earlier testing here. */
html, body {
  height: 100%;
  /* svh, not dvh: dvh tracks the browser chrome live, so .hero
     (height:100% of this) would resize as the address bar
     collapses/expands mid-scroll on mobile, shoving .works up/down by
     the toolbar's height right as momentum scroll settles — svh stays
     pinned to the toolbar-visible size and never moves. */
  height: 100svh;
  overscroll-behavior: none;
  /* No scroll-behavior:smooth here — Lenis (see setupSmoothScroll in
     script.js) owns smooth scrolling site-wide, including in-page
     anchor jumps like the "Works" nav link, via its own scrollTo. The
     two fighting over the same frames causes visible jitter. */
}

body {
  overflow: hidden;
  margin: 0;
  background: var(--bg);
  color: var(--ink);
  font-family: "Neue Montreal", -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
  transition: background-color 0.3s ease, color 0.3s ease;
}

a {
  color: inherit;
  text-decoration: none;
}

/* Header */

.site-header {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 10;
  height: var(--header-h);
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 0 var(--header-pad-x);
  background: var(--bg);
}

.site-brand {
  /* stays above the mobile full-screen nav overlay (see .nav-items),
     which is a descendant with its own explicit z-index — an
     unpositioned sibling paints below any explicitly z-indexed
     descendant within the header's stacking context regardless of the
     numeric value, so this needs its own explicit z-index to win */
  z-index: 2;
  display: flex;
  align-items: center;
  gap: 14px;
}

.site-name {
  font-weight: 500;
  font-size: 16px;
  line-height: normal;
  white-space: nowrap;
}

/* A literal day/night switch — track + sliding knob, not an icon
   button — reusing the site's own --bg/--ink tokens rather than
   hardcoded colors: --bg (near-white in light, near-black in dark) is
   already exactly the two track colors a switch like this wants, and
   --ink (black in light, near-white in dark) already gives the right
   knob/icon tone to contrast against it, both for free. */
.theme-toggle {
  position: relative;
  display: block;
  width: 44px;
  height: 24px;
  padding: 0;
  border: 1px solid var(--card-frame);
  border-radius: 999px;
  background: var(--bg);
  cursor: pointer;
  transition: background-color 0.3s ease, border-color 0.3s ease, transform 0.15s var(--flip-ease);
  animation: fade-up 0.6s var(--flip-ease) both;
  animation-delay: 0.05s;
}

.theme-toggle:hover {
  transform: scale(1.05);
}

.theme-toggle:active {
  transform: scale(0.94);
}

/* Sun sits in the left lane, moon in the right — each only actually
   visible on the side the knob has vacated (see .theme-toggle-knob
   below), so between the two of them exactly one shows at a time,
   cross-fading + popping in rather than hard-cutting via display. */
.theme-toggle-icon {
  position: absolute;
  top: 50%;
  width: 12px;
  height: 12px;
  color: var(--ink);
  opacity: 0;
  transform: translateY(-50%) scale(0.4) rotate(-35deg);
  transition: opacity 0.25s ease, transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1), color 0.3s ease;
  pointer-events: none;
}

.theme-toggle-icon svg {
  display: block;
  width: 100%;
  height: 100%;
}

.theme-toggle-icon--sun {
  left: 5px;
  opacity: 1;
  transform: translateY(-50%) scale(1) rotate(0deg);
}

.theme-toggle-icon--moon {
  right: 5px;
}

html[data-theme="dark"] .theme-toggle-icon--sun {
  opacity: 0;
  transform: translateY(-50%) scale(0.4) rotate(35deg);
}

html[data-theme="dark"] .theme-toggle-icon--moon {
  opacity: 1;
  transform: translateY(-50%) scale(1) rotate(0deg);
}

/* Rests on the right (light mode) or left (dark mode) — see
   html[data-theme="dark"] below. Slides via `left`, not `transform`,
   specifically so `transform` stays free for the click-triggered
   squish (.is-toggling, in the keyframes below) without the two
   competing over the same property. */
.theme-toggle-knob {
  position: absolute;
  top: 2px;
  left: 22px;
  width: 18px;
  height: 18px;
  border-radius: 50%;
  background: var(--ink);
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
  transition: left 0.4s cubic-bezier(0.34, 1.56, 0.64, 1), background-color 0.3s ease;
}

html[data-theme="dark"] .theme-toggle-knob {
  left: 2px;
}

/* A small jelly squish on click, on top of the knob's own slide —
   toggled briefly by setupThemeToggle in script.js. Squashed
   horizontally then rebounded rather than a plain scale pulse, so it
   reads as a soft, "cute" bounce instead of a mechanical blink. */
@keyframes theme-toggle-squish {
  0% {
    transform: scale(1, 1);
  }
  35% {
    transform: scale(0.72, 1.18);
  }
  65% {
    transform: scale(1.1, 0.88);
  }
  100% {
    transform: scale(1, 1);
  }
}

.theme-toggle-knob.is-toggling {
  animation: theme-toggle-squish 0.42s var(--flip-ease);
}

.site-nav {
  flex: 1;
  display: flex;
  align-items: center;
  justify-content: flex-end;
}

.nav-items {
  display: flex;
  align-items: center;
  gap: 48px;
}

/* Pairs the theme toggle with a .nav-time — the toggle always sits to
   the left of the time it's grouped with. Two such pairs exist for
   the same reason the clock itself already came in two copies (see
   setupIndianTime): --drawer lives inside .nav-items for the
   mobile/tablet inline row and the full-screen drawer; --fixed is a
   separate element in .site-header, shown only at the desktop
   breakpoint below, flush at the header's right edge regardless of
   where .nav-items positions itself. --fixed has to be a sibling
   rather than living inside .nav-items: .nav-items is itself
   position:absolute at that breakpoint, so an absolutely-positioned
   child of it would anchor against its own (centered, shrink-to-fit)
   box instead of the full header width — this rule has to come before
   the media query below that turns it back on there, or a
   same-specificity rule later in the file would just win regardless
   of which one the media query matches. */
.nav-time-group {
  display: flex;
  align-items: center;
  gap: 10px;
}

.nav-time-group--fixed {
  display: none;
}

/* .site-header is position:fixed, so it's the containing block (full
   viewport width) for .nav-time-group--fixed above, not .site-nav
   (which only spans the space after the logo). */
@media (min-width: 1025px) {
  /* Works/About Me/Playground/Contact sit as one tight, evenly-gapped
     cluster centered on the page's horizontal middle; the toggle +
     Indian-time clock moved out to the far right edge, flush with the
     same inset as the logo (see .nav-time-group--fixed) — the reverse
     of an earlier iteration where the clock sat at center and the
     links stretched from there to the right edge.
     .nav-time-group--drawer (still first in nav-items' DOM order) is
     hidden here so it doesn't render inline as an extra item in the
     centered cluster. */
  .nav-items {
    position: absolute;
    left: 50%;
    top: 50%;
    transform: translate(-50%, -50%);
    justify-content: center;
    gap: 80px;
  }

  .nav-time-group--drawer {
    display: none;
  }

  /* fade-in, not fade-up — fade-up's keyframes also animate transform,
     and animation-fill-mode:both holds that keyframe's transform value
     permanently once the entrance animation ends, silently overwriting
     this rule's own translateY(-50%) (the thing centering the group
     vertically) with the keyframe's translateY(0) instead. fade-in
     only ever touches opacity, so the static transform here survives. */
  .nav-time-group--fixed {
    display: flex;
    position: absolute;
    right: var(--header-pad-x);
    top: 50%;
    transform: translateY(-50%);
    animation: fade-in 0.6s var(--flip-ease) both;
    animation-delay: 0.08s;
  }
}

.nav-link {
  position: relative;
  font-weight: 400;
  font-size: 16px;
  white-space: nowrap;
  transition: opacity 0.2s;
}

.nav-link::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: -3px;
  height: 1px;
  background: currentColor;
  transform: scaleX(0);
  transform-origin: right;
  transition: transform 0.3s var(--flip-ease);
}

.nav-link:hover {
  opacity: 0.6;
}

.nav-link:hover::after {
  transform: scaleX(1);
  transform-origin: left;
}

.nav-link.is-active::after {
  transform: scaleX(1);
  transform-origin: left;
}

.nav-time {
  font-weight: 400;
  font-size: 16px;
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
}

/* Only rendered inside the mobile nav drawer (node 11:151) — the
   desktop nav row has its own separate .site-footer at the page
   bottom, so this stays hidden until the mobile block below shows it. */
.nav-footer {
  display: none;
}

.nav-icon {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 24px;
  height: 24px;
  background: none;
  border: none;
  padding: 0;
  cursor: pointer;
  transition: opacity 0.2s, transform 0.15s var(--flip-ease);
}

.nav-icon:hover {
  opacity: 0.6;
  transform: scale(1.08);
}

.nav-icon:active {
  transform: scale(0.9);
}

.nav-icon img {
  width: 100%;
  height: 100%;
  display: block;
}

/* "What I'm Listening to" — Spotify now-playing dropdown (Figma node
   13:219+). Trigger wraps the play icon; the dropdown reveals on hover,
   keyboard focus, or tap (see script.js for the click toggle, needed
   since touch devices have no hover). */
.now-playing-trigger {
  position: relative;
}

.now-playing-dropdown {
  position: absolute;
  top: calc(100% + 43px);
  right: 0;
  width: 173px;
  opacity: 0;
  visibility: hidden;
  transform: translateY(-8px) scale(0.96);
  transform-origin: top right;
  pointer-events: none;
  transition: opacity 0.25s var(--flip-ease), transform 0.25s var(--flip-ease), visibility 0.25s;
}

.now-playing-trigger:hover .now-playing-dropdown,
.now-playing-trigger:focus-within .now-playing-dropdown,
.now-playing-trigger.now-playing-open .now-playing-dropdown {
  opacity: 1;
  visibility: visible;
  transform: translateY(0) scale(1);
  pointer-events: auto;
}

.now-playing-label {
  margin: 0 0 8px;
  font-size: 14px;
  font-weight: 400;
  text-align: right;
  white-space: nowrap;
}

.now-playing-card {
  width: 173px;
  height: 173px;
  padding: 7px;
  border-radius: 25px;
  background: var(--card-frame);
  box-sizing: border-box;
}

.now-playing-art-wrap {
  position: relative;
  width: 100%;
  height: 100%;
  border-radius: 20px;
  overflow: hidden;
  background: #111;
}

.now-playing-art {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  transition: opacity 0.3s;
}

.now-playing-scrim {
  position: absolute;
  inset: 0;
  background: linear-gradient(to top, rgba(0, 0, 0, 0.7) 0%, rgba(0, 0, 0, 0.15) 50%, rgba(0, 0, 0, 0) 68%);
}

.now-playing-info {
  position: absolute;
  left: 9px;
  right: 9px;
  bottom: 9px;
  color: #fff;
}

.now-playing-title {
  margin: 0;
  font-size: 14px;
  line-height: 1.25;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.now-playing-artist {
  margin: 2px 0 6px;
  font-size: 7px;
  opacity: 0.85;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.now-playing-progress {
  position: relative;
  height: 2px;
  margin-bottom: 4px;
  border-radius: 2px;
  background: rgba(255, 255, 255, 0.35);
}

.now-playing-progress-fill {
  position: absolute;
  inset: 0 auto 0 0;
  width: 0%;
  border-radius: 2px;
  background: #fff;
  transition: width 1s linear;
}

.now-playing-times {
  display: flex;
  justify-content: space-between;
  font-size: 7px;
}

/* "last-played" (nothing actively playing) has no meaningful progress —
   hide the bar/timestamps instead of showing an inert 0:00 */
.now-playing-card--static .now-playing-progress,
.now-playing-card--static .now-playing-times {
  display: none;
}

/* Hamburger toggle — mobile only (see @media max-width:900px below) */
.nav-toggle {
  display: none;
  position: relative;
  z-index: 2;
  flex-direction: column;
  justify-content: center;
  gap: 9px;
  width: 46px;
  height: 24px;
  background: none;
  border: none;
  padding: 0;
  cursor: pointer;
  animation: fade-up 0.6s var(--flip-ease) both;
  transition: transform 0.15s var(--flip-ease);
}

.nav-toggle:active {
  transform: scale(0.9);
}

.nav-toggle span {
  display: block;
  width: 100%;
  height: 2px;
  background: var(--ink);
}

/* Hero */

.hero {
  height: 100%;
  display: flex;
  flex-direction: column;
  padding-top: var(--header-h);
  overflow: hidden;
}

.intro {
  flex: none;
  max-width: 560px;
  margin: clamp(40px, 16vh, 200px) 0 0 30px;
  font-weight: 500;
  font-size: 24px;
  line-height: normal;
}

.rows {
  flex: 1;
  min-height: 0;
  width: 100%;
  display: flex;
  align-items: center;
  padding: clamp(16px, 4vh, 48px) 0 clamp(16px, 6vh, 96px);
}

.row {
  width: 100%;
  min-height: 0;
  overflow: hidden;
  /* extra vertical room so the hover lift (translateY on .card-inner)
     never actually overflows the clipped box */
  padding: clamp(24px, 6vh, 90px) 0;
  cursor: grab;
  /* the row drives its own horizontal drag (see script.js), but a
     touch here still needs to scroll the PAGE vertically — pan-y lets
     native vertical scrolling through while leaving horizontal
     gestures for our own pointer handlers. script.js also waits to see
     which direction a touch commits to before claiming it, so a
     vertical swipe that starts on the grid still scrolls the page
     instead of getting swallowed as a (near-zero) horizontal drag. */
  touch-action: pan-y;
  animation: fade-in 0.9s ease-out both;
  animation-delay: 0.25s;
}

.row.dragging {
  cursor: grabbing;
}

.row.dragging,
.row.dragging * {
  user-select: none;
}

.track {
  display: flex;
  align-items: center;
  gap: var(--gap);
  width: max-content;
  will-change: transform;
}

/* Card */

.card {
  position: relative;
  flex: none;
  width: var(--card-w);
  height: var(--card-h);
  display: flex;
  align-items: center;
  justify-content: center;
}

/* --tilt-x/--tilt-y are written per pointermove by the cursor-tracking
   tilt in setupDraggableMarquee (script.js) — they default to 0 here
   so the card sits at its plain resting transform until a pointer
   with real hover support (desktop) moves over it. */
.card-inner {
  position: relative;
  width: var(--inner-w);
  height: var(--inner-h);
  transform: var(--card-tilt-rest) rotateX(var(--tilt-x, 0deg)) rotateY(var(--tilt-y, 0deg));
  transform-style: preserve-3d;
  /* Short and linear-ish so the tilt tracks the cursor directly rather
     than trailing behind it — the old 0.6s var(--flip-ease) was tuned
     for a one-shot rest→hover snap, not a value that updates on every
     pointermove. */
  transition: transform 0.2s ease-out;
}

/* Hover: straightens toward a flatter, more face-on pose (rotateY
   -50deg → -40deg, skewY 20deg → 14deg) instead of just adding the
   cursor tilt on top of the full resting skew — reads as a clean 3D
   tilt instead of a small wobble on an already-extreme angle. No
   translateY here — the card tilts in place, it never shifts position. */
.card-inner.is-tilting {
  transform: perspective(1143px) rotateY(-40deg) skewY(14deg) rotateX(var(--tilt-x, 0deg)) rotateY(var(--tilt-y, 0deg));
}

.card-layer {
  position: absolute;
  inset: 0;
  overflow: hidden;
  border-radius: 0;
}

.card-layer img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

.card-layer--faded {
  opacity: 0.7;
  -webkit-mask-image: linear-gradient(to top, rgba(0, 0, 0, 0.2) 0%, #000 46%, #000 100%);
  mask-image: linear-gradient(to top, rgba(0, 0, 0, 0.2) 0%, #000 46%, #000 100%);
  transition: opacity 0.5s var(--flip-ease);
}

.card-layer--no-mask {
  -webkit-mask-image: none;
  mask-image: none;
}

.card-layer--plain {
  opacity: 0;
  border-radius: 0;
  box-shadow: none;
  transition: opacity 0.5s var(--flip-ease);
}

.card:hover .card-layer--faded {
  opacity: 0;
}

.card:hover .card-layer--plain {
  opacity: 1;
}

/* Recent Works — sits below the fixed-height .hero, so the homepage
   needs to scroll now. .hero itself is untouched: it's still exactly
   one viewport tall (height:100% of a body that's still height:100dvh
   here — only the overflow changes), so this section just extends the
   page below it rather than fighting for space inside it. */
body.page-home {
  overflow-y: auto;
  overflow-x: hidden;
}

.works {
  padding: clamp(48px, 8vh, 96px) 0 clamp(64px, 10vh, 120px);
}

.works-inner {
  max-width: 1450px;
  margin: 0 auto;
  padding: 0 var(--header-pad-x);
  display: flex;
  flex-direction: column;
  gap: clamp(48px, 7vh, 72px);
}

/* Shown in place of the grid when every project is hidden (see the
   CMS's per-project toggle and buildWorks in script.js) — a message
   instead of just an empty section, so it reads as "nothing published
   yet" rather than looking broken. */
.works-empty {
  margin: 0;
  font-size: clamp(16px, 1.5vw, 20px);
  color: var(--muted);
}

/* Generic one-time scroll reveal (see setupStaggeredReveal in
   script.js) — starts hidden and slides up into place the first time
   it scrolls into view, rather than animating on page load, since
   below-the-fold content's load-time animation would already be over
   by the time anyone scrolls down to see it. Applied per element
   (each work image, each About column, ...) rather than to a whole
   section at once, so a group of them scrolling into view together
   fans in staggered instead of snapping in all at once. */
.reveal-up {
  opacity: 0;
  transform: translateY(28px);
  transition: opacity 0.5s var(--flip-ease), transform 0.5s var(--flip-ease);
}

.reveal-up.is-visible {
  opacity: 1;
  transform: translateY(0);
}

/* Line-reveal mask (see splitLines in script.js) — the outer span
   clips a paragraph's line to its own box; the inner .reveal-up span
   is what actually slides up, so the line appears to rise out from
   behind a hard edge rather than just fading in place. */
.reveal-line-mask {
  display: block;
  overflow: hidden;
}

/* Needed for the translateY reveal to visibly apply on what are
   otherwise inline text fragments (see splitLetters/splitLines in
   script.js) — a plain inline element doesn't reliably move under
   transform the way an inline-block one does. */
.reveal-letter {
  display: inline-block;
}

/* Visually hidden but still readable by screen readers — carries the
   real, unsplit sentence for split-text reveals (see splitLetters/
   splitLines in script.js), since the visible split-into-spans version
   is aria-hidden. Standard clip-rect technique, not display:none. */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

/* Only wraps .work-item when the project actually has a case study
   (see buildWorks in script.js) — plain, unlinked projects render with
   no .work-link ancestor at all, so this never has to account for a
   "disabled" state. display:block so the anchor doesn't otherwise
   affect .work-item's own flow/spacing (an <a> is inline by default). */
.work-link {
  display: block;
  color: inherit;
  text-decoration: none;
  transition: opacity 0.2s;
}

.work-link:hover {
  opacity: 0.75;
}

.work-header {
  display: flex;
  align-items: baseline;
  gap: 12px;
  margin: 0 0 clamp(16px, 2.5vh, 24px);
}

.work-title {
  margin: 0;
  font-weight: 500;
  font-size: 16px;
}

.work-category {
  margin: 0;
  font-family: "Ubuntu Mono", "SF Mono", Menlo, monospace;
  font-size: 16px;
  color: var(--muted);
}

/* Every image in the row shares the same column width (equal 1fr
   tracks); each one's height then just follows its own natural aspect
   ratio at that width — no cropping, no fixed height. A future CMS can
   drop in any image of any shape and the row lays out correctly. */
.work-images {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  align-items: start;
  gap: 9px;
}

/* overflow:hidden clips the scaled-up image (see .work-image img
   below) to this box's edges for the scroll parallax's translateY
   headroom. The transition here is for the magnetic tilt
   (setupWorkCardTilt in script.js) — that writes a fresh
   perspective()/rotate()/translate() straight to this element's own
   inline style on every pointermove, on top of (not instead of) the
   parallax below, which stays on the <img> itself; a short transition
   on both the live tracking and the pointerleave reset is what makes
   it read as "magnetic" (a little spring/lag behind the cursor)
   rather than either rigidly glued to it or snapping back abruptly. */
.work-image {
  overflow: hidden;
  transition: transform 0.3s var(--flip-ease);
}

/* scale(1.1) reserves headroom around the frame so the parallax
   translateY (see setupWorksParallax in script.js) has room to drift
   without ever exposing an edge — .work-image's overflow:hidden clips
   the rest. --parallax-y defaults to 0px so images sit centered and
   still until JS is ready (or never runs, e.g. reduced-motion). */
.work-image img {
  width: 100%;
  height: auto;
  display: block;
  transform: scale(1.1) translateY(var(--parallax-y, 0px));
}

.site-footer {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 24px;
  padding: 24px var(--header-pad-x) 32px;
}

.footer-links {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 23px;
}

.footer-link {
  position: relative;
  font-size: 16px;
  transition: opacity 0.2s;
}

a.footer-link::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: -3px;
  height: 1px;
  background: currentColor;
  transform: scaleX(0);
  transform-origin: right;
  transition: transform 0.3s var(--flip-ease);
}

a.footer-link:hover {
  opacity: 0.6;
}

a.footer-link:hover::after {
  transform: scaleX(1);
  transform-origin: left;
}

.footer-year {
  margin: 0;
  font-size: 16px;
}

/* About page — scrolls normally like Works/Playground, and uses the
   same clamp(48px, 8vh, 96px) gap below the fixed header that those
   two use (see .works-page-inner / .playground-hero), so the space
   between the logo and the intro text reads identically across every
   page instead of About having its own (much tighter) value. */
body.page-about {
  min-height: 100dvh;
  overflow-y: auto;
  overflow-x: hidden;
}

.about {
  padding-top: var(--header-h);
}

.about-inner {
  width: 100%;
  max-width: 1450px;
  margin: 0 auto;
  padding: clamp(48px, 8vh, 96px) var(--header-pad-x) clamp(48px, 8vh, 96px);
  display: flex;
  flex-direction: column;
  gap: clamp(28px, 5vh, 72px);
}

.about-hero {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 48px;
}

.about-hero-text {
  display: flex;
  flex-direction: column;
  gap: 24px;
  max-width: 700px;
}

.about-intro {
  margin: 0;
  font-weight: 500;
  font-size: clamp(17px, 1.5vw, 22px);
  line-height: 1.5;
}

.about-resume-link {
  align-self: start;
  font-size: 16px;
  font-weight: 500;
  text-decoration: underline;
  text-underline-offset: 3px;
  transition: opacity 0.2s;
}

.about-resume-link:hover {
  opacity: 0.6;
}

/* Jigsaw puzzle — a size×size grid of tiles (see setupJigsawPuzzle in
   script.js), each tile a slice of data-puzzle-image positioned via
   background-position and shuffled into a random slot on load.
   .about-puzzle-label calls out that it's interactive (users were
   missing it otherwise, especially on touch where there's no hover
   cue) — same bracketed-label idiom as .about-col-label. */
.about-puzzle-wrap {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 10px;
  flex-shrink: 0;
}

.about-puzzle-label {
  margin: 0;
  font-family: "Ubuntu Mono", "SF Mono", Menlo, monospace;
  font-size: 12px;
  letter-spacing: 0.02em;
  color: var(--muted);
  text-align: center;
}

/* Sized/aspect-ratio'd to match the source photo (3:4) so tiles never
   distort. */
.about-puzzle {
  width: clamp(200px, 16vw + 90px, 280px);
  aspect-ratio: 3 / 4;
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  grid-template-rows: repeat(4, 1fr);
  gap: 3px;
  overflow: hidden;
  border-radius: 12px;
  background: var(--card-frame);
}

/* touch-action is deliberately left at its default (auto), not none —
   see the touch handling in setupJigsawPuzzle (script.js): touch/pen
   never drags at all (tap-to-select-then-tap-to-swap instead), so
   there's nothing here that needs to claim the gesture away from
   native scrolling. A none/pan-y value would still block a scroll
   that happens to start with a touchdown on a tile, even though nothing
   goes on to treat it as a drag. */
.puzzle-tile {
  position: relative;
  background-repeat: no-repeat;
  cursor: grab;
  will-change: transform;
}

.puzzle-tile.is-dragging {
  cursor: grabbing;
  z-index: 2;
  box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35);
}

/* Tap-to-select feedback for touch/pen — the first tap picks a tile
   (highlighted here), the second tap on a different tile swaps them. */
.puzzle-tile.is-selected {
  z-index: 2;
  outline: 2px solid var(--ink);
  outline-offset: -2px;
}

/* Seams disappear once solved — the only "you got it" signal, no
   banner or label. */
.about-puzzle.is-solved .puzzle-tile {
  box-shadow: none;
}

.about-puzzle.is-solved {
  gap: 0;
}

/* Experience / Education / Certifications / Field of Interest / Tools /
   Interests — a 3x2 grid of bracket-labeled lists, mirroring the Figma
   layout exactly rather than the card/tag treatment used before. */
.about-columns {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  column-gap: 40px;
  row-gap: clamp(28px, 5vh, 56px);
}

.about-col-label {
  margin: 0 0 clamp(14px, 2vh, 27px);
  font-family: "Ubuntu Mono", "SF Mono", Menlo, monospace;
  font-size: 14px;
  color: var(--muted);
}

.about-col-list {
  margin: 0;
  padding: 0;
  list-style: none;
  display: flex;
  flex-direction: column;
  gap: 9px;
}

.about-col-list li {
  font-size: 15px;
  font-weight: 500;
  line-height: 1.4;
}

/* Playground (node 75:2) — scrolls normally like the home page, not a
   single fixed viewport like About's desktop treatment, since the
   image grid below can grow indefinitely. */
body.page-playground {
  overflow-y: auto;
  overflow-x: hidden;
}

.playground {
  padding-top: var(--header-h);
}

.playground-inner {
  max-width: 1450px;
  margin: 0 auto;
  padding: 0 var(--header-pad-x);
}

.playground-hero {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 48px;
  padding: clamp(48px, 8vh, 96px) 0;
}

.playground-intro {
  margin: 0;
  max-width: 600px;
  font-weight: 500;
  font-size: clamp(17px, 1.5vw, 22px);
  line-height: 1.5;
}

/* The iPod photo and the "screen" overlay are locked together as one
   unit: .ipod-screen is positioned by PERCENTAGE against .ipod-mockup
   (not fixed px), so whatever width the mockup renders at — desktop,
   tablet, a folded phone — the screen tracks the device's actual
   screen exactly. They can never drift apart from each other.
   --ipod-w is a single continuous formula (no breakpoint, so no jump
   at any width) that .ipod-screen's font-size below derives from via
   calc() — one source of truth, instead of two separately-hardcoded
   clamp()s that have to be kept in sync by hand (an earlier version of
   this did exactly that, and only one of the two got updated for
   mobile, which is why the widget's contents were wrong-sized there). */
.ipod-mockup {
  --ipod-w: clamp(150px, 12vw + 100px, 216px);
  position: relative;
  width: var(--ipod-w);
  flex-shrink: 0;
  aspect-ratio: 216 / 384;
  /* fade-in, not fade-up — fade-up's keyframes also animate transform
     (translateY), and with fill-mode:both that held end-state
     permanently overrides any transform set via JS afterward (CSS
     animations take precedence over inline styles for the properties
     they control) — which silently broke the drag entirely, since
     setupDraggableIpod also positions this element via transform.
     fade-in only touches opacity, leaving transform free for the drag. */
  animation: fade-in 0.7s var(--flip-ease) both;
  animation-delay: 0.1s;
}

/* Desktop/tablet only (see setupDraggableIpod in script.js, gated the
   same way) — a single-finger drag on touch is also how the page
   itself scrolls, so dragging the iPod there would fight page scroll.
   Mouse/trackpad users don't have that conflict: a click-drag and a
   wheel/trackpad-scroll are different input channels, so this never
   touches or blocks scrolling. */
@media (min-width: 901px) {
  .ipod-mockup {
    cursor: grab;
  }

  .ipod-mockup.is-dragging {
    cursor: grabbing;
    z-index: 5;
  }
}

.ipod-device {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  -webkit-user-drag: none;
  user-select: none;
  /* calc(), not %: a percentage border-radius resolves horizontal and
     vertical radii separately (% of width, % of height) — on this box
     (216x384, much taller than wide) that stretched the corner into a
     tall ellipse instead of Figma's uniform 15px circular one. calc()
     is a length, so both axes get the same radius regardless of the
     box's aspect ratio. 0.0694 = 15px of the 216px-wide Figma frame. */
  border-radius: calc(var(--ipod-w) * 0.0694);
}

/* Percentages measured directly from the downloaded photo's own
   pixels (scanned for the black border: left=30, top=26, right=705,
   bottom=515 out of 736x1308 — see the PIL script used to find these),
   NOT from Figma's frame coordinates like an earlier version of this
   did. The photo turned out to be a pre-baked mockup with its own fake
   "Now Playing" screen already drawn into the image (a different
   track's art/title baked into the pixels) — Figma's node position for
   the "live" overlay layer was only an approximation of where that
   baked-in card actually sits in the exported bitmap, close enough to
   look right in Figma but off by ~1-2% once measured against the real
   file, which reads as a visible crop/misalignment once you're
   comparing this box's border against the photo's own border right
   next to it. Positioning is still done as a percentage of
   .ipod-mockup's own box (never a fixed pixel offset), so device and
   screen still can't drift apart from each other at any width — these
   are just the corrected percentages.
   font-size below is derived from --ipod-w (0.917120 for this
   element's 91.71%-of-mockup width, x0.01 for "1% of that") instead of
   its own separately-hardcoded clamp() — see the comment on
   .ipod-mockup for why: a value computed from the one true width can't
   fall out of sync with it. Everything inside is then sized in em
   against this, so text/art/progress-bar all scale together with the
   widget as one unit, not just the outer box resizing while its
   contents stay a fixed, increasingly-mismatched pixel size. (Tried
   container query units for this first; dropped them after finding
   they resolved against the wrong box in this exact flex +
   position:absolute nesting — calc()/em has no such edge case.) */
.ipod-screen {
  position: absolute;
  left: 4.076%;
  top: 1.988%;
  width: 91.712%;
  height: 37.385%;
  display: flex;
  flex-direction: column;
  background: #fff;
  border: 2px solid #000;
  border-radius: 1.031em; /* the real iPod screen's own 2px-of-194px radius, not a guess */
  overflow: hidden;
  box-sizing: border-box;
  font-size: calc(var(--ipod-w) * 0.0091712);
}

/* The screen's own title bar (node 77:212) — a brushed-metal gradient
   strip with "Now Playing" sitting on it, not just plain text above a
   divider line like an earlier version of this had. Colors sampled
   directly from the Figma asset's pixels (top ~#fafbfb fading to
   ~#aeb6b8 at the bottom). */
.pg-player-topbar {
  flex-shrink: 0;
  display: flex;
  align-items: center;
  height: 5.67em;
  padding: 0 1.55em;
  background: linear-gradient(to bottom, #fafbfb, #aeb6b8);
  border-bottom: 1px solid #9aa3a5;
  box-sizing: border-box;
}

.pg-player-content {
  flex: 1;
  min-height: 0;
  display: flex;
  flex-direction: column;
  padding: 4.64em 6.19em;
  box-sizing: border-box;
}

.pg-player-label {
  margin: 0;
  font-weight: 500;
  font-size: 4.12em;
  color: #000;
  white-space: nowrap;
}

.pg-player-row {
  display: flex;
  align-items: center;
  gap: 4em;
  min-width: 0;
}

/* A real turntable record, not a square thumbnail — the album art
   becomes the vinyl's center label (.pg-player-art below), ringed by
   grooves (a repeating-radial-gradient, not an image asset) and a
   spindle hole, with the whole disc spinning together as one rigid
   piece. Same 37em footprint the plain square art used to have, so
   .pg-player-row's layout doesn't shift.
   Rotation itself is driven entirely from JS (setupVinylScratch), not
   a CSS animation — see that function's own comment for why: it's
   the only way a scratch drag and the auto-spin-while-playing state
   can hand off to each other without a jump, since they'd otherwise
   be two independent, un-coordinated rotation sources fighting over
   the same transform. touch-action:none claims the drag gesture on
   this small circular control instead of it fighting page scroll on
   touch — reasonable for a self-contained control this size, unlike
   e.g. the jigsaw puzzle's much larger tap area. */
.pg-player-vinyl {
  position: relative;
  width: 37em;
  height: 37em;
  flex-shrink: 0;
  border-radius: 50%;
  background:
    repeating-radial-gradient(circle at 50% 50%, rgba(255, 255, 255, 0.07) 0, rgba(255, 255, 255, 0.07) 0.06em, transparent 0.06em, transparent 0.22em),
    radial-gradient(circle at 35% 30%, rgba(255, 255, 255, 0.16), transparent 55%),
    #111;
  box-shadow: 0 0.15em 0.5em rgba(0, 0, 0, 0.35);
  cursor: grab;
  touch-action: none;
  user-select: none;
  transition: box-shadow 0.2s;
}

/* A little lift while scratching — safe to transition (unlike
   transform, which script.js is writing to every frame/pointermove;
   easing that would just make it lag behind the drag). */
.pg-player-vinyl.is-scratching {
  cursor: grabbing;
  box-shadow: 0 0.3em 0.9em rgba(0, 0, 0, 0.45);
}

.pg-player-art {
  position: absolute;
  top: 50%;
  left: 50%;
  width: 64%;
  height: 64%;
  transform: translate(-50%, -50%);
  object-fit: cover;
  border-radius: 50%;
  background: #ddd;
  box-shadow: 0 0 0 0.15em rgba(0, 0, 0, 0.5) inset;
  -webkit-user-drag: none;
  user-select: none;
}

/* The spindle hole — dead center, on top of the label, so it reads as
   a hole punched through the record rather than a dot sitting on it. */
.pg-player-vinyl-hole {
  position: absolute;
  top: 50%;
  left: 50%;
  width: 6%;
  height: 6%;
  transform: translate(-50%, -50%);
  border-radius: 50%;
  background: #000;
  box-shadow: 0 0 0 0.06em rgba(255, 255, 255, 0.15);
}

.pg-player-meta {
  min-width: 0;
}

.pg-player-title {
  margin: 0;
  font-weight: 500;
  font-size: 5.3em;
  color: #000;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.pg-player-artist {
  /* % not em, same self-compounding reason as .pg-player-label above */
  margin: 1.5% 0 0;
  font-size: 3.7em;
  color: #818181;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* Long titles/artists scroll to reveal the rest instead of just
   clipping with the ellipsis above — see setMarqueeText in script.js,
   which measures overflow and only adds .is-marquee (and the
   --marquee-shift/--marquee-duration it reads below) when the text
   actually doesn't fit. .pg-player-title/.pg-player-artist stay the
   fixed-width overflow:hidden "window" this scrolls behind; this span
   is what actually moves. */
.pg-marquee-text {
  display: inline-block;
  white-space: nowrap;
}

.pg-marquee-text.is-marquee {
  animation: pg-marquee var(--marquee-duration, 8s) ease-in-out infinite;
}

/* Pause at the start so the beginning is readable, scroll left far
   enough for --marquee-shift (the exact overflow, set in JS) to bring
   the tail into view, pause there too, then scroll back — an iPod
   Click Wheel's own back-and-forth marquee, not an infinite
   conveyor-belt loop. */
@keyframes pg-marquee {
  0%, 12% {
    transform: translateX(0);
  }
  45%, 55% {
    transform: translateX(var(--marquee-shift, 0));
  }
  88%, 100% {
    transform: translateX(0);
  }
}

.pg-player-scrub {
  margin-top: auto;
  display: flex;
  align-items: center;
  gap: 3em;
}

.pg-player-time {
  font-size: 3.6em;
  color: #000;
  white-space: nowrap;
}

.pg-player-progress {
  flex: 1;
  height: 4.64em;
  background: #f3f3f3;
  overflow: hidden;
}

.pg-player-progress-fill {
  height: 100%;
  width: 0%;
  background: linear-gradient(to top, #42a4e3, #42cee5);
  transition: width 1s linear;
}

/* Image grid — same fixed-column-width, natural-height, no-crop rule
   as the Works section's .work-images (see buildPlaygroundImages in
   script.js), just 8 columns instead of 4. */
.playground-images {
  display: grid;
  grid-template-columns: repeat(8, 1fr);
  gap: 9px;
  padding-bottom: clamp(64px, 10vh, 120px);
}

.playground-image img {
  width: 100%;
  height: auto;
  display: block;
}

.playground-image p {
  margin: 6px 0 0;
  font-family: "Ubuntu Mono", "SF Mono", Menlo, monospace;
  font-size: 12px;
  color: var(--muted);
}

.playground-image {
  cursor: pointer;
}

/* Lightbox — a single instance appended to <body> by setupLightbox
   (script.js), reused for every image rather than one per cell.
   Deliberately NOT theme-aware (always a dark scrim/light text,
   regardless of html[data-theme]) — the same reasoning as the iPod
   mockup's own fixed colors: this is its own fixed viewing
   environment, not site chrome that should repaint with the toggle.
   opacity+visibility (not display) is what setupLightbox toggles via
   .is-open, so the fade transition below actually has something to
   animate. */
.lightbox {
  position: fixed;
  inset: 0;
  z-index: 50;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(8, 8, 10, 0.95);
  opacity: 0;
  visibility: hidden;
  transition: opacity 0.25s var(--flip-ease), visibility 0s 0.25s;
}

.lightbox.is-open {
  opacity: 1;
  visibility: visible;
  transition: opacity 0.25s var(--flip-ease), visibility 0s;
}

.lightbox-stage {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 12px;
  max-width: min(88vw, 1200px);
  max-height: 86vh;
}

.lightbox-image {
  display: block;
  max-width: 100%;
  max-height: 78vh;
  width: auto;
  height: auto;
  object-fit: contain;
}

.lightbox-label {
  margin: 0;
  font-family: "Ubuntu Mono", "SF Mono", Menlo, monospace;
  font-size: 13px;
  color: rgba(255, 255, 255, 0.6);
}

.lightbox-close,
.lightbox-nav {
  position: fixed;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 44px;
  height: 44px;
  border: none;
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.08);
  color: #fff;
  cursor: pointer;
  transition: opacity 0.2s, background 0.2s, transform 0.15s var(--flip-ease);
}

.lightbox-close:hover,
.lightbox-nav:hover {
  background: rgba(255, 255, 255, 0.16);
  transform: scale(1.06);
}

.lightbox-close:active,
.lightbox-nav:active {
  transform: scale(0.92);
}

.lightbox-close {
  top: clamp(16px, 3vh, 32px);
  right: clamp(16px, 3vw, 32px);
}

.lightbox-nav--prev {
  left: clamp(12px, 3vw, 32px);
  top: 50%;
  transform: translateY(-50%);
}

.lightbox-nav--prev:hover {
  transform: translateY(-50%) scale(1.06);
}

.lightbox-nav--prev:active {
  transform: translateY(-50%) scale(0.92);
}

.lightbox-nav--next {
  right: clamp(12px, 3vw, 32px);
  top: 50%;
  transform: translateY(-50%);
}

.lightbox-nav--next:hover {
  transform: translateY(-50%) scale(1.06);
}

.lightbox-nav--next:active {
  transform: translateY(-50%) scale(0.92);
}

/* Works (node 38:500) — scrolls normally like Playground/home, same
   reason (the project grid below can grow indefinitely). The grid
   itself reuses .works/.works-inner (id="works-list") and buildWorks()
   unchanged from the home page's own Works section — same data, same
   CLS-safe image sizing, same per-image reveal-on-scroll, so none of
   that had to be rebuilt for a second page. */
body.page-works {
  overflow-y: auto;
  overflow-x: hidden;
}

.works-page {
  padding-top: var(--header-h);
}

.works-page-inner {
  max-width: 1450px;
  margin: 0 auto;
  padding: clamp(48px, 8vh, 96px) var(--header-pad-x) 0;
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: 24px;
}

/* .works (id="works") is shared with the homepage's Recent Works
   section, so this only widens the gap below the description here —
   scoped by body.page-works rather than touching .works itself, which
   would've also pushed the homepage's works section further from its
   hero. The description's own position (.works-page-inner's
   padding-top above) is untouched. */
body.page-works .works {
  padding-top: clamp(160px, 26vh, 320px);
}

.works-page-intro {
  margin: 0;
  max-width: 460px;
  font-weight: 500;
  font-size: clamp(17px, 1.5vw, 22px);
  line-height: 1.5;
}

/* Case study pages (case-study.html, one per /works/<slug>) — scrolls
   normally like Works/Playground, same header gap as every other
   subpage. Entirely built by buildCaseStudy() in script.js from a
   project's caseStudy.blocks; the classes below are the same fixed
   vocabulary that function renders from (heading/paragraph/image/
   image-grid/quote), reused across however many of each a given case
   study has, in whatever order the CMS put them in. */
body.page-case-study {
  overflow-y: auto;
  overflow-x: hidden;
}

.case-study {
  padding-top: var(--header-h);
}

.case-study-inner {
  width: 100%;
  max-width: 1450px;
  margin: 0 auto;
  padding: clamp(48px, 8vh, 96px) var(--header-pad-x) clamp(64px, 10vh, 120px);
  display: flex;
  flex-direction: column;
  gap: clamp(28px, 5vh, 56px);
}

.case-study-notfound-text {
  margin: 0;
  font-size: 18px;
}

.case-study-back {
  align-self: flex-start;
  font-size: 14px;
  font-family: "Ubuntu Mono", "SF Mono", Menlo, monospace;
  color: var(--muted);
  text-decoration: underline;
  text-underline-offset: 3px;
  transition: opacity 0.2s;
}

.case-study-back:hover {
  opacity: 0.6;
}

.case-study-header {
  display: flex;
  flex-direction: column;
  gap: 16px;
  max-width: 760px;
}

.case-study-category {
  margin: 0;
  font-family: "Ubuntu Mono", "SF Mono", Menlo, monospace;
  font-size: 16px;
  color: var(--muted);
}

.case-study-title {
  margin: 0;
  font-weight: 500;
  font-size: clamp(28px, 4vw, 48px);
  line-height: 1.1;
}

/* Same intro-paragraph treatment as .about-intro/.works-page-intro —
   deliberately, since it gets the same splitLines() line-reveal every
   other page's description does (see buildCaseStudy/DOMContentLoaded
   in script.js). */
.case-study-subtitle {
  margin: 0;
  font-weight: 500;
  font-size: clamp(17px, 1.5vw, 22px);
  line-height: 1.5;
}

.case-study-meta {
  display: flex;
  flex-wrap: wrap;
  gap: 32px;
  margin-top: 8px;
}

.case-study-meta-item {
  display: flex;
  flex-direction: column;
  gap: 6px;
}

/* Same bracketed-label idiom as .about-col-label. */
.case-study-meta-label {
  margin: 0;
  font-family: "Ubuntu Mono", "SF Mono", Menlo, monospace;
  font-size: 14px;
  color: var(--muted);
}

.case-study-meta-value {
  margin: 0;
  font-size: 15px;
  font-weight: 500;
}

.case-study-cover img {
  width: 100%;
  height: auto;
  display: block;
}

.case-study-body {
  display: flex;
  flex-direction: column;
  gap: clamp(28px, 5vh, 48px);
}

.case-study-block-heading {
  margin: 0;
  max-width: 760px;
  font-weight: 500;
  font-size: clamp(22px, 2.5vw, 32px);
  line-height: 1.25;
}

.case-study-block-paragraph {
  margin: 0;
  max-width: 760px;
  font-size: 16px;
  line-height: 1.7;
}

.case-study-block-image img {
  width: 100%;
  height: auto;
  display: block;
}

.case-study-block-caption {
  margin: 8px 0 0;
  font-family: "Ubuntu Mono", "SF Mono", Menlo, monospace;
  font-size: 13px;
  color: var(--muted);
}

/* grid-template-columns is set inline per-block in script.js (2-4
   columns, matching however many images that block actually has) —
   same "equal columns, no crop" idea as .work-images, just a variable
   column count instead of always 4. */
.case-study-block-grid {
  display: grid;
  align-items: start;
  gap: 9px;
}

.case-study-block-grid-item img {
  width: 100%;
  height: auto;
  display: block;
}

.case-study-block-quote {
  margin: 0;
  max-width: 760px;
  padding-left: 24px;
  border-left: 2px solid var(--card-frame);
}

.case-study-block-quote p {
  margin: 0;
  font-weight: 500;
  font-size: clamp(18px, 2vw, 24px);
  line-height: 1.4;
}

.case-study-block-quote cite {
  display: block;
  margin-top: 12px;
  font-family: "Ubuntu Mono", "SF Mono", Menlo, monospace;
  font-size: 13px;
  font-style: normal;
  color: var(--muted);
}

.case-study-next {
  padding-top: clamp(24px, 4vh, 48px);
  border-top: 1px solid var(--card-frame);
}

.case-study-next-link {
  display: flex;
  flex-direction: column;
  gap: 8px;
  color: inherit;
  text-decoration: none;
  transition: opacity 0.2s;
}

.case-study-next-link:hover {
  opacity: 0.7;
}

.case-study-next-label {
  font-family: "Ubuntu Mono", "SF Mono", Menlo, monospace;
  font-size: 14px;
  color: var(--muted);
}

.case-study-next-title {
  font-weight: 500;
  font-size: clamp(22px, 3vw, 32px);
}

/* Contact page — scrolls normally like Works/Playground (a submitted
   form can grow the page with a status message), same header gap as
   every other subpage. */
body.page-contact {
  overflow-y: auto;
  overflow-x: hidden;
}

.contact {
  padding-top: var(--header-h);
}

.contact-inner {
  width: 100%;
  max-width: 1450px;
  margin: 0 auto;
  padding: clamp(48px, 8vh, 96px) var(--header-pad-x) clamp(64px, 10vh, 120px);
  display: flex;
  flex-direction: column;
  gap: clamp(32px, 6vh, 64px);
}

.contact-intro {
  margin: 0;
  max-width: 600px;
  font-weight: 500;
  font-size: clamp(17px, 1.5vw, 22px);
  line-height: 1.5;
}

.contact-form {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  column-gap: 40px;
  row-gap: 32px;
  max-width: 780px;
}

.contact-field {
  display: flex;
  flex-direction: column;
  gap: 10px;
}

.contact-field--full {
  grid-column: 1 / -1;
}

.contact-label {
  margin: 0;
  font-family: "Ubuntu Mono", "SF Mono", Menlo, monospace;
  font-size: 14px;
  color: var(--muted);
}

/* Underline-only fields, same idiom as the nav/footer links' underline
   treatment rather than a boxed input — nothing on this site sits
   inside a bordered container except the puzzle/iPod frames. */
.contact-input {
  width: 100%;
  margin: 0;
  padding: 8px 0 10px;
  font-family: inherit;
  font-size: 16px;
  font-weight: 500;
  color: var(--ink);
  background: none;
  border: none;
  border-bottom: 1px solid var(--card-frame);
  border-radius: 0;
  transition: border-color 0.2s;
}

.contact-input::placeholder {
  color: var(--muted);
}

.contact-input:focus {
  outline: none;
  border-bottom-color: var(--ink);
}

.contact-textarea {
  min-height: 120px;
  line-height: 1.5;
  resize: vertical;
}

.contact-actions {
  display: flex;
  align-items: center;
  gap: 24px;
  flex-wrap: wrap;
  margin-top: 8px;
}

/* Same treatment as .about-resume-link — an underlined text action,
   not a boxed button, so it reads as part of the same design language
   rather than a one-off form-specific control. */
.contact-submit {
  font-family: inherit;
  font-size: 16px;
  font-weight: 500;
  color: var(--ink);
  background: none;
  border: none;
  padding: 0;
  cursor: pointer;
  text-decoration: underline;
  text-underline-offset: 3px;
  transition: opacity 0.2s;
}

.contact-submit:hover {
  opacity: 0.6;
}

.contact-submit:disabled {
  opacity: 0.4;
  cursor: default;
}

.contact-status {
  margin: 0;
  font-size: 14px;
  color: var(--muted);
}

.contact-status.is-error {
  color: var(--danger);
}

/* Safety net for unusually short desktop viewports (a small browser
   window, not just a small screen) — the vh-based clamps above already
   shrink gaps, but below ~700px of height that alone isn't quite
   enough to avoid clipping, so trim a bit further here. */
@media (min-width: 901px) and (max-height: 700px) {
  .about-inner {
    gap: clamp(16px, 3vh, 32px);
  }

  .about-hero {
    gap: 14px;
  }

  .about-intro {
    font-size: 16px;
    line-height: 1.4;
  }

  .contact-inner {
    gap: clamp(20px, 4vh, 40px);
  }

  .contact-intro {
    font-size: 16px;
    line-height: 1.4;
  }

  .about-columns {
    row-gap: clamp(16px, 3vh, 32px);
  }

  .about-col-label {
    margin-bottom: 10px;
  }

  .about-col-list {
    gap: 6px;
  }

  .about-col-list li {
    font-size: 13.5px;
  }
}

@media (max-width: 900px) {
  :root {
    /* iPhone 16/17 Pro Figma frame (node 11:96): card/gap sizes are the
       desktop values scaled by ~0.8988 */
    --card-w: 110.187px;
    --card-h: 273px;
    --inner-w: 126.625px;
    --inner-h: 210.608px;
    --gap: 69.214px;
    --header-pad-x: 20px;
  }

  .nav-toggle {
    display: flex;
  }

  /* Hamburger → X, matching the Figma open-state frame (node 11:151).
     The X's lines are shorter than the hamburger's — 20px vs the full
     46px width — so it reads as a compact close icon, not an oversized
     one stretched across the same span. */
  .nav-toggle span {
    transition: transform 0.25s var(--flip-ease), width 0.25s var(--flip-ease), opacity 0.15s;
  }

  body.nav-open .nav-toggle {
    align-items: center;
  }

  body.nav-open .nav-toggle span:nth-child(1) {
    width: 20px;
    transform: translateY(5.5px) rotate(45deg);
  }

  body.nav-open .nav-toggle span:nth-child(2) {
    width: 20px;
    transform: translateY(-5.5px) rotate(-45deg);
  }

  /* Full-screen overlay menu (node 11:151), not a small dropdown: the
     Indian-time clock sits at the top, then Works/About Me/Playground/
     Contact stack left-aligned below it at 22px with a 34px line rhythm.
     Opens as a top-down wipe (clip-path) with each row fading up in a
     stagger, echoing the same fade-up rhythm the desktop nav uses on
     page load — but re-triggerable, since it's a transition tied to
     body.nav-open rather than a one-shot keyframe animation. */
  .nav-items {
    position: fixed;
    inset: 0;
    z-index: 1;
    flex-direction: column;
    align-items: flex-start;
    gap: 0;
    padding: 120px 20px 0;
    background: var(--bg);
    clip-path: inset(0 0 100% 0);
    visibility: hidden;
    pointer-events: none;
    transition: clip-path 0.5s var(--flip-ease), visibility 0s 0.5s;
  }

  body.nav-open .nav-items {
    clip-path: inset(0 0 0 0);
    visibility: visible;
    pointer-events: auto;
    transition: clip-path 0.5s var(--flip-ease), visibility 0s;
  }

  .nav-items > * {
    animation: none;
    opacity: 0;
    transform: translateY(16px);
    transition: opacity 0.4s var(--flip-ease), transform 0.4s var(--flip-ease);
  }

  body.nav-open .nav-items > * {
    opacity: 1;
    transform: translateY(0);
  }

  body.nav-open .nav-items > *:nth-child(1) { transition-delay: 0.16s; }
  body.nav-open .nav-items > *:nth-child(2) { transition-delay: 0.22s; }
  body.nav-open .nav-items > *:nth-child(3) { transition-delay: 0.28s; }
  body.nav-open .nav-items > *:nth-child(4) { transition-delay: 0.34s; }
  body.nav-open .nav-items > *:nth-child(5) { transition-delay: 0.4s; }
  body.nav-open .nav-items > *:nth-child(6) { transition-delay: 0.46s; }

  .nav-items .nav-link {
    font-size: 22px;
    line-height: 34px;
    margin-bottom: 14px;
  }

  .nav-items .nav-time {
    font-size: 22px;
  }

  /* The margin lives on the group (toggle + time together), not the
     bare time span, so it pushes the whole pair away from the Works
     link below rather than just the text inside it. */
  .nav-items .nav-time-group {
    margin-bottom: 56px;
  }

  /* Social links + year (node 11:151) also live inside the nav drawer
     itself on mobile, not just the real page footer below — same look
     as .site-footer above (.footer-links handles the column layout),
     just with margin-top: auto to pin it to the bottom of the fixed
     full-screen overlay while Works/About Me/etc. stay put at the top. */
  .nav-items .nav-footer {
    display: flex;
    align-items: flex-end;
    justify-content: space-between;
    gap: 24px;
    width: 100%;
    margin-top: auto;
    padding: 0 20px 40px 0;
  }

  .intro {
    margin: 64px 20px 0;
    font-size: 22px;
  }

  .works-inner {
    padding: 0 20px;
    gap: 48px;
  }

  .work-header {
    flex-direction: column;
    gap: 4px;
    margin-bottom: 16px;
  }

  .work-images {
    grid-template-columns: repeat(2, 1fr);
    gap: 8px;
  }

  /* Same layout as the mobile nav drawer's copy of this section
     (.nav-items .nav-footer below): links stacked in a column on the
     left, year pinned to the bottom-right of that column. */
  .site-footer {
    align-items: flex-end;
    gap: 24px;
    padding: 24px 20px 40px;
  }

  .footer-links {
    flex-direction: column;
    align-items: flex-start;
    gap: 12px;
  }

  /* Same 32px top-gap override Works/Playground use on mobile (see
     .works-page-inner / .playground-hero below) — side padding
     already shrinks with them via --header-pad-x above, so this is
     the only property that needs touching. */
  .about-inner {
    padding-top: 32px;
  }

  .about-hero {
    flex-direction: column;
    gap: 32px;
  }

  .about-puzzle-wrap {
    width: 100%;
    max-width: 320px;
    align-self: center;
  }

  .about-puzzle {
    width: 100%;
  }

  .about-columns {
    grid-template-columns: 1fr;
    row-gap: 40px;
  }

  .playground-hero {
    flex-direction: column;
    align-items: center;
    gap: 32px;
    padding: 32px 0 40px;
  }

  .playground-intro {
    align-self: stretch;
  }

  .playground-images {
    grid-template-columns: repeat(3, 1fr);
    gap: 8px;
  }

  .playground-image p {
    font-size: 10px;
  }

  .lightbox-close,
  .lightbox-nav {
    width: 36px;
    height: 36px;
  }

  .lightbox-image {
    max-height: 68vh;
  }

  .works-page-inner {
    padding-top: 32px;
  }

  .contact-inner {
    padding-top: 32px;
  }

  .contact-form {
    grid-template-columns: 1fr;
  }

  .case-study-inner {
    padding-top: 32px;
  }

  /* Overrides the inline 2-4 column style set per-block in script.js —
     a 3-4 image grid gets too cramped to be worth it at phone width. */
  .case-study-block-grid {
    grid-template-columns: repeat(2, 1fr) !important;
  }
}
