/* ---------------------------------------------------
   Design tokens
--------------------------------------------------- */
:root {
  /* Dark theme, brand hue: deep indigo (264) — every neutral below is
     tinted toward it. --color-paper/--color-ink keep their names (they're
     used all over the file as "page background" / "primary text") even
     though a dark theme inverts which is literally lighter. */
  --color-brand:       oklch(72% 0.14 264);
  --color-brand-hover: oklch(80% 0.12 264);

  --color-paper: #030303;
  --color-surface: oklch(18% 0.014 264);
  --color-border: oklch(28% 0.016 264);

  --color-ink:    oklch(96% 0.006 264);
  --color-muted:  oklch(72% 0.014 264);
  --color-faint:  oklch(52% 0.012 264);

  --font-display: "Bricolage Grotesque", system-ui, sans-serif;
  --font-body: "Public Sans", system-ui, sans-serif;

  /* Fixed rem scale — body UI */
  --text-body: 1rem;
  --text-small: 0.9rem;

  /* 4pt spacing scale */
  --space-xs: 4px;
  --space-sm: 8px;
  --space-md: 12px;
  --space-lg: 16px;
  --space-xl: 24px;
  --space-2xl: 32px;
  --space-3xl: 48px;
  --space-4xl: 64px;
  --space-5xl: 96px;

  --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
  --radius-md: 12px;
  --radius-lg: 20px;
}

/* ---------------------------------------------------
   Reset
--------------------------------------------------- */
*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
  margin: 0;
  background: var(--color-paper);
  color: var(--color-ink);
  font-family: var(--font-body);
  font-size: var(--text-body);
  line-height: 1.6;
  -webkit-font-smoothing: antialiased;
}
img, svg { display: block; max-width: 100%; }
a { color: inherit; text-decoration: none; }
ul, ol { margin: 0; padding: 0; list-style: none; }
h1, h2, h3, p { margin: 0; }

.wrap {
  width: min(1120px, 100% - 48px);
  margin-inline: auto;
}

/* One shared flowing-color canvas behind the entire page (flowBackground.js)
   — Hero and Work aren't two effects, this is the single background both of
   them happen to sit on top of. z-index:-1 keeps it behind all normal-flow
   content while still painting over body's own background-color, which
   stays as a plain fallback if this script never runs. */
#flow-background {
  position: fixed;
  inset: 0;
  z-index: -1;
  pointer-events: none;
}

/* Subtle grain so large flat dark areas don't feel sterile. Deliberately
   NOT using mix-blend-mode — that depends on interacting correctly with
   whatever color is underneath, which is easy to get wrong (multiply only
   darkens, screen only lightens, etc). Instead the filter below recolors
   the noise to solid white with varying *opacity*, so it's just translucent
   white speckle sitting on top — visible against any background, no
   blend-mode math required. */
.grain {
  position: fixed;
  inset: -20%; /* oversized so the jump animation never exposes an edge */
  pointer-events: none;
  z-index: 999;
  opacity: 0.13;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='3' stitchTiles='stitch' result='noise'/%3E%3CfeColorMatrix in='noise' type='matrix' values='0 0 0 0 1  0 0 0 0 1  0 0 0 0 1  0 0 0 1.4 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
  background-size: 140px 140px;
  animation: grain-flicker 0.5s steps(8) infinite;
}
/* Jumps between offsets instead of sliding smoothly — steps(8) above turns
   this into 8 discrete cuts per cycle, which reads as flicker, not motion. */
@keyframes grain-flicker {
  0%   { transform: translate(0%, 0%); }
  12%  { transform: translate(-6%, 4%); }
  24%  { transform: translate(5%, -8%); }
  36%  { transform: translate(-4%, -5%); }
  48%  { transform: translate(7%, 6%); }
  60%  { transform: translate(-8%, 2%); }
  72%  { transform: translate(3%, -6%); }
  84%  { transform: translate(-3%, 7%); }
  100% { transform: translate(0%, 0%); }
}

/* ---------------------------------------------------
   Header
--------------------------------------------------- */
.site-header {
  /* Floats above the page as one connected glass capsule spanning the same
     .wrap max-width as the rest of the page's content — wordmark and nav
     sit inside it, pinned to its far left and far right. */
  position: sticky;
  top: var(--space-lg);
  z-index: 20;
  pointer-events: none;
}
.header-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: var(--space-md) var(--space-2xl);
  border-radius: 999px;
  background: color-mix(in oklch, var(--color-paper) 55%, transparent);
  backdrop-filter: blur(16px);
  -webkit-backdrop-filter: blur(16px);
  box-shadow:
    inset 0 0 0 1px color-mix(in oklch, var(--color-ink) 14%, transparent),
    0 10px 30px -12px rgba(0, 0, 0, 0.6);
  /* Not auto — this box is nearly the full page width and, being
     position:sticky on .site-header, stays pinned near the top of the
     viewport at all times while scrolling. Whatever content happens to
     scroll underneath it (e.g. the work list's rows) would otherwise have
     clicks swallowed by this box even when landing on its empty middle
     space, not an actual link — which is exactly what caused clicking
     "MISE" to sometimes land on Resume/LinkedIn/About Me instead,
     depending on which part of the pill happened to overlap it. Only the
     actual link elements re-enable pointer-events below, so everywhere
     else in the pill lets clicks pass through to whatever is really
     underneath. */
  pointer-events: none;
}
.wordmark,
.site-nav a {
  pointer-events: auto;
}
.wordmark {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 1.05rem;
  letter-spacing: -0.01em;
}
.site-nav {
  display: flex;
  align-items: center;
  gap: var(--space-2xl);
}
.site-nav a {
  font-size: var(--text-small);
  color: var(--color-muted);
  position: relative;
  padding-bottom: 2px;
}
.site-nav a::after {
  content: "";
  position: absolute;
  left: 0; right: 100%;
  bottom: -3px;
  height: 1.5px;
  background: var(--color-brand);
  transition: right 0.3s var(--ease-out-expo);
}
.site-nav a:hover { color: var(--color-ink); }
.site-nav a:hover::after { right: 0; }

