/* Portfolio styles, shared by every page.
   Order is tokens -> base -> page -> entries -> contact -> motion.
   One stylesheet on purpose. The pages are hand-written HTML with no build
   step, so anything duplicated across files drifts. Colors and spacing live
   here once. */

/* The one webfont, used site-wide (see the body font-family below).
   The target look is palantir.com, whose type is set in Alliance No.2.
   Alliance is a paid Degarism license, so this pulls Inter, the closest
   freely-licensed grotesque, and matches Alliance's tight tracking where the
   headings are styled. @import instead of a <link> because the HTML is
   hand-written per page; one line here reaches every page at once. */
@import url('https://fonts.googleapis.com/css2?family=Inter:ital,wght@0,400;0,500;0,700;1,400&display=swap');

/* ============ tokens ============ */
/* Light by default; html[data-theme='dark'] restates the same grayscale as
   a dark mirror. theme-toggle.js sets that attribute from the corner button
   and stores the choice, and each page's <head> re-applies it inline before
   first paint so there's no flash. Not keyed to prefers-color-scheme: the
   site is light unless the visitor flips it.
   Palette matches goldmanzachary.com. A plain grayscale scheme, no colored
   accent hue. --accent reuses the same gray as --muted rather than
   inventing a hue that site doesn't have. */
:root {
  color-scheme: light;
  --bg: #fafafa;
  --fg: #1a1a1a;
  --muted: #727272;
  --line: #eeeeee;
  --accent: #727272;
  /* one-off grays, tokenized so the dark theme can restate them */
  --card: #ffffff;
  --muted-strong: #5f5f5f;
  --faint: #b8b8b8;
  --tint: rgba(26, 26, 26, 0.04);
  --scroll-thumb: #d6d6d6;
  --scroll-thumb-hover: #b8b8b8;

  /* The text measure: how wide a column of body copy is allowed to get, and
     with it the label rules, the anecdote borders, and the footer rule, which
     all stop where the paragraph lines stop. Nine rules used to write 38rem
     out by hand and had to be kept in agreement; it lives here now so the
     column can be widened in one place. body.home overrides it (see below).
     In rem on purpose: fit-home.js scales the root font-size, so the column
     narrows with the type and the line breaks hold at any scale. */
  --measure: 38rem;
}
html[data-theme='dark'] {
  color-scheme: dark;
  --bg: #121212;
  --fg: #e6e6e6;
  --muted: #9a9a9a;
  --line: #262626;
  --accent: #9a9a9a;
  --card: #1c1c1c;
  --muted-strong: #b5b5b5;
  --faint: #555555;
  --tint: rgba(255, 255, 255, 0.06);
  --scroll-thumb: #333333;
  --scroll-thumb-hover: #444444;
}

/* ============ base ============ */
* { box-sizing: border-box; }

html {
  -webkit-text-size-adjust: 100%;
  /* A thin, quiet scrollbar in the site's own grays: a light thumb on no
     track, present but not calling attention to itself. scrollbar-width and
     scrollbar-color cover Firefox and Chrome/Edge; the ::-webkit-scrollbar
     rules below only matter to Safari, which ignores these two (Chrome
     ignores the webkit pseudos once scrollbar-color is set, so the two
     stylings don't fight). */
  scrollbar-width: thin;
  scrollbar-color: var(--scroll-thumb) transparent;
  /* Reserve the scrollbar's width whether or not this page needs one. Without
     it, a short page (a project) has no scrollbar and a long one (the listing)
     does, so the centred layout jumps sideways every time you navigate
     between them, which reads as the page breaking, not as a scrollbar. */
  scrollbar-gutter: stable;
  /* NOT scroll-behavior: smooth. Every page navigation here is a full page
     load (there's no SPA router), and every browser restores your scroll
     position on back/forward. With smooth-scroll turned on globally, that
     restoration animates instead of snapping instantly. On the tall projects
     listing that shows up as the page visibly gliding/jumping right after a
     back navigation lands. That's what "back button bugs out" was. The only
     same-page anchor left on the site is the accessibility skip-link below,
     which reads better as an instant jump anyway. */
}
/* Safari's version of the thin gray scrollbar above */
html::-webkit-scrollbar { width: 8px; }
html::-webkit-scrollbar-track { background: transparent; }
html::-webkit-scrollbar-thumb {
  background: var(--scroll-thumb);
  border-radius: 4px;
}
html::-webkit-scrollbar-thumb:hover { background: var(--scroll-thumb-hover); }

body {
  margin: 0;
  background: var(--bg);
  color: var(--fg);
  /* Inter site-wide (see the @import at the top), the stand-in for
     palantir.com's licensed Alliance No.2. Body copy joined the headings
     here once mixing the two faces started to show at paragraph sizes.
     Helvetica/Arial catch the flash before the webfont arrives; both are
     close enough in metrics that the swap doesn't reflow much. */
  font-family: 'Inter', Helvetica, Arial, sans-serif;
  line-height: 1.6;
  -webkit-font-smoothing: antialiased;
}

a { color: inherit; }
/* 700, not 600. The reasoning survived the switch off Lato intact, because
   Helvetica and Arial don't ship a semibold either. Ask for 600 and the
   browser either rounds it to a real face or synthesizes fake bold by
   smearing the 400 outlines, which looks muddy at these sizes. 700 is a face
   both fonts genuinely have. */
h1, h2, h3 { font-weight: 700; letter-spacing: -0.02em; }
/* h1 (.page-title / .project-title) is already set apart by size (1.25rem
   against 1rem body text), so it doesn't need full bold on top of that to
   read as a heading. h2 (section labels) stays at 700; it's smaller and leans
   on weight more than size. Entry titles opt back out; see .entry h3. */
h1 { font-weight: 400; }

/* one shared measure. Every block lines up on the same left edge */
.wrap { max-width: 52rem; margin: 0 auto; padding: 0 1.5rem; }

/* .centered pages: centred rather than left aligned inside the wider wrap
   above. Sizing the wrap down to the measure removes the leftover space a
   left-aligned column would otherwise pool on the right, so the name, the
   nav, the body copy and the footer rule sit centred in the viewport. Home
   and the projects list carry this class: both are a single column of text
   with nothing sitting off to the side. Individual project pages don't —
   their rail-plus-prose layout wants the wide .wrap, content left aligned
   inside it, same as before.

   The +3rem is this rule's own 1.5rem of side padding, twice. box-sizing is
   border-box everywhere (see the * rule at the top of base), so that padding
   sits inside max-width: without it the column would come out 3rem narrower
   than the measure and rewrap every paragraph on the page.

   Below this width there is no leftover space to distribute, so this changes
   nothing on a phone; it only takes effect where the gap existed. */
