/* =========================================================================
   jasminexiong.com
   Two families only: Averia Serif Libre for display, Source Serif 4 for
   everything else. Labels and buttons get their character from caps and
   letter-spacing rather than a third face.
   ========================================================================= */

/* ---- Fonts ------------------------------------------------------------ */

@font-face {
  font-family: "Source Serif 4";
  src: url("/assets/fonts/sourceserif-regular.woff2") format("woff2");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: "Source Serif 4";
  src: url("/assets/fonts/sourceserif-italic.woff2") format("woff2");
  font-weight: 400;
  font-style: italic;
  font-display: swap;
}
@font-face {
  font-family: "Source Serif 4";
  src: url("/assets/fonts/sourceserif-semibold.woff2") format("woff2");
  font-weight: 600;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: "Playfair Display";
  src: url("/assets/fonts/playfair-regular.woff2") format("woff2");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: "Playfair Display";
  src: url("/assets/fonts/playfair-bold.woff2") format("woff2");
  font-weight: 700;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: "DM Serif Display";
  src: url("/assets/fonts/dmserif-regular.woff2") format("woff2");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

/* ---- Tokens ----------------------------------------------------------- */

:root {
  color-scheme: light dark;

  /* Drives the spacing scale. */
  --grid: 8px;

  --s-1: var(--grid);
  --s-2: calc(var(--grid) * 2);
  --s-3: calc(var(--grid) * 3);
  --s-4: calc(var(--grid) * 4);
  --s-5: calc(var(--grid) * 5);
  --s-6: calc(var(--grid) * 6);
  --s-8: calc(var(--grid) * 8);
  --s-12: calc(var(--grid) * 12);
  --s-16: calc(var(--grid) * 16);

  --measure: 36rem;
  /* 69rem − 2×gutter = 66rem of content: the same width as the work strip's
     heading, so the header, the case study pages, and the home page all share
     one left edge. Prose stays capped at --measure inside it. */
  --measure-wide: 69rem;
  --gutter: var(--s-3);

  --font-body: "Source Serif 4", Georgia, "Times New Roman", serif;
  --font-display: "Playfair Display", Georgia, "Times New Roman", serif;
  --font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  /* Figures only. Playfair's hairlines vanish at metric size; DM Serif keeps
     weight in the thins, so the numbers stay readable and stay serif. */
  --font-figure: "DM Serif Display", Georgia, "Times New Roman", serif;

  /* Soft cream and black, with the red from the face illustration carrying
     the warmth. */
  --cream: #f0e0d9;
  --ink:   #14120f;
  --red:   #d05a52;

  --bg:      light-dark(#f0e0d9, #14120f);
  --fg:      light-dark(#14120f, #f0e4da);
  /* Cards lift off the ground rather than glaring white against it. */
  --paper:   light-dark(#faf5ef, #1c1a16);
  --muted:   light-dark(
               color-mix(in oklab, #14120f 74%, #f0e0d9),
               color-mix(in oklab, #f0e4da 72%, #14120f));
  --rule:    light-dark(
               color-mix(in oklab, #14120f 20%, #f0e0d9),
               color-mix(in oklab, #f0e4da 17%, #14120f));
  --accent:  light-dark(#a83a2e, #e2857b);
  --mark:    light-dark(#e79a90, color-mix(in oklab, #d05a52 62%, #14120f));
  --eye-white: light-dark(#fffdfa, #1c1a16);
  --blush:   #d05a52;
  --snore:   #9db8e9;
  --blush-alpha: light-dark(0.85, 0.95);


  --ease: cubic-bezier(0.2, 0, 0, 1);
  --dur: 200ms;
}

:root[data-theme="light"] { color-scheme: light; }
:root[data-theme="dark"]  { color-scheme: dark; }

/* ---- Reset ------------------------------------------------------------ */

*, *::before, *::after { box-sizing: border-box; }

html { -webkit-text-size-adjust: 100%; }

body {
  margin: 0;
  overflow-x: clip;
  background: var(--bg);
  color: var(--fg);
  font-family: var(--font-body);
  font-size: 1.0625rem;
  line-height: 1.7;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
}

img, svg, video { display: block; max-width: 100%; }
img, video { height: auto; }
/* The author-origin `display: block` above outranks the UA's `[hidden]`
   rule whatever the specificity, so hidden media needs saying twice. */
img[hidden], svg[hidden], video[hidden] { display: none; }

/* ---- Layout ----------------------------------------------------------- */

.wrap {
  width: 100%;
  max-width: var(--measure);
  margin-inline: auto;
  padding-inline: var(--gutter);
}

.wrap--wide { max-width: var(--measure-wide); }

/* Every page uses the same outer column so the frame never jumps between
   Work and About; prose is constrained separately for readability. */
main.wrap { max-width: var(--measure-wide); }
main { padding-bottom: var(--s-16); }

main :is(p, h1, h2, h3, .lede, .entries, .note, .cs-outcome, .eyebrow, .meta) {
  max-width: var(--measure);
}

/* The nameplate is an h1 but is meant to run the full width. */
main .nameplate,
main .hero-typing { max-width: none; }

/* The work grid shares the wide measure with everything else on the page, so
   the heading, the cards and the header all sit on one left edge. */
#work {
  width: min(66rem, 100vw - 3rem);
  margin-inline: auto;
}

#work .section-rule { max-width: none; }

section { margin-top: var(--s-12); }

/* ---- Header ----------------------------------------------------------- */

.site-header {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: var(--s-1) var(--s-4);
  padding-block: var(--s-4) var(--s-8);
}

.wordmark {
  /* Deliberately NOT view-transition-name'd. It used to be, to hold the mark
     still across navigations — but snapshots are taken in viewport coordinates,
     so clicking a tile from anywhere below the fold meant the old wordmark was
     above the viewport and the browser flew it back in. Letting it ride along
     with `root` keeps it still in the only way that actually holds. */
  display: inline-flex;
  align-items: center;
  gap: 0.45em;
  font-family: var(--font-display);
  font-size: 1.0625rem;
  letter-spacing: 0.005em;
  text-decoration: none;
  color: var(--fg);
  white-space: nowrap;
}

/* Black artwork on transparency, so dark mode just inverts it. */
.wordmark img { width: 3.4rem; height: auto; }
:root[data-theme="dark"] .wordmark img { filter: invert(1); }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .wordmark img { filter: invert(1); }
}

.site-nav {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-3);
  font-family: var(--font-body);
  font-size: 0.875rem;
  letter-spacing: 0.09em;
  text-transform: uppercase;
}

.site-nav a {
  color: var(--muted);
  text-decoration: none;
  padding-block: 3px 2px;
  border-bottom: 1px solid transparent;
  transition: color var(--dur) var(--ease), border-color var(--dur) var(--ease);
}

.site-nav a:hover { color: var(--fg); border-bottom-color: var(--accent); }
.site-nav a[aria-current="page"] { color: var(--fg); border-bottom-color: var(--accent); }

/* Nav and theme toggle share the right-hand end of the header, separated by a
   hairline so the toggle doesn't read as a fourth nav item. */
.header-end {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--s-2) var(--s-3);
}

.header-end .theme-toggle { padding-left: var(--s-3); position: relative; }

.header-end .theme-toggle::before {
  content: "";
  position: absolute;
  left: 0;
  top: 0.1em;
  bottom: 0.1em;
  border-left: 1px solid var(--rule);
}

/* ---- Typography ------------------------------------------------------- */

h1, h2, h3 {
  font-family: var(--font-display);
  font-weight: 400;
  letter-spacing: -0.015em;
  text-wrap: balance;
}

h1 {
  margin: 0 0 var(--s-3);
  font-size: clamp(1.5rem, 1.15rem + 1.75vw, 2.5rem);
  line-height: 1.2;
}

h2 {
  margin: 0 0 var(--s-3);
  font-size: 1.25rem;
  line-height: 1.4;
}

h3 {
  margin: 0 0 var(--s-1);
  font-size: 1.0625rem;
  line-height: 1.5;
}

p { margin: 0 0 var(--s-3); }

/* About — the four principles. The number rides in the heading itself
   ("One - Stay curious"), and the title carries the same hand-drawn scribble
   the case-study bet titles use, so the two pages share one underline idiom.
   See .doodle--underline. */
.principle {
  max-width: var(--measure);
  /* Bottom margin matches h3:has(.bet-title) — the scribble hangs below the
     baseline and needs the room, or it lands on the paragraph underneath. */
  margin: var(--s-5) 0 var(--s-3);
  font-size: 1.25rem;
  text-wrap: pretty;
}

/* Each h3 is the only one of its type inside its own .principle-row, so the
   old :first-of-type rule would zero out the gap above every principle, not
   just the first. Scope it to the first row instead. */
.principle-row:first-child .principle { margin-top: 0; }

/* Each principle runs alongside a photo. Same rule as .cs-split: prose on the
   left at the reading measure, photo on the right, never alternating - the
   words keep the one left edge they share with the intro, the now-list and the
   contact block, so nothing on the page has to be re-found after a photo.
   The photo column is deliberately small; these are snapshots in the margin
   rather than case-study assets. */
/* No row-gap: stacked, the heading's own margin-bottom and the last
   paragraph's already space this out, and a gap on top of them doubles it. */
.principle-row { display: grid; align-items: start; }

/* .principle-text carries no styles of its own on narrow screens - the
   paragraphs keep their own margins and the figure simply follows them. */
.principle-figure {
  margin: 0 0 var(--s-2);
  max-width: min(13rem, 100%);
}

.principle-figure img {
  width: 100%;
  aspect-ratio: 1;
  /* The source PNGs carry a rounded corner in their alpha channel already, but
     only a 1.8%-of-width one. Anything comfortably above that swallows the
     baked curve whole rather than clipping a second curve inside it. Kept
     modest so the photo's corner stays near the drawn frame's crossing lines
     instead of pulling away from them. */
  border-radius: 0.375rem;
}

/* The drawn frame: one continuous wobbling loop that closes a little past its
   own start, the way a box drawn in a single pen stroke does. Four separate
   strokes read as dashes with tick marks at the corners; one path reads as a
   frame. The wrapper only exists to give it something to be absolute in - the
   caption stays outside it and never gets covered. */
.principle-frame { position: relative; }

/* Doubled class: the generic `.doodle { color: var(--accent) }` sits further
   down the file at the same specificity and would otherwise win on source
   order, painting the frame red. */
.doodle.doodle--frame {
  position: absolute;
  /* Slightly outside the photo, so the line frames it rather than running
     along its edge and getting lost against a busy picture. */
  inset: -0.3rem;
  width: 100%;
  height: 100%;
  /* The global `img, svg, video { max-width: 100% }` is fine here, but the
     paths run past the viewBox on every side and need to stay visible - that
     comes from .doodle's own `overflow: visible`. */
  pointer-events: none;
  /* Ink, not literal black: on the dark theme --fg flips to cream, so the
     line stays a drawn line instead of disappearing into the background. */
  color: var(--fg);
}

/* No non-scaling-stroke here, unlike every other doodle. Chrome normalises
   pathLength="1" in user space but measures stroke-dasharray in device space,
   so with a non-scaling stroke the draw-on dash comes out short by exactly the
   viewBox scale factor - this frame maps a 100-unit box onto 13rem (~2.08x)
   and stopped drawing at 48%. The other doodles scale *down*, which makes the
   same dash harmlessly too long, which is why only this one showed it.
   Letting the stroke scale puts the dash and the geometry in one space again.
   1.1 user units over that 2.08x is ~2.3px: heavier than a margin doodle,
   lighter than the heading scribble. Doubled class to outrank `.doodle path`. */
.doodle.doodle--frame path {
  stroke-width: 1.1;
  vector-effect: none;
}

/* One squiggle drawn four times reads as a repeat, so each frame gets a
   different reflection of the same path. Cheaper than four hand-tuned paths
   and the wobble lands somewhere new every time. */
.principle-row:nth-child(2) .doodle--frame { transform: scaleX(-1); }
.principle-row:nth-child(3) .doodle--frame { transform: scaleY(-1); }
.principle-row:nth-child(4) .doodle--frame { transform: scale(-1); }

@media (min-width: 56rem) {
  .principle-row {
    grid-template-columns: minmax(0, var(--measure)) minmax(0, 13rem);
    gap: 0 clamp(3rem, 7vw, 6rem);
  }
  /* The heading spans both columns and the photo shares a grid row with the
     paragraphs, so the top of the photo lands on the top of the prose rather
     than up beside the title and its scribble. Grid does the aligning, so it
     stays true whatever the heading wraps to. */
  .principle { grid-column: 1 / -1; }
  .principle-text > :last-child { margin-bottom: 0; }
  .principle-figure { max-width: none; margin: 0; }
}

.principle-n {
  color: var(--muted);
  font-size: 0.85em;
  letter-spacing: 0.1em;
  text-transform: uppercase;
}


a { color: var(--accent); text-underline-offset: 0.2em; text-decoration-thickness: 1px; }
a:hover { text-decoration: none; }

strong { font-weight: 700; }

/* Eyebrow — the small label above a heading. */
.eyebrow {
  margin: 0 0 var(--s-2);
  font-family: var(--font-body);
  font-size: 0.875rem;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--muted);
}

.lede {
  font-size: 1.125rem;
  line-height: 1.7;
  color: var(--fg);
  text-wrap: pretty;
}

.section-rule {
  margin: 0 0 var(--s-4);
  padding-bottom: var(--s-1);
  border-bottom: 1px solid var(--rule);
  font-family: var(--font-body);
  font-size: 0.75rem;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--muted);
}

/* On case study pages the section headings are real titles, not eyebrows —
   seven small uppercase labels in a row read as "a lot of small text".
   Scoped to <article> so About and the home page keep the label style.
   The heading TEXT (Context, The problem…) is placeholder wording Jasmine
   is rewriting; only the dress changes here. */
article .section-rule {
  border-bottom: 0;
  padding-bottom: 0;
  margin-bottom: var(--s-3);
  font-family: var(--font-display);
  font-size: clamp(1.5rem, 3vw, 1.875rem);
  font-weight: 400;
  line-height: 1.25;
  letter-spacing: 0;
  text-transform: none;
  color: var(--fg);
}

/* Marker highlight sitting under the baseline, the way a real one would. */
.mark {
  background-image: linear-gradient(var(--mark), var(--mark));
  background-repeat: no-repeat;
  background-position: 0 88%;
  background-size: 100% 0.42em;
  padding-inline: 0.06em;
}

/* ---- Status line (playful microcopy) ---------------------------------- */

.status {
  display: inline-flex;
  align-items: baseline;
  gap: 0.5em;
  font-family: var(--font-body);
  font-size: 0.75rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--muted);
}

.status::before {
  content: "";
  flex: none;
  align-self: flex-start;
  width: 6px;
  height: 6px;
  margin-top: 0.55em;
  border-radius: 50%;
  background: var(--accent);
}

/* ---- Intro + eyes ----------------------------------------------------- */

.intro { margin-top: var(--s-4); }

.intro-head {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: var(--s-4);
}

/* ---- The face -------------------------------------------------------- */
/* Solid hand-drawn shapes rather than outlines. The eyes read as filled
   almonds; the catchlight inside each one is what follows the cursor. */

.face {
  width: min(26rem, 74vw);
  height: auto;
  margin-inline: auto;
}

/* The face drifts behind the pupils when following the cursor. The lag is
   the point — it reads as a head turning rather than a rigid pan. */
@media (prefers-reduced-motion: no-preference) {
  .face { transition: translate 260ms cubic-bezier(0.22, 0.61, 0.36, 1); }
}

.face--sm { width: min(11rem, 40vw); margin-inline: 0; }

/* Jasmine's exported parts. Fills are stripped in the source and assigned
   here so the whole face inverts with the theme. */
/* The eyes follow the theme: dark iris on cream in light, cream iris on dark
   in dark. Pinning them to fixed colours instead makes the iris match the
   dark background exactly, and the eyes collapse into crescents. */
.fc-white { fill: var(--eye-white); }
.fc-iris  { fill: var(--fg); }
.fc-spark { fill: var(--eye-white); }
.fc-shade { fill: var(--fg); opacity: 0.18; }
.fc-lid   { fill: var(--fg); }
.fc-closed { fill: var(--fg); opacity: 0; }

/* Snore bubble — hidden by default, shown while the lights are off. */
.face-wrap { position: relative; display: inline-block; }

.fc-snore { opacity: 0; transition: opacity 320ms var(--ease); }
/* Reads as a bubble: mostly see-through with a defined rim. stroke-width is
   in the path's own units, which sit inside the BUBBLE scale, so it renders
   thicker than the number suggests. */
.fc-bubble path {
  fill: var(--snore);
  fill-opacity: 0.34;
  stroke: var(--snore);
  stroke-width: 1.1;
  stroke-linejoin: round;
}

.fc-pop path { fill: var(--snore); fill-opacity: 0.85; }

/* Origin 0 0 is the group's own local origin — parked at the nostril by the
   parent translate — so the bubble grows out of the nose. */
.fc-bubble { transform-origin: 0 0; scale: 0.12; opacity: 0; }
.fc-pop { transform-origin: 0 0; opacity: 0; }

/* Only the bubble is interactive; the rest of the face never intercepts. */
.face { pointer-events: none; }
.fc-hit { pointer-events: none; }
.fc-snore.is-full .fc-hit { pointer-events: auto; cursor: pointer; }
.fc-cheek { opacity: var(--blush-alpha); }

.fc-line  { fill: var(--blush); }

/* Blink. Scaling the eye group is enough — the cheeks, nose and smile sit
   outside it, so they stay put. */
/* Lights off: the face sleeps — eyes stay shut, blinking stops, zzz drift up. */
:root[data-theme="dark"] .fc-open,
:root[data-theme="dark"] .fc-lid { opacity: 0 !important; animation: none !important; }
:root[data-theme="dark"] .fc-closed { opacity: 1 !important; animation: none !important; }
:root[data-theme="dark"] .fc-snore { opacity: 1; }

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .fc-open,
  :root:not([data-theme="light"]) .fc-lid { opacity: 0 !important; animation: none !important; }
  :root:not([data-theme="light"]) .fc-closed { opacity: 1 !important; animation: none !important; }
  :root:not([data-theme="light"]) .fc-snore { opacity: 1; }
}

@media (prefers-reduced-motion: no-preference) {
  /* Inflate from the nostril, then wait to be popped. snore.js drives the
     states; the transitions live here so it stays declarative. */
  .fc-bubble { transition: scale 3200ms cubic-bezier(0.25, 0.6, 0.3, 1), opacity 260ms linear; }

  /* One frame at nothing, with the transition off, so the next state grows
     from the nostril instead of easing down from the popped size. */
  .fc-snore.is-reset .fc-bubble { scale: 0.12; opacity: 0; transition: none; }

  .fc-snore.is-inflating .fc-bubble { scale: 1; opacity: 1; }

  .fc-snore.is-full .fc-bubble {
    scale: 1;
    opacity: 1;
    animation: bubble-idle 2.6s ease-in-out infinite;
  }

  .fc-snore.is-popping .fc-bubble {
    opacity: 0;
    scale: 1.1;
    transition: opacity 90ms linear, scale 120ms ease-out;
  }

  .fc-snore.is-popping .fc-pop { animation: snore-pop 300ms ease-out forwards; }

  /* Barely-there breathing, so a full bubble reads as waiting rather than stuck. */
  @keyframes bubble-idle {
    0%, 100% { scale: 1; }
    50%      { scale: 1.035; }
  }

  @keyframes snore-pop {
    0%   { opacity: 0; scale: 0.55; }
    35%  { opacity: 1; scale: 1; }
    75%  { opacity: 0.85; scale: 1.3; }
    100% { opacity: 0; scale: 1.4; }
  }

  /* A blink swaps cels rather than squashing. The two states cross-fade over
     ~55ms and overlap slightly, which reads as a lid travelling rather than a
     hard cut — and the close is quicker than the open, as a real blink is.

     One long 21s cycle holds seven blinks rather than one, at deliberately
     uneven gaps (0.85s to 4.2s) - about 20 a minute, a resting human rate.
     The pair at 8.0s and 8.85s is a double blink. Because the gaps never
     repeat inside the cycle, the rhythm reads as involuntary rather than
     metronomic; the loop point is the only regular thing about it.

     Blink starts, in ms into the cycle: 1900, 5100, 8000, 8850, 12300, 15200,
     18700. Each one runs the same 245ms cross-fade the single blink used to,
     so the percentages below are just those times mapped onto 21s - retime the
     list and recompute the whole set rather than nudging one stop. */
  .fc-open, .fc-lid { animation: blink-open 21s ease-in-out infinite; }
  .fc-closed { animation: blink-closed 21s ease-in-out infinite; }

  @keyframes blink-open {
    0%, 9.048% { opacity: 1; }
    9.31%, 9.833% { opacity: 0; }
    10.214%, 24.286% { opacity: 1; }
    24.548%, 25.071% { opacity: 0; }
    25.452%, 38.095% { opacity: 1; }
    38.357%, 38.881% { opacity: 0; }
    39.262%, 42.143% { opacity: 1; }
    42.405%, 42.929% { opacity: 0; }
    43.31%, 58.571% { opacity: 1; }
    58.833%, 59.357% { opacity: 0; }
    59.738%, 72.381% { opacity: 1; }
    72.643%, 73.167% { opacity: 0; }
    73.548%, 89.048% { opacity: 1; }
    89.31%, 89.833% { opacity: 0; }
    90.214%, 100% { opacity: 1; }
  }

  @keyframes blink-closed {
    0%, 9.119% { opacity: 0; }
    9.381%, 9.762% { opacity: 1; }
    10.143%, 24.357% { opacity: 0; }
    24.619%, 25% { opacity: 1; }
    25.381%, 38.167% { opacity: 0; }
    38.429%, 38.81% { opacity: 1; }
    39.19%, 42.214% { opacity: 0; }
    42.476%, 42.857% { opacity: 1; }
    43.238%, 58.643% { opacity: 0; }
    58.905%, 59.286% { opacity: 1; }
    59.667%, 72.452% { opacity: 0; }
    72.714%, 73.095% { opacity: 1; }
    73.476%, 89.119% { opacity: 0; }
    89.381%, 89.762% { opacity: 1; }
    90.143%, 100% { opacity: 0; }
  }
}

/* ---- Hero (above the fold) -------------------------------------------- */

/* Two beats above the fold: the face up top, the typing line in the middle,
   and the name filling the bottom edge. */
.hero {
  /* Subtract the header, which sits above this, then add back enough that the
     bottom quarter of the name falls past the fold — so scrolling reveals the
     rest of it rather than the name being cut off. The 9.6vw comes from the
     nameplate's aspect ratio: it renders at 0.236 x its width, and the fold
     should land three-quarters down the caps. */
  min-height: calc(100svh - 8rem + 9.6vw);
  margin-top: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: flex-start;
  gap: var(--s-4);
  padding-block: var(--s-4) var(--s-6);
  padding-inline: var(--s-2);
  text-align: center;
}

.hero .face-wrap { margin-top: auto; }
.hero .nameplate { margin-top: auto; }

.hero-typing {
  margin: 0;
  font-family: var(--font-display);
  font-size: clamp(1.25rem, 0.9rem + 1.6vw, 2.125rem);
  line-height: 1.35;
  letter-spacing: -0.005em;
}

.typed { color: var(--accent); }

.caret {
  display: inline-block;
  width: 0.07em;
  height: 1.05em;
  translate: 0 0.16em;
  background: var(--accent);
}

@media (prefers-reduced-motion: no-preference) {
  .caret { animation: caret 1.05s steps(1) infinite; }
  @keyframes caret { 50% { opacity: 0; } }
}

/* The name spans the full viewport, breaking out of the content column.
   Drawn as SVG text with textLength so it fits its box exactly at any width
   rather than guessing a vw size and overflowing. */
.nameplate {
  margin: 0;
  width: 100%;
}

.nameplate svg { display: block; width: 100%; height: auto; }
.nameplate text { font-family: var(--font-display); fill: var(--fg); }

.sr-only {
  position: absolute;
  width: 1px; height: 1px;
  padding: 0; margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

.hero-statement {
  margin: 0;
  max-width: 20ch;
  font-family: var(--font-body);
  font-size: clamp(1.75rem, 0.9rem + 4.2vw, 3.5rem);
  line-height: 1.15;
  letter-spacing: -0.035em;
  text-wrap: balance;
}

.hero-meta {
  display: flex;
  align-items: center;
  gap: var(--s-2);
  width: 100%;
  max-width: var(--measure-wide);
  margin: 0;
  font-family: var(--font-body);
  font-size: 0.75rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--muted);
}

.hero-meta .rule { flex: 1; height: 1px; background: var(--rule); }

.hero-cue {
  font-family: var(--font-body);
  font-size: 0.75rem;
  letter-spacing: 0.08em;
  color: var(--muted);
  text-decoration: none;
}

.hero-cue:hover { color: var(--accent); }

@media (max-width: 34rem) {
  .hero-meta { flex-direction: column; gap: var(--s-1); }
  .hero-meta .rule { display: none; }
}

/* ---- Project tiles (below the fold) ----------------------------------- */

/* Two columns, ordinary vertical scroll. The row-gap is larger than the
   column-gap so each card still reads as its own object rather than as a
   cell in a table. */
.tiles {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  align-items: start;
  gap: var(--s-12) clamp(2rem, 4vw, 3.5rem);
  margin: 0;
  padding: var(--s-2) 0 var(--s-4);
  list-style: none;
}

.tile {
  position: relative;
  display: flex;
  flex-direction: column;
  transition: transform var(--dur) var(--ease);
}

/* Dedicated slot for the project's hero image. 16:9 matches the exported
   images and the case study hero exactly, so the morph between them moves
   without also resizing — which is what stops it reading as a stretch. */
.tile-media {
  aspect-ratio: 16 / 9;
  display: grid;
  place-items: center;
  /* Same corner as the case study assets, so a tile and the hero it morphs
     into are the same shape. overflow clips the cover-fitted image to it. */
  border-radius: var(--asset-radius, 10px);
  overflow: hidden;
  background: light-dark(#e9e4de, #262320);
  color: var(--muted);
  font-size: 0.75rem;
  letter-spacing: 0.12em;
  text-transform: uppercase;
}

.tile-media img { width: 100%; height: 100%; object-fit: cover; }

.tile-body {
  display: flex;
  flex-direction: column;
  gap: var(--s-1);
  padding: var(--s-3);
}

/* Thematic doodle, tucked into the corner and revealed on hover. */
.tile-doodle {
  position: absolute;
  top: -34px;
  right: -30px;
  width: 116px;
  height: auto;
  color: var(--fg);
  fill: currentColor;
  stroke: currentColor;
  pointer-events: none;
  opacity: 0;
  rotate: -10deg;
  scale: 0.8;
  transition: opacity 220ms var(--ease), rotate 260ms var(--ease), scale 260ms var(--ease);
}

.tile:hover .tile-doodle,
.tile:focus-within .tile-doodle {
  opacity: 1;
  rotate: 5deg;
  scale: 1;
}

@media (prefers-reduced-motion: reduce) {
  .tile-doodle { transition-duration: 1ms; }
}

/* No card chrome any more — the image sits on the paper and the type hangs
   underneath it, so the lift is the only hover affordance the card needs. */
.tile:hover,
.tile:focus-within {
  transform: translateY(-3px);
}

/* Exit choreography (tiles.js adds .is-leaving on click, then navigates):
   the caption lifts up into the picture and is gone before the view
   transition starts, so the only thing left to move is the image itself. */
.tile.is-leaving .tile-body,
.tile.is-leaving .tile-doodle {
  animation: tile-exit 170ms var(--ease) forwards;
}

@keyframes tile-exit {
  to { opacity: 0; translate: 0 -18px; }
}

.tile-title {
  margin: 0;
  font-family: var(--font-display);
  font-size: 1.375rem;
  font-weight: 400;
  line-height: 1.25;
}

.tile-title a {
  color: var(--fg);
  text-decoration: none;
}

/* The whole tile is the hit target. */
.tile-title a::after { content: ""; position: absolute; inset: 0; }

.tile-meta {
  font-family: var(--font-body);
  font-size: 0.6875rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--muted);
}

/* ---- Project entries (homepage) --------------------------------------- */

.projects { margin: 0; padding: 0; list-style: none; }

.project {
  padding-block: var(--s-4);
  border-top: 1px solid var(--rule);
}

/* The section rule already draws the line above the first entry. */
.project:first-child { border-top: 0; padding-top: 0; }
.project:last-child { border-bottom: 1px solid var(--rule); }

.project-title {
  margin: 0 0 var(--s-1);
  font-family: var(--font-body);
  font-size: 1.25rem;
  font-weight: 400;
  line-height: 1.3;
}

.project-title a {
  color: var(--fg);
  text-decoration: none;
  background-image: linear-gradient(var(--accent), var(--accent));
  background-repeat: no-repeat;
  background-position: 0 100%;
  background-size: 0 1px;
  transition: background-size var(--dur) var(--ease);
}

.project-title a:hover,
.project-title a:focus-visible { background-size: 100% 1px; }

/* Metadata block — the rhythm borrowed from the reference site. */
.meta {
  display: grid;
  grid-template-columns: 7rem 1fr;
  gap: 0 var(--s-2);
  margin: 0 0 var(--s-2);
  font-family: var(--font-body);
  font-size: 0.8125rem;
  line-height: 1.85;
}

.meta dt {
  color: var(--muted);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  font-size: 0.6875rem;
}

.meta dd { margin: 0; color: var(--fg); }

.project-about { margin: 0; color: var(--muted); }

@media (max-width: 62rem) {
  .tiles { grid-template-columns: 1fr; gap: var(--s-8); }
  .tile-doodle { width: 96px; top: -26px; right: -22px; }
}

@media (max-width: 30rem) {
  .meta { grid-template-columns: 1fr; gap: 0; }
  .meta dd { margin-bottom: var(--s-1); }
}

/* ---- Case study ------------------------------------------------------- */

.cs-hero { margin-top: var(--s-4); }

/* Two-column hero: the words on the left, the image on the right, so a tile
   image travels sideways into place rather than dropping down the page.
   No breakout — the container is already wide, and sharing its edges with
   everything else on the page is the point. Below this width it stacks —
   a 400px column of metrics is worse than a stacked one. */
/* The hero demo sits below the text and runs the full content column. A screen
   recording is only readable at size, so it gets the whole width and the prose
   above it stays capped at the reading measure. */
.cs-hero-text { min-width: 0; }

.figure--hero {
  margin: var(--s-5) 0 0;
  width: 100%;
}

/* Credits under the title. Same uppercase register as the metric labels, so
   the header reads as one family instead of three competing type styles. */
.cs-facts {
  margin: var(--s-2) 0 var(--s-4);
}

.cs-facts p {
  margin: 0;
  font-family: var(--font-body);
  font-size: 0.9375rem;
  line-height: 1.7;
  color: var(--muted);
}

.cs-fact-key {
  letter-spacing: 0.08em;
  text-transform: uppercase;
  font-size: 0.8125rem;
}

.cs-outcome {
  font-size: 1.125rem;
  line-height: 1.7;
  text-wrap: pretty;
}

/* The window every figure in the strip is measured over. Sits above the
   numbers so it qualifies all three rather than any one of them. */
.cs-metrics-window {
  margin: var(--s-5) 0 calc(var(--s-1) * -1);
  font-size: 0.875rem;
  color: var(--muted);
}

.cs-metrics-window + .cs-metrics { margin-top: var(--s-2); }

.cs-metrics {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-3) var(--s-4);
  margin: var(--s-5) 0 var(--s-6);
  list-style: none;
  padding: 0;
}

/* Let long labels wrap inside their column rather than pushing the strip
   onto a second row. */
.cs-metrics div { flex: 1 1 8rem; min-width: 0; }

.cs-metric-value {
  display: block;
  font-family: var(--font-figure);
  font-size: 1.75rem;
  line-height: 1.2;
  font-variant-numeric: tabular-nums;
  color: var(--fg);
}

.cs-metric-label {
  display: block;
  margin-top: 3px;
  font-family: var(--font-body);
  font-size: 0.875rem;
  line-height: 1.35;
  color: var(--muted);
}

/* ---- Figures — the marked-up printout ---------------------------------- */

.figure {
  margin: var(--s-6) 0;
}

/* "Wide" now just means the full content column — no breakout, so every
   figure shares its left edge with the header and the text. */
.figure--wide {
  width: 100%;
  margin-inline: 0;
}

/* Inset: a figure deliberately held below the full column and centred, for
   dense screens that read better small with the detail one click away in the
   lightbox. One knob, so a figure can opt into a narrower width than the
   default without a second class. */
.figure--inset {
  width: 100%;
  max-width: var(--inset, 46rem);
  margin-inline: auto;
}

/* The sheet: a printed page set down slightly askew, with a tape corner. */
.sheet {
  /* One knob for every asset's corner. Assets sit flush in the layout now:
     the paper frame, its shadow and the printout tilt all came off, because
     the frame cropped the screens in rather than presenting them. */
  --asset-radius: 10px;
  position: relative;
  border-radius: var(--asset-radius);
}

/* Images and placeholders alike, whatever the sheet is holding. */
.sheet > * { border-radius: var(--asset-radius); }

.sheet img,
.sheet video { width: 100%; }

/* Placeholder shown until real screens are exported. */
.sheet--empty {
  display: grid;
  place-items: center;
  border: 1px solid var(--rule);
  min-height: 15rem;
  color: var(--muted);
  font-family: var(--font-body);
  font-size: 0.8125rem;
  text-align: center;
  background-image: repeating-linear-gradient(
    45deg, transparent 0 10px, var(--grid-ink) 10px 11px);
}

.figcaption {
  margin-top: var(--s-2);
  font-family: var(--font-body);
  font-size: 0.8125rem;
  line-height: 1.6;
  color: var(--muted);
}

/* Each bet gets a number in the accent, so the three of them read as a set
   rather than three more subheadings in the same colour as the prose. */
.bet-num {
  margin-right: 0.4em;
  font-family: var(--font-figure);
  font-size: 1.5em;
  line-height: 1;
  color: var(--accent);
  font-variant-numeric: tabular-nums;
}

/* The scribble hangs below the heading, so the h3 has to reserve room for it
   or it lands on the first line of the paragraph underneath. */
.bet-title,
.principle-title {
  position: relative;
  display: inline-block;
}

h3:has(.bet-title) { margin-bottom: var(--s-3); }

.doodle--underline {
  position: absolute;
  top: 100%;
  /* Overshoot the text at both ends, the way a marker stroke does. The
     min-width keeps a short heading from squashing the zigzag back into the
     oval it is meant not to be - the path stretches to fit, so a narrow box
     compresses its crossings. */
  left: -0.4rem;
  width: calc(100% + 1.7rem);
  min-width: 13rem;
  /* The global `img, svg, video { max-width: 100% }` would clamp the
     overshoot straight back to the heading's width. */
  max-width: none;
  height: 1.5em;
  margin-top: -0.35em;
  pointer-events: none;
}

/* Thicker than the other doodles: this one reads as a marker stroke under a
   heading rather than a pen line in the margin. Doubled class so it outranks
   the generic `.doodle path` rule further down the file, which shares its
   specificity and would otherwise win on source order. */
.doodle.doodle--underline path { stroke-width: 4; }

/* The bet titles are all long enough to need the 13rem floor above; the
   principle titles are not ("Stay curious" is half that), and the floor left
   the stroke dangling past the words and closing into a blob. Dropping it
   lets every principle underline overshoot by the same 1.7rem instead. */
.principle-title .doodle--underline { min-width: 0; }

/* ---- Lightbox — click an image to zoom it ------------------------------ */

/* Pointing hand plus a shadow on hover: the cursor says it is clickable,
   the lift says which one you are about to click. Images and clips alike. */
.is-zoomable {
  cursor: pointer;
  transition: box-shadow var(--dur) var(--ease);
}

.is-zoomable:hover,
.is-zoomable:focus-visible {
  box-shadow: 0 2px 6px -2px rgb(0 0 0 / 0.16), 0 10px 28px -10px rgb(0 0 0 / 0.32);
}

@media (prefers-reduced-motion: reduce) {
  .is-zoomable { transition: none; }
}

.is-zoomable:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
}

.lightbox {
  position: fixed;
  inset: 0;
  z-index: 100;
  display: grid;
  place-items: center;
  padding: clamp(var(--s-2), 4vw, var(--s-6));
  background: light-dark(rgb(20 18 15 / 0.82), rgb(8 7 6 / 0.88));
  opacity: 0;
  transition: opacity var(--dur) var(--ease);
  cursor: zoom-out;
}

.lightbox.is-open { opacity: 1; }
.lightbox[hidden] { display: none; }

.lightbox__stage {
  display: grid;
  gap: var(--s-2);
  justify-items: center;
  max-width: 100%;
  max-height: 100%;
}

.lightbox :is(img, video) {
  max-width: 100%;
  /* Leave room for the caption under it rather than letting the image
     push the caption off the bottom of the viewport. */
  max-height: calc(100vh - 8rem);
  width: auto;
  height: auto;
  object-fit: contain;
  border-radius: var(--asset-radius, 10px);
  background: #fff;
  cursor: default;
  transform: scale(0.98);
  transition: transform var(--dur) var(--ease);
}

.lightbox.is-open :is(img, video) { transform: scale(1); }

.lightbox video[controls] { cursor: auto; }

.lightbox__caption {
  max-width: 44rem;
  margin: 0;
  font-family: var(--font-body);
  font-size: 0.875rem;
  line-height: 1.6;
  text-align: center;
  color: #e8ded5;
}

.lightbox__close {
  position: absolute;
  top: var(--s-2);
  right: var(--s-2);
  width: 2.5rem;
  height: 2.5rem;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: rgb(255 255 255 / 0.12);
  color: #fff;
  font-size: 1.5rem;
  line-height: 1;
  cursor: pointer;
}

.lightbox__close:hover { background: rgb(255 255 255 / 0.22); }

/* The page behind the overlay shouldn't scroll under it. */
.has-lightbox, .has-lightbox body { overflow: hidden; }

@media (prefers-reduced-motion: reduce) {
  .lightbox, .lightbox img { transition: none; }
}

/* Margin note — handwriting in the gutter, folds inline on narrow screens. */
.note {
  position: relative;
  margin: var(--s-4) 0;
  padding-left: var(--s-3);
  border-left: 2px solid var(--accent);
  font-size: 0.9375rem;
  color: var(--muted);
  text-wrap: pretty;
}

.note strong { color: var(--fg); font-weight: 400; }

/* ---- Prev / next ------------------------------------------------------ */

.pagination {
  display: flex;
  justify-content: space-between;
  gap: var(--s-3);
  margin-top: var(--s-12);
  padding-top: var(--s-3);
  border-top: 1px solid var(--rule);
  font-family: var(--font-body);
  font-size: 0.8125rem;
}

.pagination a { color: var(--muted); text-decoration: none; }
.pagination a:hover { color: var(--accent); }

/* When only "next" remains (back lives at the top now), keep it on the right
   where a forward link reads naturally. */
.pagination a:only-child { margin-left: auto; }

/* The way back, at the top where it's needed. */
.cs-back {
  margin: var(--s-4) 0 0;
  font-family: var(--font-body);
  font-size: 0.8125rem;
}
.cs-back a { color: var(--muted); text-decoration: none; }
.cs-back a:hover { color: var(--accent); }

/* Authoring helper for case study bodies: a two-column block that stacks on
   narrow screens. Wrap any two children — two figures, a figure and a
   paragraph — in <div class="cols-2">. One-column content needs no wrapper. */
.cols-2 { display: grid; gap: var(--s-3) var(--s-4); align-items: start; }
@media (min-width: 56rem) {
  .cols-2 { grid-template-columns: 1fr 1fr; }
  .cols-2 .figure { margin-block: var(--s-3); }
}

/* A row of assets matched on HEIGHT rather than width, for pairing a wide
   board with a tall phone screen. cols-2 gives both the same column, so the
   phone ends up half the height of the board; here each figure grows in
   proportion to its own aspect ratio, which lands both on exactly the same
   height at any width - no fixed height to break when the row gets narrow.

   Set --ar per figure to its width/height (a 1874x2012 board is .93, a
   540x1078 phone is .50). Cap the row with max-width if the full column is
   more than the pair needs. Stacks below 40rem. */
.cols-h { display: grid; gap: var(--s-3); align-items: start; }

@media (min-width: 40rem) {
  .cols-h { display: flex; gap: var(--s-4); align-items: flex-start; }
  .cols-h .figure {
    flex: var(--ar, 1) 1 0;
    min-width: 0;
    margin: 0;
  }
}

/* ---- Case-study body layout ------------------------------------------- */
/* Lifted from the page-scoped trial on the Agentic AppStore case study once a
   second case study (Mover) needed the same rows, table and lists. */

/* A split row: prose on the left, its images on the right. Sections without
   images skip the wrapper entirely and run at the normal reading measure, so
   the page keeps a rhythm instead of becoming a rigid two-column grid. */
.cs-split { display: grid; gap: var(--s-3); align-items: start; }

@media (min-width: 56rem) {
  .cs-split {
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
    gap: var(--s-4) clamp(2.5rem, 5vw, 4rem);
    margin-bottom: var(--s-6);
  }
  /* Text is already capped at --measure globally; inside a column it just
     fills what it's given. */
  .cs-split-text > :last-child { margin-bottom: 0; }

  /* The images ride along with a long passage of text rather than sitting at
     the top of a tall empty column. */
  .cs-split-media {
    position: sticky;
    top: var(--s-3);
    display: grid;
    gap: var(--s-3);
  }
  .cs-split-media .figure { margin: 0; }

  /* Two small screens side by side rather than stacked — stacking two full
     -width images against three sentences of text is what leaves a hole in
     the left column. */
  .cs-split-media--2up { grid-template-columns: 1fr 1fr; gap: var(--s-2); }

  /* A tall screenshot (a Slack thread) shouldn't stretch to the full column
     width, or it drives the row height past anything the text can match. */
  .cs-split-media--narrow { max-width: 22rem; }

  /* Text always runs in the left column, images always in the right. No
     alternating sides — one left edge for every word on the page, so the eye
     never has to go looking for where the reading resumes. */
}

/* Grey placeholder boxes standing in for real screens. Sized by aspect ratio
   so the page's proportions are honest about what's coming. */
.ph {
  display: grid;
  place-items: center;
  aspect-ratio: var(--ar, 16 / 10);
  padding: var(--s-2);
  border: 1px solid var(--rule);
  /* Match whatever radius the sheet gives its assets, so a placeholder reads
     as a stand-in for the real image rather than a different kind of object. */
  border-radius: var(--asset-radius, 2px);
  background: light-dark(
    color-mix(in oklab, #14120f 9%, #f0e0d9),
    color-mix(in oklab, #f0e4da 8%, #14120f));
  color: var(--muted);
  font-family: var(--font-body);
  font-size: 0.8125rem;
  line-height: 1.5;
  text-align: center;
  text-wrap: pretty;
}
.ph--tall  { --ar: 3 / 4; }
.ph--wide  { --ar: 2 / 1; }
.ph--squat { --ar: 16 / 7; }

/* Impact table — enclosed rather than a set of loose rules, so the numbers
   read as one object. The wrapper carries the border and the radius, because
   a border-collapse table clips its own corners square. */
.cs-table-scroll {
  overflow-x: auto;
  margin: var(--s-4) 0;
  border: 1px solid var(--rule);
  border-radius: 10px;
}
.cs-table {
  width: 100%;
  border-collapse: collapse;
  font-size: 0.9375rem;
  text-align: left;
}
.cs-table th, .cs-table td {
  padding: var(--s-2);
  border-bottom: 1px solid var(--rule);
  vertical-align: baseline;
}
/* No rule under the last row: the wrapper's border already closes it off. */
.cs-table tbody tr:last-child :is(th, td) { border-bottom: 0; }
/* Nor down the outside edges — only between the columns. */
.cs-table :is(th, td):not(:last-child) { border-right: 1px solid var(--rule); }
/* A touch darker than the page ground, so the header sits into the table
   rather than lifting off it like the cards do. */
.cs-table thead :is(th, td) {
  background: light-dark(
    color-mix(in oklab, #14120f 7%, #f0e0d9),
    color-mix(in oklab, #f0e4da 7%, #14120f));
}
.cs-table thead tr:first-child th:first-child { border-top-left-radius: 9px; }
.cs-table thead tr:first-child th:last-child { border-top-right-radius: 9px; }
.cs-table th {
  font-family: var(--font-body);
  font-size: 0.6875rem;
  font-weight: 400;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--muted);
}
.cs-table td strong { color: var(--accent); font-weight: 400; }

/* Outside a split column the list has no column to constrain it, so it needs
   the reading measure the surrounding prose already gets. */
.cs-list { margin: 0 0 var(--s-3); padding-left: 1.1em; max-width: var(--measure); }
.cs-list li { margin-bottom: var(--s-1); text-wrap: pretty; }

/* A quiet rule between key bets, so each bet reads as its own argument
   rather than the next paragraph of the last one. */
.bet-rule {
  height: 0;
  margin: var(--s-5) 0;
  border: 0;
  border-top: 1px solid var(--rule);
}

/* Three states of one thing, read left to right with arrows between: how a
   pattern evolved. Equal heights and varying widths, so it scans as a
   sequence. Stacks on narrow screens, where the arrows turn to point down. */
.evolution {
  display: grid;
  justify-content: center;
  justify-items: center;
  align-items: center;
  gap: var(--s-2);
}

@media (min-width: 56rem) {
  .evolution {
    grid-auto-flow: column;
    grid-auto-columns: auto;
    gap: clamp(0.5rem, 2.5vw, 2rem);
  }
}

.evolution__step { display: grid; gap: var(--s-1); justify-items: center; }

/* Height-constrained rather than width-constrained: the three assets have
   very different aspect ratios, and a shared height is what makes them read
   as one sequence. Beats `.sheet img { width: 100% }` on specificity. */
.evolution .sheet :is(img, video) {
  width: auto;
  max-width: 100%;
  max-height: 18rem;
}

.evolution .ph {
  aspect-ratio: auto;
  width: 13rem;
  height: 18rem;
}

.evolution__label {
  max-width: 18rem;
  margin: 0;
  font-family: var(--font-body);
  font-size: 0.75rem;
  line-height: 1.5;
  color: var(--muted);
  text-align: center;
  text-wrap: pretty;
}

/* The arrow is artwork, not an asset: it inherits the text colour so it
   holds up in either theme. */
.evolution__arrow { display: grid; place-items: center; color: var(--muted); }
.evolution__arrow svg {
  width: clamp(2rem, 5vw, 3.5rem);
  height: auto;
  fill: currentColor;
}

@media (max-width: 55.999rem) {
  .evolution__arrow svg { transform: rotate(90deg); }
}

/* ---- Personal --------------------------------------------------------- */

.entries { margin: 0; padding: 0; list-style: none; }

.entry {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0 var(--s-2);
  padding-block: var(--s-1);
  border-bottom: 1px dotted var(--rule);
}

/* Titles share a fixed column so the authors and artists line up down the
   page instead of starting at a ragged edge. A title longer than the column
   wraps inside it rather than pushing the second column out of line. */
.entry-title {
  flex: 0 0 14rem;
  font-weight: 700;
}

.entry-by { color: var(--muted); }

/* Too narrow for two columns — the byline would be squeezed to a few
   characters, so the row falls back to one item per line. */
@media (max-width: 30rem) {
  .entry-title { flex-basis: 100%; }
}

/* Spotify's player is a third-party iframe and keeps its own dress in both
   themes, so it takes a full row under the title rather than trying to sit
   inline with it. flex-basis forces the wrap in the .entry flex row. */
.entry-embed {
  flex-basis: 100%;
  margin-top: var(--s-1);
  border: 0;
  border-radius: 12px;
}

.entry time {
  margin-left: auto;
  font-family: var(--font-body);
  font-size: 0.75rem;
  color: var(--muted);
  font-variant-numeric: tabular-nums;
}

/* ---- Doodles ---------------------------------------------------------- */

.doodle {
  color: var(--accent);
  overflow: visible;
}

.doodle path,
.doodle circle,
.doodle line {
  fill: none;
  stroke: currentColor;
  stroke-width: 1.5;
  stroke-linecap: round;
  stroke-linejoin: round;
  vector-effect: non-scaling-stroke;
}

/* Undrawn state only exists when JS has opted in — no-JS renders complete. */
[data-draw] .doodle :is(path, circle, line) {
  stroke-dasharray: 1;
  stroke-dashoffset: 1;
}

[data-draw] .doodle.is-drawn :is(path, circle, line) {
  animation: draw 700ms var(--ease) forwards;
  animation-delay: calc(var(--i, 0) * 90ms);
}

@keyframes draw { to { stroke-dashoffset: 0; } }

/* ---- Footer ----------------------------------------------------------- */

.site-footer {
  margin-top: var(--s-16);
  padding-block: var(--s-4) var(--s-8);
  font-family: var(--font-body);
  font-size: 0.875rem;
  letter-spacing: 0.06em;
  color: var(--muted);
}

.colophon {
  margin: 0;
  display: flex;
  justify-content: center;
  align-items: baseline;
  gap: 1px;
}

.colophon-caret {
  display: inline-block;
  width: 1px;
  height: 1em;
  translate: 0 0.12em;
  background: var(--accent);
  opacity: 0;
}

.colophon.is-typing .colophon-caret,
.colophon.is-done .colophon-caret { opacity: 1; }

@media (prefers-reduced-motion: no-preference) {
  .colophon.is-done .colophon-caret { animation: caret 1.05s steps(1) infinite; }
}

/* Contact icons on About. */
.contact {
  display: flex;
  gap: var(--s-2);
  margin: 0;
  padding: 0;
  list-style: none;
}

.contact a {
  display: grid;
  place-items: center;
  width: 2.75rem;
  height: 2.75rem;
  color: var(--fg);
  transition: color var(--dur) var(--ease), translate var(--dur) var(--ease);
}

.contact a:hover { color: var(--accent); translate: 0 -2px; }
.contact svg { width: 1.5rem; height: 1.5rem; fill: currentColor; }
.contact svg.stroked {
  fill: none;
  stroke: currentColor;
  stroke-width: 1.6;
  stroke-linecap: round;
  stroke-linejoin: round;
}


.theme-toggle { display: inline-flex; align-items: center; }

.theme-btn {
  display: grid;
  place-items: center;
  width: 2rem;
  height: 2rem;
  padding: 0;
  border: 0;
  background: none;
  color: var(--muted);
  cursor: pointer;
  border-radius: 4px;
  transition: color var(--dur) var(--ease);
}

.theme-btn:hover { color: var(--fg); }
.theme-btn svg { width: 1.25rem; height: 1.25rem; fill: none; stroke: currentColor;
                 stroke-width: 1.5; stroke-linecap: round; }

/* Rays on when the lights are on, off when they're off. */
.theme-btn .rays { opacity: 1; transition: opacity var(--dur) var(--ease); }
.theme-btn .filament { opacity: 1; }
:root[data-theme="dark"] .theme-btn .rays { opacity: 0; }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .theme-btn .rays { opacity: 0; }
}


/* ---- Focus ------------------------------------------------------------ */

:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
  border-radius: 1px;
}

.skip-link {
  position: absolute;
  left: -9999px;
  top: 0;
  background: var(--paper);
  color: var(--fg);
  padding: var(--s-1) var(--s-2);
  border: 1px solid var(--accent);
  z-index: 10;
}

.skip-link:focus { left: var(--s-2); top: var(--s-2); }

/* ---- View transitions -------------------------------------------------- */

/* A tile opens into its case study, and ONE shared element carries the
   motion: the tile's media box grows into the hero figure via a per-project
   `m-<slug>` view-transition-name set inline in the markup. Text never
   travels — the tile caption exits upward before navigation (.is-leaving,
   added by tiles.js) and the destination text arrives line by line
   (.cs-hero-text stagger below), so nothing reads as type being dragged
   across the screen.

   The class lets the timing be written once instead of once per slug.
   `view-transition-class` ships in the same browsers as cross-document
   transitions; where it's missing these rules simply don't match and the
   morph runs at the default duration. */
.tile-media    { view-transition-class: cs-media; }
.cs-hero .sheet { view-transition-class: cs-media; }

@media (prefers-reduced-motion: no-preference) {
  @view-transition { navigation: auto; }

  ::view-transition-group(*) {
    animation-duration: var(--dur);
    animation-timing-function: var(--ease);
  }

  ::view-transition-old(root) {
    animation: fade-out 140ms var(--ease) both;
  }

  ::view-transition-new(root) {
    animation: rise-in 220ms var(--ease) both;
  }

  /* Slower than --dur on purpose: at 200ms an expand of this distance reads as
     a jump rather than a movement. */
  ::view-transition-group(.cs-media) {
    animation-duration: 360ms;
  }

  /* The media box changes aspect ratio on the way (16:10 tile → wide hero).
     Without this the two snapshots stretch into each other's box and the
     contents visibly squash; `cover` keeps them filling the frame instead. */
  ::view-transition-old(.cs-media),
  ::view-transition-new(.cs-media) {
    height: 100%;
    object-fit: cover;
  }
}

/* The case study text doesn't travel — it arrives. Eyebrow, title, outcome,
   metrics, one after another, quickly: the last line starts 260ms in, so the
   whole entrance is over well before anyone starts reading. */
@media (prefers-reduced-motion: no-preference) {
  .cs-hero-text > * { animation: rise-in 300ms var(--ease) both; }
  .cs-hero-text > :nth-child(1) { animation-delay: 50ms; }
  .cs-hero-text > :nth-child(2) { animation-delay: 120ms; }
  .cs-hero-text > :nth-child(3) { animation-delay: 190ms; }
  .cs-hero-text > :nth-child(4) { animation-delay: 260ms; }
}

@keyframes fade-out { to { opacity: 0; } }
@keyframes rise-in { from { opacity: 0; transform: translateY(8px); } }

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