/* ---------------------------------------------------------------
   David Boselli — Portfolio
   Design tokens derived from Figma (file tMbcXm95zAFN7N3Z5IRhsr)
---------------------------------------------------------------- */

/* ---------------------------------------------------------------
   Fonts — self-hosted so first paint doesn't wait on a third party.
   Inter v20 ships as a variable font: one file per subset covers the
   whole 400–600 range we use, hence the `font-weight` range below
   rather than three separate faces. Licensed under the SIL OFL 1.1.
   Files pulled from fonts.gstatic.com; see assets/fonts/README.md.
---------------------------------------------------------------- */

@font-face {
  font-family: 'Inter';
  font-style: normal;
  font-weight: 400 600;
  font-display: swap;
  src: url('assets/fonts/inter-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

@font-face {
  font-family: 'Inter';
  font-style: normal;
  font-weight: 400 600;
  font-display: swap;
  src: url('assets/fonts/inter-latin-ext.woff2') format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

:root {
  /* Opt into both schemes so native UI (scrollbars, form controls, the
     PDF viewer chrome) also follows the system setting. */
  color-scheme: light dark;

  --bg: #ffffff;
  --fg: #000000;
  --muted: rgba(0, 0, 0, 0.54);
  --border: #e7e7e7;
  --placeholder: #dcdcdc;
  --surface: #f5f5f5;

  /* The Work rail's tile, behind the product shots. Its own token rather
     than --placeholder, which it used to share: rail shots are exported over
     transparency, so this colour is the page showing through the artwork and
     wants to sit almost flush with --bg. --placeholder still has to read as
     "no image yet" on a case study block, and still has to be a neutral a
     photograph can load over on Photos without flashing, so the two stopped
     wanting the same value.

     The two schemes move in opposite directions on purpose. Light steps
     toward the artwork (#f8f9fb to #f2f4f7) so the tile actually frames a
     shot instead of dissolving into --bg; dark steps away from it (#333333
     to #2a2a2a) so the tile stops competing with a near-black page. */
  --shot-bg: #f2f4f7;

  /* One column for everything: the floating nav, the content, and the
     footer all share it, so the bar always sits exactly over the text it
     navigates. */
  --content-w: 720px;

  /* The floating nav's cap — currently the same measure as the content, but
     kept its own knob since the two have moved independently before. */
  --topnav-w: 720px;

  /* What a floating bar is built from: the padding above and below its
     content, and the height of the content row itself. The nav and the photo
     viewer's bar both read these, so the two cannot drift apart in height. */
  --bar-pad-y: 12px;
  --bar-row: 28px;

  /* The nav's inner measure: its 720 cap less its 24px padding either side.
     A centred text column of this width lands on exactly the same vertical
     as the nav's own text, at every viewport rather than one — both resolve
     to 50vw minus 336. */
  --text-w: 672px;

  /* The inner vertical: where body copy starts, which is the same line the
     nav's own text starts on. Named because the Work index cannot centre a
     column to get there — its rails must overflow right, so its copy has to
     be offset from the media gutter by hand. The max() is the narrow case,
     where the nav has stopped centring and sits on the plain gutter plus its
     own padding; above the crossover both branches agree. */
  --text-left: max(
    calc(var(--gap) + var(--gap-lg)),
    calc(50vw - var(--text-w) / 2)
  );

  /* Where the media column starts: the left edge of the centred --media-w.
     The rails hang off this, which is the outer of the page's two verticals.
     Copy uses the inner one (--text-w, below), so imagery runs 80px wider on
     each side than the text and the nav do. Tracks the column exactly rather
     than approximating it: below the crossover both this and the column's own
     min() resolve to the plain gutter. */
  --gutter-left: max(var(--gap), calc(50vw - var(--media-w) / 2));

  /* The Work rails' shot height. Width follows from each shot's own
     aspect-ratio, so this single number sets the whole rail's scale. */
  --shot-h: 640px;

  /* Imagery on Work, About and Photos runs on this measure while the copy
     stays on --text-w. 880 against the text's 720 box is the Figma's pair of
     verticals: media 80px proud of the nav on each side, copy inset 24 within
     it. Both columns are centred, so the text does not move when this does. */
  --media-w: 880px;

  /* Where the content starts, on every page. 201 is the y the Figma frames
     put the first media at, measured from the top of the page. Our bar comes
     out 98.5 tall against the frame's 104.5, so this is ~86px of clear air
     below it rather than the frame's 80; the content line is what matches.
     If the bar's padding or type changes its height, retune this. */
  --nav-clearance: 201px;

  /* The two faces a floating bar can wear, each defined once. Three things
     select between them and every one of them used to restate the values:
     the light default below, the dark scheme's override, and the
     `topnav--light/--dark` classes nav.js forces over toned content. Keeping
     three copies in step was a manual invariant, and it had already broken
     once (the dark fill sat at 0.05 alpha, borrowing darkness from whatever
     was under it instead of carrying its own). Now there is one place. */
  --face-light-bg: rgba(255, 255, 255, 0.72);
  --face-light-fg: #000000;
  --face-light-muted: rgba(0, 0, 0, 0.5);
  --face-light-border: rgba(0, 0, 0, 0.1);

  --face-dark-bg: rgba(32, 32, 32, 0.72);
  --face-dark-fg: #ffffff;
  --face-dark-muted: rgba(255, 255, 255, 0.5);
  --face-dark-border: rgba(255, 255, 255, 0.1);

  /* The floating nav's own surface. Translucent over a strong backdrop blur,
     so the content stays legible scrolling under it. Light by default; the
     dark scheme and the forced-face classes repoint these at the dark set. */
  --nav-bg: var(--face-light-bg);
  --nav-fg: var(--face-light-fg);
  --nav-muted: var(--face-light-muted);
  --nav-border: var(--face-light-border);

  /* One hairline, resolved lazily so it follows whichever face is current.
     The nav shell, its divider and the viewer's panels all draw the same
     0.5px edge; this is that edge, named once. */
  --hairline: 0.5px solid var(--nav-border);

  /* The spacing ladder, small to large. The bottom three rungs were added
     after the fact: 8, 12 and 20 were already the three most common literals
     in the file (21 declarations between them) with nowhere to point. */
  --gap-xs: 8px;
  --gap-sm: 12px;
  --gap: 16px;
  --gap-md: 20px;
  --gap-lg: 24px;
  --gap-xl: 36px;
  /* The two editorial steps: media to the copy that belongs to it, and one
     project to the next. Deliberately far apart, so a card reads as one
     thing and its neighbour as another. Stepped 80 → 120 → 160 as the
     cards grew captions and display titles: each project got taller and
     denser, and the seam between two needed more air to stay a seam. */
  --gap-media: 40px;
  --gap-section: 160px;

  /* Softer corners everywhere — tiles, blocks, the unlock card's controls —
     sized to sit with the nav's 18px shell. --radius-sm is for controls small
     enough that 12px would round away too much of the shape. */
  --radius: 12px;
  --radius-sm: 8px;

  --fs-sm: 12px;
  --lh-sm: 16px;
  /* The site's reading size. Raised from 14px, which was too small to sit
     with for a paragraph. --body2 below is kept in step with it so the
     reading text and the surrounding UI stay one size — the page headers all
     run on --fs-md, and letting only the prose grow would have left them
     smaller than the copy they introduce. */
  --fs-md: 16px;
  --lh-md: 24px;

  /* Type scale — four tiers of matched {heading, body, meta} sets, tier 1
     largest. Body is anchored at 1rem (16px), matching --fs-md; the ladder
     steps out from there on a roughly 1.25 modular ratio. Sizes are rem so
     they respect the user's root size. */
  /* Above the four tiers: the Work index's editorial pair, added when its
     projects took the eyebrow/headline/lede shape. Display is the project
     title; lede is the date line and description under it. */
  --display-fs: 3rem;    --display-lh: 1.1; --display-fw: 600; --display-ls: -0.02em;
  --lede-fs: 1.25rem;    --lede-lh: 1.5;

  --h1-fs: 1.75rem;   --h1-lh: 1.2;  --h1-fw: 600; --h1-ls: -0.02em;
  --h2-fs: 1.375rem;  --h2-lh: 1.25; --h2-fw: 600; --h2-ls: -0.015em;
  --h3-fs: 1.125rem;  --h3-lh: 1.35; --h3-fw: 600; --h3-ls: -0.01em;
  --h4-fs: 1rem;      --h4-lh: 1.4;  --h4-fw: 600; --h4-ls: -0.005em;

  --body1-fs: 1.0625rem; --body1-lh: 1.6;  --body1-fw: 400; --body1-ls: 0;
  --body2-fs: 1rem;      --body2-lh: 1.55; --body2-fw: 400; --body2-ls: 0;
  --body3-fs: 0.875rem;  --body3-lh: 1.5;  --body3-fw: 400; --body3-ls: 0;
  --body4-fs: 0.75rem;   --body4-lh: 1.5;  --body4-fw: 400; --body4-ls: 0;

  --meta1-fs: 0.875rem;  --meta1-lh: 1.4;  --meta1-fw: 500; --meta1-ls: 0.01em;
  --meta2-fs: 0.8125rem; --meta2-lh: 1.4;  --meta2-fw: 500; --meta2-ls: 0.02em;
  --meta3-fs: 0.75rem;   --meta3-lh: 1.4;  --meta3-fw: 500; --meta3-ls: 0.03em;
  --meta4-fs: 0.6875rem; --meta4-lh: 1.35; --meta4-fw: 500; --meta4-ls: 0.05em;

  /* Motion. One easing curve for everything — a fast start that decelerates
     into place — and three durations: hover-weight, element-weight, and the
     asymmetric pair used for page transitions (out shorter than in, so the
     incoming page leads). */
  --ease: cubic-bezier(0.2, 0, 0, 1);
  --dur-fast: 120ms;
  --dur: 180ms;
  --dur-page-out: 160ms;
  --dur-page-in: 220ms;
  /* The rail settling back after being pulled past its end. Longer than the
     rest: a spring that snaps reads as a glitch rather than as elastic. */
  --dur-spring: 380ms;

  /* How far a tile recedes under the pointer. Two values, because a fixed
     percentage is not a fixed gesture: 2% is ~14px on a Work card and under
     2px on a 36px thumbnail, where it also has to compete with that strip's
     own jump from 45% to full opacity. The small one is for controls at
     thumbnail scale. */
  --hover-scale: 0.98;
  --hover-scale-sm: 0.94;
}

/* ---------------------------------------------------------------
   Dark mode — follows the OS/browser setting automatically.
   Only the color tokens are overridden; every element already reads
   these variables, so backgrounds, text, borders, and the placeholder
   tiles all flip together. The nav's four tokens just repoint at the
   dark face defined above rather than restating its values, and
   --hairline resolves --nav-border lazily, so it follows them.
---------------------------------------------------------------- */

@media (prefers-color-scheme: dark) {
  :root {
    --bg: #0e0e0e;
    --fg: #f5f5f5;
    --muted: rgba(255, 255, 255, 0.56);
    --border: #2a2a2a;
    --placeholder: #333333;
    --surface: #171717;
    /* One rung down the dark ladder, and a rung it already had:
       --bg 14, --surface 23, --border 42, --placeholder 51. This lands on
       42 rather than inventing a grey between the two. */
    --shot-bg: #2a2a2a;

    /* A dark fill at the same 0.72 the light face uses, tuned so it still
       reads as dark glass with a faint lift: over --bg it composites to
       #1b1b1b against the old #1a1a1a, one value out of 255.

       It was `rgba(255, 255, 255, 0.05)`, per the Figma dark frames, which
       worked only because nothing but flat --bg had ever passed under the
       bar. 5% alpha is no veil at all: the face was borrowing the darkness
       of the content beneath, so the first bright photograph on Photos would
       have composited to #ffffff and put white text on white — 1.0:1, the
       nav simply gone. At 0.72 over #202020 the same white photograph is
       6.5:1 and the two faces are equally self-sufficient. */
    --nav-bg: var(--face-dark-bg);
    --nav-fg: var(--face-dark-fg);
    --nav-muted: var(--face-dark-muted);
    --nav-border: var(--face-dark-border);
  }
}

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

/* No fixed heights on the roots: the document itself is the scroller, and
   the floating nav sits over it. */

body {
  margin: 0;
  background: var(--bg);
  color: var(--fg);
  font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica,
    Arial, sans-serif;
  font-size: var(--fs-md);
  line-height: var(--lh-md);
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
}

a {
  color: inherit;
  text-decoration: none;
  transition: opacity var(--dur-fast) var(--ease);
}

a:hover {
  opacity: 0.6;
}

/* Wrapping. Headings balance, so a title that breaks to two lines splits
   near its midpoint instead of stranding its last word; prose gets pretty,
   which lets the browser rework earlier breaks to avoid a one-word last
   line. Element selectors on purpose: every heading on the site is an
   h1-h3 (.panel__title, .project__title, .study's h2/h3, .unlock__title)
   and every prose block is a p, li, blockquote or figcaption, so nothing
   needs opting in class by class. Both values are ignored by browsers
   that lack them, which wrap greedily as before. */
h1,
h2,
h3 {
  text-wrap: balance;
}

p,
li,
blockquote,
figcaption {
  text-wrap: pretty;
}

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

/* Every image slot on the site (.project__shot, .block, .photo, .thumb,
   .viewer__image) fills its box with `object-fit: cover`, so a source whose
   ratio differs from the slot's is cropped rather than squashed. Cover crops
   from the centre, which is the wrong half of the frame often enough to
   matter — a face near the top of a 3:4 original loses its forehead in a 1:1
   tile. Each of those five rules reads `object-position: var(--focal, 50%
   50%)`, so the escape hatch is one inline custom property on the image, or
   on any ancestor to move a whole group:

     <img class="..." style="--focal: 50% 22%" ...>

   No :root default on purpose — the fallback in each var() already is the
   default, and --focal is per-image data rather than a design token. */

/* Keyboard focus. The UA ring is inconsistent across the mix of links and
   bare buttons here (.photo and .thumb both zero out their border), so pin
   it to the foreground token — which flips with dark mode for free. */
:focus-visible {
  outline: 2px solid var(--fg);
  outline-offset: 2px;
}

/* Visually hidden but exposed to assistive tech — used for the logical page
   heading on pages whose design has no visible title (About, Photos). */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

/* ---------------------------------------------------------------
   Motion — cross-document page transitions

   `navigation: auto` opts every same-origin navigation into the View
   Transitions API. This is progressive enhancement in the truest sense:
   a browser without support ignores the at-rule and navigates exactly as
   it does today, so there is no fallback to write or maintain. External
   links, mailto:, and the target="_blank" résumé are all untouched — the
   API only applies to same-origin document navigations.
---------------------------------------------------------------- */

@view-transition {
  navigation: auto;
}

/* Naming an element lifts it out of the root snapshot into a group of its
   own. The floating nav is identical on every page, so its group has
   matching geometry on both sides of the navigation and sits perfectly still
   while the content behind it changes — no flash, no re-render of the bar.

   The one thing that does differ between pages is the current-page marker,
   which lands as a quiet cross-fade between the two bar snapshots. */

.topnav {
  view-transition-name: topnav;
}

::view-transition-old(topnav),
::view-transition-new(topnav) {
  animation-duration: var(--dur-page-in);
  animation-timing-function: var(--ease);
}

/* Group animations move and resize each snapshot's box. Ours mostly have
   nothing to move — the bar is identical across pages — but a transition
   isn't over until its *longest* animation ends, so leaving these at the UA's
   250ms would hold the frozen snapshot up for an extra 30ms after everything
   has visibly settled. Retime them to match. */
::view-transition-group(root),
::view-transition-group(topnav) {
  animation-duration: var(--dur-page-in);
  animation-timing-function: var(--ease);
}

/* Everything left unnamed — i.e. the content area — cross-fades, with the
   incoming page rising the last few pixels into place. */

@keyframes page-out {
  to {
    opacity: 0;
  }
}

@keyframes page-in {
  from {
    opacity: 0;
    transform: translateY(8px);
  }
}

::view-transition-old(root) {
  animation: page-out var(--dur-page-out) var(--ease) both;
}

::view-transition-new(root) {
  animation: page-in var(--dur-page-in) var(--ease) both;
}

/* The UA blends the two root snapshots with `plus-lighter`, which is correct
   only when they sit exactly on top of each other. Ours don't — the incoming
   one is offset 8px — and additive blending would smear a bright ghost of the
   text through the overlap. Normal blending stacks them instead. */
::view-transition-old(root),
::view-transition-new(root) {
  mix-blend-mode: normal;
}

/* The viewer's open and close transitions (Escape and the browser-Back
   close included) reshape only the framed image (photo-active); the
   surrounding chrome is unchanged. (Stepping between photos is a plain cut —
   no transition at all.) Without this the root snapshot still runs the
   page-navigation rise + cross-fade, so the whole viewer — bar, thumbnail
   strip, the nav behind it — lifts and dissolves around a morph that should
   move nothing but the photo. Suppress the root animation while the viewer
   transitions: photos.html adds `vt-viewer` for the duration of each
   open/close. Real page navigations don't get the class, so they keep their
   rise/fade. */
/* For the length of a viewer transition the nav gives up its own group and
   rejoins the root snapshot. A named group is composited above the root, and
   the viewer — which has no name — is inside it, so the nav painted over the
   lightbox for exactly as long as the transition ran and was covered again
   the moment normal painting resumed: a flash on every open and close.
   z-index cannot reach across that, since it only orders boxes within a
   snapshot, not the snapshots themselves.

   Safe to swap because `vt-viewer` is set before the outgoing snapshot is
   captured and cleared after the transition finishes, so the name is absent
   from both sides rather than appearing on one and animating. Page
   navigations never set the class, so the nav keeps its group there — which
   is the whole reason it has one. */
:root.vt-viewer .topnav {
  view-transition-name: none;
}

:root.vt-viewer::view-transition-old(root) {
  animation: none;
  opacity: 0;
}

:root.vt-viewer::view-transition-new(root) {
  animation: none;
}

/* Shared elements -------------------------------------------------
   `hero` is handed to a Work card thumbnail and to the lead block
   of the page it links to (see assets/transitions.js); `photo-active` to a
   photo grid tile and the album viewer's frame. In both cases one element
   leaves the root snapshot and flies between the two positions instead of
   fading, so the thing you clicked is the thing that arrives.
---------------------------------------------------------------- */

/* Only `animation-duration` and `-timing-function` are set here, never
   `animation` itself: the UA puts the morph on this pseudo-element, and a
   shorthand would wipe it out. */
::view-transition-group(hero),
::view-transition-group(photo-active) {
  animation-duration: var(--dur-page-in);
  animation-timing-function: var(--ease);
}

/* The two snapshots are different shapes — a grid tile and a full-bleed
   block — so let each fill the morphing box and crop rather than stretch.
   With the placeholder tiles this only smooths the resize; it's what keeps
   real imagery from distorting mid-flight once it lands.

   `plus-lighter` is wrong here for the same reason as above: two overlapping
   --placeholder greys would blend to a brighter grey and flash. */
::view-transition-old(hero),
::view-transition-new(hero),
::view-transition-old(photo-active),
::view-transition-new(photo-active) {
  width: 100%;
  height: 100%;
  object-fit: cover;
  mix-blend-mode: normal;
}

/* ---------------------------------------------------------------
   Shell — a single column of content scrolling under a floating nav.
   The document is the scroller now; nothing in the shell clips or
   scrolls on its own.
---------------------------------------------------------------- */

.shell {
  min-height: 100vh;
}

/* ---------------------------------------------------------------
   Floating navigation — a translucent bar fixed over the content,
   sharing the content column's width so it always sits exactly over
   the text it navigates.

   Two rows: name + Contact, and the section links. On scroll the
   links row collapses away (scrolling down) and returns (scrolling
   up or at the top) — assets/nav.js drives the class; without the
   script the bar simply stays expanded.

   The surface is see-through over a strong backdrop blur, so content
   stays readable sliding under it, and every colour on the bar runs
   through the --nav-* tokens with a slow transition on the consuming
   properties. Flip the tokens — the dark scheme does it, and so do
   the `topnav--dark` / `topnav--light` override classes when marked
   content passes beneath — and the whole bar cross-fades between its
   light and dark faces rather than snapping.
---------------------------------------------------------------- */

.topnav {
  position: fixed;
  top: var(--gap);
  left: 0;
  right: 0;
  z-index: 150;
  /* Narrower than the 1024 content column on purpose — a compact bar
     floating over a wider page, centred on the same axis. */
  width: min(var(--topnav-w), calc(100% - 2 * var(--gap)));
  margin-inline: auto;
  padding: var(--bar-pad-y) var(--gap-lg);
  border-radius: 18px;
  background: var(--nav-bg);
  color: var(--nav-fg);
  border: var(--hairline);
  -webkit-backdrop-filter: blur(24px) saturate(1.8);
  backdrop-filter: blur(24px) saturate(1.8);
  transition: background-color 360ms var(--ease),
    color 360ms var(--ease),
    border-color 360ms var(--ease);
}

/* Forced faces, set by nav.js from what is under the bar. Element-level
   custom properties outrank the :root/media ones, so these win in either
   OS scheme; the transitions above carry the change. */
.topnav--light {
  --nav-bg: var(--face-light-bg);
  --nav-fg: var(--face-light-fg);
  --nav-muted: var(--face-light-muted);
  --nav-border: var(--face-light-border);
}

/* Kept in step with the dark scheme's own --nav-bg above, and for the same
   reason: this face is the one that gets forced over imagery, so it is the
   one that must not depend on the imagery being dark. */
.topnav--dark {
  --nav-bg: var(--face-dark-bg);
  --nav-fg: var(--face-dark-fg);
  --nav-muted: var(--face-dark-muted);
  --nav-border: var(--face-dark-border);
}

.topnav__bar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--gap);
  /* Sets the row's height on its own now. It was pinned to the Contact
     pill's 28px; the pill has since become a 20px glyph, so without this the
     row would collapse to 20 and the whole bar would shrink by 8. */
  min-height: var(--bar-row);
}

.topnav__name {
  font-size: var(--h4-fs);
  line-height: 20px;
  font-weight: var(--h4-fw);
  letter-spacing: var(--h4-ls);
  color: inherit;
}

/* The @ mark, replacing the old Contact pill. The glyph is inline SVG on
   `currentColor`, so it follows the bar's face wherever the tokens take it;
   the ::after carries the 44px touch target, the same trick as the close
   icon, so a 20px glyph never sets the bar's height. */
/* Styled for either element it can be. In the markup it is a mailto anchor,
   which is the whole behaviour without scripting; nav.js swaps it for a real
   <button> on load, because with scripting it is a disclosure toggle and an
   anchor would keep advertising a link it never follows. The reset and the
   hover below are what the global `a` rules were giving it, restated so the
   control looks identical before and after that swap. */
.topnav__contact {
  position: relative;
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  color: inherit;
  background: none;
  border: 0;
  padding: 0;
  font: inherit;
  cursor: pointer;
  transition: opacity var(--dur-fast) var(--ease);
}

.topnav__contact:hover {
  opacity: 0.6;
}

.topnav__contact::after {
  content: "";
  position: absolute;
  inset: -12px;
}

/* The @ becomes a close mark while the socials are showing, so the control
   that opened the row visibly offers the way back out. Both glyphs sit in
   one grid cell and crossfade; stacking them rather than swapping `display`
   keeps the button's width fixed, so nothing in the bar shifts. */
.topnav__glyph {
  grid-area: 1 / 1;
  display: block;
  transition: opacity var(--dur) var(--ease);
}

.topnav__contact {
  display: grid;
  place-items: center;
}

.topnav__glyph--close,
.topnav--social .topnav__glyph--at {
  opacity: 0;
}

.topnav--social .topnav__glyph--close {
  opacity: 1;
}

/* The collapsing row. A grid track animating 1fr → 0fr is the one clean way
   to animate an auto height; the inner nav clips against it. */
.topnav__links-wrap {
  display: grid;
  grid-template-rows: 1fr;
  transition: grid-template-rows 300ms var(--ease);
}

.topnav__links {
  /* Both rows — section links and the socials the @ toggles in — share this
     one grid cell, stacked, so the wrap's height never changes when they
     swap; visibility decides which one is real. */
  grid-area: 1 / 1;
  display: flex;
  gap: var(--gap-lg);
  min-height: 0;
  overflow: hidden;
  font-size: var(--body3-fs);
  line-height: var(--body3-lh);
  font-weight: var(--body3-fw);
  /* The divider between the two rows lives here, inside the collapsing
     track, so the compact bar keeps no stray line. Margin above it, padding
     below it, and the hairline itself all animate to zero on collapse —
     with border-box sizing they would otherwise hold the row ~24px tall
     even at a 0fr track.

     The negative inline margins pull the row out to the bar's edges so the
     rule runs the full width, and the matching padding puts the links
     themselves back on the 24px inset. */
  margin-top: var(--gap-sm);
  margin-inline: calc(-1 * var(--gap-lg));
  padding-inline: var(--gap-lg);
  border-top: var(--hairline);
  /* Matches the bar's own bottom padding, so the link text sits centred
     between the divider and the bar's lower edge. */
  padding-top: var(--gap-sm);
  transition: opacity 200ms var(--ease),
    visibility 200ms var(--ease),
    margin-top 300ms var(--ease),
    padding-top 300ms var(--ease),
    border-top-width 300ms var(--ease),
    border-color 360ms var(--ease);
}

/* The @ swaps the row between the section links and the socials, a
   crossfade in place. `visibility` rather than `display`, for three
   reasons: it transitions (discretely, at the fade's end), it drops the
   hidden row out of the tab order and the accessibility tree, and it
   keeps both rows in the box tree so the overlaid cell holds its size. */
.topnav__links--social,
.topnav--social .topnav__links:not(.topnav__links--social) {
  opacity: 0;
  visibility: hidden;
}

.topnav--social .topnav__links--social {
  opacity: 1;
  visibility: visible;
}

/* Collapsed only while left alone: pointing at the bar — or tabbing into
   it, where the links would otherwise be focusable but clipped inside the
   0fr track — slides the row back out, and it folds away again on leave.
   The same transitions carry both directions, and the scroll state class
   stays untouched, so there is nothing for nav.js to fight. */
.topnav--collapsed:not(:hover):not(:focus-within) .topnav__links-wrap {
  grid-template-rows: 0fr;
}

.topnav--collapsed:not(:hover):not(:focus-within) .topnav__links {
  opacity: 0;
  margin-top: 0;
  padding-top: 0;
  border-top-width: 0;
}

.topnav__links a {
  color: var(--nav-muted);
  transition: color 360ms var(--ease);
}

.topnav__links a:hover {
  color: var(--nav-fg);
  opacity: 1;
}

.topnav__links a[aria-current="page"] {
  color: var(--nav-fg);
}

/* Still used by the unlock page's card. Square, matching the mark's viewBox —
   the SVG carries its own light/dark fill, so there is nothing to invert. */
.logo {
  width: 20px;
  height: 20px;
  display: block;
}

/* ---------------------------------------------------------------
   Footer — the social links and copyright the old sidebar carried,
   now closing each page in the same column as everything else.
---------------------------------------------------------------- */

/* body3, the same step the nav links sit on — the two ends of the page
   read at one size. */
/* Same measure and the same narrow-width correction as About's copy, so the
   two ends of the page stay on one vertical. This one is a child of the full
   width shell rather than of a gutter-inset column, so it has to subtract
   the gutter as well as the nav's 24. */
/* Centred stack at every width — the social row above, the copyright under
   it. This was the phone arrangement; it became the only one when the
   desktop's two-ends row went. */
.footer {
  width: min(var(--text-w), calc(100% - 2 * (var(--gap) + var(--gap-lg))));
  margin-inline: auto;
  padding: var(--gap-xl) 0 64px;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--gap-lg);
  flex-wrap: wrap;
  font-size: var(--body3-fs);
  line-height: var(--body3-lh);
}