body.home .wrap, body.centered .wrap { max-width: calc(var(--measure) + 3rem); }

/* Home only: the vertical rhythm on a dial. fit-home.js turns --space down
   from 1 until the page fits the viewport, so home closes the gap by
   tightening its own whitespace rather than by shrinking its type.

   That distinction is the whole point. Scaling the root font-size (what this
   used to do) shrank the name, the nav and the footer along with everything
   else, so About/Projects/Writings rendered a few percent smaller here than
   on every other page and visibly changed size as you navigated between
   them. Type sizes are now identical site-wide and only the gaps give.

   Every value below is the same number its unscoped rule uses, times the
   dial; keep them in step if those rules change. Only home carries these,
   so nothing else on the site is affected. */
body.home { --space: 1; }
/* The top padding is the one gap that does NOT scale, and it is deliberate:
   it is what sets the y of the name and the nav, and every other page pays
   the full clamp. Tightening it here floated home's header ~12px above the
   header on every other page, so the whole row visibly jumped as you
   navigated. The header is now anchored to the same line site-wide and the
   compression comes out of the gaps below it instead — those are internal
   to the page, where nothing on another page lines up against them. */
body.home .page {
  padding-block: clamp(2rem, 6vh, 3rem)
                 calc(clamp(2rem, 5vh, 3rem) * var(--space));
}
body.home .page-header { margin-bottom: calc(3.5rem * var(--space)); }
body.home .label { margin-bottom: calc(1.75rem * var(--space)); }
body.home .prose p { margin-bottom: calc(1.1rem * var(--space)); }
/* restated: the rule above outranks the unscoped .prose p:last-child, and
   the closing paragraph still owes the section below it nothing */
body.home .prose p:last-child { margin-bottom: 0; }
body.home .anecdotes { margin-top: calc(2.75rem * var(--space)); }
body.home .anecdote-header { padding-block: calc(0.85rem * var(--space)); }
body.home footer { padding-bottom: calc(2.5rem * var(--space)); }
body.home .links { padding-top: calc(1.25rem * var(--space)); }

:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
  border-radius: 2px;
}

.skip {
  position: absolute;
  left: -9999px;
  padding: 0.6rem 1rem;
  background: var(--bg);
  border: 1px solid var(--line);
  border-radius: 6px;
  z-index: 20;
}
.skip:focus { left: 1rem; top: 1rem; }

/* visible to screen readers, not to eyes. The live region that announces a
   copied email */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

@media (max-width: 30rem) {
  .wrap { padding: 0 1.125rem; }
}

/* ============ page ============ */
/* one topic per page, so the page shell carries the vertical rhythm the
   stacked <section> blocks used to. The bottom is the smaller number because
   the footer's own top padding sits right under it.

   padding-block, not padding: this class rides on the same element as .wrap,
   and the shorthand would zero out that class's side padding, which reads
   fine on a desktop and runs the text into the screen edge on a phone. */
.page { padding-block: clamp(2rem, 6vh, 3rem) clamp(2rem, 5vh, 3rem); }

/* the page's h1, sized down to a label on purpose. The <title> already says
   whose site this is, so the heading only has to name the page */
.label {
  margin: 0 0 1.75rem;
  padding-bottom: 0.6rem;
  border-bottom: 1px solid var(--line);
  font-size: 0.78rem;
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.14em;
  color: var(--muted);
}
.label:has(+ .lede) { margin-bottom: 0.9rem; }

/* ============ back link ============ */
/* The way back up to the listing a project (or a writing post) belongs to.
   Used to be a labeled breadcrumb ("← projects") sitting inline above the
   title; now it's just the arrow, moved into the left margin so it reads as
   a side control rather than another line of text competing with the
   title/nav row above it. aria-label carries the destination for anyone who
   can't see the arrow's position to infer it. */
/* In flow (narrow screens): sits as a normal item in the .page-header row,
   before the title. In the rail (wide screens, below): pulled out to
   position: absolute and anchored to .page-header itself, so top: 50%
   centers it against the row's own box rather than a hand-picked offset
   that would drift out of sync the next time the title's spacing changes. */
.back-link {
  display: inline-block;
  color: var(--muted);
  font-size: 1.15rem;
  line-height: 1;
  text-decoration: none;
}
.back-link:hover { color: var(--fg); }

@media (min-width: 60rem) {
  .back-link {
    position: absolute;
    top: 50%;
    /* Anchored to .page-header, not the viewport. The header is centered at
       the measure's width on every page (see its auto side margins), so its
       left edge sits at `50vw - measure/2` everywhere, and everything past
       that is relative to that edge, not vw, which is what makes this a
       flat rem value instead of another `50vw - Nrem` calc: -1.25rem clears
       a gap, then translate(-100%, -50%) flips the arrow to grow leftward
       from there while centering it on the row's own height. */
    left: -1.25rem;
    transform: translate(-100%, -50%);
  }
}

/* ============ page header ============ */
/* The title and the site nav share one row, name on the left and the
   section list on the right, at every width, what the site nav's own
   comment below already described as the goal ("opposite the wordmark on
   the left") but that nav used to sit on its own line above the title
   instead of actually beside it. align-items: baseline lines up the two
   different font sizes on the same text line rather than centering their
   boxes, which would read as slightly off. Wrapping is the mobile
   fallback: nav drops to its own line under the title rather than the two
   of them fighting for space in a narrow column.

   position: relative so .back-link (a child on pages that have one) can
   anchor to this row instead of the viewport. See .back-link, above.

   The gap to whatever comes after the row lives here, not on
   .page-title/.project-title directly (both are margin: 0; see below):
   a margin on a flex item is part of its own box, and align-items:
   baseline sizes the row tall enough to hold each item's full margin box.
   A margin that size would have pushed .back-link's centering line down
   with it. Living on the container instead, it never enters that
   calculation, and .page-header + .badges below still collapses against it
   the same way it would against a title's own margin. */
.page-header {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  /* capped to the same measure as .prose/.entry text, so the nav's right
     edge lines up with where paragraph lines end instead of drifting to the
     wider .wrap edge and looking pushed past the text column */
  max-width: var(--measure);
  /* flex-start, not space-between: below 60rem .back-link is an in-flow flex
     item (see the media query above), and space-between would spread it and
     the title across the whole row width instead of keeping them together.
     .site-nav gets its own margin-left: auto below to still land on the
     right edge, so the visual result is unchanged wherever back-link is
     absent or pulled into the rail. */
  justify-content: flex-start;
  gap: 0.5rem 1.5rem;
  position: relative;
  /* One value for every page, on purpose: title-to-badges on a project page
     and name-to-prose on the about page used to carry different numbers
     (0.9rem vs 2.5rem), which read as inconsistent rather than as two
     deliberately different relationships. Now there's one gap between a
     title and whatever follows it, everywhere. */
  /* auto side margins: on the centered pages the wrap is already the
     measure wide, so this changes nothing, but on the wide project and
     writing pages it centers the header to the same viewport position, so
     the title and nav hold still when navigating between page types. Only
     this shared chrome is pinned; the content below stays left-aligned in
     the wide wrap. */
  margin: 0 auto 3.5rem;
}