/* ---------------------------------------------------
   Hero
--------------------------------------------------- */
.hero {
  /* Matches assets/img/02.svg's own ratio (12108×5824) so object-fit:cover
     on .blueprint-sketch never has to crop it — the hero box is already
     the same shape as the image, at any viewport width. */
  aspect-ratio: 12108 / 5824;
  min-height: 320px;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--space-3xl) var(--space-xl);
}
.hero-copy {
  max-width: 90ch;
  text-align: center;
}
/* Class-based (not .hero-copy p) so heroWordPush.js can clone this element
   for the blueprint-reveal lens and have the clone still pick up the same
   base typography even after it's moved outside .hero-copy. */
.hero-sentence {
  font-family: var(--font-body);
  font-weight: 700;
  color: var(--color-ink);
  font-size: clamp(1.375rem, 1.1rem + 1.4vw, 2rem);
  line-height: 1.45;
  letter-spacing: -0.01em;
  text-align: center;
}
/* Added by heroWordPush.js, which wraps each letter for the cursor-push
   effect. Letters stay static (plain paragraph) if that script never runs. */
.hero-sentence .letter {
  display: inline-block;
  will-change: transform;
}
/* Keeps a word's letters glued together as one unbreakable unit, so line
   wraps only ever happen between words (at the real space between
   word-groups), never in the middle of one. */
.hero-sentence .word-group {
  display: inline-block;
  white-space: nowrap;
}

/* Glass-lens cursor added by heroWordPush.js. position:fixed + top/left:0 so
   the translate() set from JS is plain viewport coordinates.

   No blur here on purpose — it sits above the blueprint-reveal layer
   (below), and blurring would defeat the point of that layer being legible.
   The rim + specular highlight alone are what read as "glass." */
.cursor-orb {
  position: fixed;
  top: 0;
  left: 0;
  /* min() caps it on narrow screens, same idea as the stickers — 250px on
     anything reasonably wide, shrinking below that on small viewports. */
  width: min(250px, 55vw);
  height: min(250px, 55vw);
  margin-left: calc(min(250px, 55vw) / -2);
  margin-top: calc(min(250px, 55vw) / -2);
  border-radius: 50%;
  overflow: hidden;
  pointer-events: none;
  z-index: 5;
  backdrop-filter: saturate(1.2);
  -webkit-backdrop-filter: saturate(1.2);
  box-shadow:
    inset 0 -14px 24px -10px rgba(20, 22, 30, 0.28),
    0 8px 24px -8px rgba(20, 22, 30, 0.35);
  /* Same feathered-edge technique as .work-preview: a soft alpha fade of
     the whole lens (backdrop-filter effect + shadows + highlight) down to
     nothing, well before the hard geometric border-radius clip. closest-
     side matters here too — without it, the fade is measured against the
     box's farthest corner (its diagonal), not where the circle actually
     meets the edge, and finishes too late to avoid a visible hard ring. */
  mask-image: radial-gradient(circle closest-side at 50% 50%, black 45%, transparent 96%);
  -webkit-mask-image: radial-gradient(circle closest-side at 50% 50%, black 45%, transparent 96%);
  opacity: 0;
  transition: opacity 0.4s var(--ease-out-expo);
}
.cursor-orb.is-visible {
  opacity: 1;
}
/* Specular highlight: an offset bright patch, like light catching one side
   of a curved lens rather than lighting it evenly. */
.cursor-orb::before {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: 50%;
  background: radial-gradient(
    circle at 32% 26%,
    rgba(255, 255, 255, 0.55) 0%,
    rgba(255, 255, 255, 0.16) 14%,
    transparent 32%
  );
  z-index: 2;
  pointer-events: none;
}
/* Center reticle — a small crosshair, like a measuring tool's aim point,
   so the lens itself reads as an instrument rather than just a magnifier. */
.cursor-orb::after {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  background:
    linear-gradient(rgba(255, 255, 255, 0.7) 1px, transparent 1px) center / 1px 18px no-repeat,
    linear-gradient(90deg, rgba(255, 255, 255, 0.7) 1px, transparent 1px) center / 18px 1px no-repeat;
}

/* Blueprint reveal: a full-hero layer (textured paper + Blueprint artwork,
   with a live clone of .hero-sentence, in black, positioned on top of it —
   all built in heroWordPush.js). clip-path (set from JS each frame)
   keeps the whole layer invisible everywhere except inside the lens circle,
   wherever in the hero that currently is — not just when the lens is
   directly over the words. Positioned below the orb (z-index 5) so the
   glass rim sits on top of what it's revealing. Confined to the hero's own
   box — the lens/reveal has no effect below the hero (see the Work section
   CSS further down, which has no reveal wiring at all). */