.social {
  display: flex;
  gap: var(--gap-lg);
}

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

@media (max-width: 820px) {
  /* Shorter than the desktop 640 but not a fits-on-screen height: at 400
     the widest shot still runs past a 375 screen, so a phone crops every
     shot and relies on the drag to reveal the rest. */
  :root {
    --shot-h: 400px;

    /* The 48px display title against a 295px measure is three wrapped lines
       of shouting; 36 keeps the editorial weight at phone scale. */
    --display-fs: 2.25rem;

    /* The 20px lede steps down to the reading size with it: project
       descriptions and About's bio at 16 on a phone. Everything already
       pinned to a fixed size by the narrow-width block at the end of the
       file (eyebrows, labels, dates) is unaffected. */
    --lede-fs: var(--body2-fs);
    --lede-lh: var(--body2-lh);

    /* 201 comes off the Figma frames, which are a 1404 desktop canvas, and it
       left 87px of air under the bar on a 375 screen — a desktop measurement
       spending a tenth of a phone viewport on nothing. 161 brings that to 46
       and the content starts a screen-height earlier in the scroll.

       Overriding the token rather than `.main`'s padding on purpose: the base
       `.main` rule sits later in this file than this block, so an override at
       equal specificity here would lose the cascade and silently do nothing.
       The custom property has no such problem, since the last `:root` to
       match wins and this one is below the base.

       179, not 161: the bar ends 115px down a phone screen (16 of top
       offset plus its 99 of height), and 64 under it is the same step a
       project puts between its copy and its rail, so content clears the
       bar by the gap the page uses internally — on Work, About and Photos
       alike, since all three carry `.main`. */
    --nav-clearance: 179px;

    /* 160 between projects is a desktop measure: on a phone it reads as a
       dead screen between one project and the next, since the rail above
       it is already 400 tall. */
    --gap-section: 96px;
  }
}