/* ============ site nav ============ */
/* About / Projects / Writings. The site's three top-level sections.
   Everything else on the site (individual projects, the competitive page's
   own entries) stays reachable the way it already was, an inline link from
   prose, so this only ever lists the handful of top-level sections.
   No margin of its own: it's a .page-header flex item now, so the title's
   own bottom margin (set alongside .page-title/.project-title, below) is
   what carries the gap to whatever comes after the row. */
/* pushes the nav to the row's right edge now that .page-header no longer
   uses justify-content: space-between to do it. See the note there. */
.site-nav { position: relative; margin: 0 0 0 auto; }
.site-nav ul {
  display: flex;
  flex-wrap: wrap;
  gap: 0.25rem 1.5rem;
  margin: 0;
  padding: 0;
  list-style: none;
  font-size: 0.95rem;
}
.site-nav a { color: var(--muted); text-decoration: none; }
.site-nav a:hover { color: var(--fg); }
/* the current section is the one that isn't grey. No underline, no weight
   change, so the row stays quiet */
.site-nav a[aria-current="page"] { color: var(--fg); }

/* Hamburger toggle, mobile only. Hidden and out of the flex flow above
   30rem, where the three links are short enough to just sit in the row.
   Everything below is scoped under .js (set by the inline script at the top
   of <head>, before first paint) rather than applying unconditionally: with
   JS disabled that class never lands, so the media query never collapses
   the list, and the three links fall back to the plain wrapping row this
   had before the toggle existed, nothing goes unreachable behind a button
   with no listener attached to it. */
.nav-toggle {
  display: none;
  flex-direction: column;
  justify-content: center;
  gap: 0.3rem;
  width: 1.75rem;
  height: 1.75rem;
  padding: 0;
  border: 0;
  background: none;
  cursor: pointer;
}
.nav-toggle-bar {
  display: block;
  width: 100%;
  height: 2px;
  background: var(--fg);
  border-radius: 1px;
}
/* the open state morphs the three bars into an X rather than swapping in a
   second icon, so there's one element to keep in sync with [aria-expanded]
   instead of two hidden/shown in tandem */
.nav-toggle[aria-expanded="true"] .nav-toggle-bar:nth-child(1) { transform: translateY(0.55rem) rotate(45deg); }
.nav-toggle[aria-expanded="true"] .nav-toggle-bar:nth-child(2) { opacity: 0; }
.nav-toggle[aria-expanded="true"] .nav-toggle-bar:nth-child(3) { transform: translateY(-0.55rem) rotate(-45deg); }

@media (max-width: 30rem) {
  .js .nav-toggle { display: flex; }
  /* .page-header aligns its row on the text baseline (see the note there),
     which is right for the three-link nav but wrong for the button: a
     <button> has no baseline of its own, so the browser falls back to its
     bottom margin edge, landing it too high against the title instead of
     centered on it. align-self overrides that for .site-nav specifically,
     without touching how the title itself sits in the row. */
  .js .site-nav { align-self: center; }
  /* collapsed into a dropdown anchored to .site-nav's own right edge
     (position: relative, above), not the viewport, so it stays under the
     button at any scroll position or page width in this range. */
  .js .site-nav ul {
    display: none;
    position: absolute;
    top: 100%;
    right: 0;
    flex-direction: column;
    align-items: flex-end;
    gap: 0.75rem;
    margin-top: 0.6rem;
    padding: 0.9rem 1.1rem;
    min-width: 8rem;
    background: var(--bg);
    border: 1px solid var(--line);
    border-radius: 8px;
    z-index: 10;
  }
  .js .site-nav.is-open ul { display: flex; }
}

/* The two real page titles. The about page's name, and a project's name. One
   step up from the 1.05rem entry titles in the listing. A page you landed on
   gets to be a little louder than a row in a list. */
.page-title,
.project-title { font-size: 1.25rem; }

/* home has no nav and no breadcrumb above it, so this is the page's opening
   line, with no rule under it, unlike the breadcrumb-backed titles below.
   No font-weight override, so it takes the h1 default of 400 like every other
   title on the site. It used to ask for 600, which was reasoning left over
   from when this loaded Inter. On Helvetica and Arial there is no semibold to
   resolve to, so 600 only got the name a synthesized face heavier than the
   page wanted. Size alone carries it.
   No margin: it lives on .page-header now. See the note there. */
.page-title { margin: 0; }
.lede {
  margin: 0 0 2rem;
  max-width: var(--measure);
  color: var(--muted);
  line-height: 1.7;
}

/* pairs a media card with the prose on a project page. Stacked by default,
   video above text, and only becomes a side-by-side row once there's width
   to spare, the same fallback shape the site nav rail and breadcrumb use. */
.project-layout { display: flex; flex-direction: column; gap: 1.75rem; }
@media (min-width: 40rem) {
  .project-layout { flex-direction: row; align-items: flex-start; gap: 2rem; }
  .media-card { flex: 0 0 11rem; }
  .project-layout .prose { flex: 1; min-width: 0; }
}

.media-card { margin: 0; max-width: 14rem; }

/* a column of media cards, for a page carrying more than one still or clip.
   It takes the rail position a lone .media-card would, but wider. These are
   landscape frames, and 11rem of a 2:1 clip is a thumbnail, not a demo. The
   cards inside give up their own max-width so the stack sets the width. */
.media-stack { display: flex; flex-direction: column; gap: 1.5rem; }
.media-stack .media-card { max-width: none; }
@media (min-width: 40rem) {
  .project-layout > .media-stack { flex: 0 0 17rem; }
  /* project pages now share home's .centered wrap (the same left/right
     bounds as the text column everywhere else). Inside that measure the
     17rem rail left the prose too narrow to read, so the rail gives up
     3rem and the row's gap tightens to hand the text back some width. */
  .centered .project-layout { gap: 1.75rem; }
  .centered .project-layout > .media-stack { flex-basis: 14rem; }
  /* the narrow variant keeps its own basis; without this the rule above
     out-specifies it and forces --sm rails back up to 14rem */
  .centered .project-layout > .media-stack--sm { flex-basis: 12rem; }
}
/* the narrow variant, for cards that read fine small (the Shopify page's
   portrait demo and UI slideshow). Near the 11rem a lone card used to get
   in the desktop row, and capped on phones too, where the plain stack runs
   the full column width. */