.blueprint-layer {
  position: fixed;
  top: 0;
  left: 0;
  z-index: 4;
  overflow: hidden;
  pointer-events: none;
  /* Deliberately independent of --color-paper/--color-brand: this stays
     pale paper regardless of the site's own theme (light or dark), like a
     physical sheet laid on top. */
  --blueprint-paper: oklch(97% 0.006 264);
  background-color: var(--blueprint-paper);
  /* Two-tier grid (fine 8px + bolder every 5 cells/40px), now in black
     instead of the old blue ink, layered under the fibrous paper grain
     (same feTurbulence technique as .grain elsewhere, tuned lower-
     frequency/lower-alpha for a fine paper texture rather than TV-static). */
  background-image:
    repeating-linear-gradient(0deg, rgba(0, 0, 0, 0.14) 0 1px, transparent 1px 8px),
    repeating-linear-gradient(90deg, rgba(0, 0, 0, 0.14) 0 1px, transparent 1px 8px),
    repeating-linear-gradient(0deg, rgba(0, 0, 0, 0.35) 0 1px, transparent 1px 40px),
    repeating-linear-gradient(90deg, rgba(0, 0, 0, 0.35) 0 1px, transparent 1px 40px),
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='p'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch' result='noise'/%3E%3CfeColorMatrix in='noise' type='matrix' values='0 0 0 0 0  0 0 0 0 0  0 0 0 0 0  0 0 0 0.07 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23p)'/%3E%3C/svg%3E");
  background-size: auto, auto, auto, auto, 200px 200px;
  opacity: 0;
  transition: opacity 0.4s var(--ease-out-expo);
}
.blueprint-layer.is-visible {
  opacity: 1;
}
/* assets/img/Blueprint.svg, covering the full layer — sits behind the
   cloned text purely through DOM order (both are plain position:absolute
   with no z-index, so they paint in the order they appear as children of
   .blueprint-layer). Dimmer than the text stroke so it reads as "part of
   the paper" rather than competing with the letters. */
.blueprint-sketch {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center;
  pointer-events: none;
}
/* Stickers (heroWordPush.js) — sits above the artwork, below the
   text, inside .blueprint-layer, so the lens still controls visibility.
   Selectability itself no longer depends on DOM hit-testing — see
   hoveredSticker in heroWordPush.js — so these stay pointer-events:none;
   .is-hovered/.is-held are toggled from JS, not native :hover. */
.sticker-layer {
  position: absolute;
  inset: 0;
  pointer-events: none;
}
.blueprint-sticker {
  position: absolute;
  height: auto;
  filter: drop-shadow(0 2px 5px rgba(0, 0, 0, 0.25));
  transition: filter 0.25s var(--ease-out-expo);
  pointer-events: none;
}
.blueprint-sticker.is-hovered,
.blueprint-sticker.is-held {
  filter: drop-shadow(0 10px 18px rgba(0, 0, 0, 0.35));
}
/* The cloned sentence — positioned (via JS, left/top) at the exact offset
   within .blueprint-layer that lines it up with the real text underneath. */
.blueprint-text {
  position: absolute;
  color: #000;
}

/* ---------------------------------------------------
   Work — editorial list, one giant mixed-style headline per row over a
   washed-out project screenshot
--------------------------------------------------- */
.masonry-section {
  /* Positioned so .work-preview (absolute) is confined to this section's
     own box — it can never drift over the hero above or the footer below,
     unlike position:fixed (viewport-relative) which didn't know where the
     section actually was as the page scrolled. overflow:hidden is the hard
     guarantee: even if a row's top/right percentage places the lens close
     to the edge, it gets clipped here rather than spilling into the footer. */
  position: relative;
  overflow: hidden;
  padding-top: var(--space-4xl);
  padding-inline: clamp(24px, 8vw, 120px);
  padding-bottom: var(--space-5xl);
}
.work-switch {
  display: inline-flex;
  gap: var(--space-md);
  /* The project titles below sit at .work-list's 10% margin-left PLUS
     .work-row-headline's own 50px padding-left (see below) — each pill's
     own text is inset from the pill's outer edge by just the button's own
     left padding (--space-xl) now that pills are independent (no shared
     wrapper padding), so margin-left is 10% + 50px minus that inset. */
  margin-left: calc(10% + 50px - var(--space-xl));
  margin-bottom: 40px;
}
.work-switch-btn {
  appearance: none;
  border: 1px solid var(--color-border);
  background: transparent;
  color: var(--color-muted);
  font-family: var(--font-body);
  font-size: var(--text-small);
  font-weight: 600;
  padding: var(--space-sm) var(--space-xl);
  border-radius: 999px;
  cursor: pointer;
  transition: background 0.3s var(--ease-out-expo), color 0.3s var(--ease-out-expo), border-color 0.3s var(--ease-out-expo);
}
.work-switch-btn.is-active {
  background: var(--color-ink);
  border-color: var(--color-ink);
  color: var(--color-paper);
}
.work-panel[hidden] { display: none; }
.work-panel-empty {
  color: var(--color-muted);
  margin-left: 10%;
  padding-block: var(--space-4xl);
}
.work-list {
  max-width: 40%;
  margin-left: 10%;
  display: flex;
  flex-direction: column;
  /* Without this, flex's default align-items:stretch forces every
     .work-row to fill the full container width regardless of how wide its
     actual text is — so hovering the empty space to the right of short
     titles (well before the cursor ever reaches the words) still counted
     as hovering the row and popped the preview image early. flex-start
     lets each row shrink to fit its own content instead. */
  align-items: flex-start;
  gap: clamp(16px, 4vw, 48px);
}