/* ---------------------------------------------------------------
   Main scroll area
---------------------------------------------------------------- */

/* The same column the nav floats in, cleared below it. Every page is a
   single column now — the panel-bearing pages stack their header above the
   content like everything else. */
/* Case studies, the only pages left on the bare column. It centres on the
   text measure rather than on --content-w, which resolves to exactly the
   vertical --text-left names: at 1404 both come out 366, at 800 both 64, at
   375 both 40. That is the same line the nav's own text starts on and the
   same one the Work index reaches by hand, so a case study now shares its
   left edge with every other page instead of sitting on the nav's *bar*
   edge, 24px further out.

   Full width less the gutter *and* the nav's inner padding, for the narrow
   case: below the point where 672 stops fitting, the nav stops centring but
   keeps its 24px padding, so its text stays 24 in from the gutter. */
.main {
  width: min(var(--text-w), calc(100% - 2 * (var(--gap) + var(--gap-lg))));
  margin-inline: auto;
  padding-top: var(--nav-clearance);
}

/* About and Photos: the column widens for imagery, centred on its own
   measure. Every text block inside re-centres on --content-w, so copy sits
   exactly where it did while the media runs wider on both sides. */
.main--wide {
  width: min(var(--media-w), calc(100% - 2 * var(--gap)));
}