.media-stack--sm { max-width: 14rem; }
@media (min-width: 40rem) {
  .project-layout > .media-stack--sm { flex-basis: 12rem; }
}

/* ============ media slideshow ============ */
/* several images in one card, one showing at a time (media-slides.js).
   Slides that aren't current are display: none, so with no JS the card is
   just its first image, and the nav below never appears (it ships with the
   hidden attribute). contain, not the frame's usual cover: these are UI
   screenshots, and cropping UI breaks it, so the frame letterboxes on the
   card background instead (set inline per frame). */
.media-slides .slide { display: none; }
/* absolute, so a slide's own size can never feed back into the frame's:
   the frame holds exactly its --media-ratio no matter which slide is up,
   and the layout below never shifts when the arrows change slides. contain
   centers the slide inside that fixed frame both ways. */
.media-slides .slide.is-current {
  display: block;
  position: absolute;
  inset: 0;
}
.media-slides .media-frame img { object-fit: contain; }
.slides-nav {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 0.7rem;
  margin-top: 0.45rem;
}
.slides-btn {
  border: 0;
  padding: 0 0.3rem;
  background: none;
  font: inherit;
  font-size: 1rem;
  line-height: 1;
  color: var(--muted);
  cursor: pointer;
}
.slides-btn:hover { color: var(--fg); }
.slides-count {
  font-size: 0.75rem;
  color: var(--muted);
  /* tabular so "2 / 3" is as wide as "1 / 3" and the arrows don't shift */
  font-variant-numeric: tabular-nums;
}
/* the slideshow's caption swaps per slide and the captions run one to two
   lines at rail width, so reserve two lines up front; otherwise the layout
   below bounces by a line as the arrows change slides */
.media-slides ~ figcaption { min-height: 3em; }

/* the frame is what carries the aspect ratio, not the media inside. The
   file's own dimensions don't matter to layout as long as object-fit covers
   it. The default is the portrait shape the first card on the site used;
   anything else sets --media-ratio to its source's real ratio, since cover
   crops whatever doesn't match and these clips carry text in the corner. */
.media-frame {
  position: relative;
  aspect-ratio: var(--media-ratio, 418 / 902);
  border-radius: 6px;
  overflow: hidden;
  background: #000;
}
.media-frame video,
.media-frame img { display: block; width: 100%; height: 100%; object-fit: cover; }
/* the only control a media card has. No native <video> controls, so no
   scrubber, volume, or speed menu. Click toggles play/pause; see
   media-play.js. Stays clickable across the whole frame at all times, but
   fades out while playing so it doesn't sit on top of the video, and a hover
   or focus brings it back to pause. */
.media-play {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  border: 0;
  padding: 0;
  background: rgba(0, 0, 0, 0.15);
  cursor: pointer;
}
.media-play.is-playing { background: transparent; }
.media-play.is-playing .play-icon { opacity: 0; }
.media-play.is-playing:hover .play-icon,
.media-play.is-playing:focus-visible .play-icon { opacity: 1; }

.play-icon {
  position: relative;
  width: 3rem;
  height: 3rem;
  border-radius: 50%;
  background: rgba(15, 15, 15, 0.6);
  transition: opacity 0.15s ease;
}
/* paused state. A CSS triangle, a zero-size box with only the left border
   painted, nudged right of center so its visual weight sits in the middle
   of the circle instead of its bounding box */
.play-icon::before {
  content: "";
  position: absolute;
  top: 50%;
  left: 54%;
  transform: translate(-50%, -50%);
  border-style: solid;
  border-width: 0.5rem 0 0.5rem 0.85rem;
  border-color: transparent transparent transparent #fff;
}
/* playing state. Two bars instead of the triangle, drawn as a pair of
   gradients rather than a second element */
.media-play.is-playing .play-icon::before { border-style: none; }
.media-play.is-playing .play-icon::after {
  content: "";
  position: absolute;
  top: 50%;
  left: 50%;
  width: 0.9rem;
  height: 1rem;
  transform: translate(-50%, -50%);
  background:
    linear-gradient(#fff, #fff) 0 0 / 0.28rem 1rem no-repeat,
    linear-gradient(#fff, #fff) 100% 0 / 0.28rem 1rem no-repeat;
}
/* Fullscreen, for the cards whose video has no native controls and so no
   fullscreen button of its own. Sits above .media-play, which covers the
   whole frame, so it needs the stacking order to stay clickable. Ships
   [hidden] and is revealed by media-fullscreen.js only where the browser
   actually supports it. */
.media-fullscreen {
  position: absolute;
  right: 0.5rem;
  bottom: 0.5rem;
  z-index: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 2rem;
  height: 2rem;
  padding: 0;
  border: 0;
  border-radius: 4px;
  background: rgba(15, 15, 15, 0.6);
  cursor: pointer;
  opacity: 0.55;
  transition: opacity 0.15s ease;
}
.media-fullscreen[hidden] { display: none; }
.media-frame:hover .media-fullscreen,
.media-fullscreen:hover,
.media-fullscreen:focus-visible { opacity: 1; }

/* two opposite corner brackets, drawn with borders like .play-icon rather
   than pulled in as an icon file */
.fs-icon {
  position: relative;
  width: 0.85rem;
  height: 0.85rem;
}
.fs-icon::before,
.fs-icon::after {
  content: "";
  position: absolute;
  width: 0.4rem;
  height: 0.4rem;
  border: 2px solid #fff;
}
.fs-icon::before { top: 0; left: 0; border-right: 0; border-bottom: 0; }
.fs-icon::after { right: 0; bottom: 0; border-left: 0; border-top: 0; }
/* pointing inwards once we're already fullscreen */
.media-fullscreen.is-full .fs-icon::before {
  top: 0; left: 0;
  border: 2px solid #fff; border-left: 0; border-top: 0;
}
.media-fullscreen.is-full .fs-icon::after {
  right: 0; bottom: 0;
  border: 2px solid #fff; border-right: 0; border-bottom: 0;
}

/* fullscreen drops the card's aspect ratio and rounding, and switches the
   video from cover to contain: cropping to fill is right in a small card,
   wrong on a whole screen. Prefixed selectors are separate rules on purpose,
   since one unknown pseudo-class would invalidate the whole list. */
.media-frame:fullscreen {
  aspect-ratio: auto;
  width: 100vw;
  height: 100vh;
  border-radius: 0;
}
.media-frame:fullscreen video { object-fit: contain; }
.media-frame:-webkit-full-screen {
  aspect-ratio: auto;
  width: 100vw;
  height: 100vh;
  border-radius: 0;
}
.media-frame:-webkit-full-screen video { object-fit: contain; }

/* click-to-enlarge, added by media-zoom.js. Covers the frame like
   .media-play does, but stays invisible until hover or focus: the image is
   the thing being looked at, and a badge sitting on it permanently is a
   worse trade than a hidden affordance plus a zoom cursor. */
.media-zoom {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: flex-end;
  justify-content: flex-end;
  padding: 0.5rem;
  border: 0;
  background: transparent;
  cursor: zoom-in;
}
.media-zoom .fs-icon {
  opacity: 0;
  border-radius: 4px;
  transition: opacity 0.15s ease;
}
.media-zoom:hover .fs-icon,
.media-zoom:focus-visible .fs-icon { opacity: 1; }
/* the icon is white corner brackets, invisible against a light photo without
   something behind it */
.media-zoom .fs-icon::before,
.media-zoom .fs-icon::after { filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.9)); }