.work-row {
  position: relative;
  display: block;
  overflow: hidden;
}

.work-row-headline {
  position: relative;
  text-align: left;
  /* Left padding only — the text is indented in from .work-list's own
     left edge, but still sits flush on the right, and the row's hover box
     (which shrinks to fit this content, see .work-list's align-items)
     grows with it rather than keeping some separate fixed buffer. */
  /* Fixed px, not clamp() — a constant left indent regardless of viewport
     width, instead of one that scales with it. */
  padding: clamp(12px, 2vw, 20px) 20px clamp(12px, 2vw, 20px) 50px;
  font-family: var(--font-display);
  font-size: clamp(1.5rem, 3vw, 2.5rem);
  /* Thin by default, weighting up to bold only on hover (see below) — the
     hover state itself is the emphasis now, instead of every row already
     being bold at rest. */
  font-weight: 300;
  line-height: 1.15;
  color: var(--color-ink);
  /* No transition — animating font-weight forces a reflow of the whole
     line on every frame, and that was running at the same time as the
     preview image's own clip-path/transform animation, compounding into
     visible jank. An instant snap costs one reflow instead of many. */
}
.work-row-meta {
  font-size: clamp(1.1rem, 2vw, 1.5rem);
  /* Block (not inline) so it always starts its own new line below however
     many lines the title+tagline wrap to, instead of sometimes trailing
     onto the same wrapped line as them or landing mid-paragraph. Since
     it's the last element in the markup, that new line is always the
     last line. */
  display: block;
  width: fit-content;
  margin-top: 0.25em;
}
.work-row-title {
  font-style: italic;
  /* Block, own line — the tagline (and meta, already block) now start
     from the line below instead of running on after the title. width:
     fit-content matters here as much as display:block does: without it, a
     block element fills its container's full width regardless of how
     short its own text is, so a short title would still hit-test as
     "hovered" all the way out to the width of the longer tagline below
     it — exactly the dead-space-triggers-hover problem this is fixing. */
  display: block;
  width: fit-content;
  /* transform: scale (not font-size) — font-size is a layout property, so
     animating it forces the browser to reflow (recompute line boxes, box
     sizes, everything downstream) on every single animation frame, which
     is what made the growth feel janky. transform is composited on the
     GPU and never touches layout, so this animates smoothly regardless of
     how much else is on the page — and, as a bonus, no longer needs the
     row's height reserved for a hover-sized line at all. */
  transform-origin: left center;
  transition: transform 0.4s var(--ease-out-expo);
}
/* :hover on the title itself (not .work-row:hover) — matches script.js,
   which now only shows the preview image when the cursor is actually over
   .work-row-title, not anywhere else in the row. Using the row's hover
   here would bold/enlarge the title even while hovering blank space where
   the image doesn't appear, an inconsistent pair of triggers. */
.work-row-title:hover {
  transform: scale(1.08);
  /* Only the title (and its arrow, which inherits from it) weights up on
     hover — the tagline and meta stay at their own resting weight. */
  font-weight: 800;
}
.work-row-arrow {
  display: inline-block;
  font-style: normal;
}
.work-row-tagline {
  text-transform: uppercase;
  display: block;
  width: fit-content;
  /* Slightly smaller than the title/meta, which stay at the headline's
     base size. */
  font-size: 0.9em;
}
/* Spaces between the mixed-style runs — a real space would get collapsed
   at the start of each inline element's own line-wrap, so this guarantees
   one even where two runs land back to back. */
.work-row-title::after,
.work-row-tagline::after {
  content: " ";
}

/* Circular glass-lens reveal — each hover moves it to a random spot within
   the section's right half (computed in script.js), so different titles
   reveal their image through a circle in a different place rather than
   all sharing one window. */