/* The Work index alone starts on the media gutter rather than centring,
   because its rails have to keep overflowing past the right edge. Same left
   vertical as About's photo and the Photos grid; the difference is only that
   those two can be centred and this one cannot. */
.main--rails {
  width: auto;
  margin-left: var(--gutter-left);
  margin-right: var(--gap);
}

/* Centred on the nav's inner measure, so the copy sits on the same vertical
   as the nav's text. Two things are load-bearing here.

   The max-width override, because .panel caps itself at 680 and a 680 column
   would land 5px off the line.

   And the 24 taken off each side, because below the point where --text-w
   stops fitting this has to keep matching the nav, and the nav does not go
   flush at narrow widths: it stops centring but keeps its own 24px padding,
   so its text stays 24 in from the gutter. Without this the copy went to the
   gutter itself and sat 24 left of the nav's text on a phone. Its parent is
   already inset by the gutter, so only the nav's inner padding is missing. */
.main--wide .panel {
  width: min(var(--text-w), calc(100% - 2 * var(--gap-lg)));
  max-width: var(--text-w);
  margin-inline: auto;
}


.content {
  flex: 1 1 auto;
  min-width: 0;
  padding: 0 0 64px;
}

/* Detail panel (Work / About) ------------------------------ */

/* A plain block leading its page: the header (or About's bio) sits above the
   content it introduces, capped at the reading measure so the line length
   holds even though the column is 1024 wide. */
.panel {
  max-width: var(--text-w);
  margin-bottom: var(--gap-xl);
}

.panel__inner {
  display: flex;
  flex-direction: column;
  gap: var(--gap-xl);
  align-items: flex-start;
}

/* The four page headers — Work index, Work detail, About and the photo
   viewer — share one treatment: the base 14px at --lh-md, starting on the
   same --pad-top inset and the same left edge, with the secondary half
   muted. The title carries no size or weight of its own; the case study's
   hierarchy is established by the article below it, not by this line. */
.panel__title {
  margin: 0 0 var(--gap-xs);
  font-size: var(--fs-md);
  line-height: var(--lh-md);
  font-weight: var(--body2-fw);
}

.panel__desc {
  margin: 0;
  color: var(--muted);
  font-size: var(--fs-md);
  line-height: var(--lh-md);
}

.panel__year {
  margin: 0;
  color: var(--muted);
  font-size: var(--fs-md);
  line-height: var(--lh-md);
}

/* About's page opening, on the eyebrow / headline / lede tiers the Work
   index established — the two pages speak in one voice. Panel-scoped
   rather than shared classes on purpose: the tokens are the shared thing,
   and each block keeps naming its own parts. */
.panel__eyebrow {
  margin: 0 0 var(--gap-xs);
  font-size: var(--lede-fs);
  line-height: var(--lede-lh);
  font-weight: var(--h4-fw);
}

.panel__headline {
  margin: 0;
  font-size: var(--display-fs);
  line-height: var(--display-lh);
  font-weight: var(--display-fw);
  letter-spacing: var(--display-ls);
}

.panel__lede {
  margin: var(--gap) 0 0;
  font-size: var(--lede-fs);
  line-height: var(--lede-lh);
}

/* Text column inside the panel — Figma: 440px panel, 16px padding,
   408px text column, 36px between groups, 20px (one line) between rows. */

.panel__block {
  width: 100%;
}

/* Promoted from 13px muted meta to the eyebrow treatment when the Work
   index introduced it: Experience and Education head their sections the
   same way a company heads a project. */
.panel__label {
  margin: 0 0 var(--gap-md);
  font-size: var(--lede-fs);
  line-height: var(--lede-lh);
  font-weight: var(--h4-fw);
}

.panel__entry {
  margin: 0 0 var(--gap-md);
}

.panel__entry:last-child {
  margin-bottom: 0;
}

.panel__entry strong {
  font-weight: var(--meta1-fw);
}

/* About's CV — one continuous list rather than two labelled sections.
   Organisation and role hold the text vertical; the date is right-aligned
   on the far edge of the same measure, so the block reads as a pair of
   columns without a table or a grid.

   The row is a baseline-aligned flex pair on purpose: the date sits on the
   organisation's baseline whether the body below it runs to one line or
   three, which a top-aligned row would not do once the type steps down.

   .panel__label and .panel__entry are deliberately left in place — the
   third-line date treatment they describe is still what
   work/events-manager.html uses. */
.cv {
  width: 100%;
  margin: 0;
  padding: 0;
  list-style: none;
}

.cv__row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--gap-lg);
  margin-bottom: var(--gap-xl);
}

.cv__row:last-child {
  margin-bottom: 0;
}

.cv__org,
.cv__role,
.cv__date {
  margin: 0;
}

/* A secondary line under the entry it belongs to — the soccer alum note
   under the degree. Dropped to the site's --body3 tier and muted, so it
   reads as an aside on the row rather than a third fact of equal weight.
   The row is baseline-aligned on the organisation, so adding it never
   moves the date. */
.cv__note {
  margin: var(--gap-xs) 0 0;
  font-size: var(--body3-fs);
  line-height: var(--body3-lh);
  color: var(--muted);
}

/* The organisation heads its row, so it takes the h4 weight rather than the
   meta one it started on. Both tiers are 16px, so this is a weight change
   and nothing else moves. Note that the self-hosted Inter is a variable
   font cut at 400-600: 600 is the ceiling, and it buys 2.6% more width than
   400 on a long line ("Rochester Institute of Technology" measures 254.21px
   at 400, 260.73px at 600). Weight is the whole budget this row has unless
   the role and date also drop to --muted, which they deliberately do not. */
.cv__org {
  font-weight: var(--h4-fw);
}

/* Never shrink or wrap the date: it is the shorter half of the row and the
   organisation is the half that can afford to. */
.cv__date {
  flex: none;
  text-align: right;
}

/* Below the crossover the measure is too narrow to hold both columns —
   "Rochester Institute of Technology" alone is wider than a 375 screen's
   text column — so the date drops under the role and goes back to the left
   edge. Placed after the base rules it overrides, per the note at the top
   of the layout section. */