/* the lightbox itself. A <dialog>, so Esc, the focus trap and inerting the
   page behind are the browser's job, not this file's. */
.lightbox {
  width: 100vw;
  height: 100vh;
  max-width: none;
  max-height: none;
  padding: 0;
  border: 0;
  background: transparent;
  overflow: hidden;
}
.lightbox[open] {
  display: flex;
  align-items: center;
  justify-content: center;
}
.lightbox::backdrop { background: rgba(0, 0, 0, 0.55); }
.lightbox img {
  max-width: 92vw;
  max-height: 88vh;
  width: auto;
  height: auto;
  object-fit: contain;
  border-radius: 6px;
  cursor: default;
}
.lightbox-close {
  position: absolute;
  top: 1rem;
  right: 1rem;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 2.25rem;
  height: 2.25rem;
  padding: 0;
  border: 0;
  border-radius: 4px;
  background: rgba(15, 15, 15, 0.6);
  cursor: pointer;
}
.x-icon { position: relative; width: 0.9rem; height: 0.9rem; }
.x-icon::before,
.x-icon::after {
  content: "";
  position: absolute;
  top: 50%;
  left: 0;
  width: 100%;
  height: 2px;
  background: #fff;
}
.x-icon::before { transform: rotate(45deg); }
.x-icon::after { transform: rotate(-45deg); }

/* the page behind a modal shouldn't scroll under it */
.is-modal-open { overflow: hidden; }

.media-card figcaption {
  margin: 0.6rem 0 0;
  font-size: 0.85rem;
  color: var(--muted);
  line-height: 1.5;
}

.prose { max-width: var(--measure); }
/* body copy sits a shade lighter than headings and links (muted, not fg),
   so a link reads as the darkest, most emphasized thing in the paragraph
   rather than blending into it. Matches how goldmanzachary.com/index.html
   splits its own header paragraph (muted) from its links (full dark). */
.prose p { margin: 0 0 1.1rem; color: var(--muted); line-height: 1.75; }
.prose p:last-child { margin-bottom: 0; }
/* inline links get full contrast (--fg) at rest, not just an underline. The
   surrounding paragraph is muted, so fg is what marks a link as the darkest,
   most clickable thing on the line. Hover fades to --muted rather than
   getting louder, so the paragraph settles back toward its own resting
   colour instead of drawing more attention. */
.prose a {
  color: var(--fg);
  text-decoration: underline;
  text-decoration-color: currentColor;
  text-decoration-thickness: 1px;
  text-underline-offset: 3px;
}
.prose a:hover { color: var(--muted); }

/* ============ definition tooltip ============ */
/* A term with a hover card (the "degree" span on the about page). Pure CSS:
   the card shows on hover and on keyboard focus (the span carries tabindex).
   The dashed underline marks it as "explains itself", distinct from the solid
   underline that marks a real link. The card is absolutely positioned, so it
   doesn't inherit the trigger's underline (decoration doesn't propagate to
   abs-positioned descendants). */
.def {
  position: relative;
  /* stays at the paragraph's muted color, unlike .prose a. Full contrast is
     the signal for "this navigates"; a definition term only explains itself,
     so it keeps body color and the dashed underline whispers underneath. */
  text-decoration: underline dashed;
  /* currentColor = the paragraph's muted gray. Visible, but still a step
     lighter than the near-black solid underline links get. */
  text-decoration-color: currentColor;
  text-decoration-thickness: 1px;
  text-underline-offset: 3px;
  cursor: help;
}
.def-card {
  position: absolute;
  bottom: calc(100% + 0.5rem);
  left: 50%;
  transform: translateX(-50%) translateY(4px);
  width: 19rem;
  /* never wider than the viewport minus the page padding */
  max-width: calc(100vw - 2.25rem);
  padding: 0.85rem 1.05rem;
  background: var(--card);
  border: 1px solid var(--line);
  border-radius: 10px;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
  color: var(--muted);
  font-size: 0.875rem;
  line-height: 1.6;
  opacity: 0;
  visibility: hidden;
  transition: opacity 0.15s ease, transform 0.15s ease,
    visibility 0s linear 0.15s;
  pointer-events: none;
  z-index: 10;
}
/* the card's lead-in line ("Primary Stack:"). Real bold, kept light: bold
   already separates it, so it sits just a shade darker than the muted body
   text rather than stepping toward the headings. */
.def-card strong {
  font-weight: 700;
  color: var(--muted-strong);
}

/* .def-host puts the same card on a real link (the Outpaint anchor). The
   link keeps its normal styling and destination; only the card is added.
   The card is pointer-events: none, so hovering it can't trigger a click. */
.def-host { position: relative; }

/* A card that carries a link (the Outpaint card's outpaint.com pill) has to
   stay up while the cursor travels into it, so it takes pointer events back.
   The ::before is an invisible bridge across the gap between the word and
   the card: without it the cursor leaves .def crossing those 0.5rem and the
   card fades before it can be reached. Sized for the -below variant, where
   the gap sits above the card. */
.def-card.has-action { pointer-events: auto; }
.def-card.has-action::before {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  top: -0.75rem;
  height: 0.75rem;
}
/* the card's footer row: a subtle full-width rule separating the pill from
   the body, matching the reference card this was modeled on */
.def-card .def-foot {
  display: block;
  margin-top: 0.65rem;
  padding-top: 0.65rem;
  border-top: 1px solid var(--line);
}
/* the pill itself: external-link glyph, no underline. Styled against
   .prose a, which would otherwise underline it (see specificity: two
   classes beat .prose a). --line as the fill rather than the fainter
   --tint, so the pill reads as a button at a glance. */
.def-card .def-action {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  padding: 0.28rem 0.65rem;
  background: var(--line);
  border-radius: 6px;
  color: var(--fg);
  font-size: 0.85rem;
  font-weight: 500;
  text-decoration: none;
}
.def-card .def-action:hover { background: var(--scroll-thumb); }
.def-card .def-action svg { flex: none; }