.work-preview {
  /* Relative to .masonry-section (position:relative above), not the
     viewport — keeps it confined to the work section regardless of scroll.
     top/right stay fixed at the anchor corner; script.js moves the box by
     setting transform: translate() instead (see below) rather than
     animating top/right directly. */
  position: absolute;
  top: 0;
  right: 0;
  width: clamp(300px, 30vw, 460px);
  aspect-ratio: 1 / 1;
  border-radius: 50%;
  overflow: hidden;
  z-index: 15;
  pointer-events: none;
  /* White drafting paper as the base layer — same tone as
     .blueprint-layer's --blueprint-paper. The opaque image (below) sits on
     top of it, so this only actually shows around/past the photo. */
  background-color: oklch(97% 0.006 264);
  /* This — not border-radius/overflow:hidden above — is what actually
     determines the edge you see: a soft alpha fade of the whole circle
     (paper + photo together) down to nothing, well before the hard
     geometric border-radius clip is ever reached. "closest-side" matters
     here: without it, percentages default to being measured against the
     box's *farthest corner* (its diagonal), not where the circle actually
     meets the box edge — on a square box those two distances differ by
     ~41%, so "88% transparent" was finishing well past the real edge,
     meaning overflow:hidden was still cutting off a not-yet-transparent
     ring. closest-side makes 100% land exactly on the real boundary. */
  mask-image: radial-gradient(circle closest-side at 50% 50%, black 45%, transparent 96%);
  -webkit-mask-image: radial-gradient(circle closest-side at 50% 50%, black 45%, transparent 96%);
  /* Grows from nothing at its own center to fully open — same "always
     there, just clipped away until revealed" logic as the hero's own lens
     (.blueprint-layer), not an object popping into existence. 75%
     comfortably exceeds what's needed to cover the box, so the exact edge
     doesn't matter — border-radius already clips it to a circle regardless. */
  clip-path: circle(0% at 50% 50%);
  /* transform (not top/right) is what actually animates the move to a new
     random spot — top/right are layout properties, so animating them
     forces a reflow every frame; transform is compositor-only and doesn't,
     which is what actually made this smoother. */
  transition:
    clip-path 0.5s var(--ease-out-expo),
    transform 0.5s var(--ease-out-expo);
}
.work-preview.is-visible {
  clip-path: circle(75% at 50% 50%);
}
.work-preview-img {
  position: absolute;
  /* Always fills the full circle — sizing is no longer done via inset
     (setting all four sides on an intrinsically-sized <img> is an
     over-constrained case browsers resolve inconsistently, which is what
     caused the "grew off-center instead of growing symmetrically" bug).
     Per-project size is controlled with transform: scale() instead (see
     script.js), which scales from the element's own center and has no
     such ambiguity. */
  inset: 0;
  width: 100%;
  height: 100%;
  /* contain (not cover) shows the whole image uncropped, scaled down to
     fit — cover would still crop it first and only shrink the already-
     cropped result. */
  object-fit: contain;
  transform: scale(0.85);
}

/* ---------------------------------------------------
   Project page (mise.html etc.) — light theme override
--------------------------------------------------- */
/* Everything else in the file (header capsule, footer, reveal, etc.) is
   already built on these variables rather than hardcoded colors, so
   overriding them once here is enough to flip the whole page from the
   homepage's dark theme to white/black without touching that theme
   itself — only pages whose <body> carries this class are affected. */
body.project-page {
  --color-paper: #ffffff;
  --color-surface: oklch(97% 0.006 264);
  --color-border: oklch(90% 0.006 264);
  --color-ink: oklch(20% 0.01 264);
  --color-muted: oklch(45% 0.012 264);
  --color-faint: oklch(62% 0.01 264);
}

.project-hero-banner {
  width: 100%;
  /* Matches the source SVG's own ratio (1920×756) so it never needs to
     crop — the box is already the same shape as the artwork, at any
     viewport width. */
  aspect-ratio: 1920 / 756;
}
.noallergy-hero-inner {
  padding-block: var(--space-5xl) var(--space-3xl);
}
/* Text (left) and the extension-in-context screenshot (right), side by
   side — the image fills the previously-empty right column without
   touching how the text itself wraps or sizes, since it's just a sibling
   column, not something the text flows around. */
.noallergy-intro-row {
  display: grid;
  grid-template-columns: 3fr 2fr;
  gap: var(--space-3xl);
  align-items: start;
  margin-bottom: var(--space-3xl);
}
/* Only zeroes out when intro-row is the section's last element (NoAllergy's
   hero, now that Problem/Solution moved to their own section) — Changeful's
   hero still has .changeful-overview following it and keeps the gap. */
.noallergy-intro-row:last-child { margin-bottom: 0; }
.noallergy-intro-img {
  width: 100%;
  border-radius: var(--radius-md);
}

.noallergy-problem-solution {
  padding-block: var(--space-xl) var(--space-3xl);
}
.noallergy-functions {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--space-3xl);
  padding-block: var(--space-3xl);
}
.noallergy-function-eyebrow {
  color: var(--color-faint);
  font-size: var(--text-small);
}
.noallergy-function-title {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: clamp(1.25rem, 2.5vw, 1.75rem);
  margin-top: var(--space-xs);
  margin-bottom: var(--space-lg);
}
.noallergy-function-body {
  color: var(--color-muted);
  max-width: 45ch;
  margin-bottom: var(--space-2xl);
}
.noallergy-function-img {
  width: 100%;
  border-radius: var(--radius-md);
  border: 1px solid var(--color-border);
}

/* Research + User Journey/Flow: full-width process sections reusing the
   Function heading style (same page, same weight), rather than the
   two-column Function grid. */
.noallergy-research {
  padding-block: var(--space-3xl);
}
.noallergy-design-system {
  padding-block: var(--space-3xl);
}
.noallergy-final {
  padding-block: var(--space-3xl);
}
.noallergy-final-row {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: var(--space-xl);
  margin-top: var(--space-2xl);
}
.noallergy-final-img {
  width: 100%;
  border-radius: var(--radius-md);
}
.noallergy-final-img--wide {
  margin-top: var(--space-2xl);
}
.noallergy-research-body {
  color: var(--color-muted);
  margin-bottom: var(--space-lg);
}
.noallergy-research-img {
  width: 100%;
  border-radius: var(--radius-md);
  margin-top: var(--space-2xl);
  margin-bottom: var(--space-2xl);
}
.noallergy-research-img:last-child { margin-bottom: 0; }
.noallergy-research-row {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: var(--space-xl);
  margin-top: var(--space-4xl);
}
.noallergy-research > .noallergy-research-item {
  margin-top: var(--space-2xl);
}
.noallergy-research > .noallergy-research-item .noallergy-research-img {
  width: 70%;
  margin-inline: auto;
}
/* Two journey maps shown as an overlapping stack instead of side by side:
   the back one sits in flow, the front one is pulled up and shifted right
   over it via negative margin (not absolute positioning), so the stack's
   own height stays accurate without manual overflow accounting. */