@media (max-width: 820px) {
  .cv__row {
    display: block;
  }

  .cv__date {
    text-align: left;
  }
}

/* The lede and the paragraphs after it read as one text now, so the seam
   between the intro block and the prose is a paragraph break (--gap-md),
   not the panel's 36px section gap. The flex gap still applies to every
   pair of children; this subtracts the difference at just this seam. */
.panel__intro + .panel__prose {
  margin-top: calc(var(--gap-md) - var(--gap-xl));
}

/* Lede-sized like the intro line above them — on About the whole bio is
   the read, not supporting text under it. */
.panel__prose p {
  margin: 0 0 var(--gap-md);
  font-size: var(--lede-fs);
  line-height: var(--lede-lh);
}

.panel__prose p:last-child {
  margin-bottom: 0;
}

/* Inline links in body copy sit flush with the prose in the design. */
.panel__prose a {
  text-decoration: none;
}

.panel__prose a:hover {
  text-decoration: underline;
  text-underline-offset: 2px;
  opacity: 1;
}

/* The resume link used to live here as underlined text. It is a .btn now,
   the same control the Work index uses, so this rule has no markup left. */

/* ---------------------------------------------------------------
   Work index — one full-width card after another, in the curated
   order. No columns, no packing: each project gets the whole measure.
---------------------------------------------------------------- */

.work-grid {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--gap-section);
}

/* ---------------------------------------------------------------
   Work index — each project leads with its title, date, description
   and chips, with a horizontal rail of captioned shots below them.

   The rail runs past the content column and off the right edge of the
   window rather than being cut at the column: `margin-right` pulls its
   own right edge out to the viewport, so the row of shots bleeds away
   instead of ending on a hard crop. It scrolls horizontally, by wheel
   or trackpad, and assets/drag.js adds pointer dragging on top.
---------------------------------------------------------------- */

/* Grid items default to min-width:auto, which lets the track grow to the
   rail's full scroll width. That widens the parent, and since the bleed
   below is a percentage of the parent it then resolves positive and pushes
   the rail further out still — a runaway that gave the page a horizontal
   scrollbar. Pinning the item to 0 keeps the track on the column, so the
   rail overflows inside itself as intended. */
.project {
  min-width: 0;
}

.project__rail {
  /* The rail's box spans the whole window while its *content* starts on the
     shared gutter, so the first shot lines up with the nav above it. Each
     margin cancels the column's own offset and the padding puts the content
     back; expressed against --gutter-left rather than a percentage, since
     the column is left-aligned now and a percentage of it would not reach
     the window's edge.

     Bleeding on the left as well as the right is what stops shots being cut
     at the column edge as they scroll past it — they now run off the side of
     the window instead, which is the point of a rail that pulls. */
  margin-left: calc(-1 * var(--gutter-left));
  padding-left: var(--gutter-left);
  margin-right: calc(-1 * var(--gap));
  /* Trailing room, and the reason it is the gutter rather than --gap: a rail
     should come to rest between the same two verticals the column sits on.
     Pulled to the end, the last shot's right edge lands on the column's right
     edge, mirroring the first shot starting on its left one.

     That the two numbers are equal is not a coincidence to be tidied away.
     --gutter-left is max(--gap, 50vw - --media-w / 2), so above the crossover
     the column is centred and its right margin is its left one, and below it
     both collapse to --gap. Either way the column's right edge is
     `clientWidth - --gutter-left`, which is what this padding puts there.

     It was --gap, so a rail stopped 16px off the window instead, and briefly
     it was measured in drag.js to bring the last shot's LEFT edge to the
     gutter — which overshot, leaving most of the window empty at the end.
     Padding is enough because the margins already carry the box out to the
     window; this only moves the content box inside it. */
  padding-right: var(--gutter-left);
  /* The gap the text block used to carry above itself, now below it: the
     copy leads and the rail follows. Stepped 40 → 56 → 64 by eye against
     the built page; kept as a calc rather than a bumped --gap-media so
     About's lead photo keeps its own step. */
  margin-top: calc(var(--gap-media) + var(--gap-lg));
  overflow-x: auto;
  overscroll-behavior-x: contain;
  scrollbar-width: none;
  /* The shots are the drag surface, so stop the pointer picking them up as
     images or selecting text mid-drag. */
  -webkit-user-select: none;
  user-select: none;
  /* Says the rail can be pulled before anyone tries. Only where a pointer
     can actually hover — a touch device has no cursor and needs no hint. */
  cursor: grab;
}

.project__rail.is-dragging {
  cursor: grabbing;
}

/* The shots ride on a track inside the scroller rather than being the
   scroller's own flex children, so that pulling past either end has
   something to translate that the rail can clip. Without the wrapper the
   only thing to move is the rail itself, and a transform on it would spill
   across the window and give the page a horizontal scrollbar.

   max-content because a block-level flex container would otherwise size to
   the scroller and never overflow it, which is the whole point of a rail. */
.project__track {
  display: flex;
  /* One rung up from the photo grid's tight 8: these are separate captioned
     cards in the Apple mold, not one continuous field of photographs. */
  gap: var(--gap-lg);
  width: max-content;
}

/* Only while settling back. During the pull the track has to track the
   pointer exactly, so the transition is added on release and taken off
   again once it lands. */
.project__track.is-springing {
  transition: transform var(--dur-spring) var(--ease);
}

.project__rail::-webkit-scrollbar {
  display: none;
}

.project__shot {
  flex: 0 0 auto;
  /* Height leads; each shot's own aspect-ratio sets its width, which is what
     gives the rail its varied rhythm. */
  height: var(--shot-h);
  background: var(--shot-bg);
  border-radius: var(--radius);
  overflow: hidden;
}

/* The two placeholder shapes. These carry no image, so nothing can tell them
   how wide to be and the ratio has to be stated.

   They used to be the whole story, when the rail was a `rail` and a `rail-tall`
   slot and a third shape meant a third class here. It no longer is: the rail
   slot states no ratio and snaps each source to the closed set the same way a
   photograph does, so a tile with a real image in it takes its width from that
   image and these two are only for the tiles still waiting for one. Adding a
   shape is now a matter of dropping in a source at that ratio; the set stays
   closed in snapRatio, which is where it was always actually enforced. */
.project__shot--square {
  aspect-ratio: 1 / 1;
}

.project__shot--tall {
  aspect-ratio: 9 / 16;
}

/* Height leads, so the image states its height and lets its own intrinsic ratio
   resolve the width — for itself and, through it, for the shot. The width and
   height attributes the generator writes mean that resolves at parse time,
   before a byte of image arrives, so a rail of mixed ratios lays out correctly
   on first paint and nothing shifts. object-fit stays as insurance: the crop is
   already exact, so it has nothing to do unless a hand-written tile disagrees
   with its source. */
.project__shot img {
  display: block;
  width: auto;
  height: 100%;
  object-fit: cover;
  object-position: var(--focal, 50% 50%);
  pointer-events: none;
}

/* A placeholder shape does state a width, and there the image has to fill it
   rather than size it. */
.project__shot--square img,
.project__shot--tall img {
  width: 100%;
}

/* Each track cell is a figure: the shot with its caption below, so a shot
   scrolls, drags and springs together with its own context line. The figure
   takes over the track-child role the shot used to hold; the shot's own
   sizing is untouched. */
.project__figure {
  flex: 0 0 auto;
  margin: 0;
  display: flex;
  flex-direction: column;
}

/* The caption must never widen its figure: the shot's aspect ratio owns the
   column width, and a sentence of max-content text under a tall shot would
   push the shots apart. width: 0 removes it from the figure's intrinsic
   sizing entirely, and the min-width then hands it the shot's width back at
   layout time, capped at 400px so a caption under a wide square shot keeps
   a readable measure instead of running the full 640. */
.project__caption {
  width: 0;
  min-width: min(100%, 400px);
  /* Same rung as the gap between cards, so the air around a shot reads
     evenly on both axes. */
  margin-top: var(--gap-lg);
  /* Indented off the shot's edges rather than flush with them, matching the
     reference, and by the same amount on each side so a caption that fills
     its box stays centred under the shot. Padding, not margin: box-sizing
     keeps it inside the min-width above, so the box still ends on the
     shot's right edge. */
  padding: 0 var(--gap);
  color: var(--muted);
  /* The reading size, one step below the 20px lede above the rail: the
     caption is supporting text under its shot, not a second description. */
  font-size: var(--body2-fs);
  line-height: var(--body2-lh);
  font-weight: var(--body2-fw);
}

/* The lead sentence carries the point in the foreground color, the rest
   stays muted context, matching the tiered caption pattern on apple.com. */
.project__caption strong {
  color: var(--fg);
  font-weight: var(--h4-fw);
}

/* Copy sits on the inner vertical while the rail above it sits on the outer
   one, which is the Figma's two-column arrangement: media 80px proud of the
   text on each side. The offset is the distance between the two gutters
   rather than a fixed inset, because they converge as the window narrows and
   a fixed one would push the copy off the nav's line on the way down.
   Title, date, description and the chip row all share it, so they cannot
   drift apart.

   The max-width has to carry the *right* inset as well, and this is the
   second time the nav's 24px has caught something out. --text-left puts it 24
   in from the gutter to sit on the nav's text; below the crossover --text-w
   (672) is far wider than the window, so the copy ran to the parent's edge
   and stopped 16 from the screen while starting 40 from it. Visibly heavier
   on the left. Subtracting the gutter and the nav's padding from *both* sides
   makes the measure the nav's own inner width, so the copy lands on the nav's
   text on the right as well. Above the crossover 672 is the smaller of the
   two and nothing changes. */