/* the entrance animation (see .js [data-reveal] below) leaves every revealed
   block with its own stacking context, because a filled opacity animation
   keeps generating one. Inside those, a card's z-index only counts locally,
   so a card that reaches past its own block paints UNDER the next block's
   text. Lift the block that owns the hovered/focused trigger above its
   siblings for as long as the card is up. */
[data-reveal]:has(.def:hover, .def:focus, .def:focus-within, .def.is-open, .def-host:hover, .def-host:focus-visible) {
  position: relative;
  z-index: 11;
}

/* wider card, for a bold lead line that should hold on a single line (the
   Outpaint card's "Notable Backers of Outpaint (Formerly True3D):") */
.def-card-wide { width: 24rem; }

/* opens under the word instead of over it, for triggers near the top of the
   page (the Outpaint link) where an upward card would hit the viewport edge */
.def-card-below {
  bottom: auto;
  top: calc(100% + 0.5rem);
  transform: translateX(-50%) translateY(-4px);
}
.def:hover .def-card-below,
.def:focus .def-card-below,
.def:focus-within .def-card-below,
.def-host:hover .def-card-below,
.def-host:focus-visible .def-card-below {
  transform: translateX(-50%) translateY(0);
}

.def:hover .def-card,
.def:focus .def-card,
/* focus-within too: tabbing onto a link inside a card (the outpaint.com
   pill) moves focus off the .def span itself, and the card has to survive
   that or the link can never be reached by keyboard */
.def:focus-within .def-card,
.def.is-open .def-card,
.def-host:hover .def-card,
.def-host:focus-visible .def-card {
  opacity: 1;
  visibility: visible;
  transform: translateX(-50%) translateY(0);
  transition: opacity 0.15s ease, transform 0.15s ease;
}

/* same rhythm and color as .prose p. A highlight is a paragraph with a bold
   lead-in, not a different kind of content, so it shouldn't look like one */
/* Bulleted, and hung outside the text column so the wrapped second line of a
   highlight lines up under the first rather than under the marker. The marker
   sits a shade lighter than the copy. It's punctuation, not content. */
.highlights { margin: 0; padding: 0 0 0 1.15rem; list-style: disc; }
.highlights li { margin: 0 0 1.1rem; padding-left: 0.15rem; color: var(--muted); line-height: 1.75; }
.highlights li::marker { color: var(--faint); }
.highlights li:last-child { margin-bottom: 0; }
.highlights strong { color: var(--fg); font-weight: 600; }

/* ============ anecdotes ============ */
/* An accordion, one item open at a time. The header toggles .is-open on
   .anecdote and sets inline max-height on .anecdote-content (see
   anecdotes.js); the CSS here only has to define the closed state and the
   transition between them. */
.anecdotes {
  margin: 2.75rem 0 0;
  /* same measure as .prose and .page-header: the label's rule, the row
     borders, and the + toggles all stop where the paragraph lines end
     instead of running on to the wider .wrap edge */
  max-width: var(--measure);
}
/* no top border on the first item. The "Anecdotes" .label above it already
   draws a rule, and pairing that with our own top border just doubled it up
   with an odd gap in between, from the label's own bottom margin */
.anecdote { border-bottom: 1px solid var(--line); }

.anecdote-header {
  display: flex;
  width: 100%;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  padding: 0.85rem 0;
  border: 0;
  background: none;
  color: inherit;
  font: inherit;
  text-align: left;
  cursor: pointer;
}
.anecdote-header:hover { color: var(--muted); }

.anecdote-title { font-size: 0.95rem; font-weight: 400; }

/* a plus that turns into a cross. Rotation carries the open/closed state, so
   the symbol itself never has to change */
.anecdote-toggle {
  flex: none;
  color: var(--muted);
  font-size: 1.1rem;
  line-height: 1;
}
.anecdote.is-open .anecdote-toggle { color: var(--fg); transform: rotate(45deg); }

/* the no-JS baseline for a default-open anecdote (see index.html). Plain
   CSS, no animation, so it reads correctly even if anecdotes.js never runs.
   Once it does run, it swaps this for a pixel max-height (see anecdotes.js)
   so a later close can animate instead of snapping; max-height can't
   transition smoothly away from `none`. */
.anecdote.is-open .anecdote-content { max-height: none; }

.anecdote-content { max-height: 0; overflow: hidden; }
.anecdote-content-inner {
  max-width: var(--measure);
  padding: 0 0 1.1rem;
  color: var(--muted);
  font-size: 0.9rem;
  line-height: 1.7;
}
.anecdote-content-inner p { margin: 0 0 1.1rem; }
.anecdote-content-inner p:last-child { margin-bottom: 0; }
.anecdote-content-inner a {
  color: var(--fg);
  text-decoration: underline;
  text-underline-offset: 2px;
}
.anecdote-content-inner a:hover { color: var(--muted); }

/* ============ projects ============ */
.entry { margin: 0 0 2.5rem; }
.entry:last-child { margin-bottom: 0; }
/* 400 rather than the 700 h3 defaults to, on the same reasoning as h1 up top. These
   sit at the head of an entry with an underline under them and a description
   beneath; size, position and the link rule already say "title", and full bold
   at this size made the listing read as a wall of headings. */
.entry h3 { margin: 0; font-size: 1.05rem; font-weight: 400; }

/* the listing is split into groups (company work, then personal), and each
   group is headed by a plain .label. A second label mid-list needs air above
   it; the entry's own bottom margin collapses into this one, so 3.5rem is the
   whole gap rather than 3.5 on top of 2.5. */
.entry + .label { margin-top: 3.5rem; }

/* ============ ranks ============ */
/* one line per game: the name, a plain "-", then the placement in muted
   small type. Inline flow rather than columns, so each pair reads as one
   phrase. Capped to the same 38rem measure as the rest of the text so the
   row rules stop where paragraph lines end. */
.ranks { margin: 0; padding: 0; list-style: none; max-width: var(--measure); }
.ranks > li {
  padding: 0.6rem 0;
  border-bottom: 1px solid var(--line);
}
.ranks > li:last-child { border-bottom: 0; }
.rank-value { color: var(--muted); font-size: 0.875rem; white-space: nowrap; }
/* a quieter second line under a row, for context like how a rank was earned */
.rank-note {
  margin: 0.15rem 0 0;
  color: var(--muted);
  font-size: 0.8rem;
  line-height: 1.5;
}
/* fine-print aside under the list, e.g. the retirement note. The "**" is
   literal text in the markup, part of the aside's voice */
.footnote {
  margin: 2rem 0 0;
  max-width: var(--measure);
  color: var(--muted);
  font-size: 0.8rem;
  line-height: 1.7;
}