.noallergy-research-stack {
  position: relative;
  margin-top: var(--space-2xl);
}
.noallergy-research-stack-img {
  display: block;
  width: 82%;
  border-radius: var(--radius-md);
  background: var(--color-paper);
}
.noallergy-research-stack-img--back {
  position: relative;
  z-index: 1;
}
.noallergy-research-stack-img--front {
  margin-top: -16%;
  margin-left: 18%;
  position: relative;
  z-index: 2;
  box-shadow: 0 16px 32px rgba(0, 0, 0, 0.14);
}
/* h1.noallergy-title (not just .noallergy-title) so this reliably beats
   .project-headline's own margin-bottom: 96px regardless of which rule
   happens to be declared later in the file — same class-selector
   specificity as that rule, plus the element type, wins the tie instead
   of depending on source order. */
h1.noallergy-title {
  font-weight: 800;
  margin-bottom: 30px;
}
.noallergy-tagline {
  font-family: var(--font-display);
  font-weight: 600;
  font-size: clamp(1.1rem, 2vw, 1.5rem);
  margin-bottom: var(--space-lg);
}
.noallergy-description {
  color: var(--color-muted);
  max-width: 55ch;
  margin-bottom: var(--space-3xl);
}
/* Role/Team/Focus, each its own small uppercase label above a row of pill
   tags — same idea as the reference's MADE IN / TEAM / DURATION / FOCUS
   groups, kept in this page's own light theme instead of copying its
   dark one. */
.noallergy-facts-row {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-3xl);
  margin-bottom: var(--space-3xl);
}
.noallergy-fact-label {
  color: var(--color-faint);
  font-size: var(--text-small);
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  margin-bottom: var(--space-sm);
}
.noallergy-fact-pill {
  display: inline-block;
  padding: var(--space-xs) var(--space-lg);
  border-radius: 999px;
  border: 1px solid var(--color-border);
  font-size: var(--text-small);
  margin-right: var(--space-sm);
}

.project-header {
  padding-block: var(--space-5xl) var(--space-3xl);
}
.project-headline {
  font-family: var(--font-display);
  font-weight: 600;
  font-size: clamp(2.25rem, 5vw, 3.75rem);
  letter-spacing: -0.01em;
  line-height: 1.1;
  margin-bottom: var(--space-5xl);
}
.project-facts {
  display: flex;
  flex-direction: column;
  gap: var(--space-3xl);
  margin-left: 38%;
}
.project-fact {
  display: grid;
  grid-template-columns: 160px 1fr;
  gap: var(--space-xl);
}
.project-fact-label {
  font-weight: 700;
}
.project-fact-value {
  color: var(--color-muted);
  display: flex;
  flex-direction: column;
  gap: var(--space-md);
}


/* Label-above-paragraph blocks (My Role, The Challenge, The Solution) —
   distinct from .project-fact's label-beside-value row layout above,
   since this reference puts the label directly above its own text
   instead of in a separate left column. */
.project-block {
  margin-bottom: var(--space-2xl);
}
.project-block:last-child { margin-bottom: 0; }
.project-block-label {
  font-weight: 700;
  margin-bottom: var(--space-lg);
}
.project-block p:not(.project-block-label) {
  color: var(--color-muted);
}
.project-block-row {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--space-3xl);
}

/* Overview: eyebrow + big headline + short body, then a numbered
   01/02/03 breakdown — same shape as the reference (eyebrow, serif
   headline, three-column numbered list), kept in this page's own
   typeface/weight rather than switching to an italic serif for it. */
.section-eyebrow {
  color: var(--color-faint);
  font-size: var(--text-small);
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  margin-bottom: var(--space-lg);
}
.section-title {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: clamp(1.75rem, 3.5vw, 2.75rem);
  line-height: 1.2;
  max-width: 26ch;
  margin-bottom: var(--space-lg);
}
.section-body {
  color: var(--color-muted);
  max-width: 55ch;
  margin-bottom: var(--space-2xl);
}
.changeful-overview-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: var(--space-3xl);
}
.changeful-overview-index {
  color: var(--color-faint);
  font-size: var(--text-small);
  font-weight: 600;
  margin-bottom: var(--space-md);
}
.changeful-overview-item-title {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 1.25rem;
  margin-bottom: var(--space-sm);
}
.changeful-overview-item p {
  color: var(--color-muted);
}

/* Image-left, text-right feature rows — the image is a single composite
   screenshot (two phone mockups side by side already baked into one
   file), so it just needs to sit in a wide column; the text stays
   vertically centered against it. */