.project__text,
.project__links {
  margin-left: calc(var(--text-left) - var(--gutter-left));
  max-width: min(
    var(--text-w),
    calc(100vw - 2 * (var(--gap) + var(--gap-lg)))
  );
}

/* Named for what it holds rather than for what it does, so it stays honest
   either way: it wrapped an anchor to the case study until that link came
   off, and it can wrap one again without the name going stale. It opens the
   project now, so it carries no top margin; the rail below takes the
   --gap-media instead. */
.project__text {
  display: block;
}

/* The eyebrow: who the work was for, introducing the headline under it.
   Foreground color on purpose, where the metadata lines are muted — the
   company is part of the story, not a footnote. */
.project__label {
  margin: 0 0 var(--gap-xs);
  color: var(--fg);
  font-size: var(--lede-fs);
  line-height: var(--lede-lh);
  font-weight: var(--h4-fw);
}

.project__title {
  margin: 0;
  /* Display size: the title opens the project now, and everything under it
     needs somewhere to look up to. */
  font-size: var(--display-fs);
  line-height: var(--display-lh);
  font-weight: var(--display-fw);
  letter-spacing: var(--display-ls);
}

/* Below the description now: a metadata line at the description's own size
   and weight, muted so it reads after the copy it dates. The same 16 the
   description carries above itself, so the three text rows share one
   rhythm. */
.project__date {
  margin: var(--gap) 0 0;
  color: var(--muted);
  font-size: var(--lede-fs);
  line-height: var(--lede-lh);
}

/* Optically 24 off the date, which is why this is 16 and not --gap-lg. Both
   line boxes carry half-leading, ~3.5px under the date and ~4.4px over the
   description, and that ~8px of air is already doing part of the work. The
   number to keep true here is the gap you can see, so if the type or its
   line-height changes, re-derive this rather than preserving the 16. */
.project__desc {
  margin: var(--gap) 0 0;
  font-size: var(--lede-fs);
  line-height: var(--lede-lh);
}

/* The row that holds a project's chips. This stays project-scoped, since it
   is the row inside a project; the control itself is .btn below, which is
   shared with About's resume link and so carries no block prefix.

   It is a sibling of .project__text rather than a child of it. That was
   originally forced, since the text was an anchor and an anchor cannot
   contain another one; the text is a plain block now, but keeping them
   siblings is what lets the link come back without moving anything.
   Projects with nothing public to point at simply omit the row. */
.project__links {
  display: flex;
  flex-wrap: wrap;
  gap: var(--gap-xs);
  /* 32, asked for by eye against the built page: two 16s, clear of the
     text stack's own 16 rhythm without drifting toward the rail's 64. */
  margin-top: calc(2 * var(--gap));
}

.btn {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  padding: 6px var(--gap-sm);
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  font-size: var(--body4-fs);
  line-height: var(--body4-lh);
  font-weight: var(--meta1-fw);
  white-space: nowrap;
  transition: background-color var(--dur-fast) var(--ease),
              border-color var(--dur-fast) var(--ease),
              color var(--dur-fast) var(--ease);
}

/* The outward arrow, marking that the link leaves the site. Every .btn opens
   in a new tab, so this belongs to the control rather than to each instance;
   if that ever stops being true, this moves to a modifier.

   Carried as a mask rather than an inline SVG for two reasons: the glyph
   stays in one place instead of being copied into eight anchors that could
   drift, and painting it with currentColor means it inverts along with the
   text on hover for free. Decorative, so it is a pseudo-element and stays
   out of the accessibility tree — each anchor's aria-label already says
   where it goes. */
.btn::before {
  content: '';
  flex: 0 0 auto;
  width: 14px;
  height: 14px;
  background: currentColor;
  -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M6.29148 6.70898C6.29148 7.25898 6.74148 7.70898 7.29148 7.70898H14.8815L6.00148 16.589C5.61148 16.979 5.61148 17.609 6.00148 17.999C6.39148 18.389 7.02148 18.389 7.41148 17.999L16.2915 9.11898V16.709C16.2915 17.259 16.7415 17.709 17.2915 17.709C17.8415 17.709 18.2915 17.259 18.2915 16.709V6.70898C18.2915 6.15898 17.8415 5.70898 17.2915 5.70898H7.29148C6.74148 5.70898 6.29148 6.15898 6.29148 6.70898Z' fill='white'/%3E%3C/svg%3E") center / contain no-repeat;
  mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M6.29148 6.70898C6.29148 7.25898 6.74148 7.70898 7.29148 7.70898H14.8815L6.00148 16.589C5.61148 16.979 5.61148 17.609 6.00148 17.999C6.39148 18.389 7.02148 18.389 7.41148 17.999L16.2915 9.11898V16.709C16.2915 17.259 16.7415 17.709 17.2915 17.709C17.8415 17.709 18.2915 17.259 18.2915 16.709V6.70898C18.2915 6.15898 17.8415 5.70898 17.2915 5.70898H7.29148C6.74148 5.70898 6.29148 6.15898 6.29148 6.70898Z' fill='white'/%3E%3C/svg%3E") center / contain no-repeat;
}

/* The global a:hover fades to 0.6, which on a filled control reads as
   disabled rather than as a target. Invert it instead. */
.btn:hover {
  opacity: 1;
  background: var(--fg);
  border-color: var(--fg);
  color: var(--bg);
}

/* ---------------------------------------------------------------
   The Email links' tooltip — shared by the nav and the footer, like
   .btn, so it carries no block prefix. assets/email.js creates one
   .tip on <body> and positions it; this draws it.

   It lives outside the bar rather than inside the link because
   .topnav__links carries overflow: hidden for its collapse animation,
   which clipped the pseudo-element version out of existence. Fixed
   positioning also means no ancestor can ever clip it again.

   Gated to hover-capable pointers with the same media query the script
   uses to enable itself, so touch devices get neither half: there the
   links stay plain mailto:.
---------------------------------------------------------------- */

@media (hover: hover) and (pointer: fine) {
  /* The global a:hover fades to 0.6, which reads as the link going
     quiet just as its tooltip arrives. It holds full strength instead. */
  a[data-copy-email]:hover {
    opacity: 1;
  }

  /* A solid bubble in the page's inverse — the same inversion .btn uses
     on hover — so it reads as a dark tip in the light theme and a light
     one in the dark, with no border to whisper. */
  .tip {
    position: fixed;
    /* Over the floating bar's 150, not under it. The nav tip hangs a few
       pixels into the bar's own box, so anything lower renders with its
       top edge sliced off and reads as tucked behind the navigation. */
    z-index: 200;
    transform: translateX(-50%);
    padding: 6px var(--gap-sm);
    background: var(--fg);
    border-radius: var(--radius-sm);
    color: var(--bg);
    font-size: var(--meta1-fs);
    line-height: var(--meta1-lh);
    font-weight: var(--meta1-fw);
    letter-spacing: var(--meta1-ls);
    white-space: nowrap;
    pointer-events: none;
  }

  /* The caret, pointing back at the link it belongs to. */
  .tip::after {
    content: '';
    position: absolute;
    left: 50%;
    margin-left: -5px;
    border: 5px solid transparent;
  }

  .tip[data-side='top']::after {
    top: 100%;
    border-bottom-width: 0;
    border-top-color: var(--fg);
  }

  .tip[data-side='bottom']::after {
    bottom: 100%;
    border-top-width: 0;
    border-bottom-color: var(--fg);
  }
}

/* No hover to carry a tooltip, so nothing is drawn and the script never
   creates one; the links stay plain mailto:. The rule is here so a .tip
   left in the DOM by a resize across the breakpoint cannot show. */
@media not all and (hover: hover) and (pointer: fine) {
  .tip {
    display: none;
  }
}

/* One height everywhere: 32px under a cursor and under a thumb alike. There
   was a `(hover: none)` block here that took the vertical padding to 12 and
   the chip to 44, the WCAG 2.5.5 target. Removed on David's call — the chip
   reads as the size it is, and he would rather it stay that size on a phone
   than grow into a target.

   So this is a deliberate 32 on touch, not an oversight: below the 44 of
   2.5.5 (AAA), above the 24 of 2.5.8 (AA). If it ever wants the target back
   without the height, the move is an ::after inset like the @ and × controls
   use, which grow the hit area and leave the box alone. */

/* ---------------------------------------------------------------
   Case study body — stacked full-width blocks
---------------------------------------------------------------- */

.stack {
  display: flex;
  flex-direction: column;
  gap: var(--gap);
  padding-top: 2px;
}

/* A block sitting directly in a page's flow (About's photo, mid-page
   between the intro and the CV): 56 off the text above it, then the media
   step down to what follows. Inside a .study, figure margins do this job. */
.main > .block {
  margin-top: calc(var(--gap-media) + var(--gap));
  margin-bottom: var(--gap-media);
}

.block {
  width: 100%;
  aspect-ratio: 3 / 2;
  background: var(--placeholder);
  border-radius: var(--radius);
  overflow: hidden;
}

.block img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: var(--focal, 50% 50%);
}

/* ---------------------------------------------------------------
   Long-form case study body — headings, prose, lists, quotes,
   and figures (placeholder tile + caption). All colors come from
   the shared tokens, so it flips with light/dark automatically.
---------------------------------------------------------------- */