/* the title carries the link. The underline is what marks it as clickable, so
   it can sit at full contrast like the rest of the page and save the accent
   for the hover */
.proj {
  color: var(--fg);
  text-decoration: underline;
  text-decoration-thickness: 1px;
  text-underline-offset: 3px;
}
.proj:hover { color: var(--accent); }
/* muted, same reasoning as .prose p. Body copy stays a shade lighter than
   the linked title above it */
.entry-desc { margin: 0.45rem 0 0; max-width: var(--measure); color: var(--muted); line-height: 1.7; }

/* an optional thumbnail on a listing entry: image left, text right. The image
   link duplicates the title link, so it's aria-hidden and out of the tab
   order; the title stays the entry's one real link. Cover-cropped to a wide
   ratio so any source image reads as the same shape in the list. */
.entry-with-thumb { display: flex; align-items: flex-start; gap: 1.1rem; }
.entry-thumb { flex: 0 0 8.5rem; }
.entry-thumb img {
  display: block;
  width: 100%;
  aspect-ratio: 16 / 10;
  object-fit: cover;
  border-radius: 6px;
}
.entry-with-thumb .entry-body { flex: 1; min-width: 0; }
/* a phone row is too narrow to give the image 8.5rem and still wrap the
   description sanely, so the thumb shrinks before the text does */
@media (max-width: 30rem) {
  .entry-thumb { flex-basis: 6.25rem; }
}

/* ============ stack badges ============ */
.badges {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem;
  margin: 0.8rem 0 0;
  padding: 0;
  list-style: none;
}
.badges li {
  padding: 0.1rem 0.5rem;
  border: 1px solid var(--line);
  border-radius: 3px;
  color: var(--muted);
  font-size: 0.75rem;
  line-height: 1.7;
  white-space: nowrap;
}

/* ============ project pages ============ */
/* size is set alongside .page-title, up in the page section, so the two can't
   drift apart. No margin, same reasoning as .page-title: lives on
   .page-header instead. */
.project-title { margin: 0; }
/* the gap above badges is .page-header's own 3.5rem margin now (it
   collapses against this, same as it does against any other sibling), so
   this only has to zero out .badges' own top margin and set the gap below
   it before the media/prose row */
.page-header + .badges { margin-top: 0; margin-bottom: 2rem; }
/* stands in for the write-up until there is one */
.todo { color: var(--muted); }

/* ============ reframe demo ============ */
/* The interactive aspect-ratio demo on the outpainting page, ported from the
   Outpaint marketing site's hero. See outpaint-demo.js for the behaviour.

   That site is dark; this one isn't, so the port drops its palette entirely
   rather than carrying a black panel onto a white page. The controls are built
   from --line/--muted/--fg like everything else here, and sized to sit with the
   .badges directly above them. The only dark left is inside the frame itself,
   which is just .media-frame's black backdrop for the same reason. Film reads
   against black. The outline over the footage stays white for that reason too,
   which is why it's the one hard-coded color below.

   Sizing is driven from JS, not here. The canvas height is derived from the
   rail's width so every format fits at scale 1 (see measure() in the script).
   The rules below own appearance and the non-interactive fallback only. */
.reframe-demo {
  /* the marketing site's easing, kept because it's a motion token the script
     mirrors in JS (see bezier() there), not a color, so the repaint spares it */
  --rd-ease: cubic-bezier(0.4, 0.12, 0.2, 1);

  display: flex;
  flex-direction: column;
  /* centred, not left-aligned like the caption under it. The canvas grows
     outward from the source frame as the format changes, and that only reads as
     "expanded around the original" if it expands both ways. The controls line up
     with the canvas rather than fighting it. */
  align-items: center;
  gap: 0.7rem;
}

/* format selector. A segmented control, borrowing .badges' border and type so
   it reads as part of the same page rather than a widget dropped onto it. All
   five segments stay on one row at every width. That row is narrow (it sits in
   the media rail), so the type is small and the padding is thin, and the
   segments share the width equally by flex-basis 0 rather than sizing to their
   own labels. A wrapped row put one format on a line of its own, which read as
   a second control rather than a fifth option. */
.rd-switch {
  position: relative;
  display: flex;
  flex-wrap: nowrap;
  /* the demo column is align-items: center, which would size this to its
     content and leave it narrower than the rail it sits in. Stretching claims
     the full width, which is what makes five segments fit without shrinking
     the labels away. */
  align-self: stretch;
  border: 1px solid var(--line);
  border-radius: 3px;
  overflow: hidden;
}
.rd-switch button {
  position: relative;
  z-index: 1;
  /* sized from the label, then grown to share out whatever width is left over,
     so "16:9" doesn't get the same slice as "180° fisheye" and clip it */
  flex: 1 1 auto;
  min-width: 0;
  padding: 0.35rem 0.2rem;
  border: 0;
  border-right: 1px solid var(--line);
  background: transparent;
  color: var(--muted);
  font: inherit;
  font-size: 0.66rem;
  line-height: 1.7;
  white-space: nowrap;
  cursor: pointer;
  transition: color 0.25s var(--rd-ease);
}
.rd-switch button:last-child { border-right: 0; }
.rd-switch button:hover { color: var(--fg); }
.rd-switch button.on { color: var(--fg); }
/* the selected-tab highlight. One cell the script slides between segments,
   rather than a border that jumps */
.rd-ink {
  position: absolute;
  left: 0;
  top: 0;
  width: 0;
  height: 0;
  pointer-events: none;
  background: var(--tint);
  box-shadow: inset 0 -2px 0 var(--fg);
  transition:
    transform 0.44s var(--rd-ease),
    width 0.44s var(--rd-ease),
    height 0.44s var(--rd-ease);
}

/* The stage keeps a fixed height (set by the script) while the canvas inside it
   morphs between ratios, so cycling formats never reflows the page around it. */
.rd-stage {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  min-width: 0;
}
/* black behind the footage, matching .media-frame, with no shadow or ring, since
   the other media cards on the site don't have one either. The radius is set
   per-frame from JS instead of here, because the fisheye format tweens it up to
   half the width to round the frame into a circle. */