.changeful-feature {
  display: grid;
  grid-template-columns: 3fr 2fr;
  gap: var(--space-3xl);
  align-items: center;
  padding-block: var(--space-3xl);
}
.changeful-feature-img {
  width: 100%;
}
.changeful-feature-eyebrow {
  color: var(--color-faint);
  font-size: var(--text-small);
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  margin-bottom: var(--space-md);
}
.changeful-feature-title {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: clamp(1.25rem, 2.5vw, 1.75rem);
  margin-bottom: var(--space-md);
}
.changeful-feature-text p:not(.changeful-feature-eyebrow) {
  color: var(--color-muted);
  max-width: 45ch;
}

/* Previous/Solution comparison: two equal columns, each an eyebrow
   ("A — Previous" / "B — Solution"), a muted headline, and a paragraph —
   contrasting old behavior against Changeful's, side by side so the
   before/after reads at a glance without needing a diagram. */
.changeful-compare-group {
  padding-block: var(--space-3xl);
}
.changeful-compare {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--space-3xl);
  margin-bottom: var(--space-5xl);
}
.changeful-compare:last-child {
  margin-bottom: 0;
}
.changeful-compare-eyebrow {
  color: var(--color-faint);
  font-size: var(--text-small);
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  margin-bottom: var(--space-lg);
}
.changeful-compare-col p:not(.changeful-compare-eyebrow) {
  color: var(--color-muted);
}

/* Design System: color swatches (rounded chips, matching hex printed
   underneath) and a type specimen list (one row per weight actually used
   in the Poppins family, sample word set in that real weight next to its
   name) — a compact, legible stand-in for the fuller style guide this
   project doesn't have documented elsewhere. */
.changeful-design-system {
  padding-block: var(--space-3xl);
}
/* Colors (left) and Typography (right), side by side and top-aligned. */
.changeful-ds-row {
  display: grid;
  grid-template-columns: 3fr 2fr;
  gap: var(--space-3xl);
  align-items: start;
  margin-top: var(--space-2xl);
}
.changeful-ds-scales {
  display: flex;
  flex-direction: column;
  gap: var(--space-2xl);
}
.changeful-ds-scale-name {
  font-weight: 600;
  margin-bottom: var(--space-md);
}
.changeful-ds-scale-tag {
  font-weight: 400;
  color: var(--color-faint);
  font-size: var(--text-small);
  margin-left: var(--space-sm);
}
.changeful-ds-scale-row {
  display: grid;
  /* Fixed size (not 1fr) so each swatch stays a small, consistent chip
     instead of stretching to fill the row's full width. */
  grid-template-columns: repeat(5, 108px);
  gap: var(--space-sm);
}
.changeful-ds-swatch {
  aspect-ratio: 4 / 3;
  border-radius: var(--radius-md);
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  padding: var(--space-sm);
  gap: 2px;
  color: #383838;
  font-size: 0.7rem;
  font-family: ui-monospace, "SF Mono", Menlo, monospace;
}
.changeful-ds-swatch--dark {
  color: #fff;
}
.changeful-ds-type {
  display: flex;
  flex-direction: column;
}
.changeful-ds-type-header {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--space-xl);
  padding-bottom: var(--space-md);
  border-bottom: 1px solid var(--color-border);
  color: var(--color-faint);
  font-size: var(--text-small);
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.06em;
}
.changeful-ds-type-row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--space-xl);
  padding-block: var(--space-lg);
  border-bottom: 1px solid var(--color-border);
}
.changeful-ds-type-row:last-child {
  border-bottom: none;
}
.changeful-ds-type-sample {
  font-family: "Poppins", sans-serif;
  color: var(--color-ink);
  line-height: 1;
}
.changeful-ds-type-label {
  color: var(--color-muted);
  font-size: var(--text-small);
  white-space: nowrap;
}

.changeful-final {
  padding-block: var(--space-3xl);
}
.changeful-final-img {
  width: 100%;
  border-radius: var(--radius-md);
  margin-top: var(--space-xl);
}


/* Carousel shell: arrows/dots are positioned relative to this, the
   viewport clips everything to one slide's width, and the track is the
   flex row of slides that actually gets translateX'd (script.js) to
   switch between them. */
.project-media-carousel {
  position: relative;
  /* No padding-block here — .project-media (the slide itself) has its
     own now, which is what actually matters (see its comment); doubling
     it here on top of that would just be extra empty space. */
}
.project-media-viewport {
  overflow: hidden;
}
.project-media-track {
  display: flex;
  transition: transform 0.6s var(--ease-out-expo);
}
.project-media {
  flex: 0 0 100%;
  width: 100%;
  display: grid;
  grid-template-columns: 3fr 2fr;
  gap: var(--space-3xl);
  align-items: center;
  /* .project-media-viewport needs overflow:hidden to clip the other
     carousel slides, but with no room built into the slide itself, that
     same clip was also cutting the video card's box-shadow off in a hard
     line wherever the viewport's boundary happened to land (top/bottom
     AND left/right — the video card sits flush against the slide's own
     edges, so the same clipping hit the shadow horizontally too) — this
     padding is buffer space for the shadow to bleed into and fully fade
     out before it ever reaches that boundary. */
  padding: var(--space-3xl);
}
.project-media-arrow {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  width: 44px;
  height: 44px;
  border-radius: 50%;
  border: none;
  /* Solid dark fill (not the near-white --color-surface this used to be)
     so the button itself reads clearly against the white page — the old
     version relied on just a soft shadow for definition, which wasn't
     enough contrast to see at a glance. */
  background: var(--color-ink);
  box-shadow: 0 8px 24px -8px rgba(20, 22, 30, 0.45);
  color: var(--color-paper);
  font-size: 1.25rem;
  line-height: 1;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 2;
}
.project-media-arrow:disabled {
  opacity: 0.35;
  cursor: default;
}
.project-media-arrow-prev {
  left: -22px;
}
.project-media-arrow-next {
  right: -22px;
}
.project-media-dots {
  display: flex;
  justify-content: center;
  gap: var(--space-sm);
  margin-top: var(--space-xl);
}
.project-media-dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  border: none;
  padding: 0;
  background: var(--color-border);
  cursor: pointer;
}
.project-media-dot.is-active {
  background: var(--color-ink);
}
/* Soft white card housing the screen recording — matches the reference: a
   large rounded surface, subtle shadow, the phone-width video centered
   inside with generous breathing room around it, rather than the video
   filling the card edge-to-edge. */