.study {
  max-width: var(--text-w);
  padding-top: 2px;
}

.study > :first-child {
  margin-top: 0;
}

.study p {
  margin: 0 0 var(--gap-md);
  font-size: var(--body2-fs);
  line-height: var(--body2-lh);
}

.study h2 {
  margin: 48px 0 var(--gap);
  font-size: var(--h3-fs);
  line-height: var(--h3-lh);
  font-weight: var(--h3-fw);
  letter-spacing: var(--h3-ls);
}

.study h3 {
  margin: 32px 0 var(--gap-sm);
  font-size: var(--h4-fs);
  line-height: var(--h4-lh);
  font-weight: var(--h4-fw);
  letter-spacing: var(--h4-ls);
}

.study strong {
  font-weight: var(--h4-fw);
}

.study ul {
  margin: 0 0 var(--gap-md);
  padding-left: var(--gap-md);
}

.study li {
  margin-bottom: var(--gap-xs);
  font-size: var(--body2-fs);
  line-height: var(--body2-lh);
}

.study li:last-child {
  margin-bottom: 0;
}

.study blockquote {
  margin: 0 0 var(--gap-md);
  padding-left: var(--gap);
  border-left: 2px solid var(--border);
  color: var(--muted);
  font-style: italic;
}

.study figure {
  margin: 0 0 var(--gap-lg);
}

.study figcaption {
  margin-top: var(--gap-xs);
  color: var(--muted);
  font-size: var(--meta3-fs);
  line-height: var(--meta3-lh);
  font-weight: var(--meta3-fw);
  letter-spacing: var(--meta3-ls);
}

/* ---------------------------------------------------------------
   Photos — three-column masonry + album viewer
---------------------------------------------------------------- */

/* CSS multi-column masonry: items flow and balance automatically, and the
   column count can drop by breakpoint without any JS. */
/* Three columns. At the 880 media width these run 282.7, matching the Figma
   frame. Two below 820px, where thirds get too narrow to read as
   photographs; see the breakpoint above. */
/* --gap-xs, not --gap. Tighter than the rest of the page on purpose: the
   photographs should read as one field rather than as separately framed
   cards. If this changes, the `photo` slot's `sizes` in
   tools/build-images.mjs has to change with it — it derives the column
   width from this exact number, and a disagreement makes the browser pick
   the wrong rung with nothing to show for it. */
/* Flex columns that assets/viewer.js fills, not CSS multi-column.

   Multi-column fills top-to-bottom down one column before starting the next,
   so with the page in date order the first row read left to right came out
   2026, 2024, 2022. The DOM was a perfect timeline and the eye travelled a
   different path entirely. A timeline that is only true in source order is
   not a timeline.

   The script places each photograph into whichever column is currently
   shortest, walking the list in order, so the first three land across the top
   row and the field still packs without ragged ends. Heights come from the
   manifest's ratio rather than from measuring, so the whole layout is
   arithmetic and never reads back a box mid-build.

   Column count lives here, in --photo-cols, because the breakpoint is a
   layout decision and belongs with the layout. The script reads it. */
.photo-grid {
  --photo-cols: 3;
  display: flex;
  align-items: flex-start;
  gap: var(--gap-xs);
}

.photo-grid__col {
  display: flex;
  flex-direction: column;
  gap: var(--gap-xs);
  /* Equal thirds regardless of content, so the arithmetic the script does
     matches what the browser lays out. */
  flex: 1 1 0;
  min-width: 0;
}

/* Two columns on a phone: thirds of a 343px grid come out at 104px, a
   contact sheet rather than photographs, where halves run 164.

   This has to sit *after* the rule above, not up with the other 820px
   overrides earlier in the file. Both selectors are (0,1,0), so a media
   query does not win on its own and the later declaration takes it — put
   this above and the grid silently stays at three. */
@media (max-width: 820px) {
  .photo-grid {
    --photo-cols: 2;
  }
}

/* No margin-bottom and no break-inside: the columns are flex now, so their
   own `gap` spaces these in both directions and there is no column flow left
   to break out of. */
.photo {
  width: 100%;
  background: var(--placeholder);
  border-radius: var(--radius);
  border: 0;
  padding: 0;
  cursor: pointer;
  overflow: hidden;
  transition: opacity var(--dur) var(--ease),
    transform var(--dur) var(--ease);
}

.photo:hover {
  opacity: 0.85;
  transform: scale(var(--hover-scale));
}

.photo img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: var(--focal, 50% 50%);
}

/* Album viewer — a fixed, full-viewport overlay ------------------- */

/* Full-bleed, over the floating nav (z 150): the viewer carries its own bar
   and close, and chrome should be either visible and usable or not visible
   at all — never peeking out half-covered behind a translucent bar. */
.viewer {
  position: fixed;
  inset: 0;
  z-index: 250;
  background: var(--bg);
  display: flex;
  flex-direction: column;
}

.viewer[hidden] {
  display: none;
}

/* The viewer takes focus programmatically so assistive tech lands inside the
   dialog; it is a container, not something you operate, so it should not draw
   a focus ring. It also cannot draw a legible one: the box runs the full
   height of the viewport, so the ring's top and bottom edges fall outside it
   and all that shows is two long vertical lines down the screen — which is
   what appeared as soon as an arrow key put the browser in keyboard
   modality. The controls inside it keep their rings. */
.viewer:focus,
.viewer:focus-visible {
  outline: none;
}

/* Where view transitions exist, opening the viewer is a real morph — the grid
   tile flies into the frame (photos.html drives it). Everywhere else, fall
   back to a plain fade so the overlay still doesn't snap in.

   `allow-discrete` lets `display` participate in the transition, so the
   overlay can finish fading before it leaves the box tree and the open/close
   stays a bare `hidden` toggle with no timers to keep in sync. */
@supports not (view-transition-name: none) {
  .viewer {
    opacity: 1;
    transition: opacity var(--dur) var(--ease),
      display var(--dur) var(--ease) allow-discrete;
  }

  .viewer[hidden] {
    opacity: 0;
  }

  /* The state to animate *from* on open — without it the element just
     appears at its final opacity, having had no previous rendered state. */
  @starting-style {
    .viewer:not([hidden]) {
      opacity: 0;
    }
  }

}

/* Both, for the same reason as the menu: below 820px the root element is the
   scroller and body's overflow does not propagate up to it, so locking body
   alone left the grid scrolling away behind the open viewer. photos.html
   restores the scroll position on close, since the lock pins the page to the
   top while it is on. */
:root:has(body.viewer-open),
body.viewer-open {
  overflow: hidden;
}

/* --pad-top, not --gap, so the year/place line starts on exactly the same
   baseline as the Work index intro and the panel headers. */
/* The bar and the thumbnail strip are floating panels, the same surface as
   the site nav: centred on --topnav-w, translucent over a strong blur, same
   radius and hairline. Shared here so the two can never drift apart. */
.viewer__bar,
.viewer__thumbs {
  flex: 0 0 auto;
  width: min(var(--topnav-w), calc(100% - 2 * var(--gap)));
  margin-inline: auto;
  border-radius: 18px;
  background: var(--nav-bg);
  color: var(--nav-fg);
  border: var(--hairline);
  -webkit-backdrop-filter: blur(24px) saturate(1.8);
  backdrop-filter: blur(24px) saturate(1.8);
}

.viewer__bar {
  display: flex;
  align-items: center;
  gap: var(--gap);
  padding: var(--bar-pad-y) var(--gap-lg);
  /* Built to the nav's recipe so the two bars are the same height. This bar
     has no inner row element to pin, so the row height is expressed through
     the box instead: content row, both paddings, and the two 0.5px borders,
     which border-box sizing folds into min-height. Its own content is only
     24px tall — the year and place at --lh-md — so without this it would
     come out 4px shorter than the nav rather than 4px taller. */
  min-height: calc(var(--bar-row) + 2 * var(--bar-pad-y) + 1px);
  margin-top: var(--gap);
  font-size: var(--fs-md);
  line-height: var(--lh-md);
}

.viewer__year {
  color: var(--nav-muted);
}

/* Focal length, aperture, shutter and ISO, when the file carried them. Muted
   like the year, since it is the third thing you read after where and when.

   Tabular figures because this is the one line on the site whose digits
   change while it stays in place: stepping through an album would otherwise
   jitter the whole line as 1/125 became 1/1000. */