.rd-canvas {
  position: relative;
  overflow: hidden;
  background: #000;
}
.rd-scene {
  position: absolute;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
/* the fisheye canvas is a circle (its radius is set per-frame in JS so it
   rounds continuously); the slight scale hides the antialiased edge */
.rd-canvas.is-dome .rd-scene { transform: scale(1.045); }
/* the poster bridges the gap before the first frame paints, then retires */
.rd-poster { z-index: 2; transition: opacity 0.45s var(--rd-ease); }
/* two stacked buffers. Only the front one is opaque, so a format change is a
   cross-dissolve between two playing clips rather than a cut */
.rd-video { opacity: 0; transition: opacity 0.52s var(--rd-ease); }
.rd-video.is-front { opacity: 1; }

/* the marked-out original. A white hairline with corner ticks, showing where
   the source footage sits inside the generated frame. White rather than a page
   token because it sits on the footage, not on the page. */
.rd-outline {
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  z-index: 5;
  pointer-events: none;
  box-shadow: 0 0 0 1.5px #fff, inset 0 1px 0 rgba(255, 255, 255, 0.2);
  transition: opacity 0.4s var(--rd-ease);
}
.rd-corner { position: absolute; width: 10px; height: 10px; border: 2px solid #fff; }
.rd-corner.tl { left: -1px; top: -1px; border-right: 0; border-bottom: 0; }
.rd-corner.tr { right: -1px; top: -1px; border-left: 0; border-bottom: 0; }
.rd-corner.bl { left: -1px; bottom: -1px; border-right: 0; border-top: 0; }
.rd-corner.br { right: -1px; bottom: -1px; border-left: 0; border-top: 0; }
/* The outline is always on; the marketing site's toggle for it didn't come
   over. It's hidden only on the source format, where there is no distinction to
   draw. The source is its own original. */
.reframe-demo.is-src .rd-outline { opacity: 0; }

/* No-JS fallback. The script adds .is-live once it has taken over sizing, so
   until then the stage is a plain 4:3 poster frame and the format selector
   (which does nothing without the script) stays out of the way. */
.reframe-demo:not(.is-live) .rd-switch { display: none; }
/* the radius comes from JS in the live demo, so the fallback frame has to state
   its own, the same 6px, so both paths match .media-frame */
.reframe-demo:not(.is-live) .rd-canvas { width: 100%; aspect-ratio: 720 / 544; border-radius: 6px; }
.reframe-demo:not(.is-live) .rd-outline { display: none; }

/* ============ footer / contact ============ */
/* a rule and one line of links, the way a plain HTML page ends. The heading
   and the icon row are gone. Four words say the same thing in less space. */
/* no top margin: the page's own bottom padding is the gap above the rule */
footer { padding: 0 0 2.5rem; }
/* kept for direct #contact links from outside; clear the sticky bar */
#contact { scroll-margin-top: 5rem; }

/* the rule rides on the link row itself, inside the wrap's side padding, so
   it starts and ends on the same left and right edges as the text above it */
.links {
  /* same 38rem measure as .prose/.page-header/.anecdotes: the footer rule
     ends where the text column ends instead of spanning the wider .wrap */
  max-width: var(--measure);
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0.25rem 0.6rem;
  margin: 0;
  padding: 1.25rem 0 0;
  border-top: 1px solid var(--line);
  list-style: none;
  font-size: 0.9rem;
}
/* the separator belongs to the row, not to any one link, so it goes on the
   items after the first and never gets underlined with them */
.links li + li::before {
  content: "|";
  margin-right: 0.6rem;
  color: var(--line);
}
.links a,
.links button {
  color: var(--muted);
  text-decoration: underline;
  text-underline-offset: 3px;
}
/* the button's words stack in one grid cell rather than sitting in a row, so
   the button is always as wide as the longest of them and swapping the label
   moves nothing after it. Centering splits the few spare pixels either side of
   the shorter word instead of pooling them against the next separator. */
.links button {
  display: inline-grid;
  justify-items: center;
  padding: 0;
  border: 0;
  background: none;
  font: inherit;
  cursor: pointer;
  text-decoration: none; /* the words underline themselves, just below */
}
.links button > span {
  grid-area: 1 / 1;
  text-decoration: underline;
  text-underline-offset: 3px;
}
.links a:hover,
.links button:hover { color: var(--fg); }

/* one word shows at a time. The other two keep their place in the grid (that
   is what holds the width still) and are only made invisible. */
.copy-note,
.copy-fail { opacity: 0; color: var(--accent); }
.copy-email.is-copied .copy-word,
.copy-email.is-failed .copy-word { opacity: 0; }
.copy-email.is-copied .copy-note,
.copy-email.is-failed .copy-fail { opacity: 1; }

/* ============ motion ============ */
@media (prefers-reduced-motion: no-preference) {
  .proj,
  .label a,
  .prose a,
  .links a,
  .links button,
  .anecdote-header,
  .anecdote-content-inner a,
  .site-nav a { transition: color 0.2s ease, text-decoration-color 0.2s ease; }
  .anecdote-toggle { transition: color 0.15s ease, transform 0.2s ease; }
  /* one transition for both directions, so opening is the close played in
     reverse: the same height glide, no fade. ease-in-out rather than ease
     because ease front-loads its speed, which read as sudden on open; the
     symmetric curve starts gently both ways. */
  .anecdote-content { transition: max-height 0.35s ease-in-out; }
  .nav-toggle-bar { transition: transform 0.2s ease, opacity 0.2s ease; }

}

/* Entrance, modeled on the staggered hero arrival at x.ai/api: blocks fade
   straight in, opacity only, no travel, over 0.5s ease-out, 150ms apart
   (reveal.js owns the delays, and adds .is-visible to start them).

   The hidden state is set here in the stylesheet, not by reveal.js, and that
   placement is the entire point. reveal.js runs at the end of <body>, so
   anything already painted by the time it landed showed at full opacity, got
   hidden the instant the class arrived, then faded back in. Text was quick
   enough to rarely catch it. A poster image that decoded fast caught it every
   time, and the media appeared to render twice.

   Scoped under .js, which the inline script at the top of <head> sets before
   the body paints, so the hidden state is in force at first paint and a
   visitor without JS still has nothing hidden to begin with.

   A pure opacity fade has no travel and so no vestibular trigger, which is
   why these rules sit outside the no-preference block above. The old fade-up
   needed a separate reduced-motion entrance with the 8px rise stripped out,
   and this is that entrance, now served to everyone. */
.js [data-reveal] { opacity: 0; }
.js [data-reveal].is-visible { animation: fade-in 0.5s ease-out forwards; }

@keyframes fade-in {
  from { opacity: 0; }
  to { opacity: 1; }
}

/* The reframe demo is the one component here that declares its transitions
   outside the no-preference block above. It has a lot of them, and they're
   entangled with the JS that drives it (outpaint-demo.js reads transitionend
   to sequence the video cross-dissolve). So they're written unconditionally
   and switched off here instead. The script makes the matching choice on its
   own side. Reduced motion swaps the dissolve for a cut and the geometry tween
   for an instant apply, and stops the format tour from auto-advancing. */
@media (prefers-reduced-motion: reduce) {
  .rd-ink,
  .rd-video,
  .rd-outline,
  .rd-poster { transition: none; }
}