.project-media-video {
  border-radius: var(--radius-lg);
  overflow: hidden;
  box-shadow: 0 32px 70px -20px rgba(20, 22, 30, 0.38);
  /* Explicit height (not just letting the video's own aspect ratio decide
     it) is what actually grows the container — a transform: scale() on
     the video alone paints bigger without changing the box it's clipped
     against, which isn't the same thing. */
  height: clamp(420px, 55vw, 680px);
  /* Scales the box and the video together, uniformly, from its own
     center — the video keeps the exact same relationship to its
     container (same crop, same proportions) instead of stretching or
     changing its zoom level; the whole thing just renders 10% smaller. */
  transform: scale(0.9);
}
.project-media-video-el {
  width: 100%;
  height: 100%;
  display: block;
  /* cover crops to fill this now-taller box — since the source footage
     already has blank margin on its left/right sides, losing some of
     that margin costs nothing visually while using the extra height. */
  object-fit: cover;
}
.project-media-eyebrow {
  color: var(--color-faint);
  font-size: var(--text-small);
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  margin-bottom: var(--space-md);
}
.project-media-title {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: clamp(1.75rem, 3vw, 2.5rem);
  line-height: 1.15;
  margin-bottom: var(--space-lg);
}
.project-media-text p {
  color: var(--color-ink);
  margin-bottom: var(--space-lg);
}
.project-media-list {
  display: flex;
  flex-direction: column;
  gap: var(--space-md);
}
.project-media-list li {
  position: relative;
  padding-left: var(--space-lg);
  color: var(--color-muted);
}
.project-media-list li::before {
  content: "";
  position: absolute;
  left: 2px;
  top: 0.6em;
  width: 5px;
  height: 5px;
  border-radius: 50%;
  background: var(--color-faint);
}

.project-tech {
  padding-block: var(--space-3xl);
}
.project-tech-reflection {
  display: flex;
  flex-direction: column;
  gap: var(--space-md);
}
.project-tech-reflection p:not(.project-tech-eyebrow) {
  color: var(--color-muted);
}
.project-tech-eyebrow {
  color: var(--color-faint);
  font-size: var(--text-small);
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  margin-bottom: var(--space-md);
}

/* ---------------------------------------------------
   Footer
--------------------------------------------------- */
.site-footer { padding-block: var(--space-3xl); border-top: 1px solid var(--color-border); }
.footer-row {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: var(--space-xs);
}
.footer-copyright,
.footer-rights {
  color: var(--color-faint);
  font-size: var(--text-small);
}

/* ---------------------------------------------------
   Reveal-on-scroll
--------------------------------------------------- */
.reveal {
  opacity: 0;
  transform: translateY(30px);
  transition: opacity 0.7s var(--ease-out-expo), transform 0.7s var(--ease-out-expo);
}
.reveal.is-visible { opacity: 1; transform: translateY(0); }

@media (prefers-reduced-motion: reduce) {
  .reveal { opacity: 1; transform: none; transition: none; }
  .grain { animation: none; }
}

/* ---------------------------------------------------
   Responsive
--------------------------------------------------- */
@media (max-width: 860px) {
  .site-nav { gap: var(--space-lg); }
  .changeful-ds-row {
    grid-template-columns: 1fr;
    gap: var(--space-2xl);
  }
  .noallergy-intro-row {
    grid-template-columns: 1fr;
  }
  .noallergy-functions {
    grid-template-columns: 1fr;
    gap: var(--space-2xl);
  }
  .noallergy-research-row {
    grid-template-columns: 1fr;
    gap: var(--space-2xl);
  }
  .noallergy-final-row {
    grid-template-columns: 1fr;
    gap: var(--space-2xl);
  }
  .project-fact {
    grid-template-columns: 1fr;
    gap: var(--space-xs);
  }
  .project-media {
    grid-template-columns: 1fr;
  }
  .project-media-arrow-prev {
    left: 4px;
  }
  .project-media-arrow-next {
    right: 4px;
  }
  .project-block-row {
    grid-template-columns: 1fr;
    gap: var(--space-xl);
  }
  .changeful-overview-grid {
    grid-template-columns: 1fr;
    gap: var(--space-xl);
  }
  .changeful-feature {
    grid-template-columns: 1fr;
  }
  .changeful-compare {
    grid-template-columns: 1fr;
    gap: var(--space-xl);
  }
}