.viewer__exif {
  color: var(--nav-muted);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

/* Removed entirely rather than left blank, or the bar's flex gap leaves a
   hole for every photograph whose metadata was stripped before ingest. */
.viewer__exif:empty {
  display: none;
}

/* The bar holds a year, a place and a close control on a phone, and has no
   room for a fourth item. Settings are the secondary reading, so they go
   first. Placed after the rule it overrides, not up with the other 820
   overrides earlier in the file — both selectors are (0,1,0), so a media
   query does not win on its own and the later declaration takes it. */
@media (max-width: 820px) {
  .viewer__exif {
    display: none;
  }
}

/* Close control — pushed to the far end of the bar. 44px hit area for touch,
   with only the glyph painted so it reads as a light, on-brand affordance in
   both themes. */
/* Carries the nav's close glyph, so the two are one icon rather than two
   constructions kept in step by hand. Sized to the glyph; the ::after holds
   the 44px target, so a 20px mark never sets the bar's height. Colour runs
   on the bar's own tokens, since the button sits on the floating panel
   rather than on the page. */
.viewer__close {
  position: relative;
  display: flex;
  align-items: center;
  margin-left: auto;
  flex: 0 0 auto;
  width: 20px;
  height: 20px;
  padding: 0;
  border: 0;
  background: none;
  cursor: pointer;
  color: var(--nav-muted);
  transition: color var(--dur-fast) var(--ease);
}

.viewer__close::after {
  content: "";
  position: absolute;
  inset: -12px;
}

.viewer__close:hover {
  color: var(--nav-fg);
}

.viewer__stage {
  flex: 1 1 auto;
  min-height: 0;
  padding: var(--gap);
  display: flex;
  align-items: center;
  justify-content: center;
}

/* The grey is the floor, not the surface. assets/viewer.js paints the grid
   tile's own already-decoded pixels here while the far larger `full` variant
   downloads, so the frame is never empty at the moment the open morph hands
   over. Sized and positioned to match the <img> that lands on top of it, or
   the stand-in would jump as it is replaced. */
.viewer__image {
  max-width: 100%;
  max-height: 100%;
  height: 100%;
  background-color: var(--placeholder);
  background-size: cover;
  background-position: var(--focal, 50% 50%);
  background-repeat: no-repeat;
  border-radius: var(--radius);
  overflow: hidden;
  /* The frame is also the immersive toggle (see the block after .thumb).
     zoom-in is the one cursor that promises "bigger", which is what the
     tap delivers; immersive flips it to zoom-out. */
  cursor: zoom-in;
}

/* The one slot where the frame is already the photo's own ratio, so `cover`
   never actually crops. Kept as cover rather than contain so a source that
   is a few pixels off its declared ratio fills the frame instead of leaving
   a hairline of --placeholder down one edge. */
.viewer__image img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: var(--focal, 50% 50%);
}

.viewer__thumbs {
  display: flex;
  gap: 4px;
  padding: var(--gap);
  margin-bottom: var(--gap);
  overflow-x: auto;
  scrollbar-width: none;
}

.viewer__thumbs::-webkit-scrollbar {
  display: none;
}

.thumb {
  flex: 0 0 auto;
  height: 36px;
  border: 0;
  padding: 0;
  background: var(--placeholder);
  border-radius: var(--radius-sm);
  cursor: pointer;
  opacity: 0.45;
  transition: opacity var(--dur) var(--ease),
    transform var(--dur) var(--ease);
  overflow: hidden;
}

/* The fifth image slot. It had no img rule while every tile was an empty
   div, which would have let a real thumbnail stretch to the button's box
   instead of cropping like its grid tile does. */
.thumb img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: var(--focal, 50% 50%);
}

.thumb[aria-selected="true"],
.thumb:hover,
.thumb:focus-visible {
  opacity: 1;
}

/* Hover and focus only, kept out of the rule above: the selected thumbnail
   shares that opacity but must not sit permanently shrunk. */
.thumb:hover,
.thumb:focus-visible {
  transform: scale(var(--hover-scale-sm));
}

/* No ring on these. A 2px outline offset 2px from a 36px tile crowds the
   thumbnails either side of it in a strip this tight. Focus is not dropped
   though — it takes the same full opacity and recede as hover, so it stays
   plainly visible to the keyboard, just without a box drawn round it.

   These three .thumb rules look mergeable and are not: the opacity group
   also covers [aria-selected], the transform group covers hover, and only
   this one is focus alone. Folding them would hand declarations to elements
   that should not have them. */
.thumb:focus-visible {
  outline: none;
}

/* Immersive mode — assets/viewer.js toggles this class when the
   photograph is tapped. Chrome trades for size: the thumbnail strip
   leaves the tree, the stage takes the whole viewport, and the bar
   gives up its text and shrinks to a floating pill around the close
   control — same surface, same corner of the same element, so it still
   reads as the bar it was. Stepping by key or swipe keeps working; the
   switch cuts rather than fades, the same call as stepping between
   photos. All of these outrank their base rules on specificity, so
   their position in the file is not load-bearing. */
.viewer--immersive .viewer__bar {
  position: absolute;
  top: var(--gap);
  right: var(--gap);
  z-index: 1;
  width: auto;
  min-height: 0;
  margin: 0;
  /* 12px round the 20px glyph: a 44px square, the touch minimum on its
     own, and the ::after's wider hit area still rides on top of it. */
  padding: var(--gap-sm);
}

/* The year, place and settings lines. The close button is the one child
   that is not a span, and the one that stays. */
.viewer--immersive .viewer__bar > span {
  display: none;
}

.viewer--immersive .viewer__thumbs {
  display: none;
}

.viewer--immersive .viewer__stage {
  padding: 0;
}

.viewer--immersive .viewer__image {
  border-radius: 0;
  cursor: zoom-out;
}

/* ---------------------------------------------------------------
   Responsive
---------------------------------------------------------------- */

@media (max-width: 820px) {

  /* Center the image vertically in the stage so any leftover height is
     split evenly above and below, rather than pooling below the image. */
  .viewer__stage {
    align-items: center;
  }

  .viewer__image {
    height: auto;
    width: 100%;
  }
}

/* ---------------------------------------------------------------
   Unlock (password gate) page
---------------------------------------------------------------- */

.unlock {
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--gap);
}

.unlock__card {
  width: 100%;
  max-width: 320px;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: var(--gap);
}

.unlock__title {
  margin: 4px 0 var(--gap-xs);
  font-size: var(--h4-fs);
  line-height: var(--h4-lh);
  font-weight: var(--h4-fw);
  letter-spacing: var(--h4-ls);
}

.unlock__desc {
  margin: 0;
  color: var(--muted);
  font-size: var(--fs-md);
  line-height: var(--lh-md);
}

.unlock__form {
  width: 100%;
  display: flex;
  flex-direction: column;
  gap: var(--gap-xs);
}

.unlock__input {
  width: 100%;
  padding: 10px var(--gap-sm);
  font: inherit;
  font-size: var(--fs-md);
  line-height: var(--lh-md);
  color: var(--fg);
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: var(--radius);
}

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

.unlock__input:focus {
  outline: none;
  border-color: var(--fg);
}

.unlock__btn {
  width: 100%;
  padding: 10px var(--gap-sm);
  font: inherit;
  font-size: var(--fs-md);
  font-weight: var(--meta1-fw);
  color: var(--bg);
  background: var(--fg);
  border: 0;
  border-radius: var(--radius);
  cursor: pointer;
}

.unlock__btn:hover {
  opacity: 0.85;
}

.unlock__error {
  margin: 4px 0 0;
  color: var(--muted);
  font-size: var(--fs-sm);
  line-height: var(--lh-sm);
}

.unlock__help {
  margin: 4px 0 0;
  color: var(--muted);
  font-size: var(--fs-sm);
  line-height: var(--lh-sm);
}

.unlock__help a {
  color: var(--fg);
  text-decoration: underline;
  text-underline-offset: 2px;
}

.unlock__back {
  color: var(--muted);
  font-size: var(--fs-sm);
  line-height: var(--lh-sm);
}

/* ---------------------------------------------------------------
   Narrow-width type steps — the editorial tiers come down one rung on
   a phone: eyebrows, section labels and dates 20 → 16, rail captions
   16 → 14. (The display headline's own step, 48 → 36, rides the
   --display-fs token in the 820 block near the top.)

   This block sits at the END of the file on purpose: the base rules
   it overrides are declared after the site's main 820 block, and a
   media query wins by position, not specificity — an override placed
   above its base rule is silently a no-op. That trap has bitten
   twice; see the note in the layout section.
---------------------------------------------------------------- */

@media (max-width: 820px) {
  .project__label,
  .panel__eyebrow,
  .panel__label {
    font-size: var(--h4-fs);
    line-height: var(--h4-lh);
  }

  .project__date {
    font-size: var(--body2-fs);
    line-height: var(--body2-lh);
  }

  .project__caption {
    font-size: var(--body3-fs);
    line-height: var(--body3-lh);

    /* On a phone the caption starts on the copy's vertical, not its shot's.
       Its parent track begins on the media gutter, so this is the same
       offset .project__text uses to cross from one vertical to the other,
       and it tracks the copy at every width rather than at one.

       Both sides, so the caption stays centred under its shot the way the
       base rule intends. It replaces that rule's 16 rather than adding to
       it: the box is min(100%, 400px) with border-box, so the padding eats
       into the box and the right edge still lands on the shot's.

       Phone-only on purpose. Desktop is 104 between the two verticals, and
       spending that on a caption's left padding would drive the text a
       sixth of the way into a 640px shot and read as a mistake. The two
       columns are 24 apart here and converge to nothing below 752, which is
       the range where crossing between them costs almost nothing. */
    padding: 0 calc(var(--text-left) - var(--gutter-left));
  }
}

/* ---------------------------------------------------------------
   Reduced motion — last in the file so it reads as the final word.

   Page transitions are cancelled outright rather than shortened: killing the
   animations on the pseudo-elements ends the transition immediately, which
   gives back the plain instant navigation. Everything else collapses to a
   duration too short to perceive but non-zero, so `transitionend` listeners
   and `allow-discrete` display changes still fire.
---------------------------------------------------------------- */

@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none !important;
  }

  *,
  *::before,
  *::after {
    transition-duration: 0.01ms !important;
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    scroll-behavior: auto !important;
  }
}
